Skip to content

Commit

Permalink
Add clientTraceMetadata experimental option to propagate tracing da…
Browse files Browse the repository at this point in the history
…ta to the client (#64256)

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.

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.

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.

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.

- #47660
- #62353 (Could be used as
an alternative to the server-timing header)
- getsentry/sentry-javascript#9571

---------

Co-authored-by: Jiachi Liu <[email protected]>
  • Loading branch information
2 people authored and ForsakenHarmony committed Aug 15, 2024
1 parent 3e7047a commit 88967f1
Show file tree
Hide file tree
Showing 48 changed files with 316 additions and 1 deletion.
1 change: 1 addition & 0 deletions packages/next-swc/crates/next-core/src/next_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,7 @@ pub struct ExperimentalConfig {
gzip_size: Option<bool>,

instrumentation_hook: Option<bool>,
client_trace_metadata: Option<Vec<String>>,
large_page_data_bytes: Option<f64>,
logging: Option<serde_json::Value>,
memory_based_workers_count: Option<bool>,
Expand Down
1 change: 1 addition & 0 deletions packages/next/src/build/webpack-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2069,6 +2069,7 @@ export default async function getBaseWebpackConfig(
emotion: config.compiler?.emotion,
modularizeImports: config.modularizeImports,
imageLoaderFile: config.images.loaderFile,
clientTraceMetadata: config.experimental.clientTraceMetadata,
})

const cache: any = {
Expand Down
1 change: 1 addition & 0 deletions packages/next/src/export/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,7 @@ export async function exportAppImpl(
ppr: nextConfig.experimental.ppr === true,
missingSuspenseWithCSRBailout:
nextConfig.experimental.missingSuspenseWithCSRBailout === true,
clientTraceMetadata: nextConfig.experimental.clientTraceMetadata,
swrDelta: nextConfig.experimental.swrDelta,
},
}
Expand Down
12 changes: 11 additions & 1 deletion packages/next/src/server/app-render/app-render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,10 @@ import { appendMutableCookies } from '../web/spec-extension/adapters/request-coo
import { createServerInsertedHTML } from './server-inserted-html'
import { getRequiredScripts } from './required-scripts'
import { addPathPrefix } from '../../shared/lib/router/utils/add-path-prefix'
import { makeGetServerInsertedHTML } from './make-get-server-inserted-html'
import {
getTracedMetadata,
makeGetServerInsertedHTML,
} from './make-get-server-inserted-html'
import { walkTreeWithFlightRouterState } from './walk-tree-with-flight-router-state'
import { createComponentTree } from './create-component-tree'
import { getAssetQueryString } from './get-asset-query-string'
Expand Down Expand Up @@ -859,6 +862,11 @@ async function renderToHTMLOrFlightImpl(
tree,
formState,
}: RenderToStreamOptions): Promise<RenderToStreamResult> => {
const tracingMetadata = getTracedMetadata(
getTracer().getTracePropagationData(),
renderOpts.experimental.clientTraceMetadata
)

const polyfills: JSX.IntrinsicElements['script'][] =
buildManifest.polyfillFiles
.filter(
Expand Down Expand Up @@ -947,6 +955,7 @@ async function renderToHTMLOrFlightImpl(
renderServerInsertedHTML,
serverCapturedErrors: allCapturedErrors,
basePath: renderOpts.basePath,
tracingMetadata: tracingMetadata,
})

const renderer = createStaticRenderer({
Expand Down Expand Up @@ -1276,6 +1285,7 @@ async function renderToHTMLOrFlightImpl(
renderServerInsertedHTML,
serverCapturedErrors: [],
basePath: renderOpts.basePath,
tracingMetadata: tracingMetadata,
}),
serverInsertedHTMLToHead: true,
validateRootLayout,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,26 @@ import { renderToReadableStream } from 'react-dom/server.edge'
import { streamToString } from '../stream-utils/node-web-streams-helper'
import { RedirectStatusCode } from '../../client/components/redirect-status-code'
import { addPathPrefix } from '../../shared/lib/router/utils/add-path-prefix'
import type { ClientTraceDataEntry } from '../lib/trace/tracer'

export function getTracedMetadata(
traceData: ClientTraceDataEntry[],
clientTraceMetadata: string[] | undefined
): ClientTraceDataEntry[] | undefined {
if (!clientTraceMetadata) return undefined
return traceData.filter(({ key }) => clientTraceMetadata.includes(key))
}

export function makeGetServerInsertedHTML({
polyfills,
renderServerInsertedHTML,
serverCapturedErrors,
tracingMetadata,
basePath,
}: {
polyfills: JSX.IntrinsicElements['script'][]
renderServerInsertedHTML: () => React.ReactNode
tracingMetadata: ClientTraceDataEntry[] | undefined
serverCapturedErrors: Error[]
basePath: string
}) {
Expand Down Expand Up @@ -82,6 +93,17 @@ export function makeGetServerInsertedHTML({
})
}
{serverInsertedHTML}
{tracingMetadata
? tracingMetadata.map(({ key, value }) => {
return (
<meta
key={`next-trace-data-${key}:${value}`}
name={key}
content={value}
/>
)
})
: null}
{errorMetaTags}
</>,
{
Expand Down
1 change: 1 addition & 0 deletions packages/next/src/server/app-render/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ export interface RenderOptsPartial {
ppr: boolean
missingSuspenseWithCSRBailout: boolean
swrDelta: SwrDelta | undefined
clientTraceMetadata: string[] | undefined
}
postponed?: string
/**
Expand Down
1 change: 1 addition & 0 deletions packages/next/src/server/base-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -529,6 +529,7 @@ export default abstract class Server<ServerOptions extends Options = Options> {
missingSuspenseWithCSRBailout:
this.nextConfig.experimental.missingSuspenseWithCSRBailout === true,
swrDelta: this.nextConfig.experimental.swrDelta,
clientTraceMetadata: this.nextConfig.experimental.clientTraceMetadata,
},
}

Expand Down
1 change: 1 addition & 0 deletions packages/next/src/server/config-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,7 @@ export const configSchema: zod.ZodType<NextConfig> = z.lazy(() =>
optimizePackageImports: z.array(z.string()).optional(),
optimizeServerReact: z.boolean().optional(),
instrumentationHook: z.boolean().optional(),
clientTraceMetadata: z.array(z.string()).optional(),
turbotrace: z
.object({
logLevel: z
Expand Down
6 changes: 6 additions & 0 deletions packages/next/src/server/config-shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,11 @@ export interface ExperimentalConfig {
*/
instrumentationHook?: boolean

/**
* The array of the meta tags to the client injected by tracing propagation data.
*/
clientTraceMetadata?: string[]

/**
* Using this feature will enable the `react@experimental` for the `app` directory.
*/
Expand Down Expand Up @@ -935,6 +940,7 @@ export const defaultConfig: NextConfig = {
turbotrace: undefined,
typedRoutes: false,
instrumentationHook: false,
clientTraceMetadata: undefined,
parallelServerCompiles: false,
parallelServerBuildTraces: false,
ppr:
Expand Down
28 changes: 28 additions & 0 deletions packages/next/src/server/lib/trace/tracer.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { TextMapSetter } from '@opentelemetry/api'
import type { SpanTypes } from './constants'
import { LogSpanAllowList, NextVanillaSpanAllowlist } from './constants'

Expand Down Expand Up @@ -136,6 +137,12 @@ interface NextTracer {
* Returns undefined otherwise.
*/
getActiveScopeSpan(): Span | undefined

/**
* Returns trace propagation data for the currently active context. The format is equal to data provided
* through the OpenTelemetry propagator API.
*/
getTracePropagationData(): ClientTraceDataEntry[]
}

type NextAttributeNames =
Expand All @@ -158,6 +165,20 @@ const rootSpanIdKey = api.createContextKey('next.rootSpanId')
let lastSpanId = 0
const getSpanId = () => lastSpanId++

export interface ClientTraceDataEntry {
key: string
value: string
}

const clientTraceDataSetter: TextMapSetter<ClientTraceDataEntry[]> = {
set(carrier, key, value) {
carrier.push({
key,
value,
})
},
}

class NextTracerImpl implements NextTracer {
/**
* Returns an instance to the trace with configured name.
Expand All @@ -172,6 +193,13 @@ class NextTracerImpl implements NextTracer {
return context
}

public getTracePropagationData(): ClientTraceDataEntry[] {
const activeContext = context.active()
const entries: ClientTraceDataEntry[] = []
propagation.inject(activeContext, entries, clientTraceDataSetter)
return entries
}

public getActiveScopeSpan(): Span | undefined {
return trace.getSpan(context?.active())
}
Expand Down
2 changes: 2 additions & 0 deletions test/e2e/opentelemetry/client-trace-metadata/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# files generated by next.js
node_modules/
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const dynamic = 'force-dynamic'

export default function DynamicPage() {
return <h1 id="dynamic-page-header">Dynamic Page</h1>
}
7 changes: 7 additions & 0 deletions test/e2e/opentelemetry/client-trace-metadata/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export default function Root({ children }: { children: React.ReactNode }) {
return (
<html>
<body>{children}</body>
</html>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function StaticPage() {
return <h1 id="static-page-2-header">Static Page 2</h1>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import Link from 'next/link'

export default function StaticPage() {
return (
<>
<h1 id="static-page-header">Static Page</h1>
<Link href="/dynamic-page" id="go-to-dynamic-page">
Go to dynamic page
</Link>
<Link href="/static-page-2" id="go-to-static-page">
Go to static page
</Link>
</>
)
}
Loading

0 comments on commit 88967f1

Please sign in to comment.