Skip to content

fix(otel): inject experimental.clientTraceMetadata into SSR head#1644

Merged
james-elicx merged 3 commits into
mainfrom
fix/issue-1494-client-trace-metadata
May 28, 2026
Merged

fix(otel): inject experimental.clientTraceMetadata into SSR head#1644
james-elicx merged 3 commits into
mainfrom
fix/issue-1494-client-trace-metadata

Conversation

@james-elicx
Copy link
Copy Markdown
Member

@james-elicx james-elicx commented May 28, 2026

Summary

Fixes #1494.

  • Adds support for experimental.clientTraceMetadata in next.config: when configured, vinext reads the active OpenTelemetry propagation context and emits <meta name="..." content="..."> tags in the SSR HTML head for each allow-listed key.
  • Covers both App Router and Pages Router, dev and prod servers (app-ssr-entry.ts, dev-server.ts, pages-page-response.ts).
  • @opentelemetry/api is resolved as an optional peer via runtime require(...). Apps that don't install OTel get a zero-cost no-op; the path returns "" and the head HTML is forwarded verbatim.
  • Mirrors Next.js: packages/next/src/server/lib/trace/utils.ts (getTracedMetadata) and packages/next/src/server/app-render/make-get-server-inserted-html.tsx (traceMetaTags).
  • Ports the Next.js test setup from test/e2e/opentelemetry/client-trace-metadata/ into tests/client-trace-metadata.test.ts (uses the same test keys my-test-key-1, my-test-key-2, my-parent-span-id, and verifies non-metadata-key-3 is excluded).

Test plan

  • pnpm test tests/client-trace-metadata.test.ts — new unit tests cover filter, render, and end-to-end with a mocked OTel propagator
  • pnpm test tests/next-config.test.ts tests/app-page-dispatch.test.ts tests/app-page-render.test.ts tests/app-page-stream.test.ts tests/head.test.ts tests/pages-page-response.test.ts — 248 tests pass
  • pnpm run check — format, lint, types all green
  • CI deploy suite (cloudflare/vinext Next.js Deploy Suite) — should resolve the 5 failures listed in OpenTelemetry: clientTraceMetadata not injected into SSR head #1494

Follow-ups

  • The Next.js dev/prod static-page split (dev injects on statically rendered pages, prod doesn't) is not modeled here — meta tags are emitted whenever an active OTel span is present, regardless of static vs dynamic. Worth confirming against the deploy-suite expectations.
  • ISR / cached static rendering interaction (deferred): when ISR (or any other cache that serves rendered HTML) regenerates a page inside an OTel-instrumented request, the resulting <meta name="..."> tags capture the regeneration span's trace IDs and are then served verbatim to subsequent requests until the next revalidation. This can mislead client-side tracing that joins on trace-id / parent-span-id. Out of scope for this PR — needs a dedicated design pass (likely "skip injection for cacheable static renders" or "stamp + rewrite on each serve") and matching Next.js parity work. Tracked as future follow-up.

@james-elicx
Copy link
Copy Markdown
Member Author

/bigbonk review

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 28, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@1644

commit: bd8ea48

@github-actions
Copy link
Copy Markdown

Example Preview Production Original
app-router-cloudflare preview production
pages-router-cloudflare preview production
app-router-playground preview production original
realworld-api-rest preview production
nextra-docs-template preview production
benchmarks preview production
hackernews preview production original
web preview production

@james-elicx
Copy link
Copy Markdown
Member Author

/bigbonk review

1 similar comment
@james-elicx
Copy link
Copy Markdown
Member Author

/bigbonk review

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 28, 2026

@james-elicx Bonk workflow was cancelled.

View workflow run · To retry, trigger Bonk again.

@james-elicx
Copy link
Copy Markdown
Member Author

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: fix(otel): inject experimental.clientTraceMetadata into SSR head

Clean implementation. The architecture is right — runtime logic lives in server/client-trace-metadata.ts, entries stay thin, and the config plumbing threads through all the correct paths. A few observations:

Good

  • Architecture follows guidelines. The core logic is in server/client-trace-metadata.ts (a proper typed module), not in generated entries. The entry files just pass the config value through.
  • XSS prevention is solid. Uses escapeHtmlAttr from server/html.ts for both name and content attributes. The test at tests/client-trace-metadata.test.ts:72-78 explicitly verifies injection is blocked.
  • Optional dependency pattern is correct. The require() resolution via globalThis.require with try/catch is the right approach for an optional peer. Both the "not installed" and "propagator throws" paths return empty arrays.
  • Config parsing is defensive. The clientTraceMetadata extraction at next-config.ts:1251-1255 properly filters to strings only, matching the pattern used for instrumentationClientInject and similar config fields.
  • All code paths covered. I traced clientTraceMetadata through: next-config.tsindex.tsapp-rsc-entry.tsapp-page-dispatch.tsapp-page-render.tsapp-page-stream.tsapp-ssr-entry.ts (App Router), and next-config.tsindex.tsdev-server.ts (Pages Router dev), and next-config.tspages-server-entry.tspages-page-response.ts (Pages Router prod). All paths are wired.

Issues

  1. carrierSetter.set type guard is unnecessary but harmless (client-trace-metadata.ts:31). The OTel TextMapSetter contract guarantees key and value are strings. The guard is defense-in-depth against a rogue propagator — fine to keep, but worth noting it's not matching Next.js behavior (their setter is a bare push).

  2. Lazy trace meta in app-ssr-entry.ts is cached per-request, which is correct (app-ssr-entry.ts:422-428). The let traceMetaHTML: string | null = null + lazy getter ensures it's computed once and only when the head injection path runs. This is well-done.

  3. app-ssr-entry.ts:425?? undefined coercion. The options?.clientTraceMetadata ?? undefined converts null to undefined to match getClientTraceMetadataHTML's parameter type (readonly string[] | undefined). This works but is a sign the type isn't fully harmonized — clientTraceMetadata flows as readonly string[] | null through most of the App Router pipeline but getClientTraceMetadataHTML accepts undefined. Not a bug, just a minor inconsistency.

  4. No test for the config parsing path. The tests/next-config.test.ts change (line 1022) only adds clientTraceMetadata: undefined to the default resolved config fixture. There's no test verifying that resolveNextConfig({ experimental: { clientTraceMetadata: ["key1", "key2"] } }) produces clientTraceMetadata: ["key1", "key2"], or that non-string values in the array are filtered out. The unit tests in tests/client-trace-metadata.test.ts are good for the runtime behavior, but a config-resolution test would close the loop.

  5. PR follow-up note on static vs dynamic. The PR description correctly flags that vinext emits trace meta tags whenever an active OTel span is present, regardless of static vs dynamic rendering. Next.js only injects them for dynamic renders in production (static pages are pre-rendered without an active request span). This is fine for now — in practice, prerendered pages won't have an active span at build time, so the behavior converges. But if ISR regeneration runs inside an OTel-instrumented context, the cached HTML would embed stale trace IDs. Worth keeping in mind for ISR parity.

Nits

  • The comment at dev-server.ts:1128-1131 ("Trace metadata is appended after Head shim output so it always lands in the final document head") could mention that this is intentionally after getSSRHeadHTML() so user-provided <Head> tags take precedence in document order.

Overall this is a well-structured change that follows the project's layering guidelines. The plumbing is thorough across all server entry points.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 28, 2026

Review posted on PR #1644. Here's a summary of the findings:

Overall: Clean, well-structured implementation that follows the project's architecture guidelines. The core logic lives in server/client-trace-metadata.ts (a typed helper module), entries stay thin, and the config plumbing threads correctly through all code paths (App Router, Pages Router dev, Pages Router prod).

Key positives:

  • XSS prevention is solid — uses escapeHtmlAttr for meta tag attributes, with a test verifying injection is blocked
  • Optional @opentelemetry/api dependency pattern is correct (runtime require() with try/catch)
  • All server entry points are wired up consistently
  • Lazy trace meta computation in app-ssr-entry.ts is correctly cached per-request

Notable items:

  • Missing a config-resolution test verifying that experimental.clientTraceMetadata is correctly parsed (only the default undefined is asserted)
  • Minor type inconsistency: null vs undefined for the allow-list across the App Router pipeline
  • The follow-up about static vs dynamic rendering is correctly flagged — ISR regeneration in an OTel-instrumented context could embed stale trace IDs in cached HTML

github run

@james-elicx
Copy link
Copy Markdown
Member Author

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: fix(otel): inject experimental.clientTraceMetadata into SSR head

Good change. Architecture is sound — runtime logic in server/client-trace-metadata.ts, entries stay thin, config threads correctly through all paths. I verified against the Next.js reference implementation. A few items to flag:

Correctness

Once-only injection in App Router is correct. The didInjectHeadHTML guard at app-ssr-entry.ts:429-433 ensures getTraceMetaHTML() is only called on the first getInsertedHTML() invocation (inside buildHeadInjectionHtml). Subsequent calls return insertedHTML + errorMetaHTML without trace meta. This mirrors Next.js's pattern of clearing traceMetaTags = [] after first flush in make-get-server-inserted-html.tsx:110.

Lazy computation is correctly scoped. The let traceMetaHTML: string | null = null + closure pattern at app-ssr-entry.ts:422-428 defers the OTel context read until the SSR shell renders, then caches it for the request lifetime. This is the right call — reading the OTel context too early (before the request span is active) would produce empty/wrong data.

XSS prevention is solid. escapeHtmlAttr handles ", <, >, & in both name and content attributes. The test at client-trace-metadata.test.ts:72-78 verifies this.

Parity gap: filterClientTraceMetadata with empty allow-list

Minor divergence from Next.js. In Next.js's getTracedMetadata:

if (!clientTraceMetadata) return undefined
return traceData.filter(...)  // returns [] when config is []

In vinext's filterClientTraceMetadata:

if (!allowList || allowList.length === 0) return undefined  // returns undefined when config is []

So clientTraceMetadata: [] in next.config produces undefined in vinext but [] in Next.js. Both render zero meta tags, so this is not a functional bug. But the config resolution test at next-config.test.ts:901-904 correctly asserts that clientTraceMetadata: [] resolves to [], so the divergence is localized to filterClientTraceMetadata. Might be worth aligning for consistency, but not blocking.

require() on every request — acceptable

getOpenTelemetryTraceData calls require("@opentelemetry/api") on every SSR render (when the allow-list is configured). This is fine — Node's require cache makes this a hash lookup. Next.js does the same thing at module load time (top-level require in tracer.ts:28-35) and then calls propagation.inject() per-request via getTracePropagationData(). The vinext approach is equivalent but delays the module resolution to first use, which is actually better for the no-OTel case since the require is never attempted.

The carrierSetter type guard

client-trace-metadata.ts:31 — the typeof key !== "string" || typeof value !== "string" guard is defense-in-depth that Next.js doesn't have (their setter at tracer.ts:193-199 is a bare push). The OTel TextMapSetter contract guarantees string types, so this guard only protects against a misbehaving propagator. Fine to keep — it's harmless and defensive.

Config test coverage is good

The previous review noted missing config-resolution tests. This has been addressed — tests/next-config.test.ts now has a dedicated resolveNextConfig clientTraceMetadata describe block covering: default undefined, string array, non-string filtering, non-array rejection, and empty array. This closes the loop the previous review flagged.

Dev/prod parity check

All four server entry points are wired:

  • App Router dev: index.ts:3363dev-server.ts (but this is Pages Router dev — see note below)
  • App Router prod: app-rsc-entry.ts:485,569app-page-dispatch.tsapp-page-render.tsapp-page-stream.tsapp-ssr-entry.ts
  • Pages Router dev: index.ts:3363dev-server.ts:263,1135
  • Pages Router prod: pages-server-entry.ts:124,879pages-page-response.ts:350

All paths thread clientTraceMetadata from config to the point of HTML injection. The plumbing is consistent.

One question on the dev-server wiring

index.ts:3363 passes clientTraceMetadata as the 10th positional argument to createSSRHandler. This function now has 10 positional parameters (dev-server.ts:248-263). This is getting unwieldy. Not blocking for this PR, but a future cleanup might want to move these into an options object. The positional approach is fragile — it's easy to mix up the boolean hasMiddleware with the string array clientTraceMetadata since TypeScript won't catch the mismatch if both are falsy.

ISR interaction (acknowledged follow-up)

The PR description correctly flags the ISR concern — cached HTML from a regeneration would embed the regeneration span's trace IDs. This is the right call to defer. Next.js has the same issue in production for dynamically rendered pages that get cached.

LGTM — solid implementation with thorough testing and correct architecture.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 28, 2026

Review posted on PR #1644. Here's the summary:

Overall: LGTM. The implementation is well-architected — runtime logic lives in server/client-trace-metadata.ts as a proper typed module, entries stay thin, and the config plumbing threads correctly through all four server paths (App Router dev/prod, Pages Router dev/prod).

Key findings:

  • Once-only injection in App Router is correctly guarded via didInjectHeadHTML
  • Lazy OTel context read is properly scoped per-request
  • XSS prevention via escapeHtmlAttr is solid with test coverage
  • Config test coverage has been addressed since the previous review
  • Minor parity gap: filterClientTraceMetadata returns undefined for empty allow-list [], while Next.js returns [] — no functional impact
  • Non-blocking observation: createSSRHandler now has 10 positional parameters; could benefit from an options object in a future cleanup

github run

@james-elicx james-elicx marked this pull request as ready for review May 28, 2026 13:36
@james-elicx james-elicx force-pushed the fix/issue-1494-client-trace-metadata branch from 2dfab42 to ef7eac8 Compare May 28, 2026 14:11
@james-elicx
Copy link
Copy Markdown
Member Author

Rebased on main. Conflicts in next-config.ts and pages-server-entry.ts were trivial (both branches added independent fields). pnpm run check passes. CI re-running.

@james-elicx
Copy link
Copy Markdown
Member Author

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: fix(otel): inject experimental.clientTraceMetadata into SSR head

LGTM. The implementation is architecturally sound, thoroughly tested, and follows the project's layering guidelines. I traced all code paths end-to-end and verified the plumbing is correct.

Architecture & Layering

Core runtime logic lives in server/client-trace-metadata.ts — a proper typed module with clean separation of concerns (OTel resolution, filtering, rendering). Generated entries stay thin: app-rsc-entry.ts just embeds __clientTraceMetadata as JSON, pages-server-entry.ts threads it through vinextConfig. This matches the AGENTS.md guidance on keeping entries as codegen glue.

All Code Paths Verified

I traced clientTraceMetadata through every server entry point:

  1. Config resolution: next-config.ts:1266-1270 — parses experimental.clientTraceMetadata, filters to strings only
  2. App Router RSC entry: index.ts:2231app-rsc-entry.ts:485 (embeds as __clientTraceMetadata) → app-rsc-entry.ts:569 (passes to __dispatchAppPage)
  3. App Router dispatch: app-page-dispatch.ts:661app-page-render.ts:521app-page-stream.ts:120app-ssr-entry.ts:425
  4. Pages Router dev: index.ts:3363dev-server.ts:264 (10th positional arg) → dev-server.ts:1163-1167
  5. Pages Router prod: pages-server-entry.ts:129 (embedded in vinextConfig) → pages-server-entry.ts:915pages-page-response.ts:350-351

All paths are correctly wired. Dev and prod are in sync.

Correctness Details

  • Once-only injection in App Router: The didInjectHeadHTML guard at app-ssr-entry.ts:429-433 correctly ensures trace meta tags are only emitted in the first getInsertedHTML() call (inside buildHeadInjectionHtml). This mirrors Next.js's pattern of clearing traceMetaTags = [] after first flush.

  • Lazy OTel context read: The let traceMetaHTML: string | null = null + closure pattern at app-ssr-entry.ts:422-428 defers the OTel context read until the SSR shell renders, then caches for the request. This is correct — reading eagerly before the request span is active would produce wrong data.

  • XSS prevention: escapeHtmlAttr handles ", <, >, & in both name and content attributes. The test at client-trace-metadata.test.ts:72-78 explicitly verifies injection is blocked.

  • Optional dependency: require() via globalThis.require with try/catch is the right pattern for optional peers. Both "not installed" and "propagator throws" paths return empty arrays, verified in tests.

  • Config test coverage: The resolveNextConfig clientTraceMetadata describe block at next-config.test.ts:873-923 covers: default undefined, string array, non-string filtering, non-array rejection, and empty array. This closes the loop on config resolution.

Non-blocking Observations

  1. filterClientTraceMetadata with empty []: Returns undefined while Next.js's getTracedMetadata would return []. Both produce zero meta tags — no functional impact. The config test at line 918-923 correctly asserts [] survives config resolution, so the divergence is localized to the filter function. Worth aligning for consistency in a follow-up, but not blocking.

  2. Positional parameter growth: createSSRHandler at dev-server.ts:249-265 now has 10 positional parameters. An options object would be less fragile for future additions. Non-blocking — the type system catches mismatches.

  3. ISR interaction: Correctly flagged in the PR description. Cached HTML from ISR regeneration would embed the regeneration span's trace IDs. This is the right call to defer — Next.js has the same open question for dynamically rendered pages that get cached.

Tests

140 lines of well-structured tests covering the filter, render, and end-to-end OTel mock paths. The test keys (my-test-key-1, my-test-key-2, my-parent-span-id) and the exclusion check for non-metadata-key-3 are ported from the Next.js test suite, which is the right approach per AGENTS.md.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 28, 2026

Review posted on PR #1644 with approval. Here's a summary:

Verdict: LGTM (approved)

The implementation is architecturally sound and follows the project's layering guidelines. Key findings:

  • All 5 code paths verified: Config resolution, App Router (RSC entry → dispatch → render → stream → SSR entry), Pages Router dev (index.ts → dev-server.ts), and Pages Router prod (pages-server-entry.ts → pages-page-response.ts) — all correctly wired.
  • Correctness: Once-only injection via didInjectHeadHTML, lazy OTel context read scoped per-request, solid XSS prevention via escapeHtmlAttr, and correct optional dependency pattern for @opentelemetry/api.
  • Testing: 140 lines of well-structured tests ported from the Next.js test suite covering filter, render, and end-to-end with mocked OTel propagator.
  • Non-blocking observations: Minor filterClientTraceMetadata parity gap with [] vs undefined (no functional impact), and createSSRHandler growing to 10 positional params (future cleanup candidate). ISR interaction correctly deferred as documented.

github run

When `experimental.clientTraceMetadata` is configured in next.config, read
the active OpenTelemetry context via the registered propagator and emit
`<meta name="..." content="...">` tags in the SSR HTML head for each
allow-listed key. Both App Router and Pages Router are covered (dev and
prod servers). `@opentelemetry/api` is resolved as an optional peer at
runtime, so apps without OTel get a zero-cost no-op.

Mirrors Next.js: packages/next/src/server/lib/trace/utils.ts and
packages/next/src/server/app-render/make-get-server-inserted-html.tsx.
knip flagged the helper as unused — it is only consumed internally by
`getClientTraceMetadataHTML`. Keep it module-private.
Pick one convention for the optional allow-list across the App Router
pipeline (was mixing `string[] | null` and `string[] | undefined`).
Drops the redundant `?? null` / `?? undefined` coercions and aligns
every server-side type with `ResolvedNextConfig.clientTraceMetadata`,
which is `string[] | undefined`.

Also adds a resolveNextConfig test asserting that an array passed via
experimental.clientTraceMetadata is preserved verbatim, non-string
entries are filtered, and unset / non-array values resolve to
undefined.
@james-elicx james-elicx force-pushed the fix/issue-1494-client-trace-metadata branch from ef7eac8 to bd8ea48 Compare May 28, 2026 15:42
@james-elicx
Copy link
Copy Markdown
Member Author

Rebased on main; conflict in packages/vinext/src/config/next-config.ts resolved by keeping both fields (compilerDefine/compilerDefineServer from main + clientTraceMetadata from this PR).

@james-elicx james-elicx merged commit 9e04926 into main May 28, 2026
38 checks passed
@james-elicx james-elicx deleted the fix/issue-1494-client-trace-metadata branch May 28, 2026 16:45
james-elicx added a commit to NathanDrake2406/vinext that referenced this pull request May 28, 2026
Resolves a conflict in packages/vinext/src/server/app-ssr-entry.ts
where cloudflare#1644 (clientTraceMetadata SSR head injection) and this PR
both edited the post-`waitForAllReady` setup block. Kept both: the
inline-css manifest read + font-merge wiring runs first, then the
lazy `getTraceMetaHTML` closure.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

OpenTelemetry: clientTraceMetadata not injected into SSR head

1 participant