mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-20 21:29:36 +08:00
feat(analytics): withSpan + AsyncLocalStorage ambient parenting + waitUntil serverless hook
- app.withSpan(type, [options,] fn): auto-start, ambient parent for everything created inside fn (client + server via virtual startSpan dispatch), auto-end on settle; on throw records data.error and rethrows (telemetry never fails fn) - span-context.ts: AsyncLocalStorage from the BUILT-IN node:async_hooks via a bundler-opaque dynamic import (works on Node/Bun/Deno/Workers/Edge; browsers fall back to a module-level sync stack with documented interleaving limits) - root: true drops all ambient parents; excludeParentIds filters the FINAL merged parent list (an excluded span stays excluded even when re-entering via a kept child's chain) — both on TrackOptions and StartSpanOptions - analytics.waitUntil constructor option: every batch-send promise (browser tracker + server coalescer) is passed to it so un-awaited sends survive serverless teardown; stripped from serialized app options (toClientJson) Tests: 5 span-context cases (ALS nesting across awaits, parallel isolation, sync-stack cleanup), 4 event-tracker cases (withSpan auto-end + ambient parenting, error path, root/exclude final-list semantics, waitUntil hook)
This commit is contained in:
parent
b8215a8595
commit
d1f93b8fda
@ -61,7 +61,7 @@ import { ActiveSession, Auth, BaseUser, CurrentUser, InternalUserExtra, OAuthPro
|
||||
import { StackClientApp, StackClientAppConstructorOptions, StackClientAppJson } from "../interfaces/client-app";
|
||||
import { _HexclaveAdminAppImplIncomplete } from "./admin-app-impl";
|
||||
import { TokenObject, clientVersion, createCache, createCacheBySession, createEmptyTokenStore, getAnalyticsBaseUrl, getDefaultExtraRequestHeaders, getDefaultProjectId, getDefaultPublishableClientKey, getUrls, resolveApiUrls, resolveConstructorOptions } from "./common";
|
||||
import { createInertSpan, EventTracker, getCustomTelemetryDataError, getCustomTelemetryNameError, rejectedPreCaught, warnTelemetryUnavailableOnce, type Span, type StartSpanOptions, type TrackOptions } from "./event-tracker";
|
||||
import { createInertSpan, EventTracker, getCustomTelemetryDataError, getCustomTelemetryNameError, rejectedPreCaught, warnTelemetryUnavailableOnce, withSpanImpl, type Span, type StartSpanOptions, type TrackOptions } from "./event-tracker";
|
||||
import type { CrossDomainHandoffParams } from "./redirect-page-urls";
|
||||
import { crossDomainAuthQueryParams, getCrossDomainHandoffParamsFromCurrentUrl, planRedirectToHandler } from "./redirect-page-urls";
|
||||
import { subscribeSessionRefresh } from "./session-refresh-subscription";
|
||||
@ -256,7 +256,7 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
|
||||
protected readonly _urlOptions: HandlerUrlOptions;
|
||||
protected readonly _oauthScopesOnSignIn: Partial<OAuthScopesOnSignIn>;
|
||||
|
||||
private readonly _analyticsOptions: AnalyticsOptions | undefined;
|
||||
protected readonly _analyticsOptions: AnalyticsOptions | undefined;
|
||||
private _sessionRecorder: SessionRecorder | null = null;
|
||||
protected _eventTracker: EventTracker | null = null;
|
||||
|
||||
@ -743,6 +743,7 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
|
||||
return await this._interface.sendAnalyticsEventBatch(body, await getAnalyticsSession(), opts);
|
||||
},
|
||||
sessionReplaySegmentId,
|
||||
registerBackgroundTask: this._analyticsOptions?.waitUntil,
|
||||
});
|
||||
this._eventTracker.start();
|
||||
}
|
||||
@ -4037,6 +4038,14 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
|
||||
await this._eventTracker?.flush();
|
||||
}
|
||||
|
||||
withSpan<T>(spanType: string, fn: (span: Span) => Promise<T> | T): Promise<T>;
|
||||
withSpan<T>(spanType: string, options: StartSpanOptions, fn: (span: Span) => Promise<T> | T): Promise<T>;
|
||||
withSpan<T>(spanType: string, optionsOrFn: StartSpanOptions | ((span: Span) => Promise<T> | T), maybeFn?: (span: Span) => Promise<T> | T): Promise<T> {
|
||||
// this.startSpan dispatches virtually, so the server app's userId-aware
|
||||
// startSpan is used automatically when called on a StackServerApp.
|
||||
return withSpanImpl((type, options) => this.startSpan(type, options), spanType, optionsOrFn, maybeFn);
|
||||
}
|
||||
|
||||
async getAccessToken(options?: { tokenStore?: TokenStoreInit }): Promise<string | null> {
|
||||
const user = await this.getUser({ tokenStore: options?.tokenStore ?? undefined as any });
|
||||
if (user) {
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
import { KnownErrors } from "@hexclave/shared/dist/known-errors";
|
||||
import { Result } from "@hexclave/shared/dist/utils/results";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { EventTracker } from "./event-tracker";
|
||||
import { EventTracker, withSpanImpl } from "./event-tracker";
|
||||
|
||||
async function advancePastFlush() {
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
@ -734,6 +734,132 @@ describe("EventTracker", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("withSpan auto-ends the span and ambient-parents everything created inside", async () => {
|
||||
const sentBodies: string[] = [];
|
||||
const tracker = new EventTracker({
|
||||
projectId: "internal",
|
||||
sendBatch: async (body) => {
|
||||
sentBodies.push(body);
|
||||
return Result.ok(new Response());
|
||||
},
|
||||
});
|
||||
|
||||
let innerSpanId = "";
|
||||
const result = await withSpanImpl(
|
||||
(type, options) => tracker.startSpan(type, options),
|
||||
"outer-flow",
|
||||
async (outer) => {
|
||||
expect(outer.isEnded).toBe(false);
|
||||
const inner = tracker.startSpan("inner-step"); // ambient parent: outer
|
||||
innerSpanId = inner.spanId;
|
||||
tracker.trackCustomEvent("inner_event").catch(() => {}); // ambient parent: outer
|
||||
inner.end().catch(() => {});
|
||||
return 42;
|
||||
},
|
||||
);
|
||||
expect(result).toBe(42);
|
||||
|
||||
await tracker.flush();
|
||||
const payload = JSON.parse(sentBodies[0] ?? "{}") as {
|
||||
events: { event_type: string, parent_span_ids?: string[] }[],
|
||||
spans?: { span_id: string, span_type: string, ended_at_ms: number | null, parent_span_ids: string[] }[],
|
||||
};
|
||||
const outerRow = payload.spans!.find((row) => row.span_type === "outer-flow")!;
|
||||
expect(outerRow.ended_at_ms).not.toBeNull(); // auto-ended on settle
|
||||
expect(outerRow.parent_span_ids).toEqual([]); // its own parents come from the ENCLOSING context
|
||||
const innerRow = payload.spans!.find((row) => row.span_id === innerSpanId)!;
|
||||
expect(innerRow.parent_span_ids).toEqual([outerRow.span_id]);
|
||||
const innerEvent = payload.events.find((event) => event.event_type === "inner_event")!;
|
||||
expect(innerEvent.parent_span_ids).toEqual([outerRow.span_id]);
|
||||
|
||||
// The frame is gone after withSpan settles: no ambient parent here.
|
||||
tracker.trackCustomEvent("after_frame").catch(() => {});
|
||||
await tracker.flush();
|
||||
const second = JSON.parse(sentBodies[1] ?? "{}") as { events: { event_type: string, parent_span_ids?: string[] }[] };
|
||||
expect(second.events.find((event) => event.event_type === "after_frame")!.parent_span_ids).toBeUndefined();
|
||||
});
|
||||
|
||||
it("withSpan records data.error, ends the span, and rethrows on failure", async () => {
|
||||
const sentBodies: string[] = [];
|
||||
const tracker = new EventTracker({
|
||||
projectId: "internal",
|
||||
sendBatch: async (body) => {
|
||||
sentBodies.push(body);
|
||||
return Result.ok(new Response());
|
||||
},
|
||||
});
|
||||
|
||||
await expect(withSpanImpl(
|
||||
(type, options) => tracker.startSpan(type, options),
|
||||
"failing-flow",
|
||||
async () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
)).rejects.toThrow("boom");
|
||||
|
||||
await tracker.flush();
|
||||
const payload = JSON.parse(sentBodies[0] ?? "{}") as { spans?: { span_type: string, ended_at_ms: number | null, data: Record<string, unknown> }[] };
|
||||
const row = payload.spans!.find((entry) => entry.span_type === "failing-flow")!;
|
||||
expect(row.ended_at_ms).not.toBeNull();
|
||||
expect(row.data).toEqual({ error: "boom" });
|
||||
});
|
||||
|
||||
it("root drops all ambient parents and excludeParentIds filters the FINAL merged list", async () => {
|
||||
const sentBodies: string[] = [];
|
||||
const tracker = new EventTracker({
|
||||
projectId: "internal",
|
||||
sendBatch: async (body) => {
|
||||
sentBodies.push(body);
|
||||
return Result.ok(new Response());
|
||||
},
|
||||
});
|
||||
|
||||
await withSpanImpl(
|
||||
(type, options) => tracker.startSpan(type, options),
|
||||
"outer",
|
||||
async (outer) => {
|
||||
const detached = tracker.startSpan("detached", { root: true });
|
||||
expect(detached.ref().parentSpanIds).toEqual([]);
|
||||
|
||||
const child = tracker.startSpan("child"); // chain: [outer]
|
||||
// Excluding outer removes it from the final list even though it
|
||||
// re-enters via child's frozen chain — deliberate final-list semantics:
|
||||
// this row is a child of `child` but NOT a descendant of `outer`.
|
||||
tracker.trackCustomEvent("evt", {}, { parentIds: [child], excludeParentIds: [outer] }).catch(() => {});
|
||||
child.end().catch(() => {});
|
||||
detached.end().catch(() => {});
|
||||
},
|
||||
);
|
||||
|
||||
await tracker.flush();
|
||||
const payload = JSON.parse(sentBodies[0] ?? "{}") as {
|
||||
events: { event_type: string, parent_span_ids?: string[] }[],
|
||||
spans?: { span_id: string, span_type: string, parent_span_ids: string[] }[],
|
||||
};
|
||||
const outerRow = payload.spans!.find((row) => row.span_type === "outer")!;
|
||||
const childRow = payload.spans!.find((row) => row.span_type === "child")!;
|
||||
expect(payload.spans!.find((row) => row.span_type === "detached")!.parent_span_ids).toEqual([]);
|
||||
expect(childRow.parent_span_ids).toEqual([outerRow.span_id]);
|
||||
expect(payload.events.find((event) => event.event_type === "evt")!.parent_span_ids).toEqual([childRow.span_id]);
|
||||
});
|
||||
|
||||
it("passes every batch-send promise to registerBackgroundTask (waitUntil hook)", async () => {
|
||||
const registered: Promise<unknown>[] = [];
|
||||
const tracker = new EventTracker({
|
||||
projectId: "internal",
|
||||
sendBatch: async () => Result.ok(new Response()),
|
||||
registerBackgroundTask: (promise) => registered.push(promise),
|
||||
});
|
||||
|
||||
tracker.trackCustomEvent("first").catch(() => {});
|
||||
await tracker.flush();
|
||||
tracker.trackCustomEvent("second").catch(() => {});
|
||||
await tracker.flush();
|
||||
|
||||
expect(registered).toHaveLength(2);
|
||||
await expect(Promise.all(registered)).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("silently disables when client interface returns ANALYTICS_NOT_ENABLED as an error", async () => {
|
||||
vi.useFakeTimers();
|
||||
document.body.innerHTML = "<button>Click me</button>";
|
||||
|
||||
@ -5,6 +5,7 @@ import { buildElementsChain, ELEMENTS_CHAIN_MAX_DEPTH } from "@hexclave/shared/d
|
||||
import { runAsynchronously } from "@hexclave/shared/dist/utils/promises";
|
||||
import { Result } from "@hexclave/shared/dist/utils/results";
|
||||
import { generateUuid, isAdBlockerNetworkError, isAnalyticsNotEnabledError } from "./session-replay";
|
||||
import { getAmbientSpanRefs, runWithSpanContext } from "./span-context";
|
||||
|
||||
const FLUSH_INTERVAL_MS = 10_000;
|
||||
const MAX_EVENTS_PER_BATCH = 50;
|
||||
@ -47,12 +48,29 @@ export type ParentRef = string | SpanRef | Span;
|
||||
|
||||
export type TrackOptions = {
|
||||
parentIds?: ParentRef[],
|
||||
/**
|
||||
* Drop ALL ambient parents (global spans + enclosing withSpan context); only
|
||||
* explicit parentIds apply. This is the opt-out for ambient parenting.
|
||||
*/
|
||||
root?: boolean,
|
||||
/**
|
||||
* Drop specific ambient parents ("I don't want THAT span as a parent").
|
||||
* Filters the FINAL merged parent list — an excluded span stays excluded even
|
||||
* when it re-enters via a kept child's frozen chain, which means "descendants
|
||||
* of the excluded span" queries will not match this item (by design; that is
|
||||
* the literal meaning of the option, not a dedupe bug).
|
||||
*/
|
||||
excludeParentIds?: ParentRef[],
|
||||
};
|
||||
|
||||
export type StartSpanOptions = {
|
||||
data?: Record<string, unknown>,
|
||||
parentIds?: ParentRef[],
|
||||
startedAtMs?: number,
|
||||
/** See TrackOptions.root. */
|
||||
root?: boolean,
|
||||
/** See TrackOptions.excludeParentIds. */
|
||||
excludeParentIds?: ParentRef[],
|
||||
};
|
||||
|
||||
/**
|
||||
@ -152,10 +170,19 @@ export function getCustomTelemetryDataError(data: unknown): string | null {
|
||||
export function resolveParentIds(opts: {
|
||||
explicit?: ParentRef[],
|
||||
ambient?: SpanRef[],
|
||||
/** Ignore ambient parents entirely; only explicit ones apply. */
|
||||
root?: boolean,
|
||||
/**
|
||||
* Ids to drop from the FINAL merged list (each ParentRef contributes only its
|
||||
* own id here, not its chain) — see TrackOptions.excludeParentIds.
|
||||
*/
|
||||
exclude?: ParentRef[],
|
||||
}): { ids: string[] } | { error: string } {
|
||||
const chains: string[][] = [];
|
||||
for (const ambient of opts.ambient ?? []) {
|
||||
chains.push([...ambient.parentSpanIds, ambient.spanId]);
|
||||
if (!opts.root) {
|
||||
for (const ambient of opts.ambient ?? []) {
|
||||
chains.push([...ambient.parentSpanIds, ambient.spanId]);
|
||||
}
|
||||
}
|
||||
for (const parent of opts.explicit ?? []) {
|
||||
if (typeof parent === "string") {
|
||||
@ -165,6 +192,16 @@ export function resolveParentIds(opts: {
|
||||
chains.push([...ref.parentSpanIds, ref.spanId]);
|
||||
}
|
||||
}
|
||||
const excludeIds = new Set<string>();
|
||||
for (const excluded of opts.exclude ?? []) {
|
||||
const id = typeof excluded === "string"
|
||||
? excluded
|
||||
: "ref" in excluded && typeof excluded.ref === "function" ? excluded.ref().spanId : (excluded as SpanRef).spanId;
|
||||
if (!UUID_RE.test(id)) {
|
||||
return { error: `Invalid excluded parent span id ${JSON.stringify(id)}: excludeParentIds must be span uuids` };
|
||||
}
|
||||
excludeIds.add(id);
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const merged: string[] = [];
|
||||
for (const chain of chains) {
|
||||
@ -172,7 +209,7 @@ export function resolveParentIds(opts: {
|
||||
if (!UUID_RE.test(id)) {
|
||||
return { error: `Invalid parent span id ${JSON.stringify(id)}: parent ids must be span uuids` };
|
||||
}
|
||||
if (!seen.has(id)) {
|
||||
if (!seen.has(id) && !excludeIds.has(id)) {
|
||||
seen.add(id);
|
||||
merged.push(id);
|
||||
}
|
||||
@ -185,6 +222,41 @@ export function resolveParentIds(opts: {
|
||||
return { ids: merged };
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared implementation of withSpan(): starts the span (parents come from the
|
||||
* ENCLOSING context, not itself), runs `fn` with the span as an ambient parent
|
||||
* for everything created inside, auto-ends on settle, and on throw records
|
||||
* `data.error` and rethrows. Telemetry failures never fail `fn` — the end/
|
||||
* setData promises are pre-caught and intentionally not awaited, so the
|
||||
* caller's result is never blocked on an analytics ack.
|
||||
*/
|
||||
export async function withSpanImpl<T>(
|
||||
startSpan: (spanType: string, options?: StartSpanOptions) => Span,
|
||||
spanType: string,
|
||||
optionsOrFn: StartSpanOptions | ((span: Span) => Promise<T> | T),
|
||||
maybeFn?: (span: Span) => Promise<T> | T,
|
||||
): Promise<T> {
|
||||
const options = typeof optionsOrFn === "function" ? undefined : optionsOrFn;
|
||||
const fn = typeof optionsOrFn === "function" ? optionsOrFn : maybeFn;
|
||||
if (typeof fn !== "function") {
|
||||
return await rejectedPreCaught("withSpan() requires a callback function");
|
||||
}
|
||||
const span = startSpan(spanType, options);
|
||||
return await runWithSpanContext(span.ref(), async () => {
|
||||
try {
|
||||
const result = await fn(span);
|
||||
span.end().catch(() => {});
|
||||
return result;
|
||||
} catch (error) {
|
||||
// Order matters: the merge lands before the end row is enqueued, so the
|
||||
// single deduped wire row carries both the error and the end time.
|
||||
span.setData({ error: error instanceof Error ? error.message : String(error) }).catch(() => {});
|
||||
span.end().catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A Span that records nothing. Returned wherever analytics cannot run (SSR,
|
||||
* analytics disabled, tracker torn down) so isomorphic user code never needs to
|
||||
@ -305,6 +377,9 @@ export type EventTrackerDeps = {
|
||||
// chunks from the same tab carry the same session_replay_segment_id. Falls
|
||||
// back to a fresh uuid when constructed standalone (e.g. in tests).
|
||||
sessionReplaySegmentId?: string,
|
||||
// Serverless keep-alive hook (AnalyticsOptions.waitUntil): every batch-send
|
||||
// promise is passed to it so un-awaited sends survive runtime teardown.
|
||||
registerBackgroundTask?: (promise: Promise<unknown>) => void,
|
||||
};
|
||||
|
||||
type TrackedEvent = {
|
||||
@ -448,7 +523,12 @@ export class EventTracker {
|
||||
if (nameError) return rejectedPreCaught(nameError);
|
||||
const dataError = getCustomTelemetryDataError(data);
|
||||
if (dataError) return rejectedPreCaught(dataError);
|
||||
const resolved = resolveParentIds({ explicit: options?.parentIds, ambient: this._ambientParentRefs() });
|
||||
const resolved = resolveParentIds({
|
||||
explicit: options?.parentIds,
|
||||
ambient: this._ambientParentRefs(),
|
||||
root: options?.root,
|
||||
exclude: options?.excludeParentIds,
|
||||
});
|
||||
if ("error" in resolved) return rejectedPreCaught(resolved.error);
|
||||
if (this._disabled) return Promise.resolve();
|
||||
|
||||
@ -487,7 +567,12 @@ export class EventTracker {
|
||||
console.error(`Hexclave analytics: startedAtMs must be a non-negative integer epoch-milliseconds value`);
|
||||
return createInertSpan(spanType);
|
||||
}
|
||||
const resolved = resolveParentIds({ explicit: options?.parentIds, ambient: this._ambientParentRefs() });
|
||||
const resolved = resolveParentIds({
|
||||
explicit: options?.parentIds,
|
||||
ambient: this._ambientParentRefs(),
|
||||
root: options?.root,
|
||||
exclude: options?.excludeParentIds,
|
||||
});
|
||||
if ("error" in resolved) {
|
||||
console.error(`Hexclave analytics: ${resolved.error}`);
|
||||
return createInertSpan(spanType);
|
||||
@ -600,6 +685,8 @@ export class EventTracker {
|
||||
for (const span of this._globalSpans) {
|
||||
if (!span.isEnded) refs.push(span.ref());
|
||||
}
|
||||
// Enclosing withSpan() frames, outermost first, after the globals.
|
||||
refs.push(...getAmbientSpanRefs());
|
||||
return refs;
|
||||
}
|
||||
|
||||
@ -1007,6 +1094,7 @@ export class EventTracker {
|
||||
this._inFlight.delete(tracked);
|
||||
});
|
||||
this._inFlight.add(tracked);
|
||||
this._deps.registerBackgroundTask?.(tracked);
|
||||
await tracked;
|
||||
}
|
||||
|
||||
|
||||
@ -38,6 +38,7 @@ import { _HexclaveClientAppImplIncomplete } from "./client-app-impl";
|
||||
import { clientVersion, createCache, createCacheBySession, getDefaultExtraRequestHeaders, getDefaultProjectId, getDefaultPublishableClientKey, getDefaultSecretServerKey, resolveApiUrls, resolveConstructorOptions } from "./common";
|
||||
import { createInertSpan, getCustomTelemetryDataError, getCustomTelemetryNameError, rejectedPreCaught, resolveParentIds, type Span, type SpanRef, type SpanUpdateRow, type StartSpanOptions, type TrackOptions } from "./event-tracker";
|
||||
import { generateUuid } from "./session-replay";
|
||||
import { getAmbientSpanRefs } from "./span-context";
|
||||
|
||||
import { useAsyncCache } from "./common"; // THIS_LINE_PLATFORM react-like
|
||||
|
||||
@ -1774,6 +1775,8 @@ export class _HexclaveServerAppImplIncomplete<HasTokenStore extends boolean, Pro
|
||||
for (const span of this._serverGlobalSpans) {
|
||||
if (!span.isEnded) refs.push(span.ref());
|
||||
}
|
||||
// Enclosing withSpan() frames (AsyncLocalStorage — isolated per request).
|
||||
refs.push(...getAmbientSpanRefs());
|
||||
return refs;
|
||||
}
|
||||
|
||||
@ -1785,7 +1788,12 @@ export class _HexclaveServerAppImplIncomplete<HasTokenStore extends boolean, Pro
|
||||
if (userId !== null && !SERVER_TELEMETRY_UUID_RE.test(userId)) {
|
||||
return rejectedPreCaught(`Invalid userId ${JSON.stringify(userId)}: must be a user uuid`);
|
||||
}
|
||||
const resolved = resolveParentIds({ explicit: options?.parentIds, ambient: this._serverAmbientParentRefs() });
|
||||
const resolved = resolveParentIds({
|
||||
explicit: options?.parentIds,
|
||||
ambient: this._serverAmbientParentRefs(),
|
||||
root: options?.root,
|
||||
exclude: options?.excludeParentIds,
|
||||
});
|
||||
if ("error" in resolved) return rejectedPreCaught(resolved.error);
|
||||
|
||||
let settler!: TelemetrySettler;
|
||||
@ -1826,7 +1834,12 @@ export class _HexclaveServerAppImplIncomplete<HasTokenStore extends boolean, Pro
|
||||
console.error(`Hexclave analytics: invalid userId ${JSON.stringify(userId)}: must be a user uuid`);
|
||||
return createInertSpan(spanType);
|
||||
}
|
||||
const resolved = resolveParentIds({ explicit: options?.parentIds, ambient: this._serverAmbientParentRefs() });
|
||||
const resolved = resolveParentIds({
|
||||
explicit: options?.parentIds,
|
||||
ambient: this._serverAmbientParentRefs(),
|
||||
root: options?.root,
|
||||
exclude: options?.excludeParentIds,
|
||||
});
|
||||
if ("error" in resolved) {
|
||||
console.error(`Hexclave analytics: ${resolved.error}`);
|
||||
return createInertSpan(spanType);
|
||||
@ -1965,6 +1978,9 @@ export class _HexclaveServerAppImplIncomplete<HasTokenStore extends boolean, Pro
|
||||
this._serverTelemetryInFlight.delete(tracked);
|
||||
});
|
||||
this._serverTelemetryInFlight.add(tracked);
|
||||
// Serverless keep-alive (AnalyticsOptions.waitUntil): un-awaited sends must
|
||||
// survive runtime teardown.
|
||||
this._analyticsOptions?.waitUntil?.(tracked);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -45,6 +45,15 @@ export type AnalyticsOptions = {
|
||||
* set `enabled: false` to opt out.
|
||||
*/
|
||||
replays?: AnalyticsReplayOptions,
|
||||
/**
|
||||
* Serverless keep-alive hook: every analytics batch-send promise is passed to
|
||||
* it, so un-awaited trackEvent/startSpan sends survive runtime teardown
|
||||
* without awaiting each call. Wire it to your platform's primitive, e.g.
|
||||
* `waitUntil: (p) => ctx.waitUntil(p)` on Cloudflare Workers or
|
||||
* `import { waitUntil } from "@vercel/functions"` on Vercel.
|
||||
* Not serializable — dropped when the app is serialized (toClientJson).
|
||||
*/
|
||||
waitUntil?: (promise: Promise<unknown>) => void,
|
||||
};
|
||||
|
||||
export function getSessionReplayOptions(analyticsOptions: AnalyticsOptions | undefined): AnalyticsReplayOptions {
|
||||
@ -61,6 +70,12 @@ export function getSessionReplayOptions(analyticsOptions: AnalyticsOptions | und
|
||||
* the actual runtime value is JSON-safe.
|
||||
*/
|
||||
export function analyticsOptionsToJson(options: AnalyticsOptions | undefined): AnalyticsOptions | undefined {
|
||||
// waitUntil is a function and cannot cross a JSON boundary; the serialized
|
||||
// app runs in a different environment with its own lifecycle anyway.
|
||||
if (options?.waitUntil) {
|
||||
const { waitUntil, ...rest } = options;
|
||||
options = rest;
|
||||
}
|
||||
if (!options?.replays?.blockClass) return options;
|
||||
const { blockClass, ...rest } = options.replays;
|
||||
if (!(blockClass instanceof RegExp)) return options;
|
||||
|
||||
@ -0,0 +1,95 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { SpanRef } from "./event-tracker";
|
||||
import { __setAsyncContextModeForTesting, getAmbientSpanRefs, runWithSpanContext } from "./span-context";
|
||||
|
||||
function ref(spanId: string, parentSpanIds: string[] = []): SpanRef {
|
||||
return { spanId, parentSpanIds };
|
||||
}
|
||||
|
||||
function ambientIds(): string[] {
|
||||
return getAmbientSpanRefs().map((frame) => frame.spanId);
|
||||
}
|
||||
|
||||
describe("span context (AsyncLocalStorage)", () => {
|
||||
afterEach(() => {
|
||||
__setAsyncContextModeForTesting("auto");
|
||||
});
|
||||
|
||||
it("propagates nested frames across await boundaries, outermost first", async () => {
|
||||
expect(getAmbientSpanRefs()).toEqual([]);
|
||||
await runWithSpanContext(ref("a"), async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
expect(ambientIds()).toEqual(["a"]);
|
||||
await runWithSpanContext(ref("b"), async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
expect(ambientIds()).toEqual(["a", "b"]);
|
||||
});
|
||||
// Inner frame is gone once its withSpan settles.
|
||||
expect(ambientIds()).toEqual(["a"]);
|
||||
});
|
||||
expect(getAmbientSpanRefs()).toEqual([]);
|
||||
});
|
||||
|
||||
it("isolates interleaved parallel flows — no cross-parenting under ALS", async () => {
|
||||
const seen: Record<string, string[]> = {};
|
||||
await Promise.all([
|
||||
runWithSpanContext(ref("flow1"), async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
seen.flow1 = ambientIds();
|
||||
}),
|
||||
runWithSpanContext(ref("flow2"), async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
seen.flow2 = ambientIds();
|
||||
}),
|
||||
]);
|
||||
expect(seen.flow1).toEqual(["flow1"]);
|
||||
expect(seen.flow2).toEqual(["flow2"]);
|
||||
});
|
||||
|
||||
it("frames carry their full SpanRef (chain included), not just the id", async () => {
|
||||
await runWithSpanContext(ref("child", ["root-ancestor"]), async () => {
|
||||
expect(getAmbientSpanRefs()).toEqual([{ spanId: "child", parentSpanIds: ["root-ancestor"] }]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("span context (sync-stack fallback)", () => {
|
||||
afterEach(() => {
|
||||
__setAsyncContextModeForTesting("auto");
|
||||
});
|
||||
|
||||
it("is correct for sequential nested flows and removes its own frame on settle", async () => {
|
||||
__setAsyncContextModeForTesting("sync-stack");
|
||||
await runWithSpanContext(ref("a"), async () => {
|
||||
expect(ambientIds()).toEqual(["a"]);
|
||||
await runWithSpanContext(ref("b"), async () => {
|
||||
expect(ambientIds()).toEqual(["a", "b"]);
|
||||
});
|
||||
expect(ambientIds()).toEqual(["a"]);
|
||||
});
|
||||
expect(getAmbientSpanRefs()).toEqual([]);
|
||||
});
|
||||
|
||||
it("removes its own frame even when it is no longer on top (interleaving-safe cleanup)", async () => {
|
||||
__setAsyncContextModeForTesting("sync-stack");
|
||||
let releaseFirst!: () => void;
|
||||
const firstBlocked = new Promise<void>((resolve) => {
|
||||
releaseFirst = resolve;
|
||||
});
|
||||
// Start flow1 but leave it parked on an await, then run flow2 to completion
|
||||
// while flow1's frame is still on the stack. flow2 must remove ITS frame
|
||||
// (not flow1's) even though flow1's frame sits beneath it.
|
||||
const first = runWithSpanContext(ref("flow1"), async () => {
|
||||
await firstBlocked;
|
||||
// Documented sync-stack limitation: no isolation guarantee here; the
|
||||
// cleanup contract is what this test pins down.
|
||||
});
|
||||
await runWithSpanContext(ref("flow2"), async () => {
|
||||
expect(ambientIds()).toEqual(["flow1", "flow2"]);
|
||||
});
|
||||
expect(ambientIds()).toEqual(["flow1"]);
|
||||
releaseFirst();
|
||||
await first;
|
||||
expect(getAmbientSpanRefs()).toEqual([]);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,99 @@
|
||||
import type { SpanRef } from "./event-tracker";
|
||||
|
||||
/**
|
||||
* Ambient span context for withSpan(): tracks the stack of enclosing withSpan
|
||||
* frames so telemetry created inside the callback automatically parents under
|
||||
* them (additive with global spans and explicit parentIds).
|
||||
*
|
||||
* Two implementations behind one interface:
|
||||
*
|
||||
* - **AsyncLocalStorage** (Node, Bun, Deno, Cloudflare Workers / Vercel Edge
|
||||
* with nodejs_compat): correct across await boundaries and under concurrent
|
||||
* requests — two parallel withSpan() flows can never cross-parent. Loaded via
|
||||
* a runtime-guarded dynamic import of the BUILT-IN `node:async_hooks` module
|
||||
* (not an npm dependency); the import specifier is deliberately opaque to
|
||||
* bundlers so browser builds neither resolve nor error on it.
|
||||
*
|
||||
* - **Sync stack fallback** (browsers, where no async-context primitive
|
||||
* exists): a module-level enter/exit stack. Correct for synchronous code and
|
||||
* a single concurrent flow; interleaved parallel async flows can observe each
|
||||
* other's frames (the same trade-off Sentry's browser SDK accepts). Server
|
||||
* code never hits this path.
|
||||
*/
|
||||
|
||||
type AsyncLocalStorageLike = {
|
||||
run: <T>(store: SpanRef[], fn: () => T) => T,
|
||||
getStore: () => SpanRef[] | undefined,
|
||||
};
|
||||
|
||||
let als: AsyncLocalStorageLike | null = null;
|
||||
let alsInitPromise: Promise<void> | null = null;
|
||||
// Sync-stack fallback frames (browsers / before ALS finishes loading).
|
||||
const syncStack: SpanRef[] = [];
|
||||
|
||||
async function ensureAsyncContext(): Promise<void> {
|
||||
if (alsInitPromise) return await alsInitPromise;
|
||||
alsInitPromise = (async () => {
|
||||
try {
|
||||
// Opaque specifier: bundlers must leave this as a runtime dynamic import
|
||||
// (vite/webpack hints + non-literal string), which simply rejects in
|
||||
// browsers and resolves to the built-in module everywhere node-like.
|
||||
const specifier = "node:async_hooks";
|
||||
const mod = await import(/* @vite-ignore */ /* webpackIgnore: true */ specifier) as { AsyncLocalStorage?: new () => AsyncLocalStorageLike };
|
||||
if (typeof mod.AsyncLocalStorage === "function") {
|
||||
als = new mod.AsyncLocalStorage();
|
||||
}
|
||||
} catch {
|
||||
// Browser: no async-context primitive; the sync stack is the fallback.
|
||||
als = null;
|
||||
}
|
||||
})();
|
||||
return await alsInitPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* The SpanRefs of all enclosing withSpan() frames, outermost first. Consumed by
|
||||
* the parent-resolution logic as ambient parents (alongside global spans).
|
||||
*/
|
||||
export function getAmbientSpanRefs(): SpanRef[] {
|
||||
const store = als?.getStore();
|
||||
if (store) return [...store];
|
||||
return [...syncStack];
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs `fn` with `frame` appended to the ambient span context. Always async:
|
||||
* awaiting the ALS load first is what guarantees server code gets isolation
|
||||
* from the very first withSpan() call rather than racing the module load.
|
||||
*/
|
||||
export async function runWithSpanContext<T>(frame: SpanRef, fn: () => Promise<T>): Promise<T> {
|
||||
await ensureAsyncContext();
|
||||
if (als) {
|
||||
const enclosing = als.getStore() ?? [];
|
||||
return await als.run([...enclosing, frame], fn);
|
||||
}
|
||||
syncStack.push(frame);
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
// Remove OUR frame specifically — a concurrent flow may have pushed frames
|
||||
// above ours in the meantime (the documented sync-stack limitation).
|
||||
const index = syncStack.lastIndexOf(frame);
|
||||
if (index !== -1) syncStack.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test hook: forces the sync-stack fallback (as if ALS failed to load) or
|
||||
* resets to automatic detection. Never call outside tests.
|
||||
*/
|
||||
export function __setAsyncContextModeForTesting(mode: "sync-stack" | "auto"): void {
|
||||
if (mode === "sync-stack") {
|
||||
als = null;
|
||||
alsInitPromise = Promise.resolve();
|
||||
} else {
|
||||
als = null;
|
||||
alsInitPromise = null;
|
||||
}
|
||||
syncStack.length = 0;
|
||||
}
|
||||
@ -145,6 +145,19 @@ export type StackClientApp<HasTokenStore extends boolean = boolean, ProjectId ex
|
||||
*/
|
||||
flush(): Promise<void>,
|
||||
|
||||
/**
|
||||
* Runs `fn` inside a span: the span starts on entry, is an ambient parent
|
||||
* for every trackEvent/startSpan/withSpan inside the callback, and ends
|
||||
* automatically when `fn` settles. On throw, `data.error` is recorded and
|
||||
* the error is rethrown — telemetry failures never affect `fn`'s result.
|
||||
* Ambient parenting is exact across `await`s on server runtimes
|
||||
* (AsyncLocalStorage); in browsers, parallel async flows can observe each
|
||||
* other's ambient frames (documented sync-stack fallback). Opt out of
|
||||
* ambient parents per item with `root: true` or `excludeParentIds`.
|
||||
*/
|
||||
withSpan<T>(spanType: string, fn: (span: Span) => Promise<T> | T): Promise<T>,
|
||||
withSpan<T>(spanType: string, options: StartSpanOptions, fn: (span: Span) => Promise<T> | T): Promise<T>,
|
||||
|
||||
// note: we don't special-case 'anonymous' here to return non-null, see GetPartialUserOptions for more details
|
||||
getPartialUser(options: GetCurrentPartialUserOptions<HasTokenStore> & { from: 'token' }): Promise<TokenPartialUser | null>,
|
||||
getPartialUser(options: GetCurrentPartialUserOptions<HasTokenStore> & { from: 'convex' }): Promise<TokenPartialUser | null>,
|
||||
|
||||
@ -52,6 +52,14 @@ export type StackServerApp<HasTokenStore extends boolean = boolean, ProjectId ex
|
||||
*/
|
||||
startSpan(spanType: string, options?: StartSpanOptions & { userId?: string }): Span,
|
||||
|
||||
/**
|
||||
* Server-side variant of `withSpan`: accepts `userId` in options; ambient
|
||||
* parenting is AsyncLocalStorage-backed, so concurrent requests sharing one
|
||||
* app instance never cross-parent.
|
||||
*/
|
||||
withSpan<T>(spanType: string, fn: (span: Span) => Promise<T> | T): Promise<T>,
|
||||
withSpan<T>(spanType: string, options: StartSpanOptions & { userId?: string }, fn: (span: Span) => Promise<T> | T): Promise<T>,
|
||||
|
||||
// IF_PLATFORM react-like
|
||||
useUser(options: GetCurrentUserOptions<HasTokenStore> & { or: 'redirect' }): ProjectCurrentServerUser<ProjectId>,
|
||||
useUser(options: GetCurrentUserOptions<HasTokenStore> & { or: 'throw' }): ProjectCurrentServerUser<ProjectId>,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user