From f4c2ee45757476e7531e94f8cac62dbd0446144e Mon Sep 17 00:00:00 2001 From: mantrakp04 Date: Fri, 3 Jul 2026 11:45:16 -0700 Subject: [PATCH] feat(analytics): cross-tier span propagation (client fetch header + server withSpan({ request })) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../latest/analytics/events/batch/route.tsx | 25 +- .../api/v1/analytics-events-batch.test.ts | 62 +++++ .../apps/implementations/client-app-impl.ts | 31 +++ .../apps/implementations/event-tracker.ts | 5 + .../apps/implementations/server-app-impl.ts | 173 +++++++++++-- .../implementations/server-request-context.ts | 74 ++++++ .../apps/implementations/session-replay.ts | 16 ++ .../implementations/span-propagation.test.ts | 219 ++++++++++++++++ .../apps/implementations/span-propagation.ts | 235 ++++++++++++++++++ .../apps/interfaces/server-app.ts | 22 +- 10 files changed, 827 insertions(+), 35 deletions(-) create mode 100644 packages/template/src/lib/hexclave-app/apps/implementations/server-request-context.ts create mode 100644 packages/template/src/lib/hexclave-app/apps/implementations/span-propagation.test.ts create mode 100644 packages/template/src/lib/hexclave-app/apps/implementations/span-propagation.ts diff --git a/apps/backend/src/app/api/latest/analytics/events/batch/route.tsx b/apps/backend/src/app/api/latest/analytics/events/batch/route.tsx index a94c1b07e..906d604c8 100644 --- a/apps/backend/src/app/api/latest/analytics/events/batch/route.tsx +++ b/apps/backend/src/app/api/latest/analytics/events/batch/route.tsx @@ -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(); diff --git a/apps/e2e/tests/backend/endpoints/api/v1/analytics-events-batch.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/analytics-events-batch.test.ts index ccf8f552a..1ee6a01bd 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/analytics-events-batch.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/analytics-events-batch.test.ts @@ -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(); diff --git a/packages/template/src/lib/hexclave-app/apps/implementations/client-app-impl.ts b/packages/template/src/lib/hexclave-app/apps/implementations/client-app-impl.ts index 793816343..23749e3be 100644 --- a/packages/template/src/lib/hexclave-app/apps/implementations/client-app-impl.ts +++ b/packages/template/src/lib/hexclave-app/apps/implementations/client-app-impl.ts @@ -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 { + 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()) diff --git a/packages/template/src/lib/hexclave-app/apps/implementations/event-tracker.ts b/packages/template/src/lib/hexclave-app/apps/implementations/event-tracker.ts index c6b0773f0..950a303fd 100644 --- a/packages/template/src/lib/hexclave-app/apps/implementations/event-tracker.ts +++ b/packages/template/src/lib/hexclave-app/apps/implementations/event-tracker.ts @@ -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 diff --git a/packages/template/src/lib/hexclave-app/apps/implementations/server-app-impl.ts b/packages/template/src/lib/hexclave-app/apps/implementations/server-app-impl.ts index d5111c3a3..ce91435fd 100644 --- a/packages/template/src/lib/hexclave-app/apps/implementations/server-app-impl.ts +++ b/packages/template/src/lib/hexclave-app/apps/implementations/server-app-impl.ts @@ -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(); + private readonly _serverTelemetryBuffers = new Map(); private readonly _serverGlobalSpans = new Set(); private readonly _serverTelemetryInFlight = new Set>(); - override trackEvent(eventType: string, data?: Record, options?: TrackOptions & { userId?: string }): Promise { + override trackEvent(eventType: string, data?: Record, options?: TrackOptions & { userId?: string, request?: RequestLike }): Promise { 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(spanType: string, fn: (span: Span) => Promise | T): Promise; + override withSpan(spanType: string, options: StartSpanOptions & { userId?: string, request?: RequestLike }, fn: (span: Span) => Promise | T): Promise; + override withSpan( + spanType: string, + optionsOrFn: (StartSpanOptions & { userId?: string, request?: RequestLike }) | ((span: Span) => Promise | T), + maybeFn?: (span: Span) => Promise | T, + ): Promise { + 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 { + 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 { 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 ({ spanId, parentSpanIds: [] as string[] })), + ]; + } + private _trackServerEvent(eventType: string, data: Record | undefined, options: TrackOptions | undefined, userId: string | null): Promise { const nameError = getCustomTelemetryNameError("event", eventType); if (nameError) return rejectedPreCaught(nameError); @@ -1788,9 +1884,10 @@ export class _HexclaveServerAppImplIncomplete {}); - const buffer = this._getServerTelemetryBuffer(userId); + const buffer = this._getServerTelemetryBuffer(batchContext); buffer.events.push({ event: { event_type: eventType, @@ -1811,7 +1908,7 @@ export class _HexclaveServerAppImplIncomplete | null = null; const nextVersion = () => (lastVersion = Math.max(Date.now(), lastVersion + 1)); - const enqueue = (endedAtMs: number | null): Promise => this._enqueueServerSpanUpdate(userId, { + const enqueue = (endedAtMs: number | null): Promise => this._enqueueServerSpanUpdate(batchContext, { span_id: spanId, span_type: spanType, started_at_ms: startedAtMs, @@ -1897,46 +1995,51 @@ export class _HexclaveServerAppImplIncomplete { + private _enqueueServerSpanUpdate(context: ServerRequestSpanContext, row: SpanUpdateRow): Promise { let settler!: TelemetrySettler; const promise = new Promise((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 0 ? { events: events.map((entry) => entry.event) } : {}, ...spanEntries.length > 0 ? { spans: spanEntries.map((entry) => entry.row) } : {}, }; @@ -1985,6 +2094,15 @@ export class _HexclaveServerAppImplIncomplete, 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, }; diff --git a/packages/template/src/lib/hexclave-app/apps/implementations/server-request-context.ts b/packages/template/src/lib/hexclave-app/apps/implementations/server-request-context.ts new file mode 100644 index 000000000..895b1fec5 --- /dev/null +++ b/packages/template/src/lib/hexclave-app/apps/implementations/server-request-context.ts @@ -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: (store: ServerRequestSpanContext, fn: () => T) => T, + getStore: () => ServerRequestSpanContext | undefined, +}; + +let als: AsyncLocalStorageLike | null = null; +let alsInitPromise: Promise | null = null; +// Single-value fallback (before ALS finishes loading / where it is unavailable). +let syncCurrent: ServerRequestSpanContext | null = null; + +async function ensureAsyncContext(): Promise { + 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(context: ServerRequestSpanContext, fn: () => Promise): Promise { + await ensureAsyncContext(); + if (als) { + return await als.run(context, fn); + } + const previous = syncCurrent; + syncCurrent = context; + try { + return await fn(); + } finally { + syncCurrent = previous; + } +} diff --git a/packages/template/src/lib/hexclave-app/apps/implementations/session-replay.ts b/packages/template/src/lib/hexclave-app/apps/implementations/session-replay.ts index 540646c7e..c0035110c 100644 --- a/packages/template/src/lib/hexclave-app/apps/implementations/session-replay.ts +++ b/packages/template/src/lib/hexclave-app/apps/implementations/session-replay.ts @@ -54,6 +54,22 @@ export type AnalyticsOptions = { * Not serializable — dropped when the app is serialized (toClientJson). */ waitUntil?: (promise: Promise) => 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 { diff --git a/packages/template/src/lib/hexclave-app/apps/implementations/span-propagation.test.ts b/packages/template/src/lib/hexclave-app/apps/implementations/span-propagation.test.ts new file mode 100644 index 000000000..53da76091 --- /dev/null +++ b/packages/template/src/lib/hexclave-app/apps/implementations/span-propagation.test.ts @@ -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); + }); +}); diff --git a/packages/template/src/lib/hexclave-app/apps/implementations/span-propagation.ts b/packages/template/src/lib/hexclave-app/apps/implementations/span-propagation.ts new file mode 100644 index 000000000..93dd85d12 --- /dev/null +++ b/packages/template/src/lib/hexclave-app/apps/implementations/span-propagation.ts @@ -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; + +/** 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)) { + 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 = { 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; + 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; + 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 => { + 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]; + }; +} diff --git a/packages/template/src/lib/hexclave-app/apps/interfaces/server-app.ts b/packages/template/src/lib/hexclave-app/apps/interfaces/server-app.ts index 62d51693a..fbf0f2f52 100644 --- a/packages/template/src/lib/hexclave-app/apps/interfaces/server-app.ts +++ b/packages/template/src/lib/hexclave-app/apps/interfaces/server-app.ts @@ -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, options?: TrackOptions & { userId?: string }): Promise, + trackEvent(eventType: string, data?: Record, options?: TrackOptions & { userId?: string, request?: RequestLike }): Promise, /** * 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(spanType: string, fn: (span: Span) => Promise | T): Promise, - withSpan(spanType: string, options: StartSpanOptions & { userId?: string }, fn: (span: Span) => Promise | T): Promise, + withSpan(spanType: string, options: StartSpanOptions & { userId?: string, request?: RequestLike }, fn: (span: Span) => Promise | T): Promise, // IF_PLATFORM react-like useUser(options: GetCurrentUserOptions & { or: 'redirect' }): ProjectCurrentServerUser,