From e6038897ae3c7eb2afaad6963940dc410b94b6e7 Mon Sep 17 00:00:00 2001 From: mantrakp04 Date: Fri, 3 Jul 2026 13:38:17 -0700 Subject: [PATCH] feat(analytics): dual-mode ambient parenting (exact/best-effort) + span handle kit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../apps/implementations/client-app-impl.ts | 5 + .../implementations/event-tracker.test.ts | 147 ++++++++++++++++++ .../apps/implementations/event-tracker.ts | 95 ++++++++++- .../apps/implementations/server-app-impl.ts | 42 ++++- .../apps/implementations/session-replay.ts | 18 +++ .../apps/implementations/span-context.test.ts | 80 +++++++++- .../apps/implementations/span-context.ts | 105 +++++++++++-- .../apps/implementations/span-propagation.ts | 67 +++++--- .../apps/interfaces/client-app.ts | 21 ++- 9 files changed, 530 insertions(+), 50 deletions(-) diff --git a/packages/template/src/lib/hexclave-app/apps/implementations/client-app-impl.ts b/packages/template/src/lib/hexclave-app/apps/implementations/client-app-impl.ts index 87845a694..e6ca51655 100644 --- a/packages/template/src/lib/hexclave-app/apps/implementations/client-app-impl.ts +++ b/packages/template/src/lib/hexclave-app/apps/implementations/client-app-impl.ts @@ -745,6 +745,11 @@ export class _HexclaveClientAppImplIncomplete ({ + selfOrigin: typeof window !== "undefined" ? window.location.origin : null, + allowedOrigins: this._analyticsOptions?.spanPropagation?.targets ?? [], + }), }); this._eventTracker.start(); } diff --git a/packages/template/src/lib/hexclave-app/apps/implementations/event-tracker.test.ts b/packages/template/src/lib/hexclave-app/apps/implementations/event-tracker.test.ts index 75f8ca7ce..36268d088 100644 --- a/packages/template/src/lib/hexclave-app/apps/implementations/event-tracker.test.ts +++ b/packages/template/src/lib/hexclave-app/apps/implementations/event-tracker.test.ts @@ -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) { + 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; + } + }); +}); diff --git a/packages/template/src/lib/hexclave-app/apps/implementations/event-tracker.ts b/packages/template/src/lib/hexclave-app/apps/implementations/event-tracker.ts index d4f2b010c..ff42800a0 100644 --- a/packages/template/src/lib/hexclave-app/apps/implementations/event-tracker.ts +++ b/packages/template/src/lib/hexclave-app/apps/implementations/event-tracker.ts @@ -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, options?: TrackOptions): Promise, /** 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(spanType: string, fn: (span: Span) => Promise | T): Promise, + withSpan(spanType: string, options: StartSpanOptions, fn: (span: Span) => Promise | T): Promise, + /** + * 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(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, + /** + * `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, /** 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: (childType: string, optionsOrFn: StartSpanOptions | ((child: Span) => Promise | T), maybeFn?: (child: Span) => Promise | T) => + withSpanImpl((type, opts) => span.startSpan(type, opts), childType, optionsOrFn, maybeFn), + run: (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) => 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: (childType: string, optionsOrFn: StartSpanOptions | ((child: Span) => Promise | T), maybeFn?: (child: Span) => Promise | T) => + withSpanImpl((type, opts) => span.startSpan(type, opts), childType, optionsOrFn, maybeFn), + run: (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 { + 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; } diff --git a/packages/template/src/lib/hexclave-app/apps/implementations/server-app-impl.ts b/packages/template/src/lib/hexclave-app/apps/implementations/server-app-impl.ts index 1ada8caaf..001424488 100644 --- a/packages/template/src/lib/hexclave-app/apps/implementations/server-app-impl.ts +++ b/packages/template/src/lib/hexclave-app/apps/implementations/server-app-impl.ts @@ -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 this._startServerSpan(childType, { ...childOptions, parentIds: [span, ...childOptions?.parentIds ?? []] }, userId), + withSpan: (childType: string, optionsOrFn: StartSpanOptions | ((child: Span) => Promise | T), maybeFn?: (child: Span) => Promise | T) => + withSpanImpl((type, opts) => span.startSpan(type, opts), childType, optionsOrFn, maybeFn), + run: (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] }), }; diff --git a/packages/template/src/lib/hexclave-app/apps/implementations/session-replay.ts b/packages/template/src/lib/hexclave-app/apps/implementations/session-replay.ts index c0035110c..01a14b39a 100644 --- a/packages/template/src/lib/hexclave-app/apps/implementations/session-replay.ts +++ b/packages/template/src/lib/hexclave-app/apps/implementations/session-replay.ts @@ -54,6 +54,24 @@ export type AnalyticsOptions = { * Not serializable — dropped when the app is serialized (toClientJson). */ waitUntil?: (promise: Promise) => 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 diff --git a/packages/template/src/lib/hexclave-app/apps/implementations/span-context.test.ts b/packages/template/src/lib/hexclave-app/apps/implementations/span-context.test.ts index 5a0a2a61d..69f575648 100644 --- a/packages/template/src/lib/hexclave-app/apps/implementations/span-context.test.ts +++ b/packages/template/src/lib/hexclave-app/apps/implementations/span-context.test.ts @@ -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((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((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([]); + }); +}); diff --git a/packages/template/src/lib/hexclave-app/apps/implementations/span-context.ts b/packages/template/src/lib/hexclave-app/apps/implementations/span-context.ts index cc0d02feb..3cf97ef30 100644 --- a/packages/template/src/lib/hexclave-app/apps/implementations/span-context.ts +++ b/packages/template/src/lib/hexclave-app/apps/implementations/span-context.ts @@ -28,8 +28,18 @@ type AsyncLocalStorageLike = { let als: AsyncLocalStorageLike | null = null; let alsInitPromise: Promise | 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 { if (alsInitPromise) return await alsInitPromise; @@ -38,6 +48,8 @@ async function ensureAsyncContext(): Promise { // 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 { } 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 { /** * 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(frame: SpanRef, fn: () => Promise): Promise { - 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(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; } diff --git a/packages/template/src/lib/hexclave-app/apps/implementations/span-propagation.ts b/packages/template/src/lib/hexclave-app/apps/implementations/span-propagation.ts index 25fba8bb0..d4a741828 100644 --- a/packages/template/src/lib/hexclave-app/apps/implementations/span-propagation.ts +++ b/packages/template/src/lib/hexclave-app/apps/implementations/span-propagation.ts @@ -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 => { 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 { diff --git a/packages/template/src/lib/hexclave-app/apps/interfaces/client-app.ts b/packages/template/src/lib/hexclave-app/apps/interfaces/client-app.ts index f2a7178c6..a02997e83 100644 --- a/packages/template/src/lib/hexclave-app/apps/interfaces/client-app.ts +++ b/packages/template/src/lib/hexclave-app/apps/interfaces/client-app.ts @@ -147,13 +147,20 @@ export type StackClientApp(spanType: string, fn: (span: Span) => Promise | T): Promise, withSpan(spanType: string, options: StartSpanOptions, fn: (span: Span) => Promise | T): Promise,