feat(analytics): propagate global spans + full ancestor chains cross-tier

The header's customParentSpanIds now carry the SAME ambient ancestry a
locally-tracked client event would get — global spans (setGlobalSpan) first,
then enclosing withSpan() frames, each contributing its full frozen chain —
flattened root-first and deduped, instead of only the withSpan frames' leaf
ids. Overflow keeps the NEAREST ancestors (tail), mirroring resolveParentIds.

- event-tracker: expose getAmbientParentRefs() (globals + frames)
- span-propagation: pure flattenParentRefsToIds(refs, extraParents) + nearest-10 cap
- client-app-impl: shared _getSpanPropagationContext + public
  getSpanPropagationHeaders({ parentIds }) escape hatch for non-fetch
  transports (XHR/sendBeacon/WebSocket) and explicit per-request parents
- server-app-impl: client-propagated custom parents now merge BEFORE server
  ambient frames (client ancestry is the outer context), so the root-first
  order reads client chain -> server frames -> explicit parents
This commit is contained in:
mantrakp04 2026-07-03 11:58:14 -07:00
parent f4c2ee4575
commit 9ed1fc353f
6 changed files with 137 additions and 26 deletions

View File

@ -61,9 +61,8 @@ 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, withSpanImpl, type Span, type StartSpanOptions, type TrackOptions } from "./event-tracker";
import { getAmbientSpanRefs } from "./span-context";
import { installFetchSpanPropagation } from "./span-propagation";
import { createInertSpan, EventTracker, getCustomTelemetryDataError, getCustomTelemetryNameError, rejectedPreCaught, warnTelemetryUnavailableOnce, withSpanImpl, type ParentRef, type Span, type StartSpanOptions, type TrackOptions } from "./event-tracker";
import { encodeSpanContextHeader, flattenParentRefsToIds, installFetchSpanPropagation, SPAN_CONTEXT_HEADER, type SpanPropagationContext } from "./span-propagation";
import type { CrossDomainHandoffParams } from "./redirect-page-urls";
import { crossDomainAuthQueryParams, getCrossDomainHandoffParamsFromCurrentUrl, planRedirectToHandler } from "./redirect-page-urls";
import { subscribeSessionRefresh } from "./session-refresh-subscription";
@ -761,19 +760,9 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
&& this._eventTracker
&& this._analyticsOptions?.spanPropagation?.enabled !== false
) {
const eventTracker = this._eventTracker;
const allowedOrigins = this._analyticsOptions?.spanPropagation?.targets ?? [];
installFetchSpanPropagation({
getContext: () => {
const segmentId = eventTracker.getSessionReplaySegmentId();
const customParentSpanIds = getAmbientSpanRefs().map((frame) => frame.spanId);
if (!segmentId && customParentSpanIds.length === 0) return null;
return {
projectId: this.projectId,
...segmentId ? { sessionReplaySegmentId: segmentId } : {},
...customParentSpanIds.length > 0 ? { customParentSpanIds } : {},
};
},
getContext: () => this._getSpanPropagationContext(),
getSelfOrigin: () => (typeof window !== "undefined" ? window.location.origin : null),
getAllowedOrigins: () => allowedOrigins,
});
@ -4077,6 +4066,34 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
return withSpanImpl((type, options) => this.startSpan(type, options), spanType, optionsOrFn, maybeFn);
}
/**
* The context an outgoing request should carry so backend telemetry links to
* this tab's session: the per-tab replay segment plus the SAME ambient custom
* ancestry a locally-tracked event would get global spans first, then
* enclosing withSpan() frames, each contributing its full frozen chain
* flattened root-first and deduped (the codec caps overflow to the nearest
* ancestors, mirroring resolveParentIds). `extraParents` adds explicit
* per-request parents on top. Null when there is nothing to propagate.
*/
protected _getSpanPropagationContext(extraParents?: ParentRef[]): SpanPropagationContext | null {
const tracker = this._eventTracker;
if (!tracker) return null;
const customParentSpanIds = flattenParentRefsToIds(tracker.getAmbientParentRefs(), extraParents);
const segmentId = tracker.getSessionReplaySegmentId();
if (!segmentId && customParentSpanIds.length === 0) return null;
return {
projectId: this.projectId,
...segmentId ? { sessionReplaySegmentId: segmentId } : {},
...customParentSpanIds.length > 0 ? { customParentSpanIds } : {},
};
}
getSpanPropagationHeaders(options?: { parentIds?: ParentRef[] }): Record<string, string> {
const context = this._getSpanPropagationContext(options?.parentIds);
if (!context) return {};
return { [SPAN_CONTEXT_HEADER]: encodeSpanContextHeader(context) };
}
async getAccessToken(options?: { tokenStore?: TokenStoreInit }): Promise<string | null> {
const user = await this.getUser({ tokenStore: options?.tokenStore ?? undefined as any });
if (user) {

View File

@ -695,6 +695,16 @@ export class EventTracker {
return refs;
}
/**
* The ambient parents every new event/span would get right now live global
* spans first, then enclosing withSpan() frames (outermost first), each with
* its full frozen ancestor chain. Used by cross-tier span propagation so an
* outgoing request carries the same ancestry a locally-tracked event would.
*/
getAmbientParentRefs(): SpanRef[] {
return this._ambientParentRefs();
}
private _enqueueSpanUpdate(row: SpanUpdateRow): Promise<void> {
let settler!: Settler;
const promise = preCaught(new Promise<void>((resolve, reject) => {

View File

@ -1862,17 +1862,21 @@ export class _HexclaveServerAppImplIncomplete<HasTokenStore extends boolean, Pro
}
/**
* `_serverAmbientParentRefs` plus the client-propagated custom parents (raw
* uuids from the request's span-context header) as leaf frames, so they take
* part in parent resolution and are dropped together by `root: true`. The system
* ancestry (`rti-`/`sri-`/`srsi-`) is NOT here it is composed server-side from
* the batch context's scalar ids, so it survives `root` (attribution to the
* session is always kept).
* The client-propagated custom parents (raw uuids from the request's
* span-context header global spans + enclosing client withSpan frames,
* already flattened root-first by the sender) FIRST, then
* `_serverAmbientParentRefs`: the client ancestry is the outer context a
* server span nests inside, so the merged root-first list reads
* client-chain server frames explicit parents. All of these are leaf
* frames in parent resolution, so `root: true` drops them together. The
* system ancestry (`rti-`/`sri-`/`srsi-`) is NOT here it is composed
* server-side from the batch context's scalar ids, so it survives `root`
* (attribution to the session is always kept).
*/
private _ambientParentRefsWith(batchContext: ServerRequestSpanContext): SpanRef[] {
return [
...this._serverAmbientParentRefs(),
...batchContext.customParentSpanIds.map((spanId) => ({ spanId, parentSpanIds: [] as string[] })),
...this._serverAmbientParentRefs(),
];
}

View File

@ -1,8 +1,10 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { Span } from "./event-tracker";
import {
SPAN_CONTEXT_HEADER,
decodeSpanContextHeader,
encodeSpanContextHeader,
flattenParentRefsToIds,
installFetchSpanPropagation,
shouldPropagateSpanContext,
type SpanPropagationContext,
@ -40,11 +42,13 @@ describe("span propagation header codec", () => {
expect(decodeSpanContextHeader(header)).toEqual({ projectId: "proj-1" });
});
it("caps the custom parent chain at 10 on encode", () => {
it("caps the custom parent chain at 10, keeping the NEAREST ancestors (list tail)", () => {
// Mirrors resolveParentIds' overflow rule: on a root-first list, the nearest
// ancestors are at the end, so the cap keeps the tail.
const many = Array.from({ length: 15 }, (_, i) => `55555555-5555-4555-8555-${String(i).padStart(12, "0")}`);
const decoded = decodeSpanContextHeader(encodeSpanContextHeader({ projectId: "p", customParentSpanIds: many }));
expect(decoded?.customParentSpanIds).toHaveLength(10);
expect(decoded?.customParentSpanIds).toEqual(many.slice(0, 10));
expect(decoded?.customParentSpanIds).toEqual(many.slice(-10));
});
it("returns null for missing / empty / non-string headers", () => {
@ -90,6 +94,36 @@ describe("span propagation header codec", () => {
});
});
describe("flattenParentRefsToIds", () => {
it("contributes each ref's full frozen chain, root-first", () => {
// A global span (a→b) plus a nested withSpan frame (a→c): the ancestry a
// locally-tracked event would get, so the propagated backend span matches.
expect(flattenParentRefsToIds([
{ spanId: "b", parentSpanIds: ["a"] },
{ spanId: "c", parentSpanIds: ["a"] },
])).toEqual(["a", "b", "c"]);
});
it("dedupes across overlapping chains preserving first-seen order", () => {
expect(flattenParentRefsToIds([
{ spanId: "c", parentSpanIds: ["a", "b"] },
{ spanId: "d", parentSpanIds: ["b"] },
])).toEqual(["a", "b", "c", "d"]);
});
it("appends explicit extras: raw string contributes only itself, refs/spans their chains", () => {
const live = { ref: () => ({ spanId: "s2", parentSpanIds: ["s1"] }) } as unknown as Span;
expect(flattenParentRefsToIds(
[{ spanId: "g", parentSpanIds: [] }],
["raw", { spanId: "r2", parentSpanIds: ["r1"] }, live],
)).toEqual(["g", "raw", "r1", "r2", "s1", "s2"]);
});
it("returns [] for no refs and no extras", () => {
expect(flattenParentRefsToIds([])).toEqual([]);
});
});
describe("shouldPropagateSpanContext (same-origin policy)", () => {
const self = "https://app.example.com";

View File

@ -1,4 +1,5 @@
import { decodeBase64Url, encodeBase64Url } from "@hexclave/shared/dist/utils/bytes";
import type { ParentRef, SpanRef } from "./event-tracker";
/**
* Cross-tier span propagation.
@ -51,6 +52,37 @@ export type SpanPropagationContext = {
customParentSpanIds?: string[],
};
/**
* Flattens ambient parent refs (+ optional explicit extras) into the flat,
* root-first, deduped id list the header carries. Each ref contributes its full
* frozen chain (`[...parentSpanIds, spanId]`); explicit extras follow the same
* ParentRef rules as parentIds elsewhere (a raw string contributes only itself).
* Mirrors the merge in resolveParentIds, minus validation the codec drops
* malformed ids on decode and the backend re-validates.
*/
export function flattenParentRefsToIds(refs: SpanRef[], extraParents?: ParentRef[]): string[] {
const chains: string[][] = refs.map((ref) => [...ref.parentSpanIds, ref.spanId]);
for (const parent of extraParents ?? []) {
if (typeof parent === "string") {
chains.push([parent]);
} else {
const ref: SpanRef = "ref" in parent && typeof parent.ref === "function" ? parent.ref() : parent as SpanRef;
chains.push([...ref.parentSpanIds, ref.spanId]);
}
}
const seen = new Set<string>();
const ids: string[] = [];
for (const chain of chains) {
for (const id of chain) {
if (!seen.has(id)) {
seen.add(id);
ids.push(id);
}
}
}
return ids;
}
/** Header bag shape shared by browser `Headers` and node request-like objects. */
type RequestLikeHeaders = { get: (name: string) => string | null } | Record<string, string | null>;
@ -72,7 +104,9 @@ export function encodeSpanContextHeader(context: SpanPropagationContext): string
if (context.sessionReplayId) payload.sessionReplayId = context.sessionReplayId;
if (context.sessionReplaySegmentId) payload.sessionReplaySegmentId = context.sessionReplaySegmentId;
if (context.customParentSpanIds && context.customParentSpanIds.length > 0) {
payload.customParentSpanIds = context.customParentSpanIds.slice(0, MAX_CUSTOM_PARENT_SPAN_IDS);
// Cap keeps the NEAREST ancestors (tail of a root-first list) — the same
// overflow rule resolveParentIds applies to locally-tracked items.
payload.customParentSpanIds = context.customParentSpanIds.slice(-MAX_CUSTOM_PARENT_SPAN_IDS);
}
const json = JSON.stringify(payload);
return `${SPAN_CONTEXT_VERSION}.${encodeBase64Url(new TextEncoder().encode(json))}`;
@ -115,7 +149,7 @@ export function decodeSpanContextHeader(headerValue: string | null | undefined):
if (Array.isArray(obj.customParentSpanIds)) {
const ids = obj.customParentSpanIds
.filter((id): id is string => typeof id === "string" && UUID_RE.test(id))
.slice(0, MAX_CUSTOM_PARENT_SPAN_IDS);
.slice(-MAX_CUSTOM_PARENT_SPAN_IDS);
if (ids.length > 0) context.customParentSpanIds = ids;
}
return context;

View File

@ -7,7 +7,7 @@ import { CustomerInvoicesList, CustomerInvoicesRequestOptions, CustomerProductsL
import { Project } from "../../projects";
import { ProjectCurrentUser, SyncedPartialUser, TokenPartialUser } from "../../users";
import { _HexclaveClientAppImpl } from "../implementations";
import type { Span, StartSpanOptions, TrackOptions } from "../implementations/event-tracker";
import type { ParentRef, Span, StartSpanOptions, TrackOptions } from "../implementations/event-tracker";
import { AnalyticsOptions } from "../implementations/session-replay";
/** @deprecated Use `HexclaveClientAppConstructorOptions` from the `@hexclave/*` package instead — same symbol, new brand name. See https://docs.hexclave.com/migration. */
@ -158,6 +158,18 @@ export type StackClientApp<HasTokenStore extends boolean = boolean, ProjectId ex
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>,
/**
* The cross-tier span-propagation headers (`x-hexclave-span-context`) for a
* request the SDK cannot attach them to itself `fetch` to same-origin (and
* `analytics.spanPropagation.targets`) already gets them automatically, so
* this is the escape hatch for other transports (XHR, sendBeacon, WebSocket
* handshakes) or manually-built requests. Carries the same ambient context an
* 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).
*/
getSpanPropagationHeaders(options?: { parentIds?: ParentRef[] }): 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>,
getPartialUser(options: GetCurrentPartialUserOptions<HasTokenStore> & { from: 'convex' }): Promise<TokenPartialUser | null>,