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:
mantrakp04 2026-07-03 11:45:16 -07:00
parent d1f93b8fda
commit f4c2ee4575
10 changed files with 827 additions and 35 deletions

View File

@ -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();

View File

@ -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();

View File

@ -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())

View File

@ -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

View File

@ -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,
};

View File

@ -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;
}
}

View File

@ -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 {

View File

@ -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);
});
});

View File

@ -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];
};
}

View File

@ -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>,