feat(analytics): withSpan + AsyncLocalStorage ambient parenting + waitUntil serverless hook

- app.withSpan(type, [options,] fn): auto-start, ambient parent for everything
  created inside fn (client + server via virtual startSpan dispatch), auto-end
  on settle; on throw records data.error and rethrows (telemetry never fails fn)
- span-context.ts: AsyncLocalStorage from the BUILT-IN node:async_hooks via a
  bundler-opaque dynamic import (works on Node/Bun/Deno/Workers/Edge; browsers
  fall back to a module-level sync stack with documented interleaving limits)
- root: true drops all ambient parents; excludeParentIds filters the FINAL
  merged parent list (an excluded span stays excluded even when re-entering via
  a kept child's chain) — both on TrackOptions and StartSpanOptions
- analytics.waitUntil constructor option: every batch-send promise (browser
  tracker + server coalescer) is passed to it so un-awaited sends survive
  serverless teardown; stripped from serialized app options (toClientJson)

Tests: 5 span-context cases (ALS nesting across awaits, parallel isolation,
sync-stack cleanup), 4 event-tracker cases (withSpan auto-end + ambient
parenting, error path, root/exclude final-list semantics, waitUntil hook)
This commit is contained in:
mantrakp04 2026-07-02 11:51:08 -07:00
parent b8215a8595
commit d1f93b8fda
9 changed files with 479 additions and 10 deletions

View File

@ -61,7 +61,7 @@ import { ActiveSession, Auth, BaseUser, CurrentUser, InternalUserExtra, OAuthPro
import { StackClientApp, StackClientAppConstructorOptions, StackClientAppJson } from "../interfaces/client-app";
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, type Span, type StartSpanOptions, type TrackOptions } from "./event-tracker";
import { createInertSpan, EventTracker, getCustomTelemetryDataError, getCustomTelemetryNameError, rejectedPreCaught, warnTelemetryUnavailableOnce, withSpanImpl, type Span, type StartSpanOptions, type TrackOptions } from "./event-tracker";
import type { CrossDomainHandoffParams } from "./redirect-page-urls";
import { crossDomainAuthQueryParams, getCrossDomainHandoffParamsFromCurrentUrl, planRedirectToHandler } from "./redirect-page-urls";
import { subscribeSessionRefresh } from "./session-refresh-subscription";
@ -256,7 +256,7 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
protected readonly _urlOptions: HandlerUrlOptions;
protected readonly _oauthScopesOnSignIn: Partial<OAuthScopesOnSignIn>;
private readonly _analyticsOptions: AnalyticsOptions | undefined;
protected readonly _analyticsOptions: AnalyticsOptions | undefined;
private _sessionRecorder: SessionRecorder | null = null;
protected _eventTracker: EventTracker | null = null;
@ -743,6 +743,7 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
return await this._interface.sendAnalyticsEventBatch(body, await getAnalyticsSession(), opts);
},
sessionReplaySegmentId,
registerBackgroundTask: this._analyticsOptions?.waitUntil,
});
this._eventTracker.start();
}
@ -4037,6 +4038,14 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
await this._eventTracker?.flush();
}
withSpan<T>(spanType: string, fn: (span: Span) => Promise<T> | T): Promise<T>;
withSpan<T>(spanType: string, options: StartSpanOptions, fn: (span: Span) => Promise<T> | T): Promise<T>;
withSpan<T>(spanType: string, optionsOrFn: StartSpanOptions | ((span: Span) => Promise<T> | T), maybeFn?: (span: Span) => Promise<T> | T): Promise<T> {
// this.startSpan dispatches virtually, so the server app's userId-aware
// startSpan is used automatically when called on a StackServerApp.
return withSpanImpl((type, options) => this.startSpan(type, options), spanType, optionsOrFn, maybeFn);
}
async getAccessToken(options?: { tokenStore?: TokenStoreInit }): Promise<string | null> {
const user = await this.getUser({ tokenStore: options?.tokenStore ?? undefined as any });
if (user) {

View File

@ -3,7 +3,7 @@
import { KnownErrors } from "@hexclave/shared/dist/known-errors";
import { Result } from "@hexclave/shared/dist/utils/results";
import { afterEach, describe, expect, it, vi } from "vitest";
import { EventTracker } from "./event-tracker";
import { EventTracker, withSpanImpl } from "./event-tracker";
async function advancePastFlush() {
await vi.advanceTimersByTimeAsync(10_000);
@ -734,6 +734,132 @@ describe("EventTracker", () => {
}
});
it("withSpan auto-ends the span and ambient-parents everything created inside", async () => {
const sentBodies: string[] = [];
const tracker = new EventTracker({
projectId: "internal",
sendBatch: async (body) => {
sentBodies.push(body);
return Result.ok(new Response());
},
});
let innerSpanId = "";
const result = await withSpanImpl(
(type, options) => tracker.startSpan(type, options),
"outer-flow",
async (outer) => {
expect(outer.isEnded).toBe(false);
const inner = tracker.startSpan("inner-step"); // ambient parent: outer
innerSpanId = inner.spanId;
tracker.trackCustomEvent("inner_event").catch(() => {}); // ambient parent: outer
inner.end().catch(() => {});
return 42;
},
);
expect(result).toBe(42);
await tracker.flush();
const payload = JSON.parse(sentBodies[0] ?? "{}") as {
events: { event_type: string, parent_span_ids?: string[] }[],
spans?: { span_id: string, span_type: string, ended_at_ms: number | null, parent_span_ids: string[] }[],
};
const outerRow = payload.spans!.find((row) => row.span_type === "outer-flow")!;
expect(outerRow.ended_at_ms).not.toBeNull(); // auto-ended on settle
expect(outerRow.parent_span_ids).toEqual([]); // its own parents come from the ENCLOSING context
const innerRow = payload.spans!.find((row) => row.span_id === innerSpanId)!;
expect(innerRow.parent_span_ids).toEqual([outerRow.span_id]);
const innerEvent = payload.events.find((event) => event.event_type === "inner_event")!;
expect(innerEvent.parent_span_ids).toEqual([outerRow.span_id]);
// The frame is gone after withSpan settles: no ambient parent here.
tracker.trackCustomEvent("after_frame").catch(() => {});
await tracker.flush();
const second = JSON.parse(sentBodies[1] ?? "{}") as { events: { event_type: string, parent_span_ids?: string[] }[] };
expect(second.events.find((event) => event.event_type === "after_frame")!.parent_span_ids).toBeUndefined();
});
it("withSpan records data.error, ends the span, and rethrows on failure", async () => {
const sentBodies: string[] = [];
const tracker = new EventTracker({
projectId: "internal",
sendBatch: async (body) => {
sentBodies.push(body);
return Result.ok(new Response());
},
});
await expect(withSpanImpl(
(type, options) => tracker.startSpan(type, options),
"failing-flow",
async () => {
throw new Error("boom");
},
)).rejects.toThrow("boom");
await tracker.flush();
const payload = JSON.parse(sentBodies[0] ?? "{}") as { spans?: { span_type: string, ended_at_ms: number | null, data: Record<string, unknown> }[] };
const row = payload.spans!.find((entry) => entry.span_type === "failing-flow")!;
expect(row.ended_at_ms).not.toBeNull();
expect(row.data).toEqual({ error: "boom" });
});
it("root drops all ambient parents and excludeParentIds filters the FINAL merged list", async () => {
const sentBodies: string[] = [];
const tracker = new EventTracker({
projectId: "internal",
sendBatch: async (body) => {
sentBodies.push(body);
return Result.ok(new Response());
},
});
await withSpanImpl(
(type, options) => tracker.startSpan(type, options),
"outer",
async (outer) => {
const detached = tracker.startSpan("detached", { root: true });
expect(detached.ref().parentSpanIds).toEqual([]);
const child = tracker.startSpan("child"); // chain: [outer]
// Excluding outer removes it from the final list even though it
// re-enters via child's frozen chain — deliberate final-list semantics:
// this row is a child of `child` but NOT a descendant of `outer`.
tracker.trackCustomEvent("evt", {}, { parentIds: [child], excludeParentIds: [outer] }).catch(() => {});
child.end().catch(() => {});
detached.end().catch(() => {});
},
);
await tracker.flush();
const payload = JSON.parse(sentBodies[0] ?? "{}") as {
events: { event_type: string, parent_span_ids?: string[] }[],
spans?: { span_id: string, span_type: string, parent_span_ids: string[] }[],
};
const outerRow = payload.spans!.find((row) => row.span_type === "outer")!;
const childRow = payload.spans!.find((row) => row.span_type === "child")!;
expect(payload.spans!.find((row) => row.span_type === "detached")!.parent_span_ids).toEqual([]);
expect(childRow.parent_span_ids).toEqual([outerRow.span_id]);
expect(payload.events.find((event) => event.event_type === "evt")!.parent_span_ids).toEqual([childRow.span_id]);
});
it("passes every batch-send promise to registerBackgroundTask (waitUntil hook)", async () => {
const registered: Promise<unknown>[] = [];
const tracker = new EventTracker({
projectId: "internal",
sendBatch: async () => Result.ok(new Response()),
registerBackgroundTask: (promise) => registered.push(promise),
});
tracker.trackCustomEvent("first").catch(() => {});
await tracker.flush();
tracker.trackCustomEvent("second").catch(() => {});
await tracker.flush();
expect(registered).toHaveLength(2);
await expect(Promise.all(registered)).resolves.toBeDefined();
});
it("silently disables when client interface returns ANALYTICS_NOT_ENABLED as an error", async () => {
vi.useFakeTimers();
document.body.innerHTML = "<button>Click me</button>";

View File

@ -5,6 +5,7 @@ import { buildElementsChain, ELEMENTS_CHAIN_MAX_DEPTH } from "@hexclave/shared/d
import { runAsynchronously } from "@hexclave/shared/dist/utils/promises";
import { Result } from "@hexclave/shared/dist/utils/results";
import { generateUuid, isAdBlockerNetworkError, isAnalyticsNotEnabledError } from "./session-replay";
import { getAmbientSpanRefs, runWithSpanContext } from "./span-context";
const FLUSH_INTERVAL_MS = 10_000;
const MAX_EVENTS_PER_BATCH = 50;
@ -47,12 +48,29 @@ export type ParentRef = string | SpanRef | Span;
export type TrackOptions = {
parentIds?: ParentRef[],
/**
* Drop ALL ambient parents (global spans + enclosing withSpan context); only
* explicit parentIds apply. This is the opt-out for ambient parenting.
*/
root?: boolean,
/**
* Drop specific ambient parents ("I don't want THAT span as a parent").
* Filters the FINAL merged parent list an excluded span stays excluded even
* when it re-enters via a kept child's frozen chain, which means "descendants
* of the excluded span" queries will not match this item (by design; that is
* the literal meaning of the option, not a dedupe bug).
*/
excludeParentIds?: ParentRef[],
};
export type StartSpanOptions = {
data?: Record<string, unknown>,
parentIds?: ParentRef[],
startedAtMs?: number,
/** See TrackOptions.root. */
root?: boolean,
/** See TrackOptions.excludeParentIds. */
excludeParentIds?: ParentRef[],
};
/**
@ -152,10 +170,19 @@ export function getCustomTelemetryDataError(data: unknown): string | null {
export function resolveParentIds(opts: {
explicit?: ParentRef[],
ambient?: SpanRef[],
/** Ignore ambient parents entirely; only explicit ones apply. */
root?: boolean,
/**
* Ids to drop from the FINAL merged list (each ParentRef contributes only its
* own id here, not its chain) see TrackOptions.excludeParentIds.
*/
exclude?: ParentRef[],
}): { ids: string[] } | { error: string } {
const chains: string[][] = [];
for (const ambient of opts.ambient ?? []) {
chains.push([...ambient.parentSpanIds, ambient.spanId]);
if (!opts.root) {
for (const ambient of opts.ambient ?? []) {
chains.push([...ambient.parentSpanIds, ambient.spanId]);
}
}
for (const parent of opts.explicit ?? []) {
if (typeof parent === "string") {
@ -165,6 +192,16 @@ export function resolveParentIds(opts: {
chains.push([...ref.parentSpanIds, ref.spanId]);
}
}
const excludeIds = new Set<string>();
for (const excluded of opts.exclude ?? []) {
const id = typeof excluded === "string"
? excluded
: "ref" in excluded && typeof excluded.ref === "function" ? excluded.ref().spanId : (excluded as SpanRef).spanId;
if (!UUID_RE.test(id)) {
return { error: `Invalid excluded parent span id ${JSON.stringify(id)}: excludeParentIds must be span uuids` };
}
excludeIds.add(id);
}
const seen = new Set<string>();
const merged: string[] = [];
for (const chain of chains) {
@ -172,7 +209,7 @@ export function resolveParentIds(opts: {
if (!UUID_RE.test(id)) {
return { error: `Invalid parent span id ${JSON.stringify(id)}: parent ids must be span uuids` };
}
if (!seen.has(id)) {
if (!seen.has(id) && !excludeIds.has(id)) {
seen.add(id);
merged.push(id);
}
@ -185,6 +222,41 @@ export function resolveParentIds(opts: {
return { ids: merged };
}
/**
* Shared implementation of withSpan(): starts the span (parents come from the
* ENCLOSING context, not itself), runs `fn` with the span as an ambient parent
* for everything created inside, auto-ends on settle, and on throw records
* `data.error` and rethrows. Telemetry failures never fail `fn` the end/
* setData promises are pre-caught and intentionally not awaited, so the
* caller's result is never blocked on an analytics ack.
*/
export async function withSpanImpl<T>(
startSpan: (spanType: string, options?: StartSpanOptions) => Span,
spanType: string,
optionsOrFn: StartSpanOptions | ((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;
if (typeof fn !== "function") {
return await rejectedPreCaught("withSpan() requires a callback function");
}
const span = startSpan(spanType, options);
return await runWithSpanContext(span.ref(), async () => {
try {
const result = await fn(span);
span.end().catch(() => {});
return result;
} catch (error) {
// Order matters: the merge lands before the end row is enqueued, so the
// single deduped wire row carries both the error and the end time.
span.setData({ error: error instanceof Error ? error.message : String(error) }).catch(() => {});
span.end().catch(() => {});
throw error;
}
});
}
/**
* A Span that records nothing. Returned wherever analytics cannot run (SSR,
* analytics disabled, tracker torn down) so isomorphic user code never needs to
@ -305,6 +377,9 @@ export type EventTrackerDeps = {
// chunks from the same tab carry the same session_replay_segment_id. Falls
// back to a fresh uuid when constructed standalone (e.g. in tests).
sessionReplaySegmentId?: string,
// Serverless keep-alive hook (AnalyticsOptions.waitUntil): every batch-send
// promise is passed to it so un-awaited sends survive runtime teardown.
registerBackgroundTask?: (promise: Promise<unknown>) => void,
};
type TrackedEvent = {
@ -448,7 +523,12 @@ export class EventTracker {
if (nameError) return rejectedPreCaught(nameError);
const dataError = getCustomTelemetryDataError(data);
if (dataError) return rejectedPreCaught(dataError);
const resolved = resolveParentIds({ explicit: options?.parentIds, ambient: this._ambientParentRefs() });
const resolved = resolveParentIds({
explicit: options?.parentIds,
ambient: this._ambientParentRefs(),
root: options?.root,
exclude: options?.excludeParentIds,
});
if ("error" in resolved) return rejectedPreCaught(resolved.error);
if (this._disabled) return Promise.resolve();
@ -487,7 +567,12 @@ export class EventTracker {
console.error(`Hexclave analytics: startedAtMs must be a non-negative integer epoch-milliseconds value`);
return createInertSpan(spanType);
}
const resolved = resolveParentIds({ explicit: options?.parentIds, ambient: this._ambientParentRefs() });
const resolved = resolveParentIds({
explicit: options?.parentIds,
ambient: this._ambientParentRefs(),
root: options?.root,
exclude: options?.excludeParentIds,
});
if ("error" in resolved) {
console.error(`Hexclave analytics: ${resolved.error}`);
return createInertSpan(spanType);
@ -600,6 +685,8 @@ export class EventTracker {
for (const span of this._globalSpans) {
if (!span.isEnded) refs.push(span.ref());
}
// Enclosing withSpan() frames, outermost first, after the globals.
refs.push(...getAmbientSpanRefs());
return refs;
}
@ -1007,6 +1094,7 @@ export class EventTracker {
this._inFlight.delete(tracked);
});
this._inFlight.add(tracked);
this._deps.registerBackgroundTask?.(tracked);
await tracked;
}

View File

@ -38,6 +38,7 @@ 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 { generateUuid } from "./session-replay";
import { getAmbientSpanRefs } from "./span-context";
import { useAsyncCache } from "./common"; // THIS_LINE_PLATFORM react-like
@ -1774,6 +1775,8 @@ export class _HexclaveServerAppImplIncomplete<HasTokenStore extends boolean, Pro
for (const span of this._serverGlobalSpans) {
if (!span.isEnded) refs.push(span.ref());
}
// Enclosing withSpan() frames (AsyncLocalStorage — isolated per request).
refs.push(...getAmbientSpanRefs());
return refs;
}
@ -1785,7 +1788,12 @@ 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 resolved = resolveParentIds({ explicit: options?.parentIds, ambient: this._serverAmbientParentRefs() });
const resolved = resolveParentIds({
explicit: options?.parentIds,
ambient: this._serverAmbientParentRefs(),
root: options?.root,
exclude: options?.excludeParentIds,
});
if ("error" in resolved) return rejectedPreCaught(resolved.error);
let settler!: TelemetrySettler;
@ -1826,7 +1834,12 @@ 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 resolved = resolveParentIds({ explicit: options?.parentIds, ambient: this._serverAmbientParentRefs() });
const resolved = resolveParentIds({
explicit: options?.parentIds,
ambient: this._serverAmbientParentRefs(),
root: options?.root,
exclude: options?.excludeParentIds,
});
if ("error" in resolved) {
console.error(`Hexclave analytics: ${resolved.error}`);
return createInertSpan(spanType);
@ -1965,6 +1978,9 @@ export class _HexclaveServerAppImplIncomplete<HasTokenStore extends boolean, Pro
this._serverTelemetryInFlight.delete(tracked);
});
this._serverTelemetryInFlight.add(tracked);
// Serverless keep-alive (AnalyticsOptions.waitUntil): un-awaited sends must
// survive runtime teardown.
this._analyticsOptions?.waitUntil?.(tracked);
}
}

View File

@ -45,6 +45,15 @@ export type AnalyticsOptions = {
* set `enabled: false` to opt out.
*/
replays?: AnalyticsReplayOptions,
/**
* Serverless keep-alive hook: every analytics batch-send promise is passed to
* it, so un-awaited trackEvent/startSpan sends survive runtime teardown
* without awaiting each call. Wire it to your platform's primitive, e.g.
* `waitUntil: (p) => ctx.waitUntil(p)` on Cloudflare Workers or
* `import { waitUntil } from "@vercel/functions"` on Vercel.
* Not serializable dropped when the app is serialized (toClientJson).
*/
waitUntil?: (promise: Promise<unknown>) => void,
};
export function getSessionReplayOptions(analyticsOptions: AnalyticsOptions | undefined): AnalyticsReplayOptions {
@ -61,6 +70,12 @@ export function getSessionReplayOptions(analyticsOptions: AnalyticsOptions | und
* the actual runtime value is JSON-safe.
*/
export function analyticsOptionsToJson(options: AnalyticsOptions | undefined): AnalyticsOptions | undefined {
// waitUntil is a function and cannot cross a JSON boundary; the serialized
// app runs in a different environment with its own lifecycle anyway.
if (options?.waitUntil) {
const { waitUntil, ...rest } = options;
options = rest;
}
if (!options?.replays?.blockClass) return options;
const { blockClass, ...rest } = options.replays;
if (!(blockClass instanceof RegExp)) return options;

View File

@ -0,0 +1,95 @@
import { afterEach, describe, expect, it } from "vitest";
import type { SpanRef } from "./event-tracker";
import { __setAsyncContextModeForTesting, getAmbientSpanRefs, runWithSpanContext } from "./span-context";
function ref(spanId: string, parentSpanIds: string[] = []): SpanRef {
return { spanId, parentSpanIds };
}
function ambientIds(): string[] {
return getAmbientSpanRefs().map((frame) => frame.spanId);
}
describe("span context (AsyncLocalStorage)", () => {
afterEach(() => {
__setAsyncContextModeForTesting("auto");
});
it("propagates nested frames across await boundaries, outermost first", async () => {
expect(getAmbientSpanRefs()).toEqual([]);
await runWithSpanContext(ref("a"), async () => {
await new Promise((resolve) => setTimeout(resolve, 5));
expect(ambientIds()).toEqual(["a"]);
await runWithSpanContext(ref("b"), async () => {
await new Promise((resolve) => setTimeout(resolve, 5));
expect(ambientIds()).toEqual(["a", "b"]);
});
// Inner frame is gone once its withSpan settles.
expect(ambientIds()).toEqual(["a"]);
});
expect(getAmbientSpanRefs()).toEqual([]);
});
it("isolates interleaved parallel flows — no cross-parenting under ALS", async () => {
const seen: Record<string, string[]> = {};
await Promise.all([
runWithSpanContext(ref("flow1"), async () => {
await new Promise((resolve) => setTimeout(resolve, 10));
seen.flow1 = ambientIds();
}),
runWithSpanContext(ref("flow2"), async () => {
await new Promise((resolve) => setTimeout(resolve, 5));
seen.flow2 = ambientIds();
}),
]);
expect(seen.flow1).toEqual(["flow1"]);
expect(seen.flow2).toEqual(["flow2"]);
});
it("frames carry their full SpanRef (chain included), not just the id", async () => {
await runWithSpanContext(ref("child", ["root-ancestor"]), async () => {
expect(getAmbientSpanRefs()).toEqual([{ spanId: "child", parentSpanIds: ["root-ancestor"] }]);
});
});
});
describe("span context (sync-stack fallback)", () => {
afterEach(() => {
__setAsyncContextModeForTesting("auto");
});
it("is correct for sequential nested flows and removes its own frame on settle", async () => {
__setAsyncContextModeForTesting("sync-stack");
await runWithSpanContext(ref("a"), async () => {
expect(ambientIds()).toEqual(["a"]);
await runWithSpanContext(ref("b"), async () => {
expect(ambientIds()).toEqual(["a", "b"]);
});
expect(ambientIds()).toEqual(["a"]);
});
expect(getAmbientSpanRefs()).toEqual([]);
});
it("removes its own frame even when it is no longer on top (interleaving-safe cleanup)", async () => {
__setAsyncContextModeForTesting("sync-stack");
let releaseFirst!: () => void;
const firstBlocked = new Promise<void>((resolve) => {
releaseFirst = resolve;
});
// Start flow1 but leave it parked on an await, then run flow2 to completion
// while flow1's frame is still on the stack. flow2 must remove ITS frame
// (not flow1's) even though flow1's frame sits beneath it.
const first = runWithSpanContext(ref("flow1"), async () => {
await firstBlocked;
// Documented sync-stack limitation: no isolation guarantee here; the
// cleanup contract is what this test pins down.
});
await runWithSpanContext(ref("flow2"), async () => {
expect(ambientIds()).toEqual(["flow1", "flow2"]);
});
expect(ambientIds()).toEqual(["flow1"]);
releaseFirst();
await first;
expect(getAmbientSpanRefs()).toEqual([]);
});
});

View File

@ -0,0 +1,99 @@
import type { SpanRef } from "./event-tracker";
/**
* Ambient span context for withSpan(): tracks the stack of enclosing withSpan
* frames so telemetry created inside the callback automatically parents under
* them (additive with global spans and explicit parentIds).
*
* Two implementations behind one interface:
*
* - **AsyncLocalStorage** (Node, Bun, Deno, Cloudflare Workers / Vercel Edge
* with nodejs_compat): correct across await boundaries and under concurrent
* requests two parallel withSpan() flows can never cross-parent. Loaded via
* a runtime-guarded dynamic import of the BUILT-IN `node:async_hooks` module
* (not an npm dependency); the import specifier is deliberately opaque to
* bundlers so browser builds neither resolve nor error on it.
*
* - **Sync stack fallback** (browsers, where no async-context primitive
* exists): a module-level enter/exit stack. Correct for synchronous code and
* a single concurrent flow; interleaved parallel async flows can observe each
* other's frames (the same trade-off Sentry's browser SDK accepts). Server
* code never hits this path.
*/
type AsyncLocalStorageLike = {
run: <T>(store: SpanRef[], fn: () => T) => T,
getStore: () => SpanRef[] | undefined,
};
let als: AsyncLocalStorageLike | null = null;
let alsInitPromise: Promise<void> | null = null;
// Sync-stack fallback frames (browsers / before ALS finishes loading).
const syncStack: SpanRef[] = [];
async function ensureAsyncContext(): Promise<void> {
if (alsInitPromise) return await alsInitPromise;
alsInitPromise = (async () => {
try {
// Opaque specifier: bundlers must leave this as a runtime dynamic import
// (vite/webpack hints + non-literal string), which simply rejects in
// browsers and resolves to the built-in module everywhere node-like.
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 {
// Browser: no async-context primitive; the sync stack is the fallback.
als = null;
}
})();
return await alsInitPromise;
}
/**
* The SpanRefs of all enclosing withSpan() frames, outermost first. Consumed by
* the parent-resolution logic as ambient parents (alongside global spans).
*/
export function getAmbientSpanRefs(): SpanRef[] {
const store = als?.getStore();
if (store) return [...store];
return [...syncStack];
}
/**
* Runs `fn` with `frame` appended to the ambient span context. Always async:
* awaiting the ALS load first is what guarantees server code gets isolation
* from the very first withSpan() call rather than racing the module load.
*/
export async function runWithSpanContext<T>(frame: SpanRef, fn: () => Promise<T>): Promise<T> {
await ensureAsyncContext();
if (als) {
const enclosing = als.getStore() ?? [];
return await als.run([...enclosing, frame], fn);
}
syncStack.push(frame);
try {
return await fn();
} finally {
// Remove OUR frame specifically — a concurrent flow may have pushed frames
// above ours in the meantime (the documented sync-stack limitation).
const index = syncStack.lastIndexOf(frame);
if (index !== -1) syncStack.splice(index, 1);
}
}
/**
* Test hook: forces the sync-stack fallback (as if ALS failed to load) or
* resets to automatic detection. Never call outside tests.
*/
export function __setAsyncContextModeForTesting(mode: "sync-stack" | "auto"): void {
if (mode === "sync-stack") {
als = null;
alsInitPromise = Promise.resolve();
} else {
als = null;
alsInitPromise = null;
}
syncStack.length = 0;
}

View File

@ -145,6 +145,19 @@ export type StackClientApp<HasTokenStore extends boolean = boolean, ProjectId ex
*/
flush(): Promise<void>,
/**
* Runs `fn` inside a span: the span starts on entry, is an ambient parent
* for every trackEvent/startSpan/withSpan inside the callback, and ends
* automatically when `fn` settles. On throw, `data.error` is recorded and
* the error is rethrown telemetry failures never affect `fn`'s result.
* Ambient parenting is exact across `await`s on server runtimes
* (AsyncLocalStorage); in browsers, parallel async flows can observe each
* other's ambient frames (documented sync-stack fallback). Opt out of
* ambient parents per item with `root: true` or `excludeParentIds`.
*/
withSpan<T>(spanType: string, fn: (span: Span) => Promise<T> | T): Promise<T>,
withSpan<T>(spanType: string, options: StartSpanOptions, fn: (span: Span) => Promise<T> | T): Promise<T>,
// note: we don't special-case 'anonymous' here to return non-null, see GetPartialUserOptions for more details
getPartialUser(options: GetCurrentPartialUserOptions<HasTokenStore> & { from: 'token' }): Promise<TokenPartialUser | null>,
getPartialUser(options: GetCurrentPartialUserOptions<HasTokenStore> & { from: 'convex' }): Promise<TokenPartialUser | null>,

View File

@ -52,6 +52,14 @@ export type StackServerApp<HasTokenStore extends boolean = boolean, ProjectId ex
*/
startSpan(spanType: string, options?: StartSpanOptions & { userId?: string }): Span,
/**
* Server-side variant of `withSpan`: accepts `userId` in options; ambient
* parenting is AsyncLocalStorage-backed, so concurrent requests sharing one
* app instance never cross-parent.
*/
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>,
// IF_PLATFORM react-like
useUser(options: GetCurrentUserOptions<HasTokenStore> & { or: 'redirect' }): ProjectCurrentServerUser<ProjectId>,
useUser(options: GetCurrentUserOptions<HasTokenStore> & { or: 'throw' }): ProjectCurrentServerUser<ProjectId>,