feat(analytics): cross-tier span propagation (client fetch header + server withSpan({ request })) - #1726
Conversation
…erver withSpan({ request }))
Auto-links a customer's BACKEND span to the caller's CLIENT session with zero glue.
The browser attaches one `x-hexclave-span-context` header to same-origin outgoing
fetches; `serverApp.withSpan(type, { request }, fn)` resolves the caller's refresh
token (from the session) plus that header, so the span — and everything created
inside the callback — parents under $refresh-token → $session-replay →
$session-replay-segment, exactly like a browser event.
- span-propagation.ts: versioned base64url header codec, same-origin fetch
auto-wrapper (idempotent, native header semantics preserved), origin predicate
- server-request-context.ts: AsyncLocalStorage ambient request context
- server-app-impl: withSpan/trackEvent({ request }) resolution; server telemetry
buffer re-keyed from userId to the full (user, refresh, replay, segment) context;
propagated custom parents flow as ambient frames
- backend events/batch: accept forwarded refresh_token_id/session_replay_id on
SERVER auth (rejected on client auth); compose [rti-,sri-,srsi-,cs-] + stamp
the scalar columns
- client-app-impl: install the fetch wrapper on init behind analytics.spanPropagation
(same-origin default + exact-origin allowlist); reads the live per-tab id
- tests: 24 propagation unit tests + 2 e2e (server-auth ancestry + client-auth reject)
The framework adapters (tRPC, Convex, oRPC, ElysiaJS) build on this primitive in a
separate stacked branch; with an adapter you never pass { request } yourself.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughWalkthroughThis PR adds cross-tier span propagation via a fetch header, extends server-side telemetry ( ChangesCross-tier span propagation and telemetry attribution
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant Client as Browser (ClientAppImpl)
participant Fetch as Wrapped fetch
participant Backend as Analytics Batch Endpoint
participant ServerApp as StackServerApp (server-app-impl)
participant Context as ServerRequestContext
Client->>Fetch: fetch(sameOriginUrl)
Fetch->>Fetch: encode x-hexclave-span-context header
Fetch->>Backend: request with span-context header
Backend->>ServerApp: trackEvent/withSpan({ request })
ServerApp->>Context: resolve session tokens + decode header
Context-->>ServerApp: userId, refreshTokenId, sessionReplayId, ancestry
ServerApp->>Backend: POST /analytics/events/batch (refresh_token_id, session_replay_id)
Backend->>Backend: validate & derive attribution (server/admin path)
Backend-->>ServerApp: stored event with composed parent_span_ids
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
@coderabbitai review |
✅ Action performedReview finished.
|
Greptile SummaryThis PR adds cross-tier analytics span propagation between the browser SDK, server SDK, and ingestion route. The main changes are:
Confidence Score: 4/5This is close, but the remaining attribution bug should be fixed before merging.
packages/template/src/lib/hexclave-app/apps/implementations/server-app-impl.ts
|
| Filename | Overview |
|---|---|
| apps/backend/src/app/api/latest/analytics/events/batch/route.tsx | Adds server-auth refresh and replay fields with validation that binds explicit replay ids to the refresh token and user. |
| apps/backend/src/lib/session-replays.tsx | Scopes recent replay lookup by project user when a user is known. |
| packages/template/src/lib/hexclave-app/apps/implementations/span-propagation.ts | Adds the propagation header codec and a shared global fetch wrapper provider. |
| packages/template/src/lib/hexclave-app/apps/implementations/client-app-impl.ts | Installs fetch propagation from the live client app and builds headers from the current event tracker. |
| packages/template/src/lib/hexclave-app/apps/implementations/server-app-impl.ts | Adds request-scoped server telemetry context and buffer keys, but explicit user attribution can inherit unrelated request session fields. |
| packages/template/src/lib/hexclave-app/apps/implementations/server-request-context.ts | Adds AsyncLocalStorage-backed request context for server spans and events. |
Prompt To Fix All With AI
Fix the following 1 code review issue. Work through them one at a time, proposing concise fixes.
---
### Issue 1 of 1
packages/template/src/lib/hexclave-app/apps/implementations/server-app-impl.ts:1818
**Mixed User Context**
When `trackEvent` or `startSpan` is called inside a request-scoped span with an explicit different `userId`, this branch keeps the ambient request's `refreshTokenId`, replay id, and segment id while only replacing `userId`. That sends a batch where `user_id` belongs to one user but the session ancestry belongs to the caller's request. With a replay id present, the backend rejects the batch because the replay no longer matches the supplied user; with only a refresh token present, the row can be stamped with one user's id and another user's `rti-` parent. Explicit user attribution should not inherit an unrelated ambient session context without validating that the two identities match.
Reviews (3): Last reviewed commit: "feat(analytics): enhance session replay ..." | Re-trigger Greptile
…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
There was a problem hiding this comment.
🧹 Nitpick comments (1)
packages/template/src/lib/hexclave-app/apps/implementations/server-app-impl.ts (1)
1773-1803: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick winReuse the ambient request context before fetching tokens
fetchNewTokens()does a live token fetch, so each{ request }telemetry call pays an avoidable session roundtrip. IfgetServerRequestContext()is already set, reuse it here and only resolve the session when no ambient context exists.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/template/src/lib/hexclave-app/apps/implementations/server-app-impl.ts` around lines 1773 - 1803, The _resolveServerRequestContext method is always calling _getSession(request) and fetchNewTokens() even when an ambient request context already exists. Update this flow to first reuse the context from getServerRequestContext() (or the existing ambient context source used in this class) and only fall back to resolving the session and fetching tokens when no ambient context is available. Keep the same return shape for ServerRequestSpanContext while preserving the existing project-id filtering for decoded span headers.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In
`@packages/template/src/lib/hexclave-app/apps/implementations/server-app-impl.ts`:
- Around line 1773-1803: The _resolveServerRequestContext method is always
calling _getSession(request) and fetchNewTokens() even when an ambient request
context already exists. Update this flow to first reuse the context from
getServerRequestContext() (or the existing ambient context source used in this
class) and only fall back to resolving the session and fetching tokens when no
ambient context is available. Keep the same return shape for
ServerRequestSpanContext while preserving the existing project-id filtering for
decoded span headers.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: b9a4d4c2-d6e7-44b9-8095-15572ea6bb6b
📒 Files selected for processing (10)
apps/backend/src/app/api/latest/analytics/events/batch/route.tsxapps/e2e/tests/backend/endpoints/api/v1/analytics-events-batch.test.tspackages/template/src/lib/hexclave-app/apps/implementations/client-app-impl.tspackages/template/src/lib/hexclave-app/apps/implementations/event-tracker.tspackages/template/src/lib/hexclave-app/apps/implementations/server-app-impl.tspackages/template/src/lib/hexclave-app/apps/implementations/server-request-context.tspackages/template/src/lib/hexclave-app/apps/implementations/session-replay.tspackages/template/src/lib/hexclave-app/apps/implementations/span-propagation.test.tspackages/template/src/lib/hexclave-app/apps/implementations/span-propagation.tspackages/template/src/lib/hexclave-app/apps/interfaces/server-app.ts
There was a problem hiding this comment.
2 issues found and verified against the latest diff
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
… 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
…an handle kit Browser withSpan frames can mix across concurrently interleaved async flows (no async-context primitive exists there — the reason for TC39 AsyncContext). Support BOTH policies via analytics.ambientParenting: - "exact" (default): a frame is ambient only when provably from the current flow — an exact primitive (ALS on servers/edge; AsyncContext in browsers once shipped), or the callback's SYNCHRONOUS window on the browser fallback (single-threaded sync execution cannot interleave, so prologue-open frames are exact by construction). Never wrong; can only under-attach. - "best-effort": frames also stay ambient across browser awaits (zero-glue), accepting the documented cross-flow caveat. Handle kit on every Span (browser, server, inert) — exact everywhere, both modes: span.withSpan (nested child), span.run (re-bind for callbacks/timers), span.getPropagationHeaders (non-fetch transports), span.fetch (pinned cross-tier header, same origin policy as the auto wrapper, explicit wins). Internals: sync-stack frames carry a prologueOpen flag (set false the moment the callback returns its promise); context entry runs synchronously once the ALS probe settles so sync nesting composes; getAmbientSpanRefs gains an includeSuspendedSyncFrames read policy; servers fail closed when ALS is missing. Design validated by Codex (gpt-5.5 xhigh) + a 3-lens design panel.
…vents-spans-propagation
…vents-spans-propagation
…vents-spans-propagation
…ess' into HEAD # Conflicts: # packages/template/src/lib/hexclave-app/apps/implementations/client-app-impl.ts # packages/template/src/lib/hexclave-app/apps/implementations/event-tracker.ts # packages/template/src/lib/hexclave-app/apps/implementations/server-app-impl.ts
# Conflicts: # packages/template/src/lib/hexclave-app/apps/implementations/server-app-impl.ts
…/feat/custom-events-spans-propagation
There was a problem hiding this comment.
3 issues found across 5 files (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="packages/template/src/lib/hexclave-app/apps/implementations/span-propagation.ts">
<violation number="1" location="packages/template/src/lib/hexclave-app/apps/implementations/span-propagation.ts:168">
P2: Split-port localhost requests do not propagate even when the project’s `allow_localhost` setting is enabled because the client never supplies the callback that enables this branch. Passing the live project flag into the provider would preserve the documented local-development behavior.</violation>
<violation number="2" location="packages/template/src/lib/hexclave-app/apps/implementations/span-propagation.ts:181">
P2: Configured trusted domains never affect propagation despite this helper documenting them as the zero-config split-frontend/API allowlist, so those API origins remain blocked unless duplicated in `spanPropagation.targets`. Wiring the helper into the client provider or removing the misleading fallback would keep configuration and behavior consistent.</violation>
<violation number="3" location="packages/template/src/lib/hexclave-app/apps/implementations/span-propagation.ts:363">
P1: Production fetches never create the `$http-client` bridge added here: `beginRequestSpan` is never supplied by the client app, so `httpClientSpanId` and the derived `traceparent` are never emitted. Wiring the event tracker’s request-span opener into the provider would make the advertised bridge work.</violation>
</file>
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
| * HEADERS arrive or the request settles with an error (the body stream is | ||
| * never touched). | ||
| */ | ||
| beginRequestSpan?: (info: RequestSpanInfo) => RequestSpanHandle | null, |
There was a problem hiding this comment.
P1: Production fetches never create the $http-client bridge added here: beginRequestSpan is never supplied by the client app, so httpClientSpanId and the derived traceparent are never emitted. Wiring the event tracker’s request-span opener into the provider would make the advertised bridge work.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/template/src/lib/hexclave-app/apps/implementations/span-propagation.ts, line 363:
<comment>Production fetches never create the `$http-client` bridge added here: `beginRequestSpan` is never supplied by the client app, so `httpClientSpanId` and the derived `traceparent` are never emitted. Wiring the event tracker’s request-span opener into the provider would make the advertised bridge work.</comment>
<file context>
@@ -235,13 +307,60 @@ export function buildFetchInitWithSpanContext(opts: {
+ * HEADERS arrive or the request settles with an error (the body stream is
+ * never touched).
+ */
+ beginRequestSpan?: (info: RequestSpanInfo) => RequestSpanHandle | null,
};
</file context>
| * loosen that. Invalid entries are skipped, not fatal — one bad domain in the | ||
| * dashboard must not break propagation for the rest. | ||
| */ | ||
| export function trustedDomainsToPropagationOrigins(trustedDomains: readonly string[]): string[] { |
There was a problem hiding this comment.
P2: Configured trusted domains never affect propagation despite this helper documenting them as the zero-config split-frontend/API allowlist, so those API origins remain blocked unless duplicated in spanPropagation.targets. Wiring the helper into the client provider or removing the misleading fallback would keep configuration and behavior consistent.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/template/src/lib/hexclave-app/apps/implementations/span-propagation.ts, line 181:
<comment>Configured trusted domains never affect propagation despite this helper documenting them as the zero-config split-frontend/API allowlist, so those API origins remain blocked unless duplicated in `spanPropagation.targets`. Wiring the helper into the client provider or removing the misleading fallback would keep configuration and behavior consistent.</comment>
<file context>
@@ -178,7 +164,34 @@ export function shouldPropagateSpanContext(opts: {
+ * loosen that. Invalid entries are skipped, not fatal — one bad domain in the
+ * dashboard must not break propagation for the rest.
+ */
+export function trustedDomainsToPropagationOrigins(trustedDomains: readonly string[]): string[] {
+ const origins = new Set<string>();
+ for (const domain of trustedDomains) {
</file context>
| if (target.protocol !== "http:" && target.protocol !== "https:") return false; | ||
| if (opts.selfOrigin !== null && target.origin === opts.selfOrigin) return true; | ||
| if (opts.allowedOrigins?.includes(target.origin) === true) return true; | ||
| return opts.allowLocalhost === true && isLocalhost(target); |
There was a problem hiding this comment.
P2: Split-port localhost requests do not propagate even when the project’s allow_localhost setting is enabled because the client never supplies the callback that enables this branch. Passing the live project flag into the provider would preserve the documented local-development behavior.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/template/src/lib/hexclave-app/apps/implementations/span-propagation.ts, line 168:
<comment>Split-port localhost requests do not propagate even when the project’s `allow_localhost` setting is enabled because the client never supplies the callback that enables this branch. Passing the live project flag into the provider would preserve the documented local-development behavior.</comment>
<file context>
@@ -178,7 +164,34 @@ export function shouldPropagateSpanContext(opts: {
if (opts.selfOrigin !== null && target.origin === opts.selfOrigin) return true;
- return opts.allowedOrigins?.includes(target.origin) ?? false;
+ if (opts.allowedOrigins?.includes(target.origin) === true) return true;
+ return opts.allowLocalhost === true && isLocalhost(target);
+}
+
</file context>
…ents-spans-propagation
The span-context header codec carries W3C context, and customer-request-observability.ts opens a span per customer-facing backend request that links to the caller's trace once the route proves the project may see it.
…ents-spans-propagation
Stacked on #1719 (
feat/custom-events-spans-serverless).What
Auto-links a customer's backend span to the caller's client session with zero glue. When a browser calls the customer's own backend, a server span opened with
serverApp.withSpan(type, { request }, fn)parents under that tab's$refresh-token → $session-replay → $session-replay-segmentchain — the same tree browser events already attach to.How
One header,
x-hexclave-span-context: v1.<base64url-json>, carrying raw ids ({ projectId, sessionReplaySegmentId?, sessionReplayId?, customParentSpanIds? }). The client knows only the segment id; the server resolvesrti-from the session; the backend derivessri-and composes all therti-/sri-/srsi-/cs-prefixes. Nothing client-side ever fabricates a system id.span-propagation.ts— versioned header codec, a same-originfetchauto-wrapper (idempotent via a global marker, preserves native header precedence then adds ours, skipsno-cors, restores the exact original on uninstall), and the origin predicate.server-request-context.ts— AsyncLocalStorage ambient request context, so everything inside awithSpan({ request })callback inherits it (even bareserverApp.trackEvent).server-app-impl—withSpan/trackEvent({ request })resolve the context; the server-telemetry buffer is re-keyed fromuserId→ the full(user, refreshToken, replay, segment)context so two requests never share a batch's ancestry.events/batch— accepts the forwardedrefresh_token_id/session_replay_idon server auth (the secret key is trusted), rejects them on client auth, composes[rti-, sri-, srsi-, cs-], and stamps the scalar columns (not justparent_span_ids).client-app-impl— installs the wrapper on init behindanalytics.spanPropagation(same-origin default + exact-origintargetsallowlist); reads the live per-tab id so it tracks sign-out rotation.Trust / safety
Best-effort telemetry, never authz. Only
rti-/userId/project/branchare server-trusted;sri-/srsi-/custom parents are untrusted labels from a client-controlled header. Header is same-origin only by default (cross-origin would leak context + trip CORS preflight), version/size/uuid/project validated, and any bad header is ignored — never thrown into the request.Design validated with Codex (gpt-5.5 xhigh): versioned opaque header (deliberately not
traceparent, to avoid clobbering the customer's own OTel),Date.now()-free RMT semantics, and the same-origin propagation default.Tests
span-propagation.test.ts): codec round-trip / rejection / caps, same-origin predicate, and the fetch wrapper's edge cases (Request vs string vs URL input, init-headers precedence, no-cors, idempotency, clean uninstall).analytics-events-batch.test.ts): a server-auth batch with forwarded context → CH event carries[rti-,sri-,srsi-,cs-]+ stamped scalar columns; client auth rejects the forwarded context.Follow-up (separate stack)
Framework adapters — tRPC, Convex, oRPC, ElysiaJS — build on this primitive so consumers never pass
{ request }themselves (the adapter pulls it from the framework's context). They're a full auth+telemetry integration, shipping on their own stacked branch.Summary by cubic
Links backend spans and events to the caller’s browser session and trace using same-origin
traceparent+x-hexclave-span-contextheaders and request-aware server APIs. The propagation codec lives in@hexclave/sharedfor strict, stable parsing across SDKs and backend.New Features
traceparentandx-hexclave-span-contexton same-originfetch(allowlist viaanalytics.spanPropagation.targets, skipsno-cors), and never overwrites an explicit header.getSpanPropagationHeaders({ parentIds?, root? })for non-fetch; span handle kit (span.withSpan,span.run,span.getPropagationHeaders,span.fetch) with exact-only parenting via prologue-open sync frames.withSpan(..., { request })andtrackEvent(..., { request })join the incoming W3C trace and merge client correlation labels before server frames; ambient via AsyncLocalStorage; detaches when an explicit user differs; surfaces request telemetry failures.customer-request-observabilityto mint a backend request span that links to the caller’s trace after tenancy is proven;events/batchaccepts forwardedrefresh_token_id/session_replay_idon server auth (rejects on client), composes[rti-, sri-, srsi-, cs-], and stamps scalar columns; header is versioned, size-limited, and fail-closed.Migration
analytics.spanPropagation.enabled = false.{ request }towithSpanortrackEvent.analytics.spanPropagation.targetsand allow headers in CORS:Access-Control-Allow-Headers: x-hexclave-span-context, traceparent.Written for commit 96cfe79. Summary will update on new commits.
Summary by CodeRabbit
New Features
Bug Fixes
Update — propagation moved onto the shared wire contract
packages/shared/src/utils/span-context-codec.tsx— the propagation header's encode/decode now lives in shared, so all three readers parse it identically: the client SDK that writes it,server-request-contexton the receiving side, and Hexclave's own backend when it resolves the tenancy for a cross-tier request.span-propagation.tsandserver-request-context.tsbuild parent paths from that codec plus theanalytics-wireid helpers (uuid ⇄ W3C), instead of hand-rolled parsing.Slice: 5 files. Part of a stack-wide split of one working tree into the PR each change belongs to.