From 9ed1fc353f777efe2a495ec141d79e1988884916 Mon Sep 17 00:00:00 2001 From: mantrakp04 Date: Fri, 3 Jul 2026 11:58:14 -0700 Subject: [PATCH] feat(analytics): propagate global spans + full ancestor chains cross-tier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../apps/implementations/client-app-impl.ts | 45 +++++++++++++------ .../apps/implementations/event-tracker.ts | 10 +++++ .../apps/implementations/server-app-impl.ts | 18 +++++--- .../implementations/span-propagation.test.ts | 38 +++++++++++++++- .../apps/implementations/span-propagation.ts | 38 +++++++++++++++- .../apps/interfaces/client-app.ts | 14 +++++- 6 files changed, 137 insertions(+), 26 deletions(-) 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 23749e3be..2d3bdec81 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,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 { - 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 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 { + const context = this._getSpanPropagationContext(options?.parentIds); + if (!context) return {}; + return { [SPAN_CONTEXT_HEADER]: encodeSpanContextHeader(context) }; + } + 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.ts b/packages/template/src/lib/hexclave-app/apps/implementations/event-tracker.ts index 950a303fd..d4f2b010c 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 @@ -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 { let settler!: Settler; const promise = preCaught(new Promise((resolve, reject) => { 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 ce91435fd..1ada8caaf 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 @@ -1862,17 +1862,21 @@ export class _HexclaveServerAppImplIncomplete ({ spanId, parentSpanIds: [] as string[] })), + ...this._serverAmbientParentRefs(), ]; } diff --git a/packages/template/src/lib/hexclave-app/apps/implementations/span-propagation.test.ts b/packages/template/src/lib/hexclave-app/apps/implementations/span-propagation.test.ts index 53da76091..cec210d01 100644 --- a/packages/template/src/lib/hexclave-app/apps/implementations/span-propagation.test.ts +++ b/packages/template/src/lib/hexclave-app/apps/implementations/span-propagation.test.ts @@ -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"; diff --git a/packages/template/src/lib/hexclave-app/apps/implementations/span-propagation.ts b/packages/template/src/lib/hexclave-app/apps/implementations/span-propagation.ts index 93dd85d12..30ec3a642 100644 --- a/packages/template/src/lib/hexclave-app/apps/implementations/span-propagation.ts +++ b/packages/template/src/lib/hexclave-app/apps/implementations/span-propagation.ts @@ -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(); + 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; @@ -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; 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 5f0d90b1c..f185b968e 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 @@ -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(spanType: string, fn: (span: Span) => Promise | T): Promise, withSpan(spanType: string, options: StartSpanOptions, fn: (span: Span) => Promise | T): Promise, + /** + * 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, + // 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,