mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-20 21:29:36 +08:00
feat(analytics): cross-tier span propagation (client fetch header + server withSpan({ request }))
Auto-links a customer's BACKEND span to the caller's CLIENT session with zero glue.
The browser attaches one `x-hexclave-span-context` header to same-origin outgoing
fetches; `serverApp.withSpan(type, { request }, fn)` resolves the caller's refresh
token (from the session) plus that header, so the span — and everything created
inside the callback — parents under $refresh-token → $session-replay →
$session-replay-segment, exactly like a browser event.
- span-propagation.ts: versioned base64url header codec, same-origin fetch
auto-wrapper (idempotent, native header semantics preserved), origin predicate
- server-request-context.ts: AsyncLocalStorage ambient request context
- server-app-impl: withSpan/trackEvent({ request }) resolution; server telemetry
buffer re-keyed from userId to the full (user, refresh, replay, segment) context;
propagated custom parents flow as ambient frames
- backend events/batch: accept forwarded refresh_token_id/session_replay_id on
SERVER auth (rejected on client auth); compose [rti-,sri-,srsi-,cs-] + stamp
the scalar columns
- client-app-impl: install the fetch wrapper on init behind analytics.spanPropagation
(same-origin default + exact-origin allowlist); reads the live per-tab id
- tests: 24 propagation unit tests + 2 e2e (server-auth ancestry + client-auth reject)
The framework adapters (tRPC, Convex, oRPC, ElysiaJS) build on this primitive in a
separate stacked branch; with an adapter you never pass { request } yourself.
This commit is contained in:
parent
d1f93b8fda
commit
f4c2ee4575
@ -84,6 +84,13 @@ export const POST = createSmartRouteHandler({
|
||||
// Server/admin auth only (enforced in the handler): attributes the batch
|
||||
// to a user when there is no session access token to derive one from.
|
||||
user_id: yupString().optional().matches(UUID_RE, "Invalid user_id"),
|
||||
// Server/admin auth only: the caller's resolved request context, forwarded
|
||||
// by the server SDK's withSpan({ request }) so a backend span parents under
|
||||
// the client session ($refresh-token/$session-replay/$session-replay-segment).
|
||||
// Trusted here because server auth is the customer's secret key; rejected
|
||||
// under client auth, where the same values come from the session itself.
|
||||
refresh_token_id: yupString().optional().matches(UUID_RE, "Invalid refresh_token_id"),
|
||||
session_replay_id: yupString().optional().matches(UUID_RE, "Invalid session_replay_id"),
|
||||
events: yupArray(
|
||||
yupObject({
|
||||
event_type: yupString().defined().test(
|
||||
@ -162,8 +169,8 @@ export const POST = createSmartRouteHandler({
|
||||
if (!auth.refreshTokenId) {
|
||||
throw new StatusError(StatusError.BadRequest, "A refresh token is required for analytics events");
|
||||
}
|
||||
if (body.user_id != null) {
|
||||
throw new StatusError(StatusError.BadRequest, "user_id must not be set with client auth; it is derived from the session");
|
||||
if (body.user_id != null || body.refresh_token_id != null || body.session_replay_id != null) {
|
||||
throw new StatusError(StatusError.BadRequest, "user_id / refresh_token_id / session_replay_id must not be set with client auth; they are derived from the session");
|
||||
}
|
||||
if (body.session_replay_segment_id == null) {
|
||||
throw new StatusError(StatusError.BadRequest, "session_replay_segment_id is required for analytics batches with client auth");
|
||||
@ -185,7 +192,10 @@ export const POST = createSmartRouteHandler({
|
||||
}
|
||||
}
|
||||
userId = body.user_id ?? auth.user?.id ?? null;
|
||||
refreshTokenId = auth.refreshTokenId ?? null;
|
||||
// The server SDK forwards the caller's refresh token (resolved from the
|
||||
// request session) so the backend can compose the $refresh-token/$session-replay
|
||||
// ancestry; fall back to the request's own auth for admin/session sends.
|
||||
refreshTokenId = body.refresh_token_id ?? auth.refreshTokenId ?? null;
|
||||
}
|
||||
|
||||
const projectId = auth.tenancy.project.id;
|
||||
@ -203,8 +213,13 @@ export const POST = createSmartRouteHandler({
|
||||
}
|
||||
}
|
||||
|
||||
const recentSession = refreshTokenId == null ? null : await findRecentSessionReplay(prisma, { tenancyId, refreshTokenId });
|
||||
const sessionReplayId = recentSession?.id ?? null;
|
||||
// Prefer an explicitly forwarded replay id (server SDK — exact join); otherwise
|
||||
// derive the caller's current rolling replay from their refresh token.
|
||||
let sessionReplayId = body.session_replay_id ?? null;
|
||||
if (sessionReplayId == null && refreshTokenId != null) {
|
||||
const recentSession = await findRecentSessionReplay(prisma, { tenancyId, refreshTokenId });
|
||||
sessionReplayId = recentSession?.id ?? null;
|
||||
}
|
||||
const sessionReplaySegmentId = body.session_replay_segment_id ?? null;
|
||||
|
||||
const clickhouseClient = getClickhouseAdminClient();
|
||||
|
||||
@ -786,6 +786,8 @@ async function uploadTelemetryBatch(
|
||||
batch_id?: string,
|
||||
sent_at_ms?: number,
|
||||
user_id?: string,
|
||||
refresh_token_id?: string,
|
||||
session_replay_id?: string,
|
||||
events?: unknown[],
|
||||
spans?: unknown[],
|
||||
},
|
||||
@ -869,6 +871,66 @@ it("accepts custom events and stamps system ancestry on them", async ({ expect }
|
||||
expect(row.parent_span_ids[0]).toMatch(/^rti-/);
|
||||
});
|
||||
|
||||
it("server-auth telemetry parents under the forwarded client-session context", async ({ expect }) => {
|
||||
// A server span opened with withSpan({ request }) forwards the caller's resolved
|
||||
// context (refresh token from the session, replay/segment from the propagation
|
||||
// header) as scalars; the backend composes the full $refresh-token/$session-replay/
|
||||
// $session-replay-segment ancestry and stamps the scalar columns, exactly like a
|
||||
// browser event — even though the batch is sent with the secret server key.
|
||||
await setupAnalyticsProject();
|
||||
await Auth.Otp.signIn();
|
||||
const me = await niceBackendFetch("/api/v1/users/me", { accessType: "client" });
|
||||
const userId = me.body.id as string;
|
||||
|
||||
const refreshTokenId = randomUUID();
|
||||
const sessionReplayId = randomUUID();
|
||||
const sessionReplaySegmentId = randomUUID();
|
||||
const customParent = randomUUID();
|
||||
const now = Date.now();
|
||||
|
||||
const res = await uploadTelemetryBatch({
|
||||
user_id: userId,
|
||||
refresh_token_id: refreshTokenId,
|
||||
session_replay_id: sessionReplayId,
|
||||
session_replay_segment_id: sessionReplaySegmentId,
|
||||
events: [{ event_type: "server_action", event_at_ms: now - 100, data: { ok: true }, parent_span_ids: [customParent] }],
|
||||
}, { accessType: "server" });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ inserted: 1 });
|
||||
|
||||
const queryRes = await queryAnalyticsUntil({
|
||||
query: "SELECT event_type, parent_span_ids, refresh_token_id, session_replay_id, session_replay_segment_id, user_id FROM events WHERE session_replay_segment_id = {segId:String}",
|
||||
params: { segId: sessionReplaySegmentId },
|
||||
}, (r) => r.body?.result?.length === 1);
|
||||
|
||||
expect(queryRes?.status).toBe(200);
|
||||
const row = (queryRes?.body as any).result[0];
|
||||
expect(row.event_type).toBe("server_action");
|
||||
// Root-first system ancestry composed server-side, then the custom parent (cs-).
|
||||
expect(row.parent_span_ids).toEqual([
|
||||
`rti-${refreshTokenId}`,
|
||||
`sri-${sessionReplayId}`,
|
||||
`srsi-${sessionReplaySegmentId}`,
|
||||
`cs-${customParent}`,
|
||||
]);
|
||||
// Scalar columns are stamped too (not just parent_span_ids), so replay filtering works.
|
||||
expect(row.refresh_token_id).toBe(refreshTokenId);
|
||||
expect(row.session_replay_id).toBe(sessionReplayId);
|
||||
expect(row.session_replay_segment_id).toBe(sessionReplaySegmentId);
|
||||
expect(row.user_id).toBe(userId);
|
||||
});
|
||||
|
||||
it("rejects the forwarded server context under client auth", async ({ expect }) => {
|
||||
await setupAnalyticsProject();
|
||||
await Auth.Otp.signIn();
|
||||
const res = await uploadTelemetryBatch({
|
||||
session_replay_segment_id: randomUUID(),
|
||||
refresh_token_id: randomUUID(),
|
||||
events: [{ event_type: "$page-view", event_at_ms: Date.now(), data: {} }],
|
||||
}, { accessType: "client" });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("appends the client-supplied custom parent chain after system ancestry on events", async ({ expect }) => {
|
||||
await setupAnalyticsProject();
|
||||
await Auth.Otp.signIn();
|
||||
|
||||
@ -62,6 +62,8 @@ import { StackClientApp, StackClientAppConstructorOptions, StackClientAppJson }
|
||||
import { _HexclaveAdminAppImplIncomplete } from "./admin-app-impl";
|
||||
import { TokenObject, clientVersion, createCache, createCacheBySession, createEmptyTokenStore, getAnalyticsBaseUrl, getDefaultExtraRequestHeaders, getDefaultProjectId, getDefaultPublishableClientKey, getUrls, resolveApiUrls, resolveConstructorOptions } from "./common";
|
||||
import { createInertSpan, EventTracker, getCustomTelemetryDataError, getCustomTelemetryNameError, rejectedPreCaught, warnTelemetryUnavailableOnce, withSpanImpl, type Span, type StartSpanOptions, type TrackOptions } from "./event-tracker";
|
||||
import { getAmbientSpanRefs } from "./span-context";
|
||||
import { installFetchSpanPropagation } from "./span-propagation";
|
||||
import type { CrossDomainHandoffParams } from "./redirect-page-urls";
|
||||
import { crossDomainAuthQueryParams, getCrossDomainHandoffParamsFromCurrentUrl, planRedirectToHandler } from "./redirect-page-urls";
|
||||
import { subscribeSessionRefresh } from "./session-refresh-subscription";
|
||||
@ -748,6 +750,35 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
|
||||
this._eventTracker.start();
|
||||
}
|
||||
|
||||
// Cross-tier span propagation: attach the span-context header to same-origin
|
||||
// outgoing fetches so a server `withSpan({ request })` parents under this tab's
|
||||
// client session. Gated on a live event tracker (the current per-tab id source,
|
||||
// which reflects sign-out rotation). Same-origin only unless extra exact origins
|
||||
// are allowlisted. Idempotent — installFetchSpanPropagation no-ops if already on.
|
||||
if (
|
||||
analyticsEnabled
|
||||
&& isBrowserLike()
|
||||
&& this._eventTracker
|
||||
&& this._analyticsOptions?.spanPropagation?.enabled !== false
|
||||
) {
|
||||
const eventTracker = this._eventTracker;
|
||||
const allowedOrigins = this._analyticsOptions?.spanPropagation?.targets ?? [];
|
||||
installFetchSpanPropagation({
|
||||
getContext: () => {
|
||||
const segmentId = eventTracker.getSessionReplaySegmentId();
|
||||
const customParentSpanIds = getAmbientSpanRefs().map((frame) => frame.spanId);
|
||||
if (!segmentId && customParentSpanIds.length === 0) return null;
|
||||
return {
|
||||
projectId: this.projectId,
|
||||
...segmentId ? { sessionReplaySegmentId: segmentId } : {},
|
||||
...customParentSpanIds.length > 0 ? { customParentSpanIds } : {},
|
||||
};
|
||||
},
|
||||
getSelfOrigin: () => (typeof window !== "undefined" ? window.location.origin : null),
|
||||
getAllowedOrigins: () => allowedOrigins,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
isBrowserLike()
|
||||
&& (this._isOAuthCallbackUrlHosted() || this._currentUrlLooksLikeNestedCrossDomainOAuthCallback())
|
||||
|
||||
@ -512,6 +512,11 @@ export class EventTracker {
|
||||
this._sessionReplaySegmentId = id;
|
||||
}
|
||||
|
||||
/** The current per-tab id (reflects sign-out rotation) — used by cross-tier span propagation. */
|
||||
getSessionReplaySegmentId(): string {
|
||||
return this._sessionReplaySegmentId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Buffers a custom analytics event. The returned promise resolves when the
|
||||
* batch carrying the event is acknowledged and rejects on definitive send
|
||||
|
||||
@ -23,7 +23,7 @@ import { useMemo } from "react"; // THIS_LINE_PLATFORM react-like
|
||||
import * as yup from "yup";
|
||||
import { constructRedirectUrl } from "../../../../utils/url";
|
||||
import { ApiKey, ApiKeyCreationOptions, ApiKeyUpdateOptions, apiKeyCreationOptionsToCrud, apiKeyUpdateOptionsToCrud } from "../../api-keys";
|
||||
import { ConvexCtx, GetCurrentUserOptions } from "../../common";
|
||||
import { ConvexCtx, GetCurrentUserOptions, RequestLike } from "../../common";
|
||||
import { DeprecatedOAuthConnection, OAuthConnection } from "../../connected-accounts";
|
||||
import { ServerContactChannel, ServerContactChannelCreateOptions, ServerContactChannelUpdateOptions, serverContactChannelCreateOptionsToCrud, serverContactChannelUpdateOptionsToCrud } from "../../contact-channels";
|
||||
import { Customer, CustomerProductsList, CustomerProductsRequestOptions, InlineProduct, ServerItem } from "../../customers";
|
||||
@ -36,9 +36,11 @@ import { ProjectCurrentServerUser, ServerOAuthProvider, ServerUser, ServerUserCr
|
||||
import { StackServerAppConstructorOptions } from "../interfaces/server-app";
|
||||
import { _HexclaveClientAppImplIncomplete } from "./client-app-impl";
|
||||
import { clientVersion, createCache, createCacheBySession, getDefaultExtraRequestHeaders, getDefaultProjectId, getDefaultPublishableClientKey, getDefaultSecretServerKey, resolveApiUrls, resolveConstructorOptions } from "./common";
|
||||
import { createInertSpan, getCustomTelemetryDataError, getCustomTelemetryNameError, rejectedPreCaught, resolveParentIds, type Span, type SpanRef, type SpanUpdateRow, type StartSpanOptions, type TrackOptions } from "./event-tracker";
|
||||
import { createInertSpan, getCustomTelemetryDataError, getCustomTelemetryNameError, rejectedPreCaught, resolveParentIds, withSpanImpl, type Span, type SpanRef, type SpanUpdateRow, type StartSpanOptions, type TrackOptions } from "./event-tracker";
|
||||
import { generateUuid } from "./session-replay";
|
||||
import { getAmbientSpanRefs } from "./span-context";
|
||||
import { decodeSpanContextHeader, readSpanContextHeader } from "./span-propagation";
|
||||
import { getServerRequestContext, runWithServerRequestContext, type ServerRequestSpanContext } from "./server-request-context";
|
||||
|
||||
import { useAsyncCache } from "./common"; // THIS_LINE_PLATFORM react-like
|
||||
|
||||
@ -1718,22 +1720,101 @@ export class _HexclaveServerAppImplIncomplete<HasTokenStore extends boolean, Pro
|
||||
// in all of them — prefer explicit parentIds (or span.trackEvent) on servers.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private readonly _serverTelemetryBuffers = new Map<string | null, ServerTelemetryBuffer>();
|
||||
private readonly _serverTelemetryBuffers = new Map<string, ServerTelemetryBuffer>();
|
||||
private readonly _serverGlobalSpans = new Set<Span>();
|
||||
private readonly _serverTelemetryInFlight = new Set<Promise<void>>();
|
||||
|
||||
override trackEvent(eventType: string, data?: Record<string, unknown>, options?: TrackOptions & { userId?: string }): Promise<void> {
|
||||
override trackEvent(eventType: string, data?: Record<string, unknown>, options?: TrackOptions & { userId?: string, request?: RequestLike }): Promise<void> {
|
||||
if (this._eventTracker) {
|
||||
// Browser-like environment: identity comes from the session; an explicit
|
||||
// userId would silently mis-attribute, so refuse it loudly.
|
||||
// userId would silently mis-attribute, so refuse it loudly. `request` is a
|
||||
// server-only concern (the browser auto-attaches it to outgoing fetches),
|
||||
// so it is simply ignored here.
|
||||
if (options?.userId !== undefined) {
|
||||
return rejectedPreCaught("userId is only supported for server-key telemetry; in the browser, events are attributed to the signed-in user");
|
||||
}
|
||||
return this._eventTracker.trackCustomEvent(eventType, data, options);
|
||||
}
|
||||
// `{ request }`: resolve the caller's session + client-propagated span context
|
||||
// (async) and run the send with that context ambient, so the event parents
|
||||
// under the client session ($refresh-token/$session-replay/$session-replay-segment).
|
||||
if (options?.request) {
|
||||
const { request, ...rest } = options;
|
||||
return (async () => {
|
||||
const context = await this._resolveServerRequestContext(request, options.userId ?? null);
|
||||
await runWithServerRequestContext(context, () => this._trackServerEvent(eventType, data, rest, context.userId));
|
||||
})();
|
||||
}
|
||||
return this._trackServerEvent(eventType, data, options, options?.userId ?? null);
|
||||
}
|
||||
|
||||
override withSpan<T>(spanType: string, fn: (span: Span) => Promise<T> | T): Promise<T>;
|
||||
override withSpan<T>(spanType: string, options: StartSpanOptions & { userId?: string, request?: RequestLike }, fn: (span: Span) => Promise<T> | T): Promise<T>;
|
||||
override withSpan<T>(
|
||||
spanType: string,
|
||||
optionsOrFn: (StartSpanOptions & { userId?: string, request?: RequestLike }) | ((span: Span) => Promise<T> | T),
|
||||
maybeFn?: (span: Span) => Promise<T> | T,
|
||||
): Promise<T> {
|
||||
const options = typeof optionsOrFn === "function" ? undefined : optionsOrFn;
|
||||
const fn = typeof optionsOrFn === "function" ? optionsOrFn : maybeFn;
|
||||
// No request (or browser): the inherited withSpan handles global/enclosing
|
||||
// ambient parenting and forwards userId to the server startSpan at runtime.
|
||||
if (!options?.request || this._eventTracker) {
|
||||
return super.withSpan(spanType, optionsOrFn as any, maybeFn as any);
|
||||
}
|
||||
const { request, ...rest } = options;
|
||||
return (async () => {
|
||||
const context = await this._resolveServerRequestContext(request, options.userId ?? null);
|
||||
return await runWithServerRequestContext(context, () =>
|
||||
withSpanImpl((type, opts) => this._startServerSpan(type, opts, context.userId), spanType, rest, fn));
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves an incoming request into the ambient span context for a `{ request }`
|
||||
* server span: the caller's user + refresh token from the session (server-trusted)
|
||||
* and the client-propagated replay/segment/custom-parent ids from the
|
||||
* `x-hexclave-span-context` header (untrusted labels — dropped if they name a
|
||||
* different project). Best-effort: an unauthenticated or unparseable request just
|
||||
* yields an empty context so the span still records.
|
||||
*/
|
||||
private async _resolveServerRequestContext(request: RequestLike, explicitUserId: string | null): Promise<ServerRequestSpanContext> {
|
||||
let userId: string | null = explicitUserId;
|
||||
let refreshTokenId: string | null = null;
|
||||
try {
|
||||
const session = await this._getSession(request);
|
||||
const tokens = await session.fetchNewTokens();
|
||||
if (tokens?.refreshToken != null) {
|
||||
refreshTokenId = tokens.accessToken.payload.refresh_token_id;
|
||||
if (userId == null) userId = tokens.accessToken.payload.sub;
|
||||
}
|
||||
} catch {
|
||||
// Best-effort; no session context.
|
||||
}
|
||||
const decoded = decodeSpanContextHeader(readSpanContextHeader(request.headers));
|
||||
const sameProject = decoded !== null && decoded.projectId === this.projectId ? decoded : null;
|
||||
return {
|
||||
userId,
|
||||
refreshTokenId,
|
||||
sessionReplayId: sameProject?.sessionReplayId ?? null,
|
||||
sessionReplaySegmentId: sameProject?.sessionReplaySegmentId ?? null,
|
||||
customParentSpanIds: sameProject?.customParentSpanIds ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The batch context an item is buffered/attributed under: the ambient request
|
||||
* context when inside a `{ request }` scope, else just the explicit userId. A
|
||||
* later explicit userId always wins over the request-derived one.
|
||||
*/
|
||||
private _currentServerBatchContext(explicitUserId: string | null): ServerRequestSpanContext {
|
||||
const ambient = getServerRequestContext();
|
||||
if (ambient) {
|
||||
return { ...ambient, userId: explicitUserId ?? ambient.userId };
|
||||
}
|
||||
return { userId: explicitUserId, refreshTokenId: null, sessionReplayId: null, sessionReplaySegmentId: null, customParentSpanIds: [] };
|
||||
}
|
||||
|
||||
override startSpan(spanType: string, options?: StartSpanOptions & { userId?: string }): Span {
|
||||
if (this._eventTracker) {
|
||||
if (options?.userId !== undefined) {
|
||||
@ -1764,8 +1845,8 @@ export class _HexclaveServerAppImplIncomplete<HasTokenStore extends boolean, Pro
|
||||
|
||||
override async flush(): Promise<void> {
|
||||
await super.flush();
|
||||
for (const key of [...this._serverTelemetryBuffers.keys()]) {
|
||||
this._flushServerTelemetry(key);
|
||||
for (const buffer of [...this._serverTelemetryBuffers.values()]) {
|
||||
this._flushServerTelemetry(buffer.context);
|
||||
}
|
||||
await Promise.allSettled([...this._serverTelemetryInFlight]);
|
||||
}
|
||||
@ -1780,6 +1861,21 @@ export class _HexclaveServerAppImplIncomplete<HasTokenStore extends boolean, Pro
|
||||
return refs;
|
||||
}
|
||||
|
||||
/**
|
||||
* `_serverAmbientParentRefs` plus the client-propagated custom parents (raw
|
||||
* uuids from the request's span-context header) as leaf frames, so they take
|
||||
* part in parent resolution and are dropped together by `root: true`. The system
|
||||
* ancestry (`rti-`/`sri-`/`srsi-`) is NOT here — it is composed server-side from
|
||||
* the batch context's scalar ids, so it survives `root` (attribution to the
|
||||
* session is always kept).
|
||||
*/
|
||||
private _ambientParentRefsWith(batchContext: ServerRequestSpanContext): SpanRef[] {
|
||||
return [
|
||||
...this._serverAmbientParentRefs(),
|
||||
...batchContext.customParentSpanIds.map((spanId) => ({ spanId, parentSpanIds: [] as string[] })),
|
||||
];
|
||||
}
|
||||
|
||||
private _trackServerEvent(eventType: string, data: Record<string, unknown> | undefined, options: TrackOptions | undefined, userId: string | null): Promise<void> {
|
||||
const nameError = getCustomTelemetryNameError("event", eventType);
|
||||
if (nameError) return rejectedPreCaught(nameError);
|
||||
@ -1788,9 +1884,10 @@ export class _HexclaveServerAppImplIncomplete<HasTokenStore extends boolean, Pro
|
||||
if (userId !== null && !SERVER_TELEMETRY_UUID_RE.test(userId)) {
|
||||
return rejectedPreCaught(`Invalid userId ${JSON.stringify(userId)}: must be a user uuid`);
|
||||
}
|
||||
const batchContext = this._currentServerBatchContext(userId);
|
||||
const resolved = resolveParentIds({
|
||||
explicit: options?.parentIds,
|
||||
ambient: this._serverAmbientParentRefs(),
|
||||
ambient: this._ambientParentRefsWith(batchContext),
|
||||
root: options?.root,
|
||||
exclude: options?.excludeParentIds,
|
||||
});
|
||||
@ -1801,7 +1898,7 @@ export class _HexclaveServerAppImplIncomplete<HasTokenStore extends boolean, Pro
|
||||
settler = { resolve, reject };
|
||||
});
|
||||
promise.catch(() => {});
|
||||
const buffer = this._getServerTelemetryBuffer(userId);
|
||||
const buffer = this._getServerTelemetryBuffer(batchContext);
|
||||
buffer.events.push({
|
||||
event: {
|
||||
event_type: eventType,
|
||||
@ -1811,7 +1908,7 @@ export class _HexclaveServerAppImplIncomplete<HasTokenStore extends boolean, Pro
|
||||
},
|
||||
settler,
|
||||
});
|
||||
this._afterServerTelemetryEnqueue(userId, buffer);
|
||||
this._afterServerTelemetryEnqueue(batchContext, buffer);
|
||||
return promise;
|
||||
}
|
||||
|
||||
@ -1834,9 +1931,10 @@ export class _HexclaveServerAppImplIncomplete<HasTokenStore extends boolean, Pro
|
||||
console.error(`Hexclave analytics: invalid userId ${JSON.stringify(userId)}: must be a user uuid`);
|
||||
return createInertSpan(spanType);
|
||||
}
|
||||
const batchContext = this._currentServerBatchContext(userId);
|
||||
const resolved = resolveParentIds({
|
||||
explicit: options?.parentIds,
|
||||
ambient: this._serverAmbientParentRefs(),
|
||||
ambient: this._ambientParentRefsWith(batchContext),
|
||||
root: options?.root,
|
||||
exclude: options?.excludeParentIds,
|
||||
});
|
||||
@ -1854,7 +1952,7 @@ export class _HexclaveServerAppImplIncomplete<HasTokenStore extends boolean, Pro
|
||||
let endPromise: Promise<void> | null = null;
|
||||
|
||||
const nextVersion = () => (lastVersion = Math.max(Date.now(), lastVersion + 1));
|
||||
const enqueue = (endedAtMs: number | null): Promise<void> => this._enqueueServerSpanUpdate(userId, {
|
||||
const enqueue = (endedAtMs: number | null): Promise<void> => this._enqueueServerSpanUpdate(batchContext, {
|
||||
span_id: spanId,
|
||||
span_type: spanType,
|
||||
started_at_ms: startedAtMs,
|
||||
@ -1897,46 +1995,51 @@ export class _HexclaveServerAppImplIncomplete<HasTokenStore extends boolean, Pro
|
||||
return span;
|
||||
}
|
||||
|
||||
private _enqueueServerSpanUpdate(userId: string | null, row: SpanUpdateRow): Promise<void> {
|
||||
private _enqueueServerSpanUpdate(context: ServerRequestSpanContext, row: SpanUpdateRow): Promise<void> {
|
||||
let settler!: TelemetrySettler;
|
||||
const promise = new Promise<void>((resolve, reject) => {
|
||||
settler = { resolve, reject };
|
||||
});
|
||||
promise.catch(() => {});
|
||||
const buffer = this._getServerTelemetryBuffer(userId);
|
||||
const buffer = this._getServerTelemetryBuffer(context);
|
||||
const previous = buffer.spans.get(row.span_id);
|
||||
// Latest row per span id wins within a batch; superseded rows' settlers ride
|
||||
// along so every returned promise settles with the batch that ships.
|
||||
buffer.spans.set(row.span_id, { row, settlers: [...previous?.settlers ?? [], settler] });
|
||||
this._afterServerTelemetryEnqueue(userId, buffer);
|
||||
this._afterServerTelemetryEnqueue(context, buffer);
|
||||
return promise;
|
||||
}
|
||||
|
||||
private _getServerTelemetryBuffer(userId: string | null): ServerTelemetryBuffer {
|
||||
let buffer = this._serverTelemetryBuffers.get(userId);
|
||||
private _getServerTelemetryBuffer(context: ServerRequestSpanContext): ServerTelemetryBuffer {
|
||||
// Coalesce by the full batch context, not just userId: telemetry from two
|
||||
// requests (even the same user) that carry different client-session context
|
||||
// must ship as separate batches so each row gets the right ancestry.
|
||||
const key = serializeServerBatchKey(context);
|
||||
let buffer = this._serverTelemetryBuffers.get(key);
|
||||
if (!buffer) {
|
||||
buffer = { events: [], spans: new Map(), scheduled: false };
|
||||
this._serverTelemetryBuffers.set(userId, buffer);
|
||||
buffer = { events: [], spans: new Map(), scheduled: false, context };
|
||||
this._serverTelemetryBuffers.set(key, buffer);
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
private _afterServerTelemetryEnqueue(userId: string | null, buffer: ServerTelemetryBuffer): void {
|
||||
private _afterServerTelemetryEnqueue(context: ServerRequestSpanContext, buffer: ServerTelemetryBuffer): void {
|
||||
// Stay well under the server's 500-items-per-batch cap: ship immediately once
|
||||
// the coalesced batch is large, otherwise wait for the microtask boundary.
|
||||
if (buffer.events.length + buffer.spans.size >= SERVER_TELEMETRY_MAX_ITEMS_PER_BATCH) {
|
||||
this._flushServerTelemetry(userId);
|
||||
this._flushServerTelemetry(context);
|
||||
return;
|
||||
}
|
||||
if (buffer.scheduled) return;
|
||||
buffer.scheduled = true;
|
||||
queueMicrotask(() => this._flushServerTelemetry(userId));
|
||||
queueMicrotask(() => this._flushServerTelemetry(context));
|
||||
}
|
||||
|
||||
private _flushServerTelemetry(userId: string | null): void {
|
||||
const buffer = this._serverTelemetryBuffers.get(userId);
|
||||
private _flushServerTelemetry(context: ServerRequestSpanContext): void {
|
||||
const key = serializeServerBatchKey(context);
|
||||
const buffer = this._serverTelemetryBuffers.get(key);
|
||||
if (!buffer) return;
|
||||
this._serverTelemetryBuffers.delete(userId);
|
||||
this._serverTelemetryBuffers.delete(key);
|
||||
const events = buffer.events;
|
||||
const spanEntries = [...buffer.spans.values()];
|
||||
if (events.length === 0 && spanEntries.length === 0) return;
|
||||
@ -1946,10 +2049,16 @@ export class _HexclaveServerAppImplIncomplete<HasTokenStore extends boolean, Pro
|
||||
settlers.push(...entry.settlers);
|
||||
}
|
||||
|
||||
const ctx = buffer.context;
|
||||
const payload = {
|
||||
batch_id: generateUuid(),
|
||||
sent_at_ms: Date.now(),
|
||||
...userId !== null ? { user_id: userId } : {},
|
||||
...ctx.userId !== null ? { user_id: ctx.userId } : {},
|
||||
// Resolved request context (server auth): the backend composes the
|
||||
// $refresh-token/$session-replay/$session-replay-segment ancestry from these.
|
||||
...ctx.refreshTokenId !== null ? { refresh_token_id: ctx.refreshTokenId } : {},
|
||||
...ctx.sessionReplayId !== null ? { session_replay_id: ctx.sessionReplayId } : {},
|
||||
...ctx.sessionReplaySegmentId !== null ? { session_replay_segment_id: ctx.sessionReplaySegmentId } : {},
|
||||
...events.length > 0 ? { events: events.map((entry) => entry.event) } : {},
|
||||
...spanEntries.length > 0 ? { spans: spanEntries.map((entry) => entry.row) } : {},
|
||||
};
|
||||
@ -1985,6 +2094,15 @@ export class _HexclaveServerAppImplIncomplete<HasTokenStore extends boolean, Pro
|
||||
}
|
||||
|
||||
const SERVER_TELEMETRY_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
/**
|
||||
* Buffer coalescing key. customParentSpanIds are intentionally excluded — they are
|
||||
* per-item parents (they ride on each row's parent_span_ids), not part of the batch
|
||||
* identity, so items sharing user/refresh/replay/segment still batch together.
|
||||
*/
|
||||
function serializeServerBatchKey(context: ServerRequestSpanContext): string {
|
||||
return JSON.stringify([context.userId, context.refreshTokenId, context.sessionReplayId, context.sessionReplaySegmentId]);
|
||||
}
|
||||
// Below the route's 500-items cap with headroom, so a coalesced batch can never
|
||||
// be rejected for size.
|
||||
const SERVER_TELEMETRY_MAX_ITEMS_PER_BATCH = 400;
|
||||
@ -2006,4 +2124,7 @@ type ServerTelemetryBuffer = {
|
||||
}[],
|
||||
spans: Map<string, { row: SpanUpdateRow, settlers: TelemetrySettler[] }>,
|
||||
scheduled: boolean,
|
||||
// The batch context every item in this buffer shares; becomes the payload's
|
||||
// user_id / refresh_token_id / session_replay_id / session_replay_segment_id.
|
||||
context: ServerRequestSpanContext,
|
||||
};
|
||||
|
||||
@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Ambient server request context for `withSpan({ request })` / `trackEvent({ request })`.
|
||||
*
|
||||
* Resolved ONCE from an incoming request — the caller's refresh token + user
|
||||
* (server-trusted, from the session) and the client-propagated session-replay
|
||||
* context (untrusted labels, from the `x-hexclave-span-context` header) — then made
|
||||
* ambient for the duration of the callback so every span/event created inside,
|
||||
* including bare `serverApp.trackEvent(...)` calls, links to the same client
|
||||
* session without threading the context by hand.
|
||||
*
|
||||
* AsyncLocalStorage-backed (see span-context.ts for the same rationale) so
|
||||
* concurrent requests sharing one app instance never cross-contaminate; a
|
||||
* single-value sync fallback covers environments without `node:async_hooks`
|
||||
* (which server-key telemetry never actually runs in).
|
||||
*/
|
||||
|
||||
/** The resolved, prefix-free context. Ids are raw uuids; the backend applies `rti-`/`sri-`/`srsi-`/`cs-`. */
|
||||
export type ServerRequestSpanContext = {
|
||||
userId: string | null,
|
||||
refreshTokenId: string | null,
|
||||
sessionReplayId: string | null,
|
||||
sessionReplaySegmentId: string | null,
|
||||
customParentSpanIds: string[],
|
||||
};
|
||||
|
||||
type AsyncLocalStorageLike = {
|
||||
run: <T>(store: ServerRequestSpanContext, fn: () => T) => T,
|
||||
getStore: () => ServerRequestSpanContext | undefined,
|
||||
};
|
||||
|
||||
let als: AsyncLocalStorageLike | null = null;
|
||||
let alsInitPromise: Promise<void> | null = null;
|
||||
// Single-value fallback (before ALS finishes loading / where it is unavailable).
|
||||
let syncCurrent: ServerRequestSpanContext | null = null;
|
||||
|
||||
async function ensureAsyncContext(): Promise<void> {
|
||||
if (alsInitPromise) return await alsInitPromise;
|
||||
alsInitPromise = (async () => {
|
||||
try {
|
||||
// Opaque specifier so bundlers leave this as a runtime dynamic import — it
|
||||
// simply rejects in the browser and resolves to the built-in everywhere
|
||||
// node-like. Mirrors span-context.ts.
|
||||
const specifier = "node:async_hooks";
|
||||
const mod = await import(/* @vite-ignore */ /* webpackIgnore: true */ specifier) as { AsyncLocalStorage?: new () => AsyncLocalStorageLike };
|
||||
if (typeof mod.AsyncLocalStorage === "function") {
|
||||
als = new mod.AsyncLocalStorage();
|
||||
}
|
||||
} catch {
|
||||
als = null;
|
||||
}
|
||||
})();
|
||||
return await alsInitPromise;
|
||||
}
|
||||
|
||||
/** The ambient request context, or null when not inside a `{ request }` scope. */
|
||||
export function getServerRequestContext(): ServerRequestSpanContext | null {
|
||||
return als?.getStore() ?? syncCurrent;
|
||||
}
|
||||
|
||||
/** Runs `fn` with `context` ambient. Awaits the ALS load first so the very first
|
||||
* call gets per-request isolation rather than racing the module load. */
|
||||
export async function runWithServerRequestContext<T>(context: ServerRequestSpanContext, fn: () => Promise<T>): Promise<T> {
|
||||
await ensureAsyncContext();
|
||||
if (als) {
|
||||
return await als.run(context, fn);
|
||||
}
|
||||
const previous = syncCurrent;
|
||||
syncCurrent = context;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
syncCurrent = previous;
|
||||
}
|
||||
}
|
||||
@ -54,6 +54,22 @@ export type AnalyticsOptions = {
|
||||
* Not serializable — dropped when the app is serialized (toClientJson).
|
||||
*/
|
||||
waitUntil?: (promise: Promise<unknown>) => void,
|
||||
/**
|
||||
* Cross-tier span propagation. When on, the browser attaches an
|
||||
* `x-hexclave-span-context` header to SAME-ORIGIN outgoing `fetch` requests, so a
|
||||
* server span opened with `serverApp.withSpan(type, { request })` parents under
|
||||
* this tab's client session. Enabled by default whenever analytics is enabled.
|
||||
*/
|
||||
spanPropagation?: {
|
||||
/** Set false to stop auto-attaching the header to outgoing fetch. @default true */
|
||||
enabled?: boolean,
|
||||
/**
|
||||
* Extra exact origins (besides same-origin) allowed to receive the header —
|
||||
* for a split frontend/api domain, e.g. `["https://api.example.com"]`. The
|
||||
* receiving server must also list the header in `Access-Control-Allow-Headers`.
|
||||
*/
|
||||
targets?: string[],
|
||||
},
|
||||
};
|
||||
|
||||
export function getSessionReplayOptions(analyticsOptions: AnalyticsOptions | undefined): AnalyticsReplayOptions {
|
||||
|
||||
@ -0,0 +1,219 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
SPAN_CONTEXT_HEADER,
|
||||
decodeSpanContextHeader,
|
||||
encodeSpanContextHeader,
|
||||
installFetchSpanPropagation,
|
||||
shouldPropagateSpanContext,
|
||||
type SpanPropagationContext,
|
||||
} from "./span-propagation";
|
||||
|
||||
const SEG = "11111111-1111-4111-8111-111111111111";
|
||||
const REPLAY = "22222222-2222-4222-8222-222222222222";
|
||||
const CUSTOM_A = "33333333-3333-4333-8333-333333333333";
|
||||
const CUSTOM_B = "44444444-4444-4444-8444-444444444444";
|
||||
|
||||
describe("span propagation header codec", () => {
|
||||
it("uses the x-hexclave-span-context header name", () => {
|
||||
expect(SPAN_CONTEXT_HEADER).toBe("x-hexclave-span-context");
|
||||
});
|
||||
|
||||
it("round-trips a full context and prefixes the version", () => {
|
||||
const context: SpanPropagationContext = {
|
||||
projectId: "proj-1",
|
||||
sessionReplayId: REPLAY,
|
||||
sessionReplaySegmentId: SEG,
|
||||
customParentSpanIds: [CUSTOM_A, CUSTOM_B],
|
||||
};
|
||||
const header = encodeSpanContextHeader(context);
|
||||
expect(header.startsWith("v1.")).toBe(true);
|
||||
expect(decodeSpanContextHeader(header)).toEqual(context);
|
||||
});
|
||||
|
||||
it("round-trips a minimal context (projectId + segment only)", () => {
|
||||
const context: SpanPropagationContext = { projectId: "proj-1", sessionReplaySegmentId: SEG };
|
||||
expect(decodeSpanContextHeader(encodeSpanContextHeader(context))).toEqual(context);
|
||||
});
|
||||
|
||||
it("omits empty/absent optional fields from the wire", () => {
|
||||
const header = encodeSpanContextHeader({ projectId: "proj-1", customParentSpanIds: [] });
|
||||
expect(decodeSpanContextHeader(header)).toEqual({ projectId: "proj-1" });
|
||||
});
|
||||
|
||||
it("caps the custom parent chain at 10 on encode", () => {
|
||||
const many = Array.from({ length: 15 }, (_, i) => `55555555-5555-4555-8555-${String(i).padStart(12, "0")}`);
|
||||
const decoded = decodeSpanContextHeader(encodeSpanContextHeader({ projectId: "p", customParentSpanIds: many }));
|
||||
expect(decoded?.customParentSpanIds).toHaveLength(10);
|
||||
expect(decoded?.customParentSpanIds).toEqual(many.slice(0, 10));
|
||||
});
|
||||
|
||||
it("returns null for missing / empty / non-string headers", () => {
|
||||
expect(decodeSpanContextHeader(null)).toBeNull();
|
||||
expect(decodeSpanContextHeader(undefined)).toBeNull();
|
||||
expect(decodeSpanContextHeader("")).toBeNull();
|
||||
expect(decodeSpanContextHeader(123 as unknown as string)).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores unknown versions (forward compatible) and malformed values", () => {
|
||||
const v1 = encodeSpanContextHeader({ projectId: "p", sessionReplaySegmentId: SEG });
|
||||
const body = v1.slice(v1.indexOf(".") + 1);
|
||||
expect(decodeSpanContextHeader(`v2.${body}`)).toBeNull();
|
||||
expect(decodeSpanContextHeader("no-dot-here")).toBeNull();
|
||||
expect(decodeSpanContextHeader("v1.not-valid-base64!!")).toBeNull();
|
||||
expect(decodeSpanContextHeader(`v1.${Buffer.from("not json").toString("base64url")}`)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects an oversized header without decoding it", () => {
|
||||
expect(decodeSpanContextHeader(`v1.${"A".repeat(5000)}`)).toBeNull();
|
||||
});
|
||||
|
||||
it("requires a projectId, and drops (not fails on) malformed ids", () => {
|
||||
const noProject = `v1.${Buffer.from(JSON.stringify({ sessionReplaySegmentId: SEG })).toString("base64url")}`;
|
||||
expect(decodeSpanContextHeader(noProject)).toBeNull();
|
||||
|
||||
const badIds = `v1.${Buffer.from(JSON.stringify({
|
||||
projectId: "p",
|
||||
sessionReplaySegmentId: "not-a-uuid",
|
||||
sessionReplayId: REPLAY,
|
||||
customParentSpanIds: [CUSTOM_A, "nope", 42],
|
||||
})).toString("base64url")}`;
|
||||
expect(decodeSpanContextHeader(badIds)).toEqual({
|
||||
projectId: "p",
|
||||
sessionReplayId: REPLAY,
|
||||
customParentSpanIds: [CUSTOM_A],
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a JSON array (not an object) payload", () => {
|
||||
const arr = `v1.${Buffer.from(JSON.stringify([1, 2, 3])).toString("base64url")}`;
|
||||
expect(decodeSpanContextHeader(arr)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldPropagateSpanContext (same-origin policy)", () => {
|
||||
const self = "https://app.example.com";
|
||||
|
||||
it("propagates to the same origin", () => {
|
||||
expect(shouldPropagateSpanContext({ targetUrl: "https://app.example.com/api/x", selfOrigin: self })).toBe(true);
|
||||
});
|
||||
|
||||
it("propagates to a relative url (resolves against self)", () => {
|
||||
expect(shouldPropagateSpanContext({ targetUrl: "/api/checkout", selfOrigin: self })).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT propagate cross-origin by default", () => {
|
||||
expect(shouldPropagateSpanContext({ targetUrl: "https://api.stripe.com/v1/x", selfOrigin: self })).toBe(false);
|
||||
expect(shouldPropagateSpanContext({ targetUrl: "https://api.example.com/x", selfOrigin: self })).toBe(false);
|
||||
});
|
||||
|
||||
it("propagates cross-origin only when the exact origin is allowlisted", () => {
|
||||
expect(shouldPropagateSpanContext({
|
||||
targetUrl: "https://api.example.com/x",
|
||||
selfOrigin: self,
|
||||
allowedOrigins: ["https://api.example.com"],
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it("excludes non-http(s) and unparseable targets", () => {
|
||||
expect(shouldPropagateSpanContext({ targetUrl: "mailto:a@b.com", selfOrigin: self })).toBe(false);
|
||||
expect(shouldPropagateSpanContext({ targetUrl: "data:text/plain,hi", selfOrigin: self })).toBe(false);
|
||||
// Malformed absolute url (unterminated IPv6 host) throws even with a base.
|
||||
expect(shouldPropagateSpanContext({ targetUrl: "http://[", selfOrigin: self })).toBe(false);
|
||||
});
|
||||
|
||||
it("cannot resolve a relative url without a self origin, so excludes it", () => {
|
||||
expect(shouldPropagateSpanContext({ targetUrl: "/api/x", selfOrigin: null })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("installFetchSpanPropagation", () => {
|
||||
const SELF = "https://app.example.com";
|
||||
let calls: { input: unknown, init: RequestInit | undefined }[];
|
||||
let originalFetch: typeof fetch;
|
||||
let uninstall: (() => void) | null | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
calls = [];
|
||||
originalFetch = ((input: RequestInfo | URL, init?: RequestInit) => {
|
||||
calls.push({ input, init });
|
||||
return Promise.resolve({ ok: true } as Response);
|
||||
}) as typeof fetch;
|
||||
(globalThis as { fetch: typeof fetch }).fetch = originalFetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
uninstall?.();
|
||||
uninstall = undefined;
|
||||
(globalThis as { fetch: typeof fetch }).fetch = originalFetch;
|
||||
});
|
||||
|
||||
function install(context: SpanPropagationContext | null, allowedOrigins: string[] = []) {
|
||||
uninstall = installFetchSpanPropagation({
|
||||
getContext: () => context,
|
||||
getSelfOrigin: () => SELF,
|
||||
getAllowedOrigins: () => allowedOrigins,
|
||||
});
|
||||
}
|
||||
|
||||
function sentHeader(index = 0): string | null {
|
||||
const init = calls[index]?.init;
|
||||
if (!init?.headers) return null;
|
||||
return new Headers(init.headers).get(SPAN_CONTEXT_HEADER);
|
||||
}
|
||||
|
||||
it("attaches the header on a same-origin string url", async () => {
|
||||
install({ projectId: "p", sessionReplaySegmentId: SEG });
|
||||
await (globalThis as { fetch: typeof fetch }).fetch("/api/x");
|
||||
expect(decodeSpanContextHeader(sentHeader())).toEqual({ projectId: "p", sessionReplaySegmentId: SEG });
|
||||
});
|
||||
|
||||
it("does not attach cross-origin by default", async () => {
|
||||
install({ projectId: "p", sessionReplaySegmentId: SEG });
|
||||
await (globalThis as { fetch: typeof fetch }).fetch("https://evil.example/x");
|
||||
expect(sentHeader()).toBeNull();
|
||||
});
|
||||
|
||||
it("attaches to an allowlisted cross origin", async () => {
|
||||
install({ projectId: "p", sessionReplaySegmentId: SEG }, ["https://api.example.com"]);
|
||||
await (globalThis as { fetch: typeof fetch }).fetch("https://api.example.com/x");
|
||||
expect(sentHeader()).not.toBeNull();
|
||||
});
|
||||
|
||||
it("preserves a Request's own headers and adds ours", async () => {
|
||||
install({ projectId: "p", sessionReplaySegmentId: SEG });
|
||||
const req = new Request("https://app.example.com/x", { headers: { "x-custom": "1" } });
|
||||
await (globalThis as { fetch: typeof fetch }).fetch(req);
|
||||
const headers = new Headers(calls[0].init?.headers);
|
||||
expect(headers.get("x-custom")).toBe("1");
|
||||
expect(headers.get(SPAN_CONTEXT_HEADER)).not.toBeNull();
|
||||
});
|
||||
|
||||
it("preserves init.headers and adds ours", async () => {
|
||||
install({ projectId: "p", sessionReplaySegmentId: SEG });
|
||||
await (globalThis as { fetch: typeof fetch }).fetch("/x", { headers: { "x-custom": "2" } });
|
||||
const headers = new Headers(calls[0].init?.headers);
|
||||
expect(headers.get("x-custom")).toBe("2");
|
||||
expect(headers.get(SPAN_CONTEXT_HEADER)).not.toBeNull();
|
||||
});
|
||||
|
||||
it("skips no-cors requests (custom headers are stripped there anyway)", async () => {
|
||||
install({ projectId: "p", sessionReplaySegmentId: SEG });
|
||||
await (globalThis as { fetch: typeof fetch }).fetch("/x", { mode: "no-cors" });
|
||||
expect(sentHeader()).toBeNull();
|
||||
});
|
||||
|
||||
it("attaches nothing when there is no context to propagate", async () => {
|
||||
install(null);
|
||||
await (globalThis as { fetch: typeof fetch }).fetch("/x");
|
||||
expect(sentHeader()).toBeNull();
|
||||
});
|
||||
|
||||
it("is idempotent and uninstalls cleanly", async () => {
|
||||
install({ projectId: "p", sessionReplaySegmentId: SEG });
|
||||
const second = installFetchSpanPropagation({ getContext: () => null, getSelfOrigin: () => SELF, getAllowedOrigins: () => [] });
|
||||
expect(second).toBeNull();
|
||||
uninstall?.();
|
||||
uninstall = undefined;
|
||||
expect((globalThis as { fetch: typeof fetch }).fetch).toBe(originalFetch);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,235 @@
|
||||
import { decodeBase64Url, encodeBase64Url } from "@hexclave/shared/dist/utils/bytes";
|
||||
|
||||
/**
|
||||
* Cross-tier span propagation.
|
||||
*
|
||||
* When a browser calls the customer's OWN backend, we want a server span (opened
|
||||
* via `serverApp.withSpan(type, { request }, ...)`) to automatically parent under
|
||||
* the caller's client session — the `$session-replay-segment` / `$session-replay`
|
||||
* / `$refresh-token` chain — with zero glue in the customer's code.
|
||||
*
|
||||
* The browser attaches ONE header, `x-hexclave-span-context`, to same-origin
|
||||
* outgoing requests. The server reads it, resolves the caller's refresh token from
|
||||
* the request session (the ONE trusted parent), and forwards the raw ids to the
|
||||
* ingestion route, which composes the system-prefixed parents (`rti-`/`sri-`/`srsi-`)
|
||||
* exactly like it does for browser events.
|
||||
*
|
||||
* Trust model: the header is CLIENT-CONTROLLED, so `sessionReplayId` /
|
||||
* `sessionReplaySegmentId` / custom parents are untrusted labels — fine for
|
||||
* best-effort telemetry, never for authz/billing/security. Only the refresh token
|
||||
* (server-derived), userId, project, and branch are trusted. Invalid/oversized/
|
||||
* unknown-version headers are ignored, never surfaced as an error.
|
||||
*/
|
||||
|
||||
/** The single header carrying the client's ambient span context. */
|
||||
export const SPAN_CONTEXT_HEADER = "x-hexclave-span-context";
|
||||
|
||||
/** Wire format is `${VERSION}.${base64url(json)}` so we can evolve the payload. */
|
||||
const SPAN_CONTEXT_VERSION = "v1";
|
||||
|
||||
/**
|
||||
* Cap on the custom parent chain carried in the header. Mirrors `MAX_PARENT_CHAIN`
|
||||
* in event-tracker.ts and the backend analytics-events route.
|
||||
*/
|
||||
const MAX_CUSTOM_PARENT_SPAN_IDS = 10;
|
||||
|
||||
/** Reject absurd header values before we spend work decoding them. */
|
||||
const MAX_HEADER_LENGTH = 4096;
|
||||
|
||||
/** Mirrors the backend UUID_RE; the raw ids in the header must be span uuids. */
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
/**
|
||||
* The client's ambient span context, carried across the wire as raw ids (no
|
||||
* `rti-`/`sri-`/`srsi-`/`cs-` prefixes — those are applied server-side). `projectId`
|
||||
* lets the receiver ignore a header that belongs to a different Hexclave project.
|
||||
*/
|
||||
export type SpanPropagationContext = {
|
||||
projectId: string,
|
||||
sessionReplayId?: string,
|
||||
sessionReplaySegmentId?: string,
|
||||
customParentSpanIds?: string[],
|
||||
};
|
||||
|
||||
/** Header bag shape shared by browser `Headers` and node request-like objects. */
|
||||
type RequestLikeHeaders = { get: (name: string) => string | null } | Record<string, string | null>;
|
||||
|
||||
/** Reads the span-context header from a request's headers (case-insensitive). */
|
||||
export function readSpanContextHeader(headers: RequestLikeHeaders): string | null {
|
||||
if (typeof (headers as { get?: unknown }).get === "function") {
|
||||
return (headers as { get: (name: string) => string | null }).get(SPAN_CONTEXT_HEADER);
|
||||
}
|
||||
const lower = SPAN_CONTEXT_HEADER.toLowerCase();
|
||||
for (const [name, value] of Object.entries(headers as Record<string, string | null>)) {
|
||||
if (name.toLowerCase() === lower) return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Serializes a context into the `x-hexclave-span-context` header value. */
|
||||
export function encodeSpanContextHeader(context: SpanPropagationContext): string {
|
||||
const payload: Record<string, unknown> = { projectId: context.projectId };
|
||||
if (context.sessionReplayId) payload.sessionReplayId = context.sessionReplayId;
|
||||
if (context.sessionReplaySegmentId) payload.sessionReplaySegmentId = context.sessionReplaySegmentId;
|
||||
if (context.customParentSpanIds && context.customParentSpanIds.length > 0) {
|
||||
payload.customParentSpanIds = context.customParentSpanIds.slice(0, MAX_CUSTOM_PARENT_SPAN_IDS);
|
||||
}
|
||||
const json = JSON.stringify(payload);
|
||||
return `${SPAN_CONTEXT_VERSION}.${encodeBase64Url(new TextEncoder().encode(json))}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a header value back into a context. Returns null for anything missing,
|
||||
* oversized, wrong-version, non-decodable, or structurally invalid — a bad header
|
||||
* must never throw into the request path. Individual fields are dropped (not
|
||||
* fatal) when they fail uuid validation, so a partially-corrupt header still
|
||||
* yields whatever ids are well-formed.
|
||||
*/
|
||||
export function decodeSpanContextHeader(headerValue: string | null | undefined): SpanPropagationContext | null {
|
||||
if (typeof headerValue !== "string" || headerValue.length === 0 || headerValue.length > MAX_HEADER_LENGTH) {
|
||||
return null;
|
||||
}
|
||||
const dot = headerValue.indexOf(".");
|
||||
if (dot === -1) return null;
|
||||
if (headerValue.slice(0, dot) !== SPAN_CONTEXT_VERSION) return null;
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
const json = new TextDecoder().decode(decodeBase64Url(headerValue.slice(dot + 1)));
|
||||
parsed = JSON.parse(json);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
|
||||
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
if (typeof obj.projectId !== "string" || obj.projectId.length === 0) return null;
|
||||
|
||||
const context: SpanPropagationContext = { projectId: obj.projectId };
|
||||
if (typeof obj.sessionReplayId === "string" && UUID_RE.test(obj.sessionReplayId)) {
|
||||
context.sessionReplayId = obj.sessionReplayId;
|
||||
}
|
||||
if (typeof obj.sessionReplaySegmentId === "string" && UUID_RE.test(obj.sessionReplaySegmentId)) {
|
||||
context.sessionReplaySegmentId = obj.sessionReplaySegmentId;
|
||||
}
|
||||
if (Array.isArray(obj.customParentSpanIds)) {
|
||||
const ids = obj.customParentSpanIds
|
||||
.filter((id): id is string => typeof id === "string" && UUID_RE.test(id))
|
||||
.slice(0, MAX_CUSTOM_PARENT_SPAN_IDS);
|
||||
if (ids.length > 0) context.customParentSpanIds = ids;
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the span-context header may ride along to `targetUrl`. Default policy is
|
||||
* SAME-ORIGIN ONLY: attaching a custom header cross-origin both leaks the context
|
||||
* to third parties and forces a CORS preflight that the third party won't allow,
|
||||
* breaking the customer's request. `allowedOrigins` opts specific extra origins in
|
||||
* (e.g. a split `api.example.com`), matched by exact origin. Non-http(s) targets
|
||||
* and unparseable urls are always excluded.
|
||||
*/
|
||||
export function shouldPropagateSpanContext(opts: {
|
||||
targetUrl: string | URL,
|
||||
selfOrigin: string | null,
|
||||
allowedOrigins?: readonly string[],
|
||||
}): boolean {
|
||||
let target: URL;
|
||||
try {
|
||||
target = typeof opts.targetUrl === "string"
|
||||
? new URL(opts.targetUrl, opts.selfOrigin ?? undefined)
|
||||
: opts.targetUrl;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (target.protocol !== "http:" && target.protocol !== "https:") return false;
|
||||
if (opts.selfOrigin !== null && target.origin === opts.selfOrigin) return true;
|
||||
return opts.allowedOrigins?.includes(target.origin) ?? false;
|
||||
}
|
||||
|
||||
/** Marker on globalThis so the fetch wrapper installs at most once (HMR / multiple app instances). */
|
||||
const FETCH_WRAP_MARKER = "__hexclaveSpanPropagationFetch";
|
||||
|
||||
function requestInputUrl(input: unknown): string | null {
|
||||
if (typeof input === "string") return input;
|
||||
if (input instanceof URL) return input.href;
|
||||
if (typeof input === "object" && input !== null && typeof (input as { url?: unknown }).url === "string") {
|
||||
return (input as { url: string }).url; // Request
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function requestInputMode(input: unknown, init: RequestInit | undefined): string | undefined {
|
||||
if (init?.mode) return init.mode;
|
||||
if (typeof input === "object" && input !== null && typeof (input as { mode?: unknown }).mode === "string") {
|
||||
return (input as { mode: string }).mode;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export type FetchSpanPropagationOptions = {
|
||||
/** The current ambient client context, or null when there is nothing to link. */
|
||||
getContext: () => SpanPropagationContext | null,
|
||||
/** The page's own origin (e.g. `window.location.origin`), or null if unknown. */
|
||||
getSelfOrigin: () => string | null,
|
||||
/** Extra exact origins allowed to receive the header (split frontend/api domains). */
|
||||
getAllowedOrigins: () => readonly string[],
|
||||
};
|
||||
|
||||
/**
|
||||
* Installs a global `fetch` wrapper that attaches `x-hexclave-span-context` to
|
||||
* same-origin (or allowlisted) outgoing requests. Idempotent (a global marker
|
||||
* guards against HMR / multiple app instances), chains through the existing fetch
|
||||
* so it composes with other wrappers (Sentry/OTel), and never lets propagation
|
||||
* throw into the caller's request. Header merging preserves native fetch header
|
||||
* semantics exactly, then adds ours. Returns an uninstaller, or null if fetch is
|
||||
* unavailable or already wrapped.
|
||||
*/
|
||||
export function installFetchSpanPropagation(options: FetchSpanPropagationOptions): (() => void) | null {
|
||||
const g = globalThis as typeof globalThis & Record<string, unknown>;
|
||||
if (typeof g.fetch !== "function" || g[FETCH_WRAP_MARKER]) return null;
|
||||
|
||||
// Keep the exact original reference to restore on uninstall, but call it bound —
|
||||
// an unbound `fetch` throws "Illegal invocation" in browsers.
|
||||
const originalFetch = g.fetch as typeof fetch;
|
||||
const callFetch = originalFetch.bind(globalThis) as typeof fetch;
|
||||
|
||||
const wrapped = ((input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||
try {
|
||||
// no-cors strips non-safelisted headers anyway — skip to avoid surprises.
|
||||
if (requestInputMode(input, init) !== "no-cors") {
|
||||
const url = requestInputUrl(input);
|
||||
if (url !== null && shouldPropagateSpanContext({
|
||||
targetUrl: url,
|
||||
selfOrigin: options.getSelfOrigin(),
|
||||
allowedOrigins: options.getAllowedOrigins(),
|
||||
})) {
|
||||
const context = options.getContext();
|
||||
if (context) {
|
||||
// Reproduce native header precedence: init.headers wins over a Request's
|
||||
// own headers; a bare Request keeps its headers. Then add ours on top,
|
||||
// without mutating the caller's objects.
|
||||
const isRequest = typeof Request !== "undefined" && input instanceof Request;
|
||||
const base: HeadersInit | undefined = init?.headers !== undefined
|
||||
? init.headers
|
||||
: (isRequest ? (input as Request).headers : undefined);
|
||||
const headers = new Headers(base);
|
||||
headers.set(SPAN_CONTEXT_HEADER, encodeSpanContextHeader(context));
|
||||
return callFetch(input, { ...init, headers });
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Any failure in propagation falls through to the untouched request.
|
||||
}
|
||||
return callFetch(input as RequestInfo | URL, init);
|
||||
}) as typeof fetch;
|
||||
|
||||
g.fetch = wrapped;
|
||||
g[FETCH_WRAP_MARKER] = true;
|
||||
|
||||
return () => {
|
||||
if (g.fetch === wrapped) g.fetch = originalFetch;
|
||||
delete g[FETCH_WRAP_MARKER];
|
||||
};
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
import { KnownErrors } from "@hexclave/shared";
|
||||
import { Result } from "@hexclave/shared/dist/utils/results";
|
||||
import type { GenericQueryCtx } from "convex/server";
|
||||
import { AsyncStoreProperty, GetCurrentPartialUserOptions, GetCurrentUserOptions } from "../../common";
|
||||
import { AsyncStoreProperty, GetCurrentPartialUserOptions, GetCurrentUserOptions, RequestLike } from "../../common";
|
||||
import { CustomerProductsList, CustomerProductsRequestOptions, InlineProduct, ServerItem } from "../../customers";
|
||||
import { DataVaultStore } from "../../data-vault";
|
||||
import { EmailDeliveryInfo, SendEmailOptions } from "../../email";
|
||||
@ -43,12 +43,21 @@ export type StackServerApp<HasTokenStore extends boolean = boolean, ProjectId ex
|
||||
* (there is no session to derive it from). Items coalesce per userId and
|
||||
* send on the next microtask; `await` the promise (or call `flush()`) as the
|
||||
* delivery guarantee — the server has no page-lifetime flush cadence.
|
||||
*
|
||||
* Pass `request` (the incoming Request) to auto-attribute to the caller AND
|
||||
* parent the event under their client session — the `$refresh-token` /
|
||||
* `$session-replay` / `$session-replay-segment` chain — resolved from the
|
||||
* session + the `x-hexclave-span-context` header the browser SDK attaches
|
||||
* automatically. With `request`, `userId` is derived from the session unless
|
||||
* explicitly overridden.
|
||||
*/
|
||||
trackEvent(eventType: string, data?: Record<string, unknown>, options?: TrackOptions & { userId?: string }): Promise<void>,
|
||||
trackEvent(eventType: string, data?: Record<string, unknown>, options?: TrackOptions & { userId?: string, request?: RequestLike }): Promise<void>,
|
||||
|
||||
/**
|
||||
* Server-side variant of `startSpan`: attribution is explicit via `userId`.
|
||||
* Child spans and span-attached events inherit the span's userId.
|
||||
* Child spans and span-attached events inherit the span's userId. To link a
|
||||
* span to the caller's client session, use `withSpan(type, { request }, fn)` —
|
||||
* `startSpan` is synchronous and cannot resolve a request.
|
||||
*/
|
||||
startSpan(spanType: string, options?: StartSpanOptions & { userId?: string }): Span,
|
||||
|
||||
@ -56,9 +65,14 @@ export type StackServerApp<HasTokenStore extends boolean = boolean, ProjectId ex
|
||||
* Server-side variant of `withSpan`: accepts `userId` in options; ambient
|
||||
* parenting is AsyncLocalStorage-backed, so concurrent requests sharing one
|
||||
* app instance never cross-parent.
|
||||
*
|
||||
* Pass `request` to auto-parent the span (and everything created inside the
|
||||
* callback) under the caller's client session, resolved from the session + the
|
||||
* `x-hexclave-span-context` header. This is the primitive the framework
|
||||
* adapters build on — with an adapter you never pass `request` yourself.
|
||||
*/
|
||||
withSpan<T>(spanType: string, fn: (span: Span) => Promise<T> | T): Promise<T>,
|
||||
withSpan<T>(spanType: string, options: StartSpanOptions & { userId?: string }, fn: (span: Span) => Promise<T> | T): Promise<T>,
|
||||
withSpan<T>(spanType: string, options: StartSpanOptions & { userId?: string, request?: RequestLike }, fn: (span: Span) => Promise<T> | T): Promise<T>,
|
||||
|
||||
// IF_PLATFORM react-like
|
||||
useUser(options: GetCurrentUserOptions<HasTokenStore> & { or: 'redirect' }): ProjectCurrentServerUser<ProjectId>,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user