Skip to content

Commit 0840d52

Browse files
lforsthuozhi
andauthored
Add clientTraceMetadata experimental option to propagate tracing data to the client (#64256)
### What? This PR adds an experimental option `clientTraceMetadata` that will use the existing OpenTelemetry functionality to propagate conventional OpenTelemetry trace information to the client. The propagation metadata is propagated to the client via meta tags, having a `name` and a `content` attribute containing the value of the tracing value: ```html <html> <head> <meta name="baggage" content="key1=val1,key2=val2"> <meta name="traceparent" content="00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"> <meta name="custom" content="foobar"> </head> </html> ``` The implementation adheres to OpenTelemetry as much as possible, treating the meta tags as if they were tracing headers on outgoing requests. The `clientTraceMetadata` will contain the keys of the metadata that're going to injected for tracing purpose. ### Why? Telemetry providers usually want to provide visibility across the entire stack, meaning it is useful for users to be able to associate, for example, web vitals on the client, with a span tree on the server. In order to be able to correlate tracing events from the front- and backend, it is necessary to share something like a trace ID or similar, that the telemetry providers can pick up and stitch back together to create a trace. ### How? The tracer was extended with a method `getTracePropagationData()` that returns the propagation data on the currently active OpenTelemetry context. We are using `makeGetServerInsertedHTML()` to inject the meta tags into the HTML head for dynamic requests. The meta tags are generated through using the newly added `getTracePropagationData()` method on the tracer. It is important to mention that **the trace information should only be propagated for the initial loading of the page, including hard navigations**. Any subsequent operations should not propagate trace data from the server to the client, as the client generally is the root of the trace. The exception is initial pageloads, since while the request starts on the client, no JS has had the opportunity to run yet, meaning there is no trace propagation on the client before the server hasn't responded. Situations that we do not want tracing information to be propagated from the server to the client: - _Prefetch requests._ Prefetches generally start on the client and are already instrumented. - _Any sort of static precomputation, including PPR._ If we include trace information in static pages, it means that all clients that will land on the static page will be part of the "precomputation" trace. This would lead to gigantic traces with a ton of unrelated data that is not useful. The special case is dev mode where it is likely fine to propagate trace information, even for static content, since it is usually not actually static in dev mode. - _Clientside (soft) navigations._ Navigations start on the client and are usually already instrumented. ### Alternatives considered An implementation that purely lives in user-land could have been implemented with `useServerInsertedHTML()`, however, that implementation would be cumbersome for users to set up, since the implementation of tracing would have to happen in a) the instrumentation hook, b) in a client-component that is used in a top-level layout. ### Related issues/discussions - #47660 - #62353 (Could be used as an alternative to the server-timing header) - getsentry/sentry-javascript#9571 --------- Co-authored-by: Jiachi Liu <[email protected]>
1 parent 6c3577d commit 0840d52

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

48 files changed

+316
-1
lines changed

packages/next-swc/crates/next-core/src/next_config.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -514,6 +514,7 @@ pub struct ExperimentalConfig {
514514
gzip_size: Option<bool>,
515515

516516
instrumentation_hook: Option<bool>,
517+
client_trace_metadata: Option<Vec<String>>,
517518
large_page_data_bytes: Option<f64>,
518519
logging: Option<serde_json::Value>,
519520
memory_based_workers_count: Option<bool>,

packages/next/src/build/webpack-config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2057,6 +2057,7 @@ export default async function getBaseWebpackConfig(
20572057
emotion: config.compiler?.emotion,
20582058
modularizeImports: config.modularizeImports,
20592059
imageLoaderFile: config.images.loaderFile,
2060+
clientTraceMetadata: config.experimental.clientTraceMetadata,
20602061
})
20612062

20622063
const cache: any = {

packages/next/src/export/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -420,6 +420,7 @@ export async function exportAppImpl(
420420
deploymentId: nextConfig.deploymentId,
421421
experimental: {
422422
isAppPPREnabled: checkIsAppPPREnabled(nextConfig.experimental.ppr),
423+
clientTraceMetadata: nextConfig.experimental.clientTraceMetadata,
423424
swrDelta: nextConfig.experimental.swrDelta,
424425
},
425426
}

packages/next/src/server/app-render/app-render.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,10 @@ import { appendMutableCookies } from '../web/spec-extension/adapters/request-coo
7676
import { createServerInsertedHTML } from './server-inserted-html'
7777
import { getRequiredScripts } from './required-scripts'
7878
import { addPathPrefix } from '../../shared/lib/router/utils/add-path-prefix'
79-
import { makeGetServerInsertedHTML } from './make-get-server-inserted-html'
79+
import {
80+
getTracedMetadata,
81+
makeGetServerInsertedHTML,
82+
} from './make-get-server-inserted-html'
8083
import { walkTreeWithFlightRouterState } from './walk-tree-with-flight-router-state'
8184
import { createComponentTree } from './create-component-tree'
8285
import { getAssetQueryString } from './get-asset-query-string'
@@ -912,6 +915,11 @@ async function renderToHTMLOrFlightImpl(
912915
tree,
913916
formState,
914917
}: RenderToStreamOptions): Promise<RenderToStreamResult> => {
918+
const tracingMetadata = getTracedMetadata(
919+
getTracer().getTracePropagationData(),
920+
renderOpts.experimental.clientTraceMetadata
921+
)
922+
915923
const polyfills: JSX.IntrinsicElements['script'][] =
916924
buildManifest.polyfillFiles
917925
.filter(
@@ -995,6 +1003,7 @@ async function renderToHTMLOrFlightImpl(
9951003
renderServerInsertedHTML,
9961004
serverCapturedErrors: allCapturedErrors,
9971005
basePath: renderOpts.basePath,
1006+
tracingMetadata: tracingMetadata,
9981007
})
9991008

10001009
const renderer = createStaticRenderer({
@@ -1319,6 +1328,7 @@ async function renderToHTMLOrFlightImpl(
13191328
renderServerInsertedHTML,
13201329
serverCapturedErrors: [],
13211330
basePath: renderOpts.basePath,
1331+
tracingMetadata: tracingMetadata,
13221332
}),
13231333
serverInsertedHTMLToHead: true,
13241334
validateRootLayout,

packages/next/src/server/app-render/make-get-server-inserted-html.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,26 @@ import { renderToReadableStream } from 'react-dom/server.edge'
99
import { streamToString } from '../stream-utils/node-web-streams-helper'
1010
import { RedirectStatusCode } from '../../client/components/redirect-status-code'
1111
import { addPathPrefix } from '../../shared/lib/router/utils/add-path-prefix'
12+
import type { ClientTraceDataEntry } from '../lib/trace/tracer'
13+
14+
export function getTracedMetadata(
15+
traceData: ClientTraceDataEntry[],
16+
clientTraceMetadata: string[] | undefined
17+
): ClientTraceDataEntry[] | undefined {
18+
if (!clientTraceMetadata) return undefined
19+
return traceData.filter(({ key }) => clientTraceMetadata.includes(key))
20+
}
1221

1322
export function makeGetServerInsertedHTML({
1423
polyfills,
1524
renderServerInsertedHTML,
1625
serverCapturedErrors,
26+
tracingMetadata,
1727
basePath,
1828
}: {
1929
polyfills: JSX.IntrinsicElements['script'][]
2030
renderServerInsertedHTML: () => React.ReactNode
31+
tracingMetadata: ClientTraceDataEntry[] | undefined
2132
serverCapturedErrors: Error[]
2233
basePath: string
2334
}) {
@@ -82,6 +93,17 @@ export function makeGetServerInsertedHTML({
8293
})
8394
}
8495
{serverInsertedHTML}
96+
{tracingMetadata
97+
? tracingMetadata.map(({ key, value }) => {
98+
return (
99+
<meta
100+
key={`next-trace-data-${key}:${value}`}
101+
name={key}
102+
content={value}
103+
/>
104+
)
105+
})
106+
: null}
85107
{errorMetaTags}
86108
</>,
87109
{

packages/next/src/server/app-render/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,7 @@ export interface RenderOptsPartial {
170170
*/
171171
isRoutePPREnabled?: boolean
172172
swrDelta: SwrDelta | undefined
173+
clientTraceMetadata: string[] | undefined
173174
}
174175
postponed?: string
175176
/**

packages/next/src/server/base-server.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -556,6 +556,7 @@ export default abstract class Server<
556556
experimental: {
557557
isAppPPREnabled,
558558
swrDelta: this.nextConfig.experimental.swrDelta,
559+
clientTraceMetadata: this.nextConfig.experimental.clientTraceMetadata,
559560
},
560561
}
561562

packages/next/src/server/config-schema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,7 @@ export const configSchema: zod.ZodType<NextConfig> = z.lazy(() =>
390390
optimizePackageImports: z.array(z.string()).optional(),
391391
optimizeServerReact: z.boolean().optional(),
392392
instrumentationHook: z.boolean().optional(),
393+
clientTraceMetadata: z.array(z.string()).optional(),
393394
turbotrace: z
394395
.object({
395396
logLevel: z

packages/next/src/server/config-shared.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,11 @@ export interface ExperimentalConfig {
365365
*/
366366
instrumentationHook?: boolean
367367

368+
/**
369+
* The array of the meta tags to the client injected by tracing propagation data.
370+
*/
371+
clientTraceMetadata?: string[]
372+
368373
/**
369374
* Using this feature will enable the `react@experimental` for the `app` directory.
370375
*/
@@ -919,6 +924,7 @@ export const defaultConfig: NextConfig = {
919924
turbotrace: undefined,
920925
typedRoutes: false,
921926
instrumentationHook: false,
927+
clientTraceMetadata: undefined,
922928
parallelServerCompiles: false,
923929
parallelServerBuildTraces: false,
924930
ppr:

packages/next/src/server/lib/trace/tracer.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { FetchEventResult } from '../../web/types'
2+
import type { TextMapSetter } from '@opentelemetry/api'
23
import type { SpanTypes } from './constants'
34
import { LogSpanAllowList, NextVanillaSpanAllowlist } from './constants'
45

@@ -149,6 +150,12 @@ interface NextTracer {
149150
* Returns undefined otherwise.
150151
*/
151152
getActiveScopeSpan(): Span | undefined
153+
154+
/**
155+
* Returns trace propagation data for the currently active context. The format is equal to data provided
156+
* through the OpenTelemetry propagator API.
157+
*/
158+
getTracePropagationData(): ClientTraceDataEntry[]
152159
}
153160

154161
type NextAttributeNames =
@@ -171,6 +178,20 @@ const rootSpanIdKey = api.createContextKey('next.rootSpanId')
171178
let lastSpanId = 0
172179
const getSpanId = () => lastSpanId++
173180

181+
export interface ClientTraceDataEntry {
182+
key: string
183+
value: string
184+
}
185+
186+
const clientTraceDataSetter: TextMapSetter<ClientTraceDataEntry[]> = {
187+
set(carrier, key, value) {
188+
carrier.push({
189+
key,
190+
value,
191+
})
192+
},
193+
}
194+
174195
class NextTracerImpl implements NextTracer {
175196
/**
176197
* Returns an instance to the trace with configured name.
@@ -185,6 +206,13 @@ class NextTracerImpl implements NextTracer {
185206
return context
186207
}
187208

209+
public getTracePropagationData(): ClientTraceDataEntry[] {
210+
const activeContext = context.active()
211+
const entries: ClientTraceDataEntry[] = []
212+
propagation.inject(activeContext, entries, clientTraceDataSetter)
213+
return entries
214+
}
215+
188216
public getActiveScopeSpan(): Span | undefined {
189217
return trace.getSpan(context?.active())
190218
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# files generated by next.js
2+
node_modules/
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export const dynamic = 'force-dynamic'
2+
3+
export default function DynamicPage() {
4+
return <h1 id="dynamic-page-header">Dynamic Page</h1>
5+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export default function Root({ children }: { children: React.ReactNode }) {
2+
return (
3+
<html>
4+
<body>{children}</body>
5+
</html>
6+
)
7+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export default function StaticPage() {
2+
return <h1 id="static-page-2-header">Static Page 2</h1>
3+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import Link from 'next/link'
2+
3+
export default function StaticPage() {
4+
return (
5+
<>
6+
<h1 id="static-page-header">Static Page</h1>
7+
<Link href="/dynamic-page" id="go-to-dynamic-page">
8+
Go to dynamic page
9+
</Link>
10+
<Link href="/static-page-2" id="go-to-static-page">
11+
Go to static page
12+
</Link>
13+
</>
14+
)
15+
}

0 commit comments

Comments
 (0)