From b769481d3d34c78eef524d0d358e258a7ad87a94 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Mon, 27 Jul 2026 14:07:15 +0200 Subject: [PATCH] feat(nextjs)!: Use `middleware` span op for Next.js middleware Aligns the Next.js middleware span op with the `@sentry/conventions` `WEB_SERVER_MIDDLEWARE_SPAN_OP` (`middleware`), replacing the previous `http.server.middleware`. The mixed `http.server`-family op was problematic for span streaming and description inference. Also adds regression coverage asserting the middleware wrapper does not start its own span (the duplicate was already removed in #18456). Co-Authored-By: Claude Opus 4.8 (1M context) --- MIGRATION.md | 6 +- .../nextjs-15/tests/middleware.test.ts | 2 +- .../nextjs-16-bun/tests/middleware.test.ts | 2 +- .../tests/middleware.test.ts | 4 +- .../tests/tunnel-route.test.ts | 2 +- .../nextjs-16/tests/middleware.test.ts | 9 +- .../nextjs-pages-dir/tests/middleware.test.ts | 4 +- .../src/common/enhanceMiddlewareRootSpan.ts | 3 +- packages/nextjs/src/edge/index.ts | 3 +- .../server/enhanceHandleRequestRootSpan.ts | 3 +- .../common/enhanceMiddlewareRootSpan.test.ts | 12 +-- .../common/wrapMiddlewareWithSentry.test.ts | 82 +++++++++++++++++++ .../enhanceHandleRequestRootSpan.test.ts | 6 +- 13 files changed, 114 insertions(+), 24 deletions(-) create mode 100644 packages/nextjs/test/common/wrapMiddlewareWithSentry.test.ts diff --git a/MIGRATION.md b/MIGRATION.md index 753a28e30733..5ed55402a376 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -266,11 +266,11 @@ Affected SDKs: `@sentry/browser` and `@sentry/deno` (and their dependents). The `console` option of `breadcrumbsIntegration` was removed. Use the `consoleIntegration` from `@sentry/core` to capture console breadcrumbs instead. -### Next.js: tracing removed from generated templates +### `@sentry/nextjs` -Affected SDKs: `@sentry/nextjs`. +**Tracing removed from generated templates:** Tracing was removed from the generated Pages Router API handler, Edge API handler, and Middleware wrapper templates. Route handlers and middleware are still instrumented automatically, so no action is required for most users. -Tracing was removed from the generated Pages Router API handler, Edge API handler, and Middleware wrapper templates. Route handlers and middleware are still instrumented automatically, so no action is required for most users. +**Middleware span op changed to `middleware`:** Next.js middleware spans now use the `middleware` span op instead of `http.server.middleware`. If you filter or alert on the previous op (e.g. in dashboards or dynamic sampling rules), update it to `middleware`. ### Cloudflare: `nodejs_compat` compatibility flag is now required diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/middleware.test.ts index 6df28d31e722..dcfe67d46d5e 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/middleware.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/middleware.test.ts @@ -14,7 +14,7 @@ test('tracesSampler receives normalizedRequest for edge middleware', async ({ re const middlewareTransaction = await middlewareTransactionPromise; expect(middlewareTransaction.contexts?.runtime?.name).toBe('vercel-edge'); - expect(middlewareTransaction.contexts?.trace?.op).toBe('http.server.middleware'); + expect(middlewareTransaction.contexts?.trace?.op).toBe('middleware'); expect(middlewareTransaction.request?.url).toContain('/api/endpoint-behind-middleware'); expect(middlewareTransaction.request?.method).toBe('GET'); }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/middleware.test.ts index 9e569acae30f..22dcf4c8e83d 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/middleware.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/middleware.test.ts @@ -12,7 +12,7 @@ test('Should create a transaction for middleware', async ({ request }) => { const middlewareTransaction = await middlewareTransactionPromise; expect(middlewareTransaction.contexts?.trace?.status).toBe('ok'); - expect(middlewareTransaction.contexts?.trace?.op).toBe('http.server.middleware'); + expect(middlewareTransaction.contexts?.trace?.op).toBe('middleware'); expect(middlewareTransaction.contexts?.runtime?.name).toBe('node'); expect(middlewareTransaction.transaction_info?.source).toBe('route'); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/middleware.test.ts index 68f6a802f771..8d7300a0ea86 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/middleware.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/middleware.test.ts @@ -29,7 +29,7 @@ test.skip('Should create a transaction for middleware', async ({ request }) => { const middlewareTransaction = await middlewareTransactionPromise; expect(middlewareTransaction.contexts?.trace?.status).toBe('ok'); - expect(middlewareTransaction.contexts?.trace?.op).toBe('http.server.middleware'); + expect(middlewareTransaction.contexts?.trace?.op).toBe('middleware'); expect(middlewareTransaction.contexts?.runtime?.name).toBe('vercel-edge'); expect(middlewareTransaction.transaction_info?.source).toBe('route'); @@ -57,7 +57,7 @@ test.skip('Faulty middlewares', async ({ request }) => { await test.step('should record transactions', async () => { const middlewareTransaction = await middlewareTransactionPromise; expect(middlewareTransaction.contexts?.trace?.status).toBe('internal_error'); - expect(middlewareTransaction.contexts?.trace?.op).toBe('http.server.middleware'); + expect(middlewareTransaction.contexts?.trace?.op).toBe('middleware'); expect(middlewareTransaction.contexts?.runtime?.name).toBe('vercel-edge'); expect(middlewareTransaction.transaction_info?.source).toBe('route'); }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-route.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-route.test.ts index a8bd7b4d925e..0b2ce06fb1bf 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-route.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-route.test.ts @@ -110,7 +110,7 @@ test('Tunnel requests should not create middleware or fetch spans', async ({ pag expect(pageloadTransaction).toBeDefined(); expect(pageloadTransaction.contexts?.trace?.op).toBe('pageload'); - const middlewareTransactions = allTransactions.filter(tx => tx.contexts?.trace?.op === 'http.server.middleware'); + const middlewareTransactions = allTransactions.filter(tx => tx.contexts?.trace?.op === 'middleware'); // We WILL have a middleware transaction for GET / (the pageload) // But we should NOT have middleware transactions for POST requests (tunnel route) 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 9fea7701312b..3e61e3a3e855 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 @@ -17,13 +17,18 @@ test('Should create a transaction for middleware', async ({ request }) => { const middlewareTransaction = await middlewareTransactionPromise; expect(middlewareTransaction.contexts?.trace?.status).toBe('ok'); - expect(middlewareTransaction.contexts?.trace?.op).toBe('http.server.middleware'); + expect(middlewareTransaction.contexts?.trace?.op).toBe('middleware'); expect(middlewareTransaction.contexts?.runtime?.name).toBe('node'); expect(middlewareTransaction.transaction_info?.source).toBe('route'); expect(middlewareTransaction.request?.method).toBe('GET'); expect(middlewareTransaction.request?.url).toContain('/api/endpoint-behind-middleware'); + // The `Middleware.execute` OTEL root span is the only `middleware` span. The build-time + // `wrapMiddlewareWithSentry` wrapper used to start a second, redundant one nested inside it. + const nestedMiddlewareSpans = middlewareTransaction.spans?.filter(span => span.op === 'middleware'); + expect(nestedMiddlewareSpans).toHaveLength(0); + // Assert that isolation scope works properly expect(middlewareTransaction.tags?.['my-isolated-tag']).toBe(true); expect(middlewareTransaction.tags?.['my-global-scope-isolated-tag']).not.toBeDefined(); @@ -52,7 +57,7 @@ test('Faulty middlewares', async ({ request }) => { await test.step('should record transactions', async () => { const middlewareTransaction = await middlewareTransactionPromise; expect(middlewareTransaction.contexts?.trace?.status).toBe('internal_error'); - expect(middlewareTransaction.contexts?.trace?.op).toBe('http.server.middleware'); + expect(middlewareTransaction.contexts?.trace?.op).toBe('middleware'); expect(middlewareTransaction.contexts?.runtime?.name).toBe('node'); expect(middlewareTransaction.transaction_info?.source).toBe('route'); }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/middleware.test.ts index 905cdcdbdba7..a76f98fb80e8 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/middleware.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/middleware.test.ts @@ -12,7 +12,7 @@ test('Should create a transaction for middleware', async ({ request }) => { const middlewareTransaction = await middlewareTransactionPromise; expect(middlewareTransaction.contexts?.trace?.status).toBe('ok'); - expect(middlewareTransaction.contexts?.trace?.op).toBe('http.server.middleware'); + expect(middlewareTransaction.contexts?.trace?.op).toBe('middleware'); expect(middlewareTransaction.contexts?.runtime?.name).toBe('vercel-edge'); expect(middlewareTransaction.transaction_info?.source).toBe('url'); @@ -37,7 +37,7 @@ test('Faulty middlewares', async ({ request }) => { await test.step('should record transactions', async () => { const middlewareTransaction = await middlewareTransactionPromise; expect(middlewareTransaction.contexts?.trace?.status).toBe('internal_error'); - expect(middlewareTransaction.contexts?.trace?.op).toBe('http.server.middleware'); + expect(middlewareTransaction.contexts?.trace?.op).toBe('middleware'); expect(middlewareTransaction.contexts?.runtime?.name).toBe('vercel-edge'); expect(middlewareTransaction.transaction_info?.source).toBe('url'); }); diff --git a/packages/nextjs/src/common/enhanceMiddlewareRootSpan.ts b/packages/nextjs/src/common/enhanceMiddlewareRootSpan.ts index 27a4cc5e5425..875c3cadd316 100644 --- a/packages/nextjs/src/common/enhanceMiddlewareRootSpan.ts +++ b/packages/nextjs/src/common/enhanceMiddlewareRootSpan.ts @@ -1,3 +1,4 @@ +import { WEB_SERVER_MIDDLEWARE_SPAN_OP } from '@sentry/conventions/op'; import { stripUrlQueryAndFragment } from '@sentry/core'; import { ATTR_NEXT_SPAN_NAME, ATTR_NEXT_SPAN_TYPE } from './nextSpanAttributes'; @@ -26,7 +27,7 @@ export function enhanceMiddlewareRootSpan(span: MutableMiddlewareRootSpan): void return; } - span.setOp('http.server.middleware'); + span.setOp(WEB_SERVER_MIDDLEWARE_SPAN_OP); const spanName = attributes[ATTR_NEXT_SPAN_NAME]; if (typeof spanName !== 'string' || !spanName || !span.getName()) { diff --git a/packages/nextjs/src/edge/index.ts b/packages/nextjs/src/edge/index.ts index 943ba1c84f5a..bb7b3946023a 100644 --- a/packages/nextjs/src/edge/index.ts +++ b/packages/nextjs/src/edge/index.ts @@ -28,6 +28,7 @@ import { setUrlProcessingMetadata } from '../common/utils/setUrlProcessingMetada import { distDirRewriteFramesIntegration } from './distDirRewriteFramesIntegration'; import { enhanceMiddlewareRootSpan } from '../common/enhanceMiddlewareRootSpan'; import { SENTRY_KIND } from '@sentry/conventions/attributes'; +import { WEB_SERVER_MIDDLEWARE_SPAN_OP } from '@sentry/conventions/op'; export * from '@sentry/vercel-edge'; export * from '../common'; @@ -134,7 +135,7 @@ export function init(options: VercelEdgeOptions = {}): void { // Make sure middleware spans get the right op if (spanAttributes?.['next.span_type'] === 'Middleware.execute') { - span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'http.server.middleware'); + span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, WEB_SERVER_MIDDLEWARE_SPAN_OP); span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'url'); } diff --git a/packages/nextjs/src/server/enhanceHandleRequestRootSpan.ts b/packages/nextjs/src/server/enhanceHandleRequestRootSpan.ts index 6822b0feecfc..666ae0896908 100644 --- a/packages/nextjs/src/server/enhanceHandleRequestRootSpan.ts +++ b/packages/nextjs/src/server/enhanceHandleRequestRootSpan.ts @@ -1,4 +1,5 @@ import { HTTP_METHOD, HTTP_REQUEST_METHOD, HTTP_ROUTE, HTTP_TARGET } from '@sentry/conventions/attributes'; +import { WEB_SERVER_MIDDLEWARE_SPAN_OP } from '@sentry/conventions/op'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, stripUrlQueryAndFragment } from '@sentry/core'; import { ATTR_NEXT_ROUTE, ATTR_NEXT_SPAN_NAME, ATTR_NEXT_SPAN_TYPE } from '../common/nextSpanAttributes'; import { TRANSACTION_ATTR_SENTRY_ROUTE_BACKFILL } from '../common/span-attributes-with-logic-attached'; @@ -64,7 +65,7 @@ export function enhanceHandleRequestRootSpan(span: MutableRootSpan): void { if (middlewareMatch) { span.setName(`middleware ${middlewareMatch[1]}`); - span.setOp('http.server.middleware'); + span.setOp(WEB_SERVER_MIDDLEWARE_SPAN_OP); } // Next.js overrides transaction names for page loads that throw an error diff --git a/packages/nextjs/test/common/enhanceMiddlewareRootSpan.test.ts b/packages/nextjs/test/common/enhanceMiddlewareRootSpan.test.ts index edd3ab52fef3..7d9b9c28a59b 100644 --- a/packages/nextjs/test/common/enhanceMiddlewareRootSpan.test.ts +++ b/packages/nextjs/test/common/enhanceMiddlewareRootSpan.test.ts @@ -40,7 +40,7 @@ describe('enhanceMiddlewareRootSpan', () => { enhanceMiddlewareRootSpan(span); expect(getName()).toBe('middleware'); - expect(getOp()).toBe('http.server.middleware'); + expect(getOp()).toBe('middleware'); }); it('sets the op but keeps the name when next.span_name is an empty string', () => { @@ -52,7 +52,7 @@ describe('enhanceMiddlewareRootSpan', () => { enhanceMiddlewareRootSpan(span); expect(getName()).toBe('middleware'); - expect(getOp()).toBe('http.server.middleware'); + expect(getOp()).toBe('middleware'); }); it('sets the op but keeps the name when next.span_name is not a string', () => { @@ -64,7 +64,7 @@ describe('enhanceMiddlewareRootSpan', () => { enhanceMiddlewareRootSpan(span); expect(getName()).toBe('middleware'); - expect(getOp()).toBe('http.server.middleware'); + expect(getOp()).toBe('middleware'); }); it('sets the op but keeps the name when the current name is empty', () => { @@ -76,7 +76,7 @@ describe('enhanceMiddlewareRootSpan', () => { enhanceMiddlewareRootSpan(span); expect(getName()).toBeUndefined(); - expect(getOp()).toBe('http.server.middleware'); + expect(getOp()).toBe('middleware'); }); it.each([ @@ -93,7 +93,7 @@ describe('enhanceMiddlewareRootSpan', () => { enhanceMiddlewareRootSpan(span); expect(getName()).toBe(expected); - expect(getOp()).toBe('http.server.middleware'); + expect(getOp()).toBe('middleware'); }); it('normalizes the plain "middleware {METHOD}" name emitted for Node.js middleware', () => { @@ -107,7 +107,7 @@ describe('enhanceMiddlewareRootSpan', () => { enhanceMiddlewareRootSpan(span); expect(getName()).toBe('middleware GET'); - expect(getOp()).toBe('http.server.middleware'); + expect(getOp()).toBe('middleware'); }); it('strips query and fragment from non-method-prefixed middleware names', () => { diff --git a/packages/nextjs/test/common/wrapMiddlewareWithSentry.test.ts b/packages/nextjs/test/common/wrapMiddlewareWithSentry.test.ts new file mode 100644 index 000000000000..bb9986b3eee6 --- /dev/null +++ b/packages/nextjs/test/common/wrapMiddlewareWithSentry.test.ts @@ -0,0 +1,82 @@ +import * as SentryCore from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { wrapMiddlewareWithSentry } from '../../src/common/wrapMiddlewareWithSentry'; + +describe('wrapMiddlewareWithSentry', () => { + beforeEach(() => { + vi.spyOn(SentryCore, 'captureException').mockReturnValue(''); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('does not start its own span when the Next.js OTEL root span is already active', async () => { + vi.spyOn(SentryCore, 'getActiveSpan').mockReturnValue({} as SentryCore.Span); + vi.spyOn(SentryCore, 'getRootSpan').mockReturnValue({} as SentryCore.Span); + const setCapturedScopesSpy = vi.spyOn(SentryCore, 'setCapturedScopesOnSpan').mockReturnValue(undefined); + const startSpanSpy = vi.spyOn(SentryCore, 'startSpan'); + + const handler = vi.fn(async (_req: Request) => new Response('ok')); + const wrapped = wrapMiddlewareWithSentry(handler); + + await wrapped(new Request('https://example.com/foo', { method: 'GET' })); + + // The middleware runs and our forked scopes are bound to the existing OTEL root span... + expect(handler).toHaveBeenCalledTimes(1); + expect(setCapturedScopesSpy).toHaveBeenCalledTimes(1); + // ...but the wrapper never starts a span itself - the `Middleware.execute` span is the transaction. + expect(startSpanSpy).not.toHaveBeenCalled(); + }); + + it('does not start its own span when no span is active', async () => { + vi.spyOn(SentryCore, 'getActiveSpan').mockReturnValue(undefined); + const startSpanSpy = vi.spyOn(SentryCore, 'startSpan'); + + const handler = vi.fn(async (_req: Request) => new Response('ok')); + const wrapped = wrapMiddlewareWithSentry(handler); + + await wrapped(new Request('https://example.com/foo', { method: 'GET' })); + + expect(handler).toHaveBeenCalledTimes(1); + expect(startSpanSpy).not.toHaveBeenCalled(); + }); + + it('captures errors thrown by the middleware when a root span is already active', async () => { + vi.spyOn(SentryCore, 'getActiveSpan').mockReturnValue({} as SentryCore.Span); + vi.spyOn(SentryCore, 'getRootSpan').mockReturnValue({} as SentryCore.Span); + vi.spyOn(SentryCore, 'setCapturedScopesOnSpan').mockReturnValue(undefined); + const captureExceptionSpy = vi.spyOn(SentryCore, 'captureException').mockReturnValue(''); + + const error = new Error('boom'); + const handler = vi.fn(async (_req: Request) => { + throw error; + }); + const wrapped = wrapMiddlewareWithSentry(handler); + + await expect(wrapped(new Request('https://example.com/foo', { method: 'GET' }))).rejects.toThrow('boom'); + + expect(captureExceptionSpy).toHaveBeenCalledTimes(1); + expect(captureExceptionSpy).toHaveBeenCalledWith( + error, + expect.objectContaining({ + mechanism: { type: 'auto.function.nextjs.wrap_middleware', handled: false }, + }), + ); + }); + + it('captures errors thrown by the middleware when no span is active', async () => { + vi.spyOn(SentryCore, 'getActiveSpan').mockReturnValue(undefined); + const captureExceptionSpy = vi.spyOn(SentryCore, 'captureException').mockReturnValue(''); + + const error = new Error('boom'); + const handler = vi.fn(async (_req: Request) => { + throw error; + }); + const wrapped = wrapMiddlewareWithSentry(handler); + + await expect(wrapped(new Request('https://example.com/foo', { method: 'GET' }))).rejects.toThrow('boom'); + + expect(captureExceptionSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/nextjs/test/server/enhanceHandleRequestRootSpan.test.ts b/packages/nextjs/test/server/enhanceHandleRequestRootSpan.test.ts index ae756e5bde5b..9545904958e8 100644 --- a/packages/nextjs/test/server/enhanceHandleRequestRootSpan.test.ts +++ b/packages/nextjs/test/server/enhanceHandleRequestRootSpan.test.ts @@ -126,7 +126,7 @@ describe('enhanceHandleRequestRootSpan', () => { expect(getName()).toBe('GET /_app'); }); - it('normalizes middleware span names and sets http.server.middleware op', () => { + it('normalizes middleware span names and sets the `middleware` op', () => { const { span, getName, getOp } = makeSpan( { [ATTR_NEXT_SPAN_TYPE]: 'BaseServer.handleRequest', @@ -138,7 +138,7 @@ describe('enhanceHandleRequestRootSpan', () => { enhanceHandleRequestRootSpan(span); expect(getName()).toBe('middleware POST'); - expect(getOp()).toBe('http.server.middleware'); + expect(getOp()).toBe('middleware'); }); it('writes the middleware op into attributes when the adapter mirrors op writes (streamed shape)', () => { @@ -163,7 +163,7 @@ describe('enhanceHandleRequestRootSpan', () => { enhanceHandleRequestRootSpan(span); expect(name).toBe('middleware GET'); - expect(attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP]).toBe('http.server.middleware'); + expect(attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP]).toBe('middleware'); }); it('rewrites GET /_error using the http.target attribute', () => {