Skip to content

Commit d9a94ba

Browse files
authored
feat(nextjs): Migrate edge event processors to span-first APIs (#20551)
Basically #20527 but for edge closes #20368
1 parent fae6a7d commit d9a94ba

4 files changed

Lines changed: 208 additions & 41 deletions

File tree

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { stripUrlQueryAndFragment } from '@sentry/core';
2+
import { ATTR_NEXT_SPAN_NAME, ATTR_NEXT_SPAN_TYPE } from '../common/nextSpanAttributes';
3+
4+
export interface MutableMiddlewareRootSpan {
5+
attributes: Record<string, unknown>;
6+
getName(): string | undefined;
7+
setName(name: string): void;
8+
}
9+
10+
/**
11+
* Normalizes the transaction name for the root span of a Next.js `Middleware.execute` request on the Edge runtime.
12+
*
13+
* Older Next.js versions append the full URL to the middleware span name (e.g. `middleware GET /foo?bar=1`),
14+
* producing high-cardinality transaction names. We collapse the name to `middleware {METHOD}` when possible,
15+
* and strip query/fragment otherwise.
16+
*
17+
* Called from two places that operate on different shapes of the same underlying root span:
18+
* - Legacy mode: from `preprocessEvent`, adapted around a transaction `Event` whose `contexts.trace.data`
19+
* holds the root span's attributes and whose `event.transaction` is the root span's name.
20+
* - Streamed mode: from `processSegmentSpan`, adapted around a `StreamedSpanJSON` (the streamed
21+
* counterpart of the legacy transaction root) directly.
22+
*/
23+
export function enhanceMiddlewareRootSpan(span: MutableMiddlewareRootSpan): void {
24+
const { attributes } = span;
25+
26+
if (attributes[ATTR_NEXT_SPAN_TYPE] !== 'Middleware.execute') {
27+
return;
28+
}
29+
30+
const spanName = attributes[ATTR_NEXT_SPAN_NAME];
31+
if (typeof spanName !== 'string' || !spanName || !span.getName()) {
32+
return;
33+
}
34+
35+
const match = spanName.match(/^middleware (GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)/);
36+
if (match) {
37+
span.setName(`middleware ${match[1]}`);
38+
} else {
39+
span.setName(stripUrlQueryAndFragment(spanName));
40+
}
41+
}

packages/nextjs/src/edge/index.ts

Lines changed: 32 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
import { context } from '@opentelemetry/api';
55
import {
66
applySdkMetadata,
7-
type EventProcessor,
87
getCapturedScopesOnSpan,
98
getCurrentScope,
109
getGlobalScope,
@@ -17,7 +16,6 @@ import {
1716
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
1817
setCapturedScopesOnSpan,
1918
spanToJSON,
20-
stripUrlQueryAndFragment,
2119
} from '@sentry/core';
2220
import { getScopesFromContext } from '@sentry/opentelemetry';
2321
import type { VercelEdgeOptions } from '@sentry/vercel-edge';
@@ -31,6 +29,7 @@ import { isBuild } from '../common/utils/isBuild';
3129
import { flushSafelyWithTimeout, isCloudflareWaitUntilAvailable, waitUntil } from '../common/utils/responseEnd';
3230
import { setUrlProcessingMetadata } from '../common/utils/setUrlProcessingMetadata';
3331
import { distDirRewriteFramesIntegration } from './distDirRewriteFramesIntegration';
32+
import { enhanceMiddlewareRootSpan } from './enhanceMiddlewareRootSpan';
3433

3534
export * from '@sentry/vercel-edge';
3635
export * from '../common';
@@ -85,6 +84,12 @@ export function init(options: VercelEdgeOptions = {}): void {
8584
...(isRunningOnCloudflare && { runtime: { name: 'cloudflare' } }),
8685
};
8786

87+
const nextjsIgnoreSpans: NonNullable<VercelEdgeOptions['ignoreSpans']> = [
88+
// (set in `dropMiddlewareTunnelRequests` during `spanStart`)
89+
{ attributes: { [TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION]: true } },
90+
];
91+
opts.ignoreSpans = [...(opts.ignoreSpans || []), ...nextjsIgnoreSpans];
92+
8893
// Use appropriate SDK metadata based on the runtime environment
8994
if (isRunningOnCloudflare) {
9095
applySdkMetadata(opts, 'nextjs', ['nextjs', 'cloudflare']);
@@ -137,61 +142,47 @@ export function init(options: VercelEdgeOptions = {}): void {
137142
// Use the preprocessEvent hook instead of an event processor, so that the users event processors receive the most
138143
// up-to-date value, but also so that the logic that detects changes to the transaction names to set the source to
139144
// "custom", doesn't trigger.
145+
// This handles the legacy (non-streamed) path where the segment span is emitted as a transaction event;
146+
// `enhanceMiddlewareRootSpan` is adapted to operate on the event's trace context, which is the segment span's data.
147+
// Span streaming bypasses event processors entirely - see the `processSegmentSpan` hook below for that path.
140148
client?.on('preprocessEvent', event => {
141-
// The otel auto inference will clobber the transaction name because the span has an http.target
142-
if (
143-
event.type === 'transaction' &&
144-
event.contexts?.trace?.data?.['next.span_type'] === 'Middleware.execute' &&
145-
event.contexts?.trace?.data?.['next.span_name']
146-
) {
147-
if (event.transaction) {
148-
// Older nextjs versions pass the full url appended to the middleware name, which results in high cardinality transaction names.
149-
// We want to remove the url from the name here.
150-
const spanName = event.contexts.trace.data['next.span_name'];
151-
152-
if (typeof spanName === 'string') {
153-
const match = spanName.match(/^middleware (GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)/);
154-
if (match) {
155-
const normalizedName = `middleware ${match[1]}`;
156-
event.transaction = normalizedName;
157-
} else {
158-
event.transaction = stripUrlQueryAndFragment(event.contexts.trace.data['next.span_name']);
159-
}
160-
}
161-
}
149+
if (event.type === 'transaction' && event.contexts?.trace?.data) {
150+
enhanceMiddlewareRootSpan({
151+
attributes: event.contexts.trace.data,
152+
getName: () => event.transaction,
153+
setName: name => {
154+
event.transaction = name;
155+
},
156+
});
162157
}
163158

164159
setUrlProcessingMetadata(event);
165160
});
166161

162+
// Streamed-span counterpart of the `preprocessEvent` hook above. Streamed segment spans never become
163+
// transaction events, so the same enhancement has to be applied here directly on the span JSON.
164+
client?.on('processSegmentSpan', span => {
165+
const attributes = (span.attributes ??= {});
166+
enhanceMiddlewareRootSpan({
167+
attributes,
168+
getName: () => span.name,
169+
setName: name => {
170+
span.name = name;
171+
},
172+
});
173+
});
174+
167175
client?.on('spanEnd', span => {
168176
if (span === getRootSpan(span)) {
169177
waitUntil(flushSafelyWithTimeout());
170178
}
171179
});
172180

173-
getGlobalScope().addEventProcessor(
174-
Object.assign(
175-
(event => {
176-
// Filter transactions that we explicitly want to drop.
177-
if (event.type === 'transaction') {
178-
if (event.contexts?.trace?.data?.[TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION]) {
179-
return null;
180-
}
181-
182-
return event;
183-
} else {
184-
return event;
185-
}
186-
}) satisfies EventProcessor,
187-
{ id: 'NextLowQualityTransactionsFilter' },
188-
),
189-
);
190-
191181
try {
192182
// @ts-expect-error `process.turbopack` is a magic string that will be replaced by Next.js
193183
if (process.turbopack) {
194184
getGlobalScope().setTag('turbopack', true);
185+
getGlobalScope().setAttribute('turbopack', true);
195186
}
196187
} catch {
197188
// Noop
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { ATTR_NEXT_SPAN_NAME, ATTR_NEXT_SPAN_TYPE } from '../../src/common/nextSpanAttributes';
3+
import { enhanceMiddlewareRootSpan } from '../../src/edge/enhanceMiddlewareRootSpan';
4+
5+
function makeSpan(attributes: Record<string, unknown>, name?: string) {
6+
let currentName = name;
7+
return {
8+
span: {
9+
attributes,
10+
getName: () => currentName,
11+
setName: (n: string) => {
12+
currentName = n;
13+
},
14+
},
15+
getName: () => currentName,
16+
};
17+
}
18+
19+
describe('enhanceMiddlewareRootSpan', () => {
20+
it('does nothing for spans that are not Middleware.execute', () => {
21+
const { span, getName } = makeSpan(
22+
{ [ATTR_NEXT_SPAN_TYPE]: 'BaseServer.handleRequest', [ATTR_NEXT_SPAN_NAME]: 'middleware GET /foo' },
23+
'GET /foo',
24+
);
25+
26+
enhanceMiddlewareRootSpan(span);
27+
28+
expect(getName()).toBe('GET /foo');
29+
});
30+
31+
it('does nothing when next.span_name is missing', () => {
32+
const { span, getName } = makeSpan({ [ATTR_NEXT_SPAN_TYPE]: 'Middleware.execute' }, 'middleware');
33+
34+
enhanceMiddlewareRootSpan(span);
35+
36+
expect(getName()).toBe('middleware');
37+
});
38+
39+
it('does nothing when next.span_name is an empty string', () => {
40+
const { span, getName } = makeSpan(
41+
{ [ATTR_NEXT_SPAN_TYPE]: 'Middleware.execute', [ATTR_NEXT_SPAN_NAME]: '' },
42+
'middleware',
43+
);
44+
45+
enhanceMiddlewareRootSpan(span);
46+
47+
expect(getName()).toBe('middleware');
48+
});
49+
50+
it('does nothing when next.span_name is not a string', () => {
51+
const { span, getName } = makeSpan(
52+
{ [ATTR_NEXT_SPAN_TYPE]: 'Middleware.execute', [ATTR_NEXT_SPAN_NAME]: 123 },
53+
'middleware',
54+
);
55+
56+
enhanceMiddlewareRootSpan(span);
57+
58+
expect(getName()).toBe('middleware');
59+
});
60+
61+
it('does nothing when the current name is empty', () => {
62+
const { span, getName } = makeSpan(
63+
{ [ATTR_NEXT_SPAN_TYPE]: 'Middleware.execute', [ATTR_NEXT_SPAN_NAME]: 'middleware GET /foo' },
64+
undefined,
65+
);
66+
67+
enhanceMiddlewareRootSpan(span);
68+
69+
expect(getName()).toBeUndefined();
70+
});
71+
72+
it.each([
73+
['middleware GET /foo', 'middleware GET'],
74+
['middleware POST /api/protected?token=abc', 'middleware POST'],
75+
['middleware DELETE /resources/[id]', 'middleware DELETE'],
76+
['middleware HEAD /', 'middleware HEAD'],
77+
])('collapses "%s" to "%s"', (spanName, expected) => {
78+
const { span, getName } = makeSpan(
79+
{ [ATTR_NEXT_SPAN_TYPE]: 'Middleware.execute', [ATTR_NEXT_SPAN_NAME]: spanName },
80+
spanName,
81+
);
82+
83+
enhanceMiddlewareRootSpan(span);
84+
85+
expect(getName()).toBe(expected);
86+
});
87+
88+
it('strips query and fragment from non-method-prefixed middleware names', () => {
89+
const { span, getName } = makeSpan(
90+
{ [ATTR_NEXT_SPAN_TYPE]: 'Middleware.execute', [ATTR_NEXT_SPAN_NAME]: '/api/foo?token=abc#section' },
91+
'/api/foo?token=abc#section',
92+
);
93+
94+
enhanceMiddlewareRootSpan(span);
95+
96+
expect(getName()).toBe('/api/foo');
97+
});
98+
99+
it('does not collapse names that do not match the middleware-method prefix', () => {
100+
// CONNECT and TRACE are not in the regex - they fall through to query/fragment stripping
101+
const { span, getName } = makeSpan(
102+
{ [ATTR_NEXT_SPAN_TYPE]: 'Middleware.execute', [ATTR_NEXT_SPAN_NAME]: 'middleware CONNECT /foo?bar=1' },
103+
'middleware CONNECT /foo?bar=1',
104+
);
105+
106+
enhanceMiddlewareRootSpan(span);
107+
108+
expect(getName()).toBe('middleware CONNECT /foo');
109+
});
110+
});

packages/nextjs/test/edgeSdk.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { Integration } from '@sentry/core';
22
import { GLOBAL_OBJ } from '@sentry/core';
33
import * as SentryVercelEdge from '@sentry/vercel-edge';
44
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5+
import { TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION } from '../src/common/span-attributes-with-logic-attached';
56
import { init } from '../src/edge';
67

78
// normally this is set as part of the build process, so mock it here
@@ -74,6 +75,30 @@ describe('Edge init()', () => {
7475
});
7576
});
7677

78+
describe('ignoreSpans', () => {
79+
function getIgnoreSpans(): NonNullable<SentryVercelEdge.VercelEdgeOptions['ignoreSpans']> {
80+
const callArgs = vercelEdgeInit.mock.calls[0]?.[0] as SentryVercelEdge.VercelEdgeOptions;
81+
return callArgs.ignoreSpans ?? [];
82+
}
83+
84+
it('appends the TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION attribute filter', () => {
85+
init({});
86+
const patterns = getIgnoreSpans();
87+
88+
expect(patterns).toContainEqual({
89+
attributes: { [TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION]: true },
90+
});
91+
});
92+
93+
it('preserves user-provided ignoreSpans entries', () => {
94+
init({ ignoreSpans: ['user-pattern', /custom-regex/] });
95+
const patterns = getIgnoreSpans();
96+
97+
expect(patterns).toContain('user-pattern');
98+
expect(patterns.some(p => p instanceof RegExp && p.source === 'custom-regex')).toBe(true);
99+
});
100+
});
101+
77102
describe('environment option', () => {
78103
const originalEnv = process.env.SENTRY_ENVIRONMENT;
79104

0 commit comments

Comments
 (0)