-
-
Notifications
You must be signed in to change notification settings - Fork 1.8k
feat(nextjs): Migrate edge event processors to span-first APIs #20551
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 3 commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| import { stripUrlQueryAndFragment } from '@sentry/core'; | ||
| import { ATTR_NEXT_SPAN_NAME, ATTR_NEXT_SPAN_TYPE } from '../common/nextSpanAttributes'; | ||
|
|
||
| export interface MutableMiddlewareRootSpan { | ||
| attributes: Record<string, unknown>; | ||
| getName(): string | undefined; | ||
| setName(name: string): void; | ||
| } | ||
|
|
||
| /** | ||
| * Normalizes the transaction name for the root span of a Next.js `Middleware.execute` request on the Edge runtime. | ||
| * | ||
| * Older Next.js versions append the full URL to the middleware span name (e.g. `middleware GET /foo?bar=1`), | ||
| * producing high-cardinality transaction names. We collapse the name to `middleware {METHOD}` when possible, | ||
| * and strip query/fragment otherwise. | ||
| * | ||
| * Called from two places that operate on different shapes of the same underlying root span: | ||
| * - Legacy mode: from `preprocessEvent`, adapted around a transaction `Event` whose `contexts.trace.data` | ||
| * holds the root span's attributes and whose `event.transaction` is the root span's name. | ||
| * - Streamed mode: from `processSegmentSpan`, adapted around a `StreamedSpanJSON` (the streamed | ||
| * counterpart of the legacy transaction root) directly. | ||
| */ | ||
| export function enhanceMiddlewareRootSpan(span: MutableMiddlewareRootSpan): void { | ||
| const { attributes } = span; | ||
|
|
||
| if (attributes[ATTR_NEXT_SPAN_TYPE] !== 'Middleware.execute') { | ||
| return; | ||
| } | ||
|
|
||
| const spanName = attributes[ATTR_NEXT_SPAN_NAME]; | ||
| if (typeof spanName !== 'string' || !spanName || !span.getName()) { | ||
| return; | ||
| } | ||
|
|
||
| const match = spanName.match(/^middleware (GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)/); | ||
| if (match) { | ||
| span.setName(`middleware ${match[1]}`); | ||
| } else { | ||
| span.setName(stripUrlQueryAndFragment(spanName)); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,7 +4,6 @@ | |
| import { context } from '@opentelemetry/api'; | ||
| import { | ||
| applySdkMetadata, | ||
| type EventProcessor, | ||
| getCapturedScopesOnSpan, | ||
| getCurrentScope, | ||
| getGlobalScope, | ||
|
|
@@ -17,7 +16,6 @@ import { | |
| SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, | ||
| setCapturedScopesOnSpan, | ||
| spanToJSON, | ||
| stripUrlQueryAndFragment, | ||
| } from '@sentry/core'; | ||
| import { getScopesFromContext } from '@sentry/opentelemetry'; | ||
| import type { VercelEdgeOptions } from '@sentry/vercel-edge'; | ||
|
|
@@ -31,6 +29,7 @@ import { isBuild } from '../common/utils/isBuild'; | |
| import { flushSafelyWithTimeout, isCloudflareWaitUntilAvailable, waitUntil } from '../common/utils/responseEnd'; | ||
| import { setUrlProcessingMetadata } from '../common/utils/setUrlProcessingMetadata'; | ||
| import { distDirRewriteFramesIntegration } from './distDirRewriteFramesIntegration'; | ||
| import { enhanceMiddlewareRootSpan } from './enhanceMiddlewareRootSpan'; | ||
|
|
||
| export * from '@sentry/vercel-edge'; | ||
| export * from '../common'; | ||
|
|
@@ -85,6 +84,13 @@ export function init(options: VercelEdgeOptions = {}): void { | |
| ...(isRunningOnCloudflare && { runtime: { name: 'cloudflare' } }), | ||
| }; | ||
|
|
||
| const nextjsIgnoreSpans: NonNullable<VercelEdgeOptions['ignoreSpans']> = [ | ||
| // Spans flagged via TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION | ||
| // (set in `dropMiddlewareTunnelRequests` during `spanStart`) | ||
| { attributes: { [TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION]: true } }, | ||
| ]; | ||
| opts.ignoreSpans = [...(opts.ignoreSpans || []), ...nextjsIgnoreSpans]; | ||
|
|
||
| // Use appropriate SDK metadata based on the runtime environment | ||
| if (isRunningOnCloudflare) { | ||
| applySdkMetadata(opts, 'nextjs', ['nextjs', 'cloudflare']); | ||
|
|
@@ -137,61 +143,47 @@ export function init(options: VercelEdgeOptions = {}): void { | |
| // Use the preprocessEvent hook instead of an event processor, so that the users event processors receive the most | ||
| // up-to-date value, but also so that the logic that detects changes to the transaction names to set the source to | ||
| // "custom", doesn't trigger. | ||
| // This handles the legacy (non-streamed) path where the segment span is emitted as a transaction event; | ||
| // `enhanceMiddlewareRootSpan` is adapted to operate on the event's trace context, which is the segment span's data. | ||
| // Span streaming bypasses event processors entirely - see the `processSegmentSpan` hook below for that path. | ||
| client?.on('preprocessEvent', event => { | ||
| // The otel auto inference will clobber the transaction name because the span has an http.target | ||
| if ( | ||
| event.type === 'transaction' && | ||
| event.contexts?.trace?.data?.['next.span_type'] === 'Middleware.execute' && | ||
| event.contexts?.trace?.data?.['next.span_name'] | ||
| ) { | ||
| if (event.transaction) { | ||
| // Older nextjs versions pass the full url appended to the middleware name, which results in high cardinality transaction names. | ||
| // We want to remove the url from the name here. | ||
| const spanName = event.contexts.trace.data['next.span_name']; | ||
|
|
||
| if (typeof spanName === 'string') { | ||
| const match = spanName.match(/^middleware (GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)/); | ||
| if (match) { | ||
| const normalizedName = `middleware ${match[1]}`; | ||
| event.transaction = normalizedName; | ||
| } else { | ||
| event.transaction = stripUrlQueryAndFragment(event.contexts.trace.data['next.span_name']); | ||
| } | ||
| } | ||
| } | ||
| if (event.type === 'transaction' && event.contexts?.trace?.data) { | ||
| enhanceMiddlewareRootSpan({ | ||
| attributes: event.contexts.trace.data, | ||
| getName: () => event.transaction, | ||
| setName: name => { | ||
| event.transaction = name; | ||
| }, | ||
| }); | ||
| } | ||
|
|
||
| setUrlProcessingMetadata(event); | ||
| }); | ||
|
|
||
| // Streamed-span counterpart of the `preprocessEvent` hook above. Streamed segment spans never become | ||
| // transaction events, so the same enhancement has to be applied here directly on the span JSON. | ||
| client?.on('processSegmentSpan', span => { | ||
| const attributes = (span.attributes ??= {}); | ||
| enhanceMiddlewareRootSpan({ | ||
| attributes, | ||
| getName: () => span.name, | ||
| setName: name => { | ||
| span.name = name; | ||
| }, | ||
| }); | ||
| }); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Feat PR missing integration or E2E testLow Severity This is a Additional Locations (1)Triggered by project rule: PR Review Guidelines for Cursor Bot Reviewed by Cursor Bugbot for commit 04e5262. Configure here. |
||
|
|
||
| client?.on('spanEnd', span => { | ||
| if (span === getRootSpan(span)) { | ||
| waitUntil(flushSafelyWithTimeout()); | ||
| } | ||
| }); | ||
|
|
||
| getGlobalScope().addEventProcessor( | ||
| Object.assign( | ||
| (event => { | ||
| // Filter transactions that we explicitly want to drop. | ||
| if (event.type === 'transaction') { | ||
| if (event.contexts?.trace?.data?.[TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION]) { | ||
| return null; | ||
| } | ||
|
|
||
| return event; | ||
| } else { | ||
| return event; | ||
| } | ||
| }) satisfies EventProcessor, | ||
| { id: 'NextLowQualityTransactionsFilter' }, | ||
| ), | ||
| ); | ||
|
|
||
| try { | ||
| // @ts-expect-error `process.turbopack` is a magic string that will be replaced by Next.js | ||
| if (process.turbopack) { | ||
| getGlobalScope().setTag('turbopack', true); | ||
| getGlobalScope().setAttribute('turbopack', true); | ||
|
chargome marked this conversation as resolved.
|
||
| } | ||
| } catch { | ||
| // Noop | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,110 @@ | ||
| import { describe, expect, it } from 'vitest'; | ||
| import { ATTR_NEXT_SPAN_NAME, ATTR_NEXT_SPAN_TYPE } from '../../src/common/nextSpanAttributes'; | ||
| import { enhanceMiddlewareRootSpan } from '../../src/edge/enhanceMiddlewareRootSpan'; | ||
|
|
||
| function makeSpan(attributes: Record<string, unknown>, name?: string) { | ||
| let currentName = name; | ||
| return { | ||
| span: { | ||
| attributes, | ||
| getName: () => currentName, | ||
| setName: (n: string) => { | ||
| currentName = n; | ||
| }, | ||
| }, | ||
| getName: () => currentName, | ||
| }; | ||
| } | ||
|
|
||
| describe('enhanceMiddlewareRootSpan', () => { | ||
| it('does nothing for spans that are not Middleware.execute', () => { | ||
| const { span, getName } = makeSpan( | ||
| { [ATTR_NEXT_SPAN_TYPE]: 'BaseServer.handleRequest', [ATTR_NEXT_SPAN_NAME]: 'middleware GET /foo' }, | ||
| 'GET /foo', | ||
| ); | ||
|
|
||
| enhanceMiddlewareRootSpan(span); | ||
|
|
||
| expect(getName()).toBe('GET /foo'); | ||
| }); | ||
|
|
||
| it('does nothing when next.span_name is missing', () => { | ||
| const { span, getName } = makeSpan({ [ATTR_NEXT_SPAN_TYPE]: 'Middleware.execute' }, 'middleware'); | ||
|
|
||
| enhanceMiddlewareRootSpan(span); | ||
|
|
||
| expect(getName()).toBe('middleware'); | ||
| }); | ||
|
|
||
| it('does nothing when next.span_name is an empty string', () => { | ||
| const { span, getName } = makeSpan( | ||
| { [ATTR_NEXT_SPAN_TYPE]: 'Middleware.execute', [ATTR_NEXT_SPAN_NAME]: '' }, | ||
| 'middleware', | ||
| ); | ||
|
|
||
| enhanceMiddlewareRootSpan(span); | ||
|
|
||
| expect(getName()).toBe('middleware'); | ||
| }); | ||
|
|
||
| it('does nothing when next.span_name is not a string', () => { | ||
| const { span, getName } = makeSpan( | ||
| { [ATTR_NEXT_SPAN_TYPE]: 'Middleware.execute', [ATTR_NEXT_SPAN_NAME]: 123 }, | ||
| 'middleware', | ||
| ); | ||
|
|
||
| enhanceMiddlewareRootSpan(span); | ||
|
|
||
| expect(getName()).toBe('middleware'); | ||
| }); | ||
|
|
||
| it('does nothing when the current name is empty', () => { | ||
| const { span, getName } = makeSpan( | ||
| { [ATTR_NEXT_SPAN_TYPE]: 'Middleware.execute', [ATTR_NEXT_SPAN_NAME]: 'middleware GET /foo' }, | ||
| undefined, | ||
| ); | ||
|
|
||
| enhanceMiddlewareRootSpan(span); | ||
|
|
||
| expect(getName()).toBeUndefined(); | ||
| }); | ||
|
|
||
| it.each([ | ||
| ['middleware GET /foo', 'middleware GET'], | ||
| ['middleware POST /api/protected?token=abc', 'middleware POST'], | ||
| ['middleware DELETE /resources/[id]', 'middleware DELETE'], | ||
| ['middleware HEAD /', 'middleware HEAD'], | ||
| ])('collapses "%s" to "%s"', (spanName, expected) => { | ||
| const { span, getName } = makeSpan( | ||
| { [ATTR_NEXT_SPAN_TYPE]: 'Middleware.execute', [ATTR_NEXT_SPAN_NAME]: spanName }, | ||
| spanName, | ||
| ); | ||
|
|
||
| enhanceMiddlewareRootSpan(span); | ||
|
|
||
| expect(getName()).toBe(expected); | ||
| }); | ||
|
|
||
| it('strips query and fragment from non-method-prefixed middleware names', () => { | ||
| const { span, getName } = makeSpan( | ||
| { [ATTR_NEXT_SPAN_TYPE]: 'Middleware.execute', [ATTR_NEXT_SPAN_NAME]: '/api/foo?token=abc#section' }, | ||
| '/api/foo?token=abc#section', | ||
| ); | ||
|
|
||
| enhanceMiddlewareRootSpan(span); | ||
|
|
||
| expect(getName()).toBe('/api/foo'); | ||
| }); | ||
|
|
||
| it('does not collapse names that do not match the middleware-method prefix', () => { | ||
| // CONNECT and TRACE are not in the regex - they fall through to query/fragment stripping | ||
| const { span, getName } = makeSpan( | ||
| { [ATTR_NEXT_SPAN_TYPE]: 'Middleware.execute', [ATTR_NEXT_SPAN_NAME]: 'middleware CONNECT /foo?bar=1' }, | ||
| 'middleware CONNECT /foo?bar=1', | ||
| ); | ||
|
|
||
| enhanceMiddlewareRootSpan(span); | ||
|
|
||
| expect(getName()).toBe('middleware CONNECT /foo'); | ||
| }); | ||
| }); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Missing integration or E2E test for feat PRLow Severity This Additional Locations (1)Triggered by project rule: PR Review Guidelines for Cursor Bot Reviewed by Cursor Bugbot for commit 7334e14. Configure here. |
||


Uh oh!
There was an error while loading. Please reload this page.