diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/middleware.test.ts index 0bb957bbc466..5386c75f31a9 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/middleware.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/middleware.test.ts @@ -7,6 +7,10 @@ test('Should create a transaction for middleware', async ({ request }) => { return transactionEvent?.transaction === 'middleware GET'; }); + const routeTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { + return transactionEvent?.transaction === 'GET /api/endpoint-behind-middleware'; + }); + const response = await request.get('/api/endpoint-behind-middleware'); expect(await response.json()).toStrictEqual({ name: 'John Doe' }); @@ -20,6 +24,12 @@ test('Should create a transaction for middleware', async ({ request }) => { // Assert that isolation scope works properly expect(middlewareTransaction.tags?.['my-isolated-tag']).toBe(true); expect(middlewareTransaction.tags?.['my-global-scope-isolated-tag']).not.toBeDefined(); + + // Tags set in middleware must not leak into other requests' events (e.g. via a shared scope when the middleware + // runs in a detached context - https://github.com/vercel/next.js/pull/95306) + const routeTransaction = await routeTransactionPromise; + expect(routeTransaction.tags?.['my-isolated-tag']).not.toBeDefined(); + expect(routeTransaction.tags?.['my-global-scope-isolated-tag']).not.toBeDefined(); }); test('Faulty middlewares', async ({ request }) => { diff --git a/packages/nextjs/src/edge/enhanceMiddlewareRootSpan.ts b/packages/nextjs/src/common/enhanceMiddlewareRootSpan.ts similarity index 61% rename from packages/nextjs/src/edge/enhanceMiddlewareRootSpan.ts rename to packages/nextjs/src/common/enhanceMiddlewareRootSpan.ts index cd71928e9109..27a4cc5e5425 100644 --- a/packages/nextjs/src/edge/enhanceMiddlewareRootSpan.ts +++ b/packages/nextjs/src/common/enhanceMiddlewareRootSpan.ts @@ -1,24 +1,23 @@ import { stripUrlQueryAndFragment } from '@sentry/core'; -import { ATTR_NEXT_SPAN_NAME, ATTR_NEXT_SPAN_TYPE } from '../common/nextSpanAttributes'; +import { ATTR_NEXT_SPAN_NAME, ATTR_NEXT_SPAN_TYPE } from './nextSpanAttributes'; export interface MutableMiddlewareRootSpan { attributes: Record; getName(): string | undefined; setName(name: string): void; + setOp(op: string): void; } /** - * Normalizes the transaction name for the root span of a Next.js `Middleware.execute` request on the Edge runtime. + * Normalizes the transaction name and op for the root span of a Next.js `Middleware.execute` request. + * + * On the Edge runtime, middleware always runs in a detached sandbox, so `Middleware.execute` is the root span. + * On the Node.js runtime, this is the case since vercel/next.js#95306, where in-process + * middleware runs in a detached OTel context instead of inside the `BaseServer.handleRequest` span. * * 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; @@ -27,6 +26,8 @@ export function enhanceMiddlewareRootSpan(span: MutableMiddlewareRootSpan): void return; } + span.setOp('http.server.middleware'); + const spanName = attributes[ATTR_NEXT_SPAN_NAME]; if (typeof spanName !== 'string' || !spanName || !span.getName()) { return; diff --git a/packages/nextjs/src/edge/index.ts b/packages/nextjs/src/edge/index.ts index 7bb24bbf6c8d..4025f43dc777 100644 --- a/packages/nextjs/src/edge/index.ts +++ b/packages/nextjs/src/edge/index.ts @@ -29,7 +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'; +import { enhanceMiddlewareRootSpan } from '../common/enhanceMiddlewareRootSpan'; export * from '@sentry/vercel-edge'; export * from '../common'; @@ -155,6 +155,9 @@ export function init(options: VercelEdgeOptions = {}): void { setName: name => { event.transaction = name; }, + setOp: op => { + event.contexts!.trace!.op = op; + }, }); } @@ -171,6 +174,9 @@ export function init(options: VercelEdgeOptions = {}): void { setName: name => { span.name = name; }, + setOp: op => { + attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP] = op; + }, }); }); diff --git a/packages/nextjs/src/server/handleOnSpanStart.ts b/packages/nextjs/src/server/handleOnSpanStart.ts index 101e61f05232..a59124a3a244 100644 --- a/packages/nextjs/src/server/handleOnSpanStart.ts +++ b/packages/nextjs/src/server/handleOnSpanStart.ts @@ -84,8 +84,13 @@ export function handleOnSpanStart(span: Span): void { addHeadersAsAttributes(headers, rootSpan); } - // We want to fork the isolation scope for incoming requests - if (spanAttributes?.[ATTR_NEXT_SPAN_TYPE] === 'BaseServer.handleRequest' && isRootSpan) { + // We want to fork the isolation scope for incoming requests. Root `Middleware.execute` spans need the same + // treatment since Next.js 16.3.0-canary.79 + if ( + (spanAttributes?.[ATTR_NEXT_SPAN_TYPE] === 'BaseServer.handleRequest' || + spanAttributes?.[ATTR_NEXT_SPAN_TYPE] === 'Middleware.execute') && + isRootSpan + ) { const scopes = getCapturedScopesOnSpan(span); const isolationScope = (scopes.isolationScope || getIsolationScope()).clone(); diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index cd70361506c8..246c00c28fd8 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -21,6 +21,7 @@ import { isBuild } from '../common/utils/isBuild'; import { isCloudflareWaitUntilAvailable } from '../common/utils/responseEnd'; import { setUrlProcessingMetadata } from '../common/utils/setUrlProcessingMetadata'; import { distDirRewriteFramesIntegration } from './distDirRewriteFramesIntegration'; +import { enhanceMiddlewareRootSpan } from '../common/enhanceMiddlewareRootSpan'; import { enhanceHandleRequestRootSpan } from './enhanceHandleRequestRootSpan'; import { handleOnSpanStart } from './handleOnSpanStart'; import { prepareSafeIdGeneratorContext } from './prepareSafeIdGeneratorContext'; @@ -249,16 +250,18 @@ export function init(options: NodeOptions): NodeClient | undefined { // Span streaming bypasses event processors entirely - see the `processSegmentSpan` hook below for that path. client?.on('preprocessEvent', event => { if (event.type === 'transaction' && event.contexts?.trace?.data) { - enhanceHandleRequestRootSpan({ + const mutableRootSpan = { attributes: event.contexts.trace.data, getName: () => event.transaction, - setName: name => { + setName: (name: string) => { event.transaction = name; }, - setOp: op => { + setOp: (op: string) => { event.contexts!.trace!.op = op; }, - }); + }; + enhanceHandleRequestRootSpan(mutableRootSpan); + enhanceMiddlewareRootSpan(mutableRootSpan); } setUrlProcessingMetadata(event); @@ -268,18 +271,20 @@ export function init(options: NodeOptions): NodeClient | undefined { // transaction events, so the same enhancement has to be applied here directly on the span JSON. client?.on('processSegmentSpan', span => { const attributes = (span.attributes ??= {}); - enhanceHandleRequestRootSpan({ + const mutableRootSpan = { attributes, getName: () => span.name, - setName: name => { + setName: (name: string) => { span.name = name; }, // For streamed spans, op lives in `attributes['sentry.op']` - mirror it there so middleware // overrides land somewhere readable (the legacy path uses a separate `event.contexts.trace.op`). - setOp: op => { + setOp: (op: string) => { attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP] = op; }, - }); + }; + enhanceHandleRequestRootSpan(mutableRootSpan); + enhanceMiddlewareRootSpan(mutableRootSpan); }); if (process.env.NODE_ENV === 'development') { diff --git a/packages/nextjs/test/edge/enhanceMiddlewareRootSpan.test.ts b/packages/nextjs/test/common/enhanceMiddlewareRootSpan.test.ts similarity index 61% rename from packages/nextjs/test/edge/enhanceMiddlewareRootSpan.test.ts rename to packages/nextjs/test/common/enhanceMiddlewareRootSpan.test.ts index 6308c6a75bab..edd3ab52fef3 100644 --- a/packages/nextjs/test/edge/enhanceMiddlewareRootSpan.test.ts +++ b/packages/nextjs/test/common/enhanceMiddlewareRootSpan.test.ts @@ -1,9 +1,10 @@ import { describe, expect, it } from 'vitest'; +import { enhanceMiddlewareRootSpan } from '../../src/common/enhanceMiddlewareRootSpan'; 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; + let currentOp: string | undefined; return { span: { attributes, @@ -11,14 +12,18 @@ function makeSpan(attributes: Record, name?: string) { setName: (n: string) => { currentName = n; }, + setOp: (op: string) => { + currentOp = op; + }, }, getName: () => currentName, + getOp: () => currentOp, }; } describe('enhanceMiddlewareRootSpan', () => { it('does nothing for spans that are not Middleware.execute', () => { - const { span, getName } = makeSpan( + const { span, getName, getOp } = makeSpan( { [ATTR_NEXT_SPAN_TYPE]: 'BaseServer.handleRequest', [ATTR_NEXT_SPAN_NAME]: 'middleware GET /foo' }, 'GET /foo', ); @@ -26,18 +31,20 @@ describe('enhanceMiddlewareRootSpan', () => { enhanceMiddlewareRootSpan(span); expect(getName()).toBe('GET /foo'); + expect(getOp()).toBeUndefined(); }); - it('does nothing when next.span_name is missing', () => { - const { span, getName } = makeSpan({ [ATTR_NEXT_SPAN_TYPE]: 'Middleware.execute' }, 'middleware'); + it('sets the op but keeps the name when next.span_name is missing', () => { + const { span, getName, getOp } = makeSpan({ [ATTR_NEXT_SPAN_TYPE]: 'Middleware.execute' }, 'middleware'); enhanceMiddlewareRootSpan(span); expect(getName()).toBe('middleware'); + expect(getOp()).toBe('http.server.middleware'); }); - it('does nothing when next.span_name is an empty string', () => { - const { span, getName } = makeSpan( + it('sets the op but keeps the name when next.span_name is an empty string', () => { + const { span, getName, getOp } = makeSpan( { [ATTR_NEXT_SPAN_TYPE]: 'Middleware.execute', [ATTR_NEXT_SPAN_NAME]: '' }, 'middleware', ); @@ -45,10 +52,11 @@ describe('enhanceMiddlewareRootSpan', () => { enhanceMiddlewareRootSpan(span); expect(getName()).toBe('middleware'); + expect(getOp()).toBe('http.server.middleware'); }); - it('does nothing when next.span_name is not a string', () => { - const { span, getName } = makeSpan( + it('sets the op but keeps the name when next.span_name is not a string', () => { + const { span, getName, getOp } = makeSpan( { [ATTR_NEXT_SPAN_TYPE]: 'Middleware.execute', [ATTR_NEXT_SPAN_NAME]: 123 }, 'middleware', ); @@ -56,10 +64,11 @@ describe('enhanceMiddlewareRootSpan', () => { enhanceMiddlewareRootSpan(span); expect(getName()).toBe('middleware'); + expect(getOp()).toBe('http.server.middleware'); }); - it('does nothing when the current name is empty', () => { - const { span, getName } = makeSpan( + it('sets the op but keeps the name when the current name is empty', () => { + const { span, getName, getOp } = makeSpan( { [ATTR_NEXT_SPAN_TYPE]: 'Middleware.execute', [ATTR_NEXT_SPAN_NAME]: 'middleware GET /foo' }, undefined, ); @@ -67,6 +76,7 @@ describe('enhanceMiddlewareRootSpan', () => { enhanceMiddlewareRootSpan(span); expect(getName()).toBeUndefined(); + expect(getOp()).toBe('http.server.middleware'); }); it.each([ @@ -75,7 +85,7 @@ describe('enhanceMiddlewareRootSpan', () => { ['middleware DELETE /resources/[id]', 'middleware DELETE'], ['middleware HEAD /', 'middleware HEAD'], ])('collapses "%s" to "%s"', (spanName, expected) => { - const { span, getName } = makeSpan( + const { span, getName, getOp } = makeSpan( { [ATTR_NEXT_SPAN_TYPE]: 'Middleware.execute', [ATTR_NEXT_SPAN_NAME]: spanName }, spanName, ); @@ -83,6 +93,21 @@ describe('enhanceMiddlewareRootSpan', () => { enhanceMiddlewareRootSpan(span); expect(getName()).toBe(expected); + expect(getOp()).toBe('http.server.middleware'); + }); + + it('normalizes the plain "middleware {METHOD}" name emitted for Node.js middleware', () => { + // Node.js middleware roots come pre-mangled by HTTP span inference (e.g. `GET middleware GET`), + // while `next.span_name` holds the original `middleware GET`. + const { span, getName, getOp } = makeSpan( + { [ATTR_NEXT_SPAN_TYPE]: 'Middleware.execute', [ATTR_NEXT_SPAN_NAME]: 'middleware GET' }, + 'GET middleware GET', + ); + + enhanceMiddlewareRootSpan(span); + + expect(getName()).toBe('middleware GET'); + expect(getOp()).toBe('http.server.middleware'); }); it('strips query and fragment from non-method-prefixed middleware names', () => {