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:
mantrakp04 2026-07-03 13:09:35 -07:00
parent 9ed1fc353f
commit 62314f1dab
4 changed files with 40 additions and 7 deletions

View File

@ -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) };
}

View File

@ -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: () => [] });

View File

@ -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 });
}
}
}
}

View File

@ -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>,