Skip to content

Commit feab86e

Browse files
chargomeclaude
andcommitted
fix(nextjs): Key the prerender trace guard on the OpenTelemetry context
Next.js computes a document's trace meta tags at the top of its prerender function, inside the span it wraps the prerender in. Recognizing that span by its attributes fails when the request is not sampled: the span is then non-recording and has no attributes, so the propagator still wrote a `sampled=0` tag into the cached shell, and every later visitor inherited the negative decision. A span nested in the prerender would have passed the check as well. Mark the prerender on the OpenTelemetry context instead, through the tracer provider the Node SDK registered, and have the propagator skip a marked context. The mark is inherited by everything that runs inside the prerender and exists whether or not the span records. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent a17aaab commit feab86e

5 files changed

Lines changed: 168 additions & 90 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
Work in this release was contributed by @zkasuran. Thank you for your contribution!
88

9-
- **feat(nextjs)**: Browser pageloads of apps with `cacheComponents` enabled now connect to the server request again. The SDK stopped emitting `sentry-trace` meta tags for these apps because a cached shell could replay the trace of the request that rendered it to every later visitor. The server SDK now refuses to hand out trace context while Next.js prerenders a document, so cached shells never carry a trace, and a tag that is in the document was always rendered for the current request. This also covers ISR pages, which no longer need the client-side meta tag removal based on `generateStaticParams`; the `isrRoutes` field of the injected route manifest is gone.
9+
- **feat(nextjs)**: Browser pageloads of apps with `cacheComponents` enabled now connect to the server request again. The SDK stopped emitting `sentry-trace` meta tags for these apps because a cached shell could replay the trace of the request that rendered it to every later visitor. The server SDK now refuses to hand out trace context while Next.js prerenders a document, so cached shells never carry a trace, and a tag that is in the document was always rendered for the current request. The guard is part of the tracer provider and propagator the SDK registers, so it does not cover apps that set up OpenTelemetry themselves. This also covers ISR pages, which no longer need the client-side meta tag removal based on `generateStaticParams`; the `isrRoutes` field of the injected route manifest is gone.
1010

1111
## 11.5.0
1212

‎packages/nextjs/src/server/index.ts‎

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -176,13 +176,11 @@ export function init(options: NodeOptions): NodeClient | undefined {
176176
addNextjsServerSpanHooks(client);
177177
}
178178

179-
// Next.js reads the trace meta tags of a document through the global propagator. The Next.js-aware
180-
// one refuses to hand out trace context while a shell is prerendered, so no cached shell can replay
181-
// a trace to later visitors. The Node SDK only sets `traceProvider` when it registered its own
182-
// tracer provider and propagator; when another OpenTelemetry setup (for example `@vercel/otel`)
183-
// owns them, theirs stays in place.
179+
// Next.js reads the trace meta tags of a document through the global propagator. Only the setup the
180+
// Node SDK owns gets the Next.js-aware one; another OpenTelemetry setup (for example `@vercel/otel`)
181+
// keeps its own tracer provider and propagator.
184182
if (client?.traceProvider) {
185-
registerNextSentryPropagator();
183+
registerNextSentryPropagator(client.traceProvider);
186184
}
187185

188186
// On the client, not the global scope, so a later `init()` after

‎packages/nextjs/src/server/nextSentryPropagator.ts‎

Lines changed: 57 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,59 @@
1-
import type { Context, TextMapSetter } from '@opentelemetry/api';
2-
import { propagation } from '@opentelemetry/api';
3-
import type { Span } from '@sentry/core';
4-
import { getActiveSpan, spanToJSON } from '@sentry/core';
1+
import type { Context, SpanOptions, TextMapSetter, Tracer, TracerProvider } from '@opentelemetry/api';
2+
import { context, createContextKey, propagation } from '@opentelemetry/api';
53
import { SentryPropagator } from '@sentry/opentelemetry';
64
import { ATTR_NEXT_SPAN_NAME, ATTR_NEXT_SPAN_TYPE } from '../common/nextSpanAttributes';
75

86
const APP_RENDER_BODY_SPAN_TYPE = 'AppRender.getBodyResult';
97
const PRERENDER_ROUTE_SPAN_NAME_PREFIX = 'prerender route';
108

9+
const PRERENDER_CONTEXT_KEY = createContextKey('sentry.nextjs.prerender');
10+
1111
/**
12-
* Whether the span is the one Next.js wraps a prerender in. Next.js computes the `sentry-trace` and
13-
* `baggage` meta tags of a document inside that span, and a prerender produces a shell that is cached
14-
* and served to later visitors. A render for a request, including the resume of a prerendered shell,
15-
* computes the tags outside of it.
12+
* Whether these are the start arguments of the span Next.js wraps a prerender in. Next.js computes the
13+
* `sentry-trace` and `baggage` meta tags of a document inside that span, and a prerender produces a
14+
* shell that is cached and served to later visitors. A render for a request, including the resume of
15+
* a prerendered shell, computes the tags outside of it.
1616
*/
17-
export function isPrerenderSpan(span: Span | undefined): boolean {
18-
if (!span) {
19-
return false;
20-
}
21-
const { attributes } = spanToJSON(span);
22-
const spanName = attributes?.[ATTR_NEXT_SPAN_NAME];
17+
export function isPrerenderSpanStart(name: string, options: SpanOptions | undefined): boolean {
18+
const attributes = options?.attributes;
19+
const spanName = attributes?.[ATTR_NEXT_SPAN_NAME] ?? name;
2320
return (
2421
attributes?.[ATTR_NEXT_SPAN_TYPE] === APP_RENDER_BODY_SPAN_TYPE &&
2522
typeof spanName === 'string' &&
2623
spanName.startsWith(PRERENDER_ROUTE_SPAN_NAME_PREFIX)
2724
);
2825
}
2926

27+
/** Whether the context belongs to a prerender started through a tracer of a wrapped provider. */
28+
export function isPrerenderContext(ctx: Context): boolean {
29+
return ctx.getValue(PRERENDER_CONTEXT_KEY) === true;
30+
}
31+
32+
/**
33+
* Marks the context of a prerender span on the OpenTelemetry context rather than on the span. The
34+
* context is inherited by everything that runs inside the prerender, and it exists even when the
35+
* request is not sampled, in which case the prerender span is non-recording and carries no
36+
* attributes to recognize it by.
37+
*/
38+
function wrapTracer(tracer: Tracer): Tracer {
39+
return {
40+
startSpan: (name, options, ctx) => tracer.startSpan(name, options, ctx),
41+
startActiveSpan: (name: string, ...args: unknown[]) => {
42+
const startActiveSpan = tracer.startActiveSpan.bind(tracer) as (name: string, ...args: unknown[]) => unknown;
43+
const options = typeof args[0] === 'function' ? undefined : (args[0] as SpanOptions | undefined);
44+
if (!isPrerenderSpanStart(name, options)) {
45+
return startActiveSpan(name, ...args);
46+
}
47+
48+
const explicitContext = args.length === 3 ? (args[1] as Context) : undefined;
49+
if (explicitContext) {
50+
return startActiveSpan(name, args[0], explicitContext.setValue(PRERENDER_CONTEXT_KEY, true), args[2]);
51+
}
52+
return context.with(context.active().setValue(PRERENDER_CONTEXT_KEY, true), () => startActiveSpan(name, ...args));
53+
},
54+
};
55+
}
56+
3057
/**
3158
* A `SentryPropagator` that does not hand out trace context while Next.js prerenders a document.
3259
* Trace context baked into a prerendered shell would be replayed to every visitor the shell is
@@ -35,17 +62,30 @@ export function isPrerenderSpan(span: Span | undefined): boolean {
3562
export class NextSentryPropagator extends SentryPropagator {
3663
/** @inheritDoc */
3764
public inject(ctx: Context, carrier: unknown, setter: TextMapSetter): void {
38-
if (isPrerenderSpan(getActiveSpan())) {
65+
if (isPrerenderContext(ctx)) {
3966
return;
4067
}
4168
super.inject(ctx, carrier, setter);
4269
}
4370
}
4471

4572
/**
46-
* Replaces the propagator the Node SDK registered with the Next.js-aware one.
73+
* Makes the tracer provider the Node SDK registered mark prerenders on the OpenTelemetry context, and
74+
* replaces the propagator it registered with one that hands out nothing on a marked context.
4775
*/
48-
export function registerNextSentryPropagator(): void {
76+
export function registerNextSentryPropagator(provider: TracerProvider): void {
77+
const wrappedTracers = new WeakMap<Tracer, Tracer>();
78+
const getTracer = provider.getTracer.bind(provider);
79+
provider.getTracer = (...args) => {
80+
const tracer = getTracer(...args);
81+
let wrapped = wrappedTracers.get(tracer);
82+
if (!wrapped) {
83+
wrapped = wrapTracer(tracer);
84+
wrappedTracers.set(tracer, wrapped);
85+
}
86+
return wrapped;
87+
};
88+
4989
// The OpenTelemetry API refuses a second global propagator, so the one from the Node SDK setup has
5090
// to be unregistered first.
5191
propagation.disable();
Lines changed: 103 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,97 +1,136 @@
1-
import { context, propagation } from '@opentelemetry/api';
1+
import { context, propagation, trace } from '@opentelemetry/api';
22
import { getCurrentScope, setCurrentClient, startSpan } from '@sentry/core';
33
import { NodeClient, getDefaultIntegrations } from '@sentry/node';
4-
import { SentryPropagator } from '@sentry/opentelemetry';
4+
import { SentryPropagator, SentryTracerProvider } from '@sentry/opentelemetry';
55
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
66
import { ATTR_NEXT_SPAN_NAME, ATTR_NEXT_SPAN_TYPE } from '../../src/common/nextSpanAttributes';
77
import {
88
NextSentryPropagator,
9-
isPrerenderSpan,
9+
isPrerenderContext,
10+
isPrerenderSpanStart,
1011
registerNextSentryPropagator,
1112
} from '../../src/server/nextSentryPropagator';
1213

13-
function injectedKeys(propagator: SentryPropagator): string[] {
14+
const PRERENDER_SPAN_OPTIONS = {
15+
attributes: {
16+
[ATTR_NEXT_SPAN_TYPE]: 'AppRender.getBodyResult',
17+
[ATTR_NEXT_SPAN_NAME]: 'prerender route (app) /[id]',
18+
},
19+
};
20+
21+
const RENDER_SPAN_OPTIONS = {
22+
attributes: {
23+
[ATTR_NEXT_SPAN_TYPE]: 'AppRender.getBodyResult',
24+
[ATTR_NEXT_SPAN_NAME]: 'render route (app) /blocking',
25+
},
26+
};
27+
28+
function inject(): Record<string, string> {
1429
const carrier: Record<string, string> = {};
15-
propagator.inject(context.active(), carrier, {
30+
propagation.inject(context.active(), carrier, {
1631
set: (c, key, value) => ((c as Record<string, string>)[key] = value),
1732
});
18-
return Object.keys(carrier);
33+
return carrier;
1934
}
2035

21-
describe('NextSentryPropagator', () => {
22-
beforeEach(() => {
23-
const client = new NodeClient({
24-
dsn: 'https://public@dsn.ingest.sentry.io/1337',
25-
tracesSampleRate: 1,
26-
integrations: getDefaultIntegrations({}),
27-
stackParser: () => [],
28-
transport: () => ({ send: () => Promise.resolve({}), flush: () => Promise.resolve(true) }),
29-
});
30-
setCurrentClient(client);
31-
client.init();
36+
function setup(tracesSampleRate: number): void {
37+
const client = new NodeClient({
38+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
39+
tracesSampleRate,
40+
enableOpenTelemetrySetup: true,
41+
integrations: getDefaultIntegrations({}),
42+
stackParser: () => [],
43+
transport: () => ({ send: () => Promise.resolve({}), flush: () => Promise.resolve(true) }),
3244
});
45+
setCurrentClient(client);
46+
client.init();
47+
48+
const provider = new SentryTracerProvider();
49+
trace.setGlobalTracerProvider(provider);
50+
propagation.setGlobalPropagator(new SentryPropagator());
51+
registerNextSentryPropagator(provider);
52+
}
53+
54+
describe('NextSentryPropagator', () => {
55+
beforeEach(() => setup(1));
3356

3457
afterEach(() => {
3558
getCurrentScope().setClient(undefined);
59+
trace.disable();
60+
context.disable();
61+
propagation.disable();
3662
vi.restoreAllMocks();
3763
});
3864

65+
it('replaces the registered global propagator', () => {
66+
const injectSpy = vi.spyOn(NextSentryPropagator.prototype, 'inject');
67+
inject();
68+
expect(injectSpy).toHaveBeenCalledTimes(1);
69+
});
70+
3971
it('hands out trace context while a route is rendered for a request', () => {
40-
const propagator = new NextSentryPropagator();
41-
42-
startSpan(
43-
{
44-
name: 'render route (app) /blocking',
45-
attributes: {
46-
[ATTR_NEXT_SPAN_TYPE]: 'AppRender.getBodyResult',
47-
[ATTR_NEXT_SPAN_NAME]: 'render route (app) /blocking',
48-
},
49-
},
50-
span => {
51-
expect(isPrerenderSpan(span)).toBe(false);
52-
expect(injectedKeys(propagator)).toEqual(expect.arrayContaining(['sentry-trace', 'baggage']));
53-
},
54-
);
72+
trace.getTracer('next.js').startActiveSpan('render route (app) /blocking', RENDER_SPAN_OPTIONS, span => {
73+
expect(isPrerenderContext(context.active())).toBe(false);
74+
expect(inject()).toEqual({
75+
'sentry-trace': `${span.spanContext().traceId}-${span.spanContext().spanId}-1`,
76+
baggage: expect.stringContaining('sentry-trace_id='),
77+
});
78+
span.end();
79+
});
5580
});
5681

5782
it('hands out nothing while a route is prerendered', () => {
58-
const propagator = new NextSentryPropagator();
59-
60-
startSpan(
61-
{
62-
name: 'prerender route (app) /[id]',
63-
attributes: {
64-
[ATTR_NEXT_SPAN_TYPE]: 'AppRender.getBodyResult',
65-
[ATTR_NEXT_SPAN_NAME]: 'prerender route (app) /[id]',
66-
},
67-
},
68-
span => {
69-
expect(isPrerenderSpan(span)).toBe(true);
70-
expect(injectedKeys(propagator)).toEqual([]);
71-
},
72-
);
83+
trace.getTracer('next.js').startActiveSpan('prerender route (app) /[id]', PRERENDER_SPAN_OPTIONS, span => {
84+
expect(isPrerenderContext(context.active())).toBe(true);
85+
expect(inject()).toEqual({});
86+
span.end();
87+
});
7388
});
7489

75-
it('does not mistake other Next.js spans for a prerender', () => {
76-
startSpan(
77-
{
78-
name: 'prerender route (app) /x',
79-
attributes: { [ATTR_NEXT_SPAN_TYPE]: 'NextNodeServer.getLayoutOrPageModule' },
80-
},
81-
span => expect(isPrerenderSpan(span)).toBe(false),
82-
);
83-
expect(isPrerenderSpan(undefined)).toBe(false);
90+
it('hands out nothing inside a span nested in a prerender', () => {
91+
trace.getTracer('next.js').startActiveSpan('prerender route (app) /[id]', PRERENDER_SPAN_OPTIONS, span => {
92+
startSpan({ name: 'server component' }, () => {
93+
expect(inject()).toEqual({});
94+
});
95+
span.end();
96+
});
8497
});
8598

86-
it('replaces the registered global propagator', () => {
87-
propagation.setGlobalPropagator(new SentryPropagator());
99+
it('hands out nothing while a route is prerendered in an unsampled trace', () => {
100+
setup(0);
88101

89-
registerNextSentryPropagator();
102+
trace.getTracer('next.js').startActiveSpan('GET /[id]', span => {
103+
expect(span.isRecording()).toBe(false);
104+
expect(inject()['sentry-trace']).toMatch(/-0$/);
90105

91-
const carrier: Record<string, string> = {};
92-
const injectSpy = vi.spyOn(NextSentryPropagator.prototype, 'inject');
93-
propagation.inject(context.active(), carrier);
94-
expect(injectSpy).toHaveBeenCalledTimes(1);
95-
propagation.disable();
106+
trace
107+
.getTracer('next.js')
108+
.startActiveSpan('prerender route (app) /[id]', PRERENDER_SPAN_OPTIONS, prerenderSpan => {
109+
expect(prerenderSpan.isRecording()).toBe(false);
110+
expect(inject()).toEqual({});
111+
prerenderSpan.end();
112+
});
113+
span.end();
114+
});
115+
});
116+
117+
it('marks an explicitly passed context', () => {
118+
trace
119+
.getTracer('next.js')
120+
.startActiveSpan('prerender route (app) /[id]', PRERENDER_SPAN_OPTIONS, context.active(), span => {
121+
expect(isPrerenderContext(context.active())).toBe(true);
122+
expect(inject()).toEqual({});
123+
span.end();
124+
});
125+
});
126+
127+
it('does not mistake other Next.js spans for a prerender', () => {
128+
expect(
129+
isPrerenderSpanStart('prerender route (app) /x', {
130+
attributes: { [ATTR_NEXT_SPAN_TYPE]: 'NextNodeServer.getLayoutOrPageModule' },
131+
}),
132+
).toBe(false);
133+
expect(isPrerenderSpanStart('prerender route (app) /x', undefined)).toBe(false);
134+
expect(isPrerenderSpanStart('prerender route (app) /x', PRERENDER_SPAN_OPTIONS)).toBe(true);
96135
});
97136
});

‎packages/nextjs/test/serverSdk.test.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,15 +71,16 @@ describe('Server init()', () => {
7171

7272
describe('trace propagator', () => {
7373
it('replaces the propagator when the Node SDK set up OpenTelemetry itself', () => {
74+
const traceProvider = {};
7475
nodeInit.mockReturnValueOnce({
7576
on: vi.fn(),
7677
addEventProcessor: vi.fn(),
77-
traceProvider: {},
78+
traceProvider,
7879
} as unknown as ReturnType<typeof SentryNode.init>);
7980

8081
init({});
8182

82-
expect(registerNextSentryPropagator).toHaveBeenCalledTimes(1);
83+
expect(registerNextSentryPropagator).toHaveBeenCalledWith(traceProvider);
8384
});
8485

8586
it('leaves a foreign OpenTelemetry setup alone', () => {

0 commit comments

Comments
 (0)