From d1f93b8fda2560645aa7666a4ae5f995862b8990 Mon Sep 17 00:00:00 2001 From: mantrakp04 Date: Thu, 2 Jul 2026 11:51:08 -0700 Subject: [PATCH] feat(analytics): withSpan + AsyncLocalStorage ambient parenting + waitUntil serverless hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .../apps/implementations/client-app-impl.ts | 13 +- .../implementations/event-tracker.test.ts | 128 +++++++++++++++++- .../apps/implementations/event-tracker.ts | 98 +++++++++++++- .../apps/implementations/server-app-impl.ts | 20 ++- .../apps/implementations/session-replay.ts | 15 ++ .../apps/implementations/span-context.test.ts | 95 +++++++++++++ .../apps/implementations/span-context.ts | 99 ++++++++++++++ .../apps/interfaces/client-app.ts | 13 ++ .../apps/interfaces/server-app.ts | 8 ++ 9 files changed, 479 insertions(+), 10 deletions(-) create mode 100644 packages/template/src/lib/hexclave-app/apps/implementations/span-context.test.ts create mode 100644 packages/template/src/lib/hexclave-app/apps/implementations/span-context.ts 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 e94990000..793816343 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 @@ -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; - 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(spanType: string, fn: (span: Span) => Promise | T): Promise; + withSpan(spanType: string, options: StartSpanOptions, fn: (span: Span) => Promise | T): Promise; + withSpan(spanType: string, optionsOrFn: StartSpanOptions | ((span: Span) => Promise | T), maybeFn?: (span: Span) => Promise | T): Promise { + // 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 { const user = await this.getUser({ tokenStore: options?.tokenStore ?? undefined as any }); if (user) { 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 1a3c7aa27..75f8ca7ce 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 @@ -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 }[] }; + 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[] = []; + 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 = ""; 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 7142e1e00..c6b0773f0 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,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, 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(); + 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(); 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( + startSpan: (spanType: string, options?: StartSpanOptions) => Span, + spanType: string, + optionsOrFn: StartSpanOptions | ((span: Span) => Promise | T), + maybeFn?: (span: Span) => Promise | T, +): Promise { + 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) => 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; } 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 cbfa1c443..d5111c3a3 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,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 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) => 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; 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 new file mode 100644 index 000000000..5a0a2a61d --- /dev/null +++ b/packages/template/src/lib/hexclave-app/apps/implementations/span-context.test.ts @@ -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 = {}; + 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((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([]); + }); +}); 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 new file mode 100644 index 000000000..cc0d02feb --- /dev/null +++ b/packages/template/src/lib/hexclave-app/apps/implementations/span-context.ts @@ -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: (store: SpanRef[], fn: () => T) => T, + getStore: () => SpanRef[] | undefined, +}; + +let als: AsyncLocalStorageLike | null = null; +let alsInitPromise: Promise | null = null; +// Sync-stack fallback frames (browsers / before ALS finishes loading). +const syncStack: SpanRef[] = []; + +async function ensureAsyncContext(): Promise { + 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(frame: SpanRef, fn: () => Promise): Promise { + 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; +} 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 aaa293b7e..5f0d90b1c 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 @@ -145,6 +145,19 @@ export type StackClientApp, + /** + * 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(spanType: string, fn: (span: Span) => Promise | T): Promise, + withSpan(spanType: string, options: StartSpanOptions, fn: (span: Span) => Promise | T): Promise, + // note: we don't special-case 'anonymous' here to return non-null, see GetPartialUserOptions for more details getPartialUser(options: GetCurrentPartialUserOptions & { from: 'token' }): Promise, getPartialUser(options: GetCurrentPartialUserOptions & { from: 'convex' }): Promise, diff --git a/packages/template/src/lib/hexclave-app/apps/interfaces/server-app.ts b/packages/template/src/lib/hexclave-app/apps/interfaces/server-app.ts index ee6ffcb86..62d51693a 100644 --- a/packages/template/src/lib/hexclave-app/apps/interfaces/server-app.ts +++ b/packages/template/src/lib/hexclave-app/apps/interfaces/server-app.ts @@ -52,6 +52,14 @@ export type StackServerApp(spanType: string, fn: (span: Span) => Promise | T): Promise, + withSpan(spanType: string, options: StartSpanOptions & { userId?: string }, fn: (span: Span) => Promise | T): Promise, + // IF_PLATFORM react-like useUser(options: GetCurrentUserOptions & { or: 'redirect' }): ProjectCurrentServerUser, useUser(options: GetCurrentUserOptions & { or: 'throw' }): ProjectCurrentServerUser,