mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-20 21:29:36 +08:00
feat(analytics): precise-control path for interleaved flows (explicit header wins + root option)
The browser sync-stack fallback can mix ambient frames from overlapping async
flows into the propagated context (fundamental until TC39 AsyncContext ships —
the span-context loader will pick it up like ALS when it does). Give affected
callers an exact override:
- the auto fetch wrapper never overwrites an explicitly-set
x-hexclave-span-context header — caller intent beats ambient
- getSpanPropagationHeaders({ parentIds, root }): root drops ambient parents
(same semantics as TrackOptions.root), so one request can be pinned to
exactly one span; the segment id (identity, not parenting) always rides
This commit is contained in:
parent
9ed1fc353f
commit
62314f1dab
@ -4075,10 +4075,14 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
|
||||
* ancestors, mirroring resolveParentIds). `extraParents` adds explicit
|
||||
* per-request parents on top. Null when there is nothing to propagate.
|
||||
*/
|
||||
protected _getSpanPropagationContext(extraParents?: ParentRef[]): SpanPropagationContext | null {
|
||||
protected _getSpanPropagationContext(extraParents?: ParentRef[], root?: boolean): SpanPropagationContext | null {
|
||||
const tracker = this._eventTracker;
|
||||
if (!tracker) return null;
|
||||
const customParentSpanIds = flattenParentRefsToIds(tracker.getAmbientParentRefs(), extraParents);
|
||||
// root drops the ambient custom parents (same meaning as TrackOptions.root) —
|
||||
// the precise-control path for interleaved async flows, where the browser's
|
||||
// shared sync stack could otherwise mix in another flow's frames. The session
|
||||
// attribution (segment id) is identity, not parenting, so it always rides.
|
||||
const customParentSpanIds = flattenParentRefsToIds(root ? [] : tracker.getAmbientParentRefs(), extraParents);
|
||||
const segmentId = tracker.getSessionReplaySegmentId();
|
||||
if (!segmentId && customParentSpanIds.length === 0) return null;
|
||||
return {
|
||||
@ -4088,8 +4092,8 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
|
||||
};
|
||||
}
|
||||
|
||||
getSpanPropagationHeaders(options?: { parentIds?: ParentRef[] }): Record<string, string> {
|
||||
const context = this._getSpanPropagationContext(options?.parentIds);
|
||||
getSpanPropagationHeaders(options?: { parentIds?: ParentRef[], root?: boolean }): Record<string, string> {
|
||||
const context = this._getSpanPropagationContext(options?.parentIds, options?.root);
|
||||
if (!context) return {};
|
||||
return { [SPAN_CONTEXT_HEADER]: encodeSpanContextHeader(context) };
|
||||
}
|
||||
|
||||
@ -242,6 +242,24 @@ describe("installFetchSpanPropagation", () => {
|
||||
expect(sentHeader()).toBeNull();
|
||||
});
|
||||
|
||||
it("never overwrites an explicitly-set span-context header (init.headers)", async () => {
|
||||
install({ projectId: "p", sessionReplaySegmentId: SEG });
|
||||
const explicit = encodeSpanContextHeader({ projectId: "p", customParentSpanIds: [CUSTOM_A] });
|
||||
await (globalThis as { fetch: typeof fetch }).fetch("/x", { headers: { [SPAN_CONTEXT_HEADER]: explicit } });
|
||||
// Passed through untouched: the wrapper must not clobber the caller's precise intent.
|
||||
expect(new Headers(calls[0].init?.headers).get(SPAN_CONTEXT_HEADER)).toBe(explicit);
|
||||
});
|
||||
|
||||
it("never overwrites an explicitly-set span-context header (Request headers)", async () => {
|
||||
install({ projectId: "p", sessionReplaySegmentId: SEG });
|
||||
const explicit = encodeSpanContextHeader({ projectId: "p", customParentSpanIds: [CUSTOM_B] });
|
||||
const req = new Request("https://app.example.com/x", { headers: { [SPAN_CONTEXT_HEADER]: explicit } });
|
||||
await (globalThis as { fetch: typeof fetch }).fetch(req);
|
||||
// No init constructed — the Request (with its own header) passes through as-is.
|
||||
expect(calls[0].init?.headers).toBeUndefined();
|
||||
expect((calls[0].input as Request).headers.get(SPAN_CONTEXT_HEADER)).toBe(explicit);
|
||||
});
|
||||
|
||||
it("is idempotent and uninstalls cleanly", async () => {
|
||||
install({ projectId: "p", sessionReplaySegmentId: SEG });
|
||||
const second = installFetchSpanPropagation({ getContext: () => null, getSelfOrigin: () => SELF, getAllowedOrigins: () => [] });
|
||||
|
||||
@ -248,8 +248,13 @@ export function installFetchSpanPropagation(options: FetchSpanPropagationOptions
|
||||
? init.headers
|
||||
: (isRequest ? (input as Request).headers : undefined);
|
||||
const headers = new Headers(base);
|
||||
headers.set(SPAN_CONTEXT_HEADER, encodeSpanContextHeader(context));
|
||||
return callFetch(input, { ...init, headers });
|
||||
// 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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -167,8 +167,14 @@ export type StackClientApp<HasTokenStore extends boolean = boolean, ProjectId ex
|
||||
* event tracked right now would get: the per-tab replay segment, global spans,
|
||||
* and enclosing `withSpan()` frames — plus any explicit `parentIds`. Returns
|
||||
* `{}` when there is nothing to propagate (analytics off, non-browser).
|
||||
*
|
||||
* Setting this header on a `fetch` also overrides the automatic one, and
|
||||
* `root: true` drops the ambient parents (only explicit `parentIds` apply) —
|
||||
* together the precise-control path when overlapping async flows could mix
|
||||
* ambient frames (the documented browser sync-stack fallback):
|
||||
* `fetch(url, { headers: app.getSpanPropagationHeaders({ parentIds: [span], root: true }) })`.
|
||||
*/
|
||||
getSpanPropagationHeaders(options?: { parentIds?: ParentRef[] }): Record<string, string>,
|
||||
getSpanPropagationHeaders(options?: { parentIds?: ParentRef[], root?: boolean }): Record<string, string>,
|
||||
|
||||
// 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>,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user