diff --git a/packages/nextjs/src/edge/enhanceMiddlewareRootSpan.ts b/packages/nextjs/src/edge/enhanceMiddlewareRootSpan.ts new file mode 100644 index 000000000000..cd71928e9109 --- /dev/null +++ b/packages/nextjs/src/edge/enhanceMiddlewareRootSpan.ts @@ -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; + 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)); + } +} diff --git a/packages/nextjs/src/edge/index.ts b/packages/nextjs/src/edge/index.ts index 96a03541ab22..e92f919a8b57 100644 --- a/packages/nextjs/src/edge/index.ts +++ b/packages/nextjs/src/edge/index.ts @@ -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,12 @@ export function init(options: VercelEdgeOptions = {}): void { ...(isRunningOnCloudflare && { runtime: { name: 'cloudflare' } }), }; + const nextjsIgnoreSpans: NonNullable = [ + // (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 +142,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; + }, + }); + }); + 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); } } catch { // Noop diff --git a/packages/nextjs/test/edge/enhanceMiddlewareRootSpan.test.ts b/packages/nextjs/test/edge/enhanceMiddlewareRootSpan.test.ts new file mode 100644 index 000000000000..6308c6a75bab --- /dev/null +++ b/packages/nextjs/test/edge/enhanceMiddlewareRootSpan.test.ts @@ -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, 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'); + }); +}); diff --git a/packages/nextjs/test/edgeSdk.test.ts b/packages/nextjs/test/edgeSdk.test.ts index de0dd041e972..8d4fee1f926e 100644 --- a/packages/nextjs/test/edgeSdk.test.ts +++ b/packages/nextjs/test/edgeSdk.test.ts @@ -2,6 +2,7 @@ import type { Integration } from '@sentry/core'; import { GLOBAL_OBJ } from '@sentry/core'; import * as SentryVercelEdge from '@sentry/vercel-edge'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION } from '../src/common/span-attributes-with-logic-attached'; import { init } from '../src/edge'; // normally this is set as part of the build process, so mock it here @@ -74,6 +75,30 @@ describe('Edge init()', () => { }); }); + describe('ignoreSpans', () => { + function getIgnoreSpans(): NonNullable { + const callArgs = vercelEdgeInit.mock.calls[0]?.[0] as SentryVercelEdge.VercelEdgeOptions; + return callArgs.ignoreSpans ?? []; + } + + it('appends the TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION attribute filter', () => { + init({}); + const patterns = getIgnoreSpans(); + + expect(patterns).toContainEqual({ + attributes: { [TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION]: true }, + }); + }); + + it('preserves user-provided ignoreSpans entries', () => { + init({ ignoreSpans: ['user-pattern', /custom-regex/] }); + const patterns = getIgnoreSpans(); + + expect(patterns).toContain('user-pattern'); + expect(patterns.some(p => p instanceof RegExp && p.source === 'custom-regex')).toBe(true); + }); + }); + describe('environment option', () => { const originalEnv = process.env.SENTRY_ENVIRONMENT;