mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-20 21:29:36 +08:00
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:
parent
f4c2ee4575
commit
9ed1fc353f
@ -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) {
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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(),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user