feat(analytics): dual-mode ambient parenting (exact/best-effort) + span handle kit

Browser withSpan frames can mix across concurrently interleaved async flows
(no async-context primitive exists there — the reason for TC39 AsyncContext).
Support BOTH policies via analytics.ambientParenting:

- "exact" (default): a frame is ambient only when provably from the current
  flow — an exact primitive (ALS on servers/edge; AsyncContext in browsers
  once shipped), or the callback's SYNCHRONOUS window on the browser fallback
  (single-threaded sync execution cannot interleave, so prologue-open frames
  are exact by construction). Never wrong; can only under-attach.
- "best-effort": frames also stay ambient across browser awaits (zero-glue),
  accepting the documented cross-flow caveat.

Handle kit on every Span (browser, server, inert) — exact everywhere, both
modes: span.withSpan (nested child), span.run (re-bind for callbacks/timers),
span.getPropagationHeaders (non-fetch transports), span.fetch (pinned
cross-tier header, same origin policy as the auto wrapper, explicit wins).

Internals: sync-stack frames carry a prologueOpen flag (set false the moment
the callback returns its promise); context entry runs synchronously once the
ALS probe settles so sync nesting composes; getAmbientSpanRefs gains an
includeSuspendedSyncFrames read policy; servers fail closed when ALS is
missing. Design validated by Codex (gpt-5.5 xhigh) + a 3-lens design panel.
This commit is contained in:
mantrakp04 2026-07-03 13:38:17 -07:00
parent 62314f1dab
commit e6038897ae
9 changed files with 530 additions and 50 deletions

View File

@ -745,6 +745,11 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
},
sessionReplaySegmentId,
registerBackgroundTask: this._analyticsOptions?.waitUntil,
ambientParenting: this._analyticsOptions?.ambientParenting,
getPropagationPolicy: () => ({
selfOrigin: typeof window !== "undefined" ? window.location.origin : null,
allowedOrigins: this._analyticsOptions?.spanPropagation?.targets ?? [],
}),
});
this._eventTracker.start();
}

View File

@ -4,6 +4,8 @@ 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, withSpanImpl } from "./event-tracker";
import { __setAsyncContextModeForTesting } from "./span-context";
import { decodeSpanContextHeader } from "./span-propagation";
async function advancePastFlush() {
await vi.advanceTimersByTimeAsync(10_000);
@ -892,3 +894,148 @@ describe("EventTracker", () => {
}
});
});
describe("EventTracker ambient modes + span handle kit", () => {
const SEG = "11111111-1111-4111-8111-111111111111";
function makeTracker(sentBodies: string[], extraDeps?: Partial<import("./event-tracker").EventTrackerDeps>) {
return new EventTracker({
projectId: "internal",
sendBatch: async (body) => {
sentBodies.push(body);
return Result.ok(new Response());
},
sessionReplaySegmentId: SEG,
...extraDeps,
});
}
afterEach(() => {
__setAsyncContextModeForTesting("auto");
vi.useRealTimers();
});
it("ambientParenting 'exact' drops suspended sync frames; 'best-effort' keeps them", async () => {
__setAsyncContextModeForTesting("sync-stack");
vi.useFakeTimers();
const run = async (mode: "exact" | "best-effort" | undefined) => {
const sentBodies: string[] = [];
const tracker = makeTracker(sentBodies, mode ? { ambientParenting: mode } : {});
try {
tracker.start();
let spanId!: string;
await withSpanImpl((type, opts) => tracker.startSpan(type, opts), "flow", async (span) => {
spanId = span.spanId;
// Synchronous prologue: provably this flow — ambient in BOTH modes.
tracker.trackCustomEvent("in_prologue").catch(() => {});
await Promise.resolve();
// Post-await on the browser fallback: only best-effort keeps the frame.
tracker.trackCustomEvent("post_await").catch(() => {});
});
await advancePastFlush();
const payload = JSON.parse(sentBodies[0] ?? "{}") as { events: { event_type: string, parent_span_ids?: string[] }[] };
return {
spanId,
prologue: payload.events.find((event) => event.event_type === "in_prologue")?.parent_span_ids,
postAwait: payload.events.find((event) => event.event_type === "post_await")?.parent_span_ids,
};
} finally {
tracker.stop();
}
};
const exact = await run(undefined); // default IS exact
expect(exact.prologue).toEqual([exact.spanId]);
expect(exact.postAwait).toBeUndefined();
const best = await run("best-effort");
expect(best.prologue).toEqual([best.spanId]);
expect(best.postAwait).toEqual([best.spanId]);
});
it("span.withSpan nests exactly under the handle and auto-ends the child", async () => {
vi.useFakeTimers();
const sentBodies: string[] = [];
const tracker = makeTracker(sentBodies);
try {
tracker.start();
const parent = tracker.startSpan("outer");
await parent.withSpan("inner", async (child) => {
expect(child.spanType).toBe("inner");
child.trackEvent("evt").catch(() => {});
});
await advancePastFlush();
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[], ended_at_ms: number | null }[],
};
const inner = payload.spans.find((row) => row.span_type === "inner")!;
expect(inner.parent_span_ids).toEqual([parent.spanId]);
expect(inner.ended_at_ms).not.toBeNull();
const evt = payload.events.find((event) => event.event_type === "evt")!;
expect(evt.parent_span_ids).toEqual([parent.spanId, inner.span_id]);
} finally {
tracker.stop();
}
});
it("span.run re-binds the span for a callback's window", async () => {
__setAsyncContextModeForTesting("sync-stack");
vi.useFakeTimers();
const sentBodies: string[] = [];
const tracker = makeTracker(sentBodies);
try {
tracker.start();
const span = tracker.startSpan("flow");
// e.g. a third-party callback, far from any withSpan prologue:
span.run(() => {
tracker.trackCustomEvent("from_callback").catch(() => {});
});
await advancePastFlush();
const payload = JSON.parse(sentBodies[0] ?? "{}") as { events: { event_type: string, parent_span_ids?: string[] }[] };
const evt = payload.events.find((event) => event.event_type === "from_callback")!;
expect(evt.parent_span_ids).toEqual([span.spanId]);
} finally {
tracker.stop();
}
});
it("span.getPropagationHeaders pins the header to the span's frozen chain + segment identity", () => {
const tracker = makeTracker([]);
const parent = tracker.startSpan("outer");
const child = parent.startSpan("inner");
const decoded = decodeSpanContextHeader(child.getPropagationHeaders()["x-hexclave-span-context"]);
expect(decoded).toEqual({
projectId: "internal",
sessionReplaySegmentId: SEG,
customParentSpanIds: [parent.spanId, child.spanId],
});
});
it("span.fetch attaches the pinned header same-origin, skips cross-origin, never clobbers explicit", async () => {
const calls: { input: unknown, init: RequestInit | undefined }[] = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
calls.push({ input, init });
return new Response();
}) as typeof fetch;
try {
const tracker = makeTracker([]);
const span = tracker.startSpan("flow");
await span.fetch("/api/x");
const attached = new Headers(calls[0].init?.headers).get("x-hexclave-span-context");
expect(decodeSpanContextHeader(attached)?.customParentSpanIds).toEqual([span.spanId]);
await span.fetch("https://third-party.example/x");
expect(calls[1].init).toBeUndefined();
const explicit = "v1.explicit-wins";
await span.fetch("/api/y", { headers: { "x-hexclave-span-context": explicit } });
expect(new Headers(calls[2].init?.headers).get("x-hexclave-span-context")).toBe(explicit);
} finally {
globalThis.fetch = originalFetch;
}
});
});

View File

@ -5,7 +5,9 @@ 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";
import { getAmbientSpanRefs, runWithSpanContext, runWithSpanFrame } from "./span-context";
// Runtime-safe: span-propagation only imports TYPES from this module.
import { buildFetchInitWithSpanContext, encodeSpanContextHeader, SPAN_CONTEXT_HEADER, type SpanPropagationContext } from "./span-propagation";
const FLUSH_INTERVAL_MS = 10_000;
const MAX_EVENTS_PER_BATCH = 50;
@ -94,6 +96,36 @@ export type Span = {
trackEvent(eventType: string, data?: Record<string, unknown>, options?: TrackOptions): Promise<void>,
/** Starts a child span of this span. */
startSpan(spanType: string, options?: StartSpanOptions): Span,
/**
* Runs `fn` inside a child span of this span (auto-ends, records errors
* same contract as the app-level withSpan). The HANDLE-based nesting path:
* parentage comes from this span, not ambient context, so it is exact in
* every environment and under any concurrency.
*/
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>,
/**
* Re-enters this span as an ambient parent for `fn` the manual-rebind
* primitive for post-await code, timers, and third-party callbacks. Under an
* exact async-context primitive (server today, browsers once AsyncContext
* ships) the context covers `fn`'s full async extent; on the browser fallback
* it is exact for `fn`'s synchronous window.
*/
run<T>(fn: () => T): T,
/**
* The cross-tier propagation headers pinned to exactly this span (and its
* frozen ancestor chain) for transports the SDK cannot instrument (XHR,
* sendBeacon, WebSocket handshakes). Setting this header on a fetch also
* overrides the automatic ambient one.
*/
getPropagationHeaders(): Record<string, string>,
/**
* `fetch` with the propagation header pinned to exactly this span, so the
* backend span opened by `withSpan({ request })` nests under it immune to
* ambient-context ambiguity. Follows the same same-origin/allowlist policy as
* the automatic wrapper and never overwrites an explicitly-set header.
*/
fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>,
/** Serializable identity + full custom ancestor chain (see SpanRef). */
ref(): SpanRef,
};
@ -277,6 +309,12 @@ export function createInertSpan(spanType: string): Span {
},
trackEvent: () => Promise.resolve(),
startSpan: (childType: string) => createInertSpan(childType),
withSpan: <T,>(childType: string, optionsOrFn: StartSpanOptions | ((child: Span) => Promise<T> | T), maybeFn?: (child: Span) => Promise<T> | T) =>
withSpanImpl((type, opts) => span.startSpan(type, opts), childType, optionsOrFn, maybeFn),
run: <T,>(fn: () => T) => fn(),
getPropagationHeaders: () => ({}),
// Still the caller's REAL request — only the telemetry is inert.
fetch: (input: RequestInfo | URL, init?: RequestInit) => globalThis.fetch(input, init),
ref: () => ({ spanId: span.spanId, parentSpanIds: [] }),
};
return span;
@ -380,6 +418,17 @@ export type EventTrackerDeps = {
// 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,
// Flow-scoped ambient-parenting policy (AnalyticsOptions.ambientParenting).
// "exact" (default): withSpan frames are ambient only when provably from the
// current flow — an exact async-context primitive (ALS/AsyncContext), or the
// synchronous prologue window on the browser fallback. "best-effort": also
// frames whose callback has suspended (zero-glue across awaits; concurrently
// interleaved flows may observe each other's frames). Global spans and the
// handle methods (span.trackEvent/withSpan/fetch/…) are unaffected by this.
ambientParenting?: "exact" | "best-effort",
// Origin policy for span.fetch / propagation headers (same-origin default +
// exact-origin allowlist). Provided by the app from analytics.spanPropagation.
getPropagationPolicy?: () => { selfOrigin: string | null, allowedOrigins: readonly string[] },
};
type TrackedEvent = {
@ -649,6 +698,11 @@ export class EventTracker {
this.trackCustomEvent(eventType, data, { ...trackOptions, parentIds: [span, ...trackOptions?.parentIds ?? []] }),
startSpan: (childType: string, childOptions?: StartSpanOptions) =>
this.startSpan(childType, { ...childOptions, parentIds: [span, ...childOptions?.parentIds ?? []] }),
withSpan: <T,>(childType: string, optionsOrFn: StartSpanOptions | ((child: Span) => Promise<T> | T), maybeFn?: (child: Span) => Promise<T> | T) =>
withSpanImpl((type, opts) => span.startSpan(type, opts), childType, optionsOrFn, maybeFn),
run: <T,>(fn: () => T) => runWithSpanFrame(span.ref(), fn),
getPropagationHeaders: () => ({ [SPAN_CONTEXT_HEADER]: encodeSpanContextHeader(this._spanPropagationContext(span)) }),
fetch: (input: RequestInfo | URL, init?: RequestInit) => this._spanFetch(span, input, init),
ref: () => ({ spanId, parentSpanIds: [...parentSpanIds] }),
};
@ -658,6 +712,38 @@ export class EventTracker {
return span;
}
/** The cross-tier context pinned to exactly `span`: its frozen chain (which
* already includes the globals/ambient captured at creation) + the per-tab
* segment identity. Raw ids the backend applies the prefixes. */
private _spanPropagationContext(span: Span): SpanPropagationContext {
const ref = span.ref();
return {
projectId: this._deps.projectId,
sessionReplaySegmentId: this._sessionReplaySegmentId,
customParentSpanIds: [...ref.parentSpanIds, ref.spanId],
};
}
private _spanFetch(span: Span, input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
try {
const policy = this._deps.getPropagationPolicy?.() ?? {
selfOrigin: typeof window !== "undefined" ? window.location.origin : null,
allowedOrigins: [],
};
const initWithHeader = buildFetchInitWithSpanContext({
input,
init,
headerValue: encodeSpanContextHeader(this._spanPropagationContext(span)),
selfOrigin: policy.selfOrigin,
allowedOrigins: policy.allowedOrigins,
});
return globalThis.fetch(input, initWithHeader ?? init);
} catch {
// Propagation must never break the caller's actual request.
return globalThis.fetch(input, init);
}
}
/**
* Registers a span as an ambient parent for all subsequently created custom
* events and spans (additive with explicit parentIds). Ending the span
@ -690,8 +776,11 @@ 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());
// Enclosing withSpan() frames, outermost first, after the globals. Exact
// primitive (ALS/AsyncContext) → the per-flow store, always. Sync-stack
// fallback → prologue-open frames are provably same-flow and always count;
// suspended frames only under the opt-in "best-effort" policy.
refs.push(...getAmbientSpanRefs({ includeSuspendedSyncFrames: this._deps.ambientParenting === "best-effort" }));
return refs;
}

View File

@ -38,8 +38,8 @@ import { _HexclaveClientAppImplIncomplete } from "./client-app-impl";
import { clientVersion, createCache, createCacheBySession, getDefaultExtraRequestHeaders, getDefaultProjectId, getDefaultPublishableClientKey, getDefaultSecretServerKey, resolveApiUrls, resolveConstructorOptions } from "./common";
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 { getAmbientSpanRefs, runWithSpanFrame } from "./span-context";
import { buildFetchInitWithSpanContext, decodeSpanContextHeader, encodeSpanContextHeader, readSpanContextHeader, SPAN_CONTEXT_HEADER } from "./span-propagation";
import { getServerRequestContext, runWithServerRequestContext, type ServerRequestSpanContext } from "./server-request-context";
import { useAsyncCache } from "./common"; // THIS_LINE_PLATFORM react-like
@ -1856,8 +1856,11 @@ 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());
// Enclosing withSpan() frames — AsyncLocalStorage on servers, isolated per
// request. If ALS is somehow unavailable, fail closed: suspended sync-stack
// frames are only readable under the opt-in "best-effort" policy (the
// provably-same-flow prologue-open frames always count).
refs.push(...getAmbientSpanRefs({ includeSuspendedSyncFrames: this._analyticsOptions?.ambientParenting === "best-effort" }));
return refs;
}
@ -1992,6 +1995,37 @@ export class _HexclaveServerAppImplIncomplete<HasTokenStore extends boolean, Pro
this._trackServerEvent(eventType, data, { ...trackOptions, parentIds: [span, ...trackOptions?.parentIds ?? []] }, userId),
startSpan: (childType: string, childOptions?: StartSpanOptions) =>
this._startServerSpan(childType, { ...childOptions, parentIds: [span, ...childOptions?.parentIds ?? []] }, userId),
withSpan: <T,>(childType: string, optionsOrFn: StartSpanOptions | ((child: Span) => Promise<T> | T), maybeFn?: (child: Span) => Promise<T> | T) =>
withSpanImpl((type, opts) => span.startSpan(type, opts), childType, optionsOrFn, maybeFn),
run: <T,>(fn: () => T) => runWithSpanFrame(span.ref(), fn),
// Pinned to exactly this span's frozen chain; carries the caller's segment
// identity when this span was resolved from a request. Raw ids — the
// receiving backend applies the prefixes.
getPropagationHeaders: () => ({
[SPAN_CONTEXT_HEADER]: encodeSpanContextHeader({
projectId: this.projectId,
...batchContext.sessionReplaySegmentId ? { sessionReplaySegmentId: batchContext.sessionReplaySegmentId } : {},
customParentSpanIds: [...parentSpanIds, spanId],
}),
}),
// Server→server fetch: no CORS and the call itself is explicit intent, so
// the origin policy is bypassed; an explicitly-set header still wins and
// no-cors requests are still skipped.
fetch: (input: RequestInfo | URL, init?: RequestInit) => {
try {
const initWithHeader = buildFetchInitWithSpanContext({
input,
init,
headerValue: span.getPropagationHeaders()[SPAN_CONTEXT_HEADER],
selfOrigin: null,
allowedOrigins: [],
bypassOriginPolicy: true,
});
return globalThis.fetch(input, initWithHeader ?? init);
} catch {
return globalThis.fetch(input, init);
}
},
ref: () => ({ spanId, parentSpanIds: [...parentSpanIds] }),
};

View File

@ -54,6 +54,24 @@ export type AnalyticsOptions = {
* Not serializable dropped when the app is serialized (toClientJson).
*/
waitUntil?: (promise: Promise<unknown>) => void,
/**
* Flow-scoped ambient-parenting policy for `withSpan()` frames.
*
* - `"exact"` (default): a frame is an ambient parent only when it provably
* belongs to the current flow via an exact async-context primitive
* (AsyncLocalStorage on servers/edge today; TC39 AsyncContext in browsers
* once it ships), or during the callback's synchronous window in browsers.
* Never wrong; after an `await` in a browser, use the span handle
* (`span.trackEvent` / `span.withSpan` / `span.fetch` / `span.run`).
* - `"best-effort"`: frames additionally stay ambient across `await`s in
* browsers (zero-glue), at the documented cost that concurrently
* interleaved flows can observe each other's frames and telemetry may pick
* up an extra, wrong custom parent.
*
* Global spans (`setGlobalSpan`) and the handle methods are exact in both
* modes; server runtimes are exact in both modes.
*/
ambientParenting?: "exact" | "best-effort",
/**
* Cross-tier span propagation. When on, the browser attaches an
* `x-hexclave-span-context` header to SAME-ORIGIN outgoing `fetch` requests, so a

View File

@ -1,6 +1,6 @@
import { afterEach, describe, expect, it } from "vitest";
import type { SpanRef } from "./event-tracker";
import { __setAsyncContextModeForTesting, getAmbientSpanRefs, runWithSpanContext } from "./span-context";
import { __setAsyncContextModeForTesting, getAmbientSpanRefs, runWithSpanContext, runWithSpanFrame } from "./span-context";
function ref(spanId: string, parentSpanIds: string[] = []): SpanRef {
return { spanId, parentSpanIds };
@ -93,3 +93,81 @@ describe("span context (sync-stack fallback)", () => {
expect(getAmbientSpanRefs()).toEqual([]);
});
});
describe("span context (exact sync-window reads)", () => {
afterEach(() => {
__setAsyncContextModeForTesting("auto");
});
function exactIds(): string[] {
return getAmbientSpanRefs({ includeSuspendedSyncFrames: false }).map((frame) => frame.spanId);
}
it("sees prologue-open frames only; suspended frames are best-effort only", async () => {
__setAsyncContextModeForTesting("sync-stack");
let releaseFirst!: () => void;
const firstBlocked = new Promise<void>((resolve) => {
releaseFirst = resolve;
});
const first = runWithSpanContext(ref("flow1"), async () => {
await firstBlocked;
});
// flow1 is suspended at its await: no longer provably the current flow.
expect(exactIds()).toEqual([]);
expect(ambientIds()).toEqual(["flow1"]);
await runWithSpanContext(ref("flow2"), async () => {
// flow2's synchronous prologue: exact reads see ONLY flow2 — this is the
// property that makes cross-flow misattribution impossible in exact mode.
expect(exactIds()).toEqual(["flow2"]);
expect(ambientIds()).toEqual(["flow1", "flow2"]);
});
releaseFirst();
await first;
expect(getAmbientSpanRefs()).toEqual([]);
});
it("keeps outer frames exact through SYNCHRONOUS nesting, and drops them after an await", async () => {
__setAsyncContextModeForTesting("sync-stack");
await runWithSpanContext(ref("a"), async () => {
expect(exactIds()).toEqual(["a"]);
await runWithSpanContext(ref("b"), async () => {
// b's prologue executes while a's callback is still mid-statement, so
// both frames are provably the current flow.
expect(exactIds()).toEqual(["a", "b"]);
});
// Back after an await: a's prologue is over — exact reads fail closed,
// best-effort reads still see the suspended frame.
expect(exactIds()).toEqual([]);
expect(ambientIds()).toEqual(["a"]);
});
});
it("runWithSpanFrame re-binds a frame exactly for a synchronous window", () => {
__setAsyncContextModeForTesting("sync-stack");
const result = runWithSpanFrame(ref("manual"), () => {
expect(exactIds()).toEqual(["manual"]);
return 42;
});
expect(result).toBe(42);
expect(getAmbientSpanRefs()).toEqual([]);
});
it("runWithSpanFrame keeps a suspended frame for best-effort readers until settle", async () => {
__setAsyncContextModeForTesting("sync-stack");
let release!: () => void;
const blocked = new Promise<void>((resolve) => {
release = resolve;
});
const promise = runWithSpanFrame(ref("callback"), async () => {
expect(exactIds()).toEqual(["callback"]);
await blocked;
});
expect(exactIds()).toEqual([]);
expect(ambientIds()).toEqual(["callback"]);
release();
await promise;
// One extra microtask for the .finally(pop) chained on the result.
await Promise.resolve();
expect(getAmbientSpanRefs()).toEqual([]);
});
});

View File

@ -28,8 +28,18 @@ type AsyncLocalStorageLike = {
let als: AsyncLocalStorageLike | null = null;
let alsInitPromise: Promise<void> | null = null;
// Sync-stack fallback frames (browsers / before ALS finishes loading).
const syncStack: SpanRef[] = [];
// Whether the ALS probe has finished (either way). Once settled, context entry
// can run the callback SYNCHRONOUSLY (no await first), which is what makes the
// sync-window guarantee compose across nested withSpan calls.
let alsSettled = false;
// Sync-stack fallback frames (browsers / before ALS finishes loading). A frame's
// `prologueOpen` is true only while its callback's SYNCHRONOUS prologue is still
// executing — JS sync execution is single-threaded and uninterruptible, so a
// prologue-open frame provably belongs to the currently-running flow. Once the
// callback suspends (returns its promise), the frame stays for best-effort
// readers but is no longer provably ours.
type SyncFrame = { ref: SpanRef, prologueOpen: boolean };
const syncStack: SyncFrame[] = [];
async function ensureAsyncContext(): Promise<void> {
if (alsInitPromise) return await alsInitPromise;
@ -38,6 +48,8 @@ async function ensureAsyncContext(): Promise<void> {
// 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.
// (When browsers ship TC39 AsyncContext, probe it here first — the whole
// sync-window machinery below then never engages.)
const specifier = "node:async_hooks";
const mod = await import(/* @vite-ignore */ /* webpackIgnore: true */ specifier) as { AsyncLocalStorage?: new () => AsyncLocalStorageLike };
if (typeof mod.AsyncLocalStorage === "function") {
@ -46,6 +58,8 @@ async function ensureAsyncContext(): Promise<void> {
} catch {
// Browser: no async-context primitive; the sync stack is the fallback.
als = null;
} finally {
alsSettled = true;
}
})();
return await alsInitPromise;
@ -54,35 +68,102 @@ async function ensureAsyncContext(): Promise<void> {
/**
* The SpanRefs of all enclosing withSpan() frames, outermost first. Consumed by
* the parent-resolution logic as ambient parents (alongside global spans).
*
* With an exact primitive (ALS/AsyncContext) the store is per-flow and always
* returned in full. On the sync-stack fallback, `includeSuspendedSyncFrames`
* (default true the historical behavior) decides whether frames whose
* callback has already suspended are included: `false` is the "exact" policy
* (only provably-same-flow prologue-open frames never another flow's),
* `true` is the "best-effort" policy (zero-glue across awaits, may mix
* concurrently interleaved flows).
*/
export function getAmbientSpanRefs(): SpanRef[] {
export function getAmbientSpanRefs(opts?: { includeSuspendedSyncFrames?: boolean }): SpanRef[] {
const store = als?.getStore();
if (store) return [...store];
return [...syncStack];
const includeSuspended = opts?.includeSuspendedSyncFrames ?? true;
return syncStack.filter((frame) => includeSuspended || frame.prologueOpen).map((frame) => frame.ref);
}
/**
* 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.
* Whether an EXACT async-context primitive backs the ambient frames right now
* (AsyncLocalStorage today; TC39 AsyncContext when browsers ship it). When
* false, getAmbientSpanRefs() serves the shared sync stack, which can mix
* frames from concurrently interleaved async flows readers use this to apply
* the `ambientParenting` policy ("exact" drops the frames, "best-effort" keeps
* them). Frames are always RECORDED either way; only reading is gated, so the
* policy can differ per consumer without losing context.
*/
export function isExactAsyncContextActive(): boolean {
return als !== null;
}
/**
* Runs `fn` with `frame` appended to the ambient span context. Awaits the ALS
* probe on the very first call (so server code gets isolation from the first
* withSpan() rather than racing the module load); once the probe has settled it
* enters the context SYNCHRONOUSLY `fn`'s synchronous prologue runs inside the
* caller's own sync block, which is what makes prologue-open frames compose
* across nested withSpan calls on the sync-stack fallback.
*/
export async function runWithSpanContext<T>(frame: SpanRef, fn: () => Promise<T>): Promise<T> {
await ensureAsyncContext();
if (!alsSettled) await ensureAsyncContext();
if (als) {
const enclosing = als.getStore() ?? [];
return await als.run([...enclosing, frame], fn);
}
syncStack.push(frame);
const syncFrame: SyncFrame = { ref: frame, prologueOpen: true };
syncStack.push(syncFrame);
try {
return await fn();
const result = fn();
// fn returned its promise — the synchronous prologue is over. The frame
// stays on the stack (suspended) for best-effort readers until settle.
syncFrame.prologueOpen = false;
return await result;
} finally {
syncFrame.prologueOpen = false;
// 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);
const index = syncStack.lastIndexOf(syncFrame);
if (index !== -1) syncStack.splice(index, 1);
}
}
/**
* Re-enters `ref` as an ambient frame for `fn` the manual-rebind primitive
* behind `span.run()`, for post-await code, timers, and third-party callbacks.
* Synchronous: under ALS/AsyncContext the context covers `fn`'s full async
* extent; on the sync-stack fallback it is exact for `fn`'s synchronous window,
* and if `fn` returns a promise the (suspended) frame additionally stays
* visible to best-effort readers until it settles.
*/
export function runWithSpanFrame<T>(ref: SpanRef, fn: () => T): T {
if (!alsSettled) ensureAsyncContext().catch(() => {});
if (als) {
const enclosing = als.getStore() ?? [];
return als.run([...enclosing, ref], fn);
}
const syncFrame: SyncFrame = { ref, prologueOpen: true };
syncStack.push(syncFrame);
const pop = () => {
const index = syncStack.lastIndexOf(syncFrame);
if (index !== -1) syncStack.splice(index, 1);
};
try {
const result = fn();
syncFrame.prologueOpen = false;
if (result instanceof Promise) {
result.finally(pop).catch(() => {});
} else {
pop();
}
return result;
} catch (error) {
syncFrame.prologueOpen = false;
pop();
throw error;
}
}
/**
* Test hook: forces the sync-stack fallback (as if ALS failed to load) or
* resets to automatic detection. Never call outside tests.
@ -91,9 +172,11 @@ export function __setAsyncContextModeForTesting(mode: "sync-stack" | "auto"): vo
if (mode === "sync-stack") {
als = null;
alsInitPromise = Promise.resolve();
alsSettled = true;
} else {
als = null;
alsInitPromise = null;
alsSettled = false;
}
syncStack.length = 0;
}

View File

@ -201,6 +201,40 @@ function requestInputMode(input: unknown, init: RequestInit | undefined): string
return undefined;
}
/**
* Builds the RequestInit for attaching a span-context header to one fetch call,
* or returns null when the header must NOT be attached: `no-cors` mode (custom
* headers are stripped there anyway), a target outside the origin policy
* (unless `bypassOriginPolicy` server-side explicit span.fetch, where CORS
* does not exist and the call itself is the intent), or a header the caller
* already set explicitly (their precise intent always wins). Reproduces native
* header precedence init.headers over a Request's own headers and never
* mutates the caller's objects. Shared by the auto fetch wrapper and span.fetch.
*/
export function buildFetchInitWithSpanContext(opts: {
input: unknown,
init: RequestInit | undefined,
headerValue: string,
selfOrigin: string | null,
allowedOrigins: readonly string[],
bypassOriginPolicy?: boolean,
}): RequestInit | null {
if (requestInputMode(opts.input, opts.init) === "no-cors") return null;
if (!opts.bypassOriginPolicy) {
const url = requestInputUrl(opts.input);
if (url === null) return null;
if (!shouldPropagateSpanContext({ targetUrl: url, selfOrigin: opts.selfOrigin, allowedOrigins: opts.allowedOrigins })) return null;
}
const isRequest = typeof Request !== "undefined" && opts.input instanceof Request;
const base: HeadersInit | undefined = opts.init?.headers !== undefined
? opts.init.headers
: (isRequest ? (opts.input as Request).headers : undefined);
const headers = new Headers(base);
if (headers.has(SPAN_CONTEXT_HEADER)) return null;
headers.set(SPAN_CONTEXT_HEADER, opts.headerValue);
return { ...opts.init, headers };
}
export type FetchSpanPropagationOptions = {
/** The current ambient client context, or null when there is nothing to link. */
getContext: () => SpanPropagationContext | null,
@ -230,32 +264,17 @@ export function installFetchSpanPropagation(options: FetchSpanPropagationOptions
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,
const context = options.getContext();
if (context) {
const initWithHeader = buildFetchInitWithSpanContext({
input,
init,
headerValue: encodeSpanContextHeader(context),
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);
// An explicitly-set header (getSpanPropagationHeaders) always wins
// over the ambient context — it is the caller's precise intent, e.g.
// pinning one request to one span under interleaved async flows.
if (!headers.has(SPAN_CONTEXT_HEADER)) {
headers.set(SPAN_CONTEXT_HEADER, encodeSpanContextHeader(context));
return callFetch(input, { ...init, headers });
}
}
});
if (initWithHeader) {
return callFetch(input, initWithHeader);
}
}
} catch {

View File

@ -147,13 +147,20 @@ export type StackClientApp<HasTokenStore extends boolean = boolean, ProjectId ex
/**
* 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`.
* for everything created 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 extent follows `analytics.ambientParenting`. Under the default
* (`"exact"`), ambient parenting covers the callback's full async extent on
* runtimes with an exact async-context primitive (servers/edge today,
* browsers once TC39 AsyncContext ships) and the callback's synchronous
* window in browsers; after an `await` in a browser, parent via the handle
* you already have `span.trackEvent` / `span.withSpan` / `span.fetch` /
* `span.run` which is exact everywhere. `"best-effort"` keeps frames
* ambient across browser `await`s (zero-glue), accepting that concurrently
* interleaved flows can observe each other's frames. 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>,