From d23c1e29aa28ec8fac07154c0bc9f59d35523c3b Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Fri, 31 Jul 2026 11:54:48 +0200 Subject: [PATCH 1/2] fix(v10/nextjs): Remove tracing from middleware wrappers Backport of: #18456 Fixes #22636 --- .../nextjs-16/tests/middleware.test.ts | 5 ++ .../src/common/wrapMiddlewareWithSentry.ts | 63 +++++--------- .../common/wrapMiddlewareWithSentry.test.ts | 82 +++++++++++++++++++ 3 files changed, 108 insertions(+), 42 deletions(-) create mode 100644 packages/nextjs/test/common/wrapMiddlewareWithSentry.test.ts 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 5386c75f31a9..d471308e58ec 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 @@ -21,6 +21,11 @@ test('Should create a transaction for middleware', async ({ request }) => { expect(middlewareTransaction.contexts?.runtime?.name).toBe('node'); expect(middlewareTransaction.transaction_info?.source).toBe('route'); + // 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 === 'http.server.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(); diff --git a/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts b/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts index d383837cbf17..a49995d1e5f2 100644 --- a/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts +++ b/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts @@ -1,14 +1,10 @@ -import type { TransactionSource } from '@sentry/core'; import { captureException, getActiveSpan, getCurrentScope, getRootSpan, handleCallbackErrors, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, setCapturedScopesOnSpan, - startSpan, winterCGRequestToRequestData, withIsolationScope, } from '@sentry/core'; @@ -17,7 +13,11 @@ import { isPathnameUnderSentryTunnelRoute } from '../common/utils/tunnelPathname import type { EdgeRouteHandler } from '../edge/types'; /** - * Wraps Next.js middleware with Sentry error and performance instrumentation. + * Wraps Next.js middleware with Sentry error instrumentation. + * + * The middleware transaction itself is created by Next.js' native OpenTelemetry instrumentation + * (the `Middleware.execute` span, normalized by `enhanceMiddlewareRootSpan`), so this wrapper no + * longer starts its own span. It only forks an isolation scope, captures errors, and flushes. * * @param middleware The middleware handler. * @returns a wrapped middleware handler. @@ -32,6 +32,7 @@ export function wrapMiddlewareWithSentry( ? (globalThis as Record)._sentryRewritesTunnelPath : undefined; + // TODO: This can never work with Turbopack, need to remove it for consistency between builds. if (tunnelRoute && typeof tunnelRoute === 'string') { const req: unknown = args[0]; // Check if the current request matches the tunnel route @@ -52,65 +53,43 @@ export function wrapMiddlewareWithSentry( } } } + // TODO: We still should add central isolation scope creation for when our build-time instrumentation does not work anymore with turbopack. return withIsolationScope(isolationScope => { const req: unknown = args[0]; const currentScope = getCurrentScope(); - let spanName: string; - let spanSource: TransactionSource; - if (req instanceof Request) { isolationScope.setSDKProcessingMetadata({ normalizedRequest: winterCGRequestToRequestData(req), }); - spanName = `middleware ${req.method}`; - spanSource = 'url'; + currentScope.setTransactionName(`middleware ${req.method}`); } else { - spanName = 'middleware'; - spanSource = 'component'; + currentScope.setTransactionName('middleware'); } - currentScope.setTransactionName(spanName); - const activeSpan = getActiveSpan(); - if (activeSpan) { - // If there is an active span, it likely means that the automatic Next.js OTEL instrumentation worked and we can - // rely on that for parameterization. - spanName = 'middleware'; - spanSource = 'component'; - + // If there is an active span, the native Next.js OTEL instrumentation created the middleware root span. + // Bind our forked scopes to it so the transaction picks up the isolation scope instead of the global one. const rootSpan = getRootSpan(activeSpan); if (rootSpan) { setCapturedScopesOnSpan(rootSpan, currentScope, isolationScope); } } - return startSpan( - { - name: spanName, - op: 'http.server.middleware', - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: spanSource, - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.nextjs.wrap_middleware', - }, + return handleCallbackErrors( + () => wrappingTarget.apply(thisArg, args), + error => { + captureException(error, { + mechanism: { + type: 'auto.function.nextjs.wrap_middleware', + handled: false, + }, + }); }, () => { - return handleCallbackErrors( - () => wrappingTarget.apply(thisArg, args), - error => { - captureException(error, { - mechanism: { - type: 'auto.function.nextjs.wrap_middleware', - handled: false, - }, - }); - }, - () => { - waitUntil(flushSafelyWithTimeout()); - }, - ); + waitUntil(flushSafelyWithTimeout()); }, ); }); 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); + }); +}); From ba785278c5bf2e843092271831971a19944f3065 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Fri, 31 Jul 2026 14:15:20 +0200 Subject: [PATCH 2/2] fix(v10/nextjs): Keep wrapper span on Next.js 13 Next.js only emits the native `Middleware.execute` OTel span from v14 onwards, so relying on it unconditionally dropped middleware transactions entirely on Next.js 13, which v10 still supports. Fall back to starting the wrapper span when no root span is active. --- .../src/common/wrapMiddlewareWithSentry.ts | 71 +++++++++++++------ .../common/wrapMiddlewareWithSentry.test.ts | 14 +++- 2 files changed, 62 insertions(+), 23 deletions(-) diff --git a/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts b/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts index a49995d1e5f2..81bdb0d3eb59 100644 --- a/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts +++ b/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts @@ -1,10 +1,14 @@ +import type { TransactionSource } from '@sentry/core'; import { captureException, getActiveSpan, getCurrentScope, getRootSpan, handleCallbackErrors, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, setCapturedScopesOnSpan, + startSpan, winterCGRequestToRequestData, withIsolationScope, } from '@sentry/core'; @@ -13,11 +17,13 @@ import { isPathnameUnderSentryTunnelRoute } from '../common/utils/tunnelPathname import type { EdgeRouteHandler } from '../edge/types'; /** - * Wraps Next.js middleware with Sentry error instrumentation. + * Wraps Next.js middleware with Sentry error and performance instrumentation. * - * The middleware transaction itself is created by Next.js' native OpenTelemetry instrumentation - * (the `Middleware.execute` span, normalized by `enhanceMiddlewareRootSpan`), so this wrapper no - * longer starts its own span. It only forks an isolation scope, captures errors, and flushes. + * From Next.js 14 onwards the middleware transaction is created by Next.js' native OpenTelemetry + * instrumentation (the `Middleware.execute` span, normalized by `enhanceMiddlewareRootSpan`). In that case this + * wrapper does not start a span of its own, as that would emit a second, redundant middleware span nested inside + * the root span. It only forks an isolation scope, captures errors, and flushes. Next.js 13 does not emit + * `Middleware.execute`, so there the wrapper still starts the transaction itself. * * @param middleware The middleware handler. * @returns a wrapped middleware handler. @@ -59,38 +65,63 @@ export function wrapMiddlewareWithSentry( const req: unknown = args[0]; const currentScope = getCurrentScope(); + let spanName: string; + let spanSource: TransactionSource; + if (req instanceof Request) { isolationScope.setSDKProcessingMetadata({ normalizedRequest: winterCGRequestToRequestData(req), }); - currentScope.setTransactionName(`middleware ${req.method}`); + spanName = `middleware ${req.method}`; + spanSource = 'url'; } else { - currentScope.setTransactionName('middleware'); + spanName = 'middleware'; + spanSource = 'component'; } + currentScope.setTransactionName(spanName); + + const runMiddleware = (): ReturnType => + handleCallbackErrors( + () => wrappingTarget.apply(thisArg, args), + error => { + captureException(error, { + mechanism: { + type: 'auto.function.nextjs.wrap_middleware', + handled: false, + }, + }); + }, + () => { + waitUntil(flushSafelyWithTimeout()); + }, + ) as ReturnType; + const activeSpan = getActiveSpan(); if (activeSpan) { - // If there is an active span, the native Next.js OTEL instrumentation created the middleware root span. - // Bind our forked scopes to it so the transaction picks up the isolation scope instead of the global one. + // The native Next.js OTEL instrumentation created the middleware root span (`Middleware.execute`, + // normalized by `enhanceMiddlewareRootSpan`). Bind our forked scopes to it so the transaction picks up + // the isolation scope instead of the global one, and do not start a second, redundant span here. const rootSpan = getRootSpan(activeSpan); if (rootSpan) { setCapturedScopesOnSpan(rootSpan, currentScope, isolationScope); } + + return runMiddleware(); } - return handleCallbackErrors( - () => wrappingTarget.apply(thisArg, args), - error => { - captureException(error, { - mechanism: { - type: 'auto.function.nextjs.wrap_middleware', - handled: false, - }, - }); - }, - () => { - waitUntil(flushSafelyWithTimeout()); + // Next.js only emits `Middleware.execute` from version 14 onwards. On Next.js 13 nothing else creates a + // middleware span, so this wrapper still has to provide the transaction itself. + return startSpan( + { + name: spanName, + op: 'http.server.middleware', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: spanSource, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.nextjs.wrap_middleware', + }, }, + runMiddleware, ); }); }, diff --git a/packages/nextjs/test/common/wrapMiddlewareWithSentry.test.ts b/packages/nextjs/test/common/wrapMiddlewareWithSentry.test.ts index bb9986b3eee6..808666694d3d 100644 --- a/packages/nextjs/test/common/wrapMiddlewareWithSentry.test.ts +++ b/packages/nextjs/test/common/wrapMiddlewareWithSentry.test.ts @@ -11,7 +11,7 @@ describe('wrapMiddlewareWithSentry', () => { vi.restoreAllMocks(); }); - it('does not start its own span when the Next.js OTEL root span is already active', async () => { + it('does not start its own span when the Next.js OTEL root span is already active (Next.js >= 14)', 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); @@ -29,7 +29,7 @@ describe('wrapMiddlewareWithSentry', () => { expect(startSpanSpy).not.toHaveBeenCalled(); }); - it('does not start its own span when no span is active', async () => { + it('starts its own span when no span is active (Next.js 13)', async () => { vi.spyOn(SentryCore, 'getActiveSpan').mockReturnValue(undefined); const startSpanSpy = vi.spyOn(SentryCore, 'startSpan'); @@ -39,7 +39,15 @@ describe('wrapMiddlewareWithSentry', () => { await wrapped(new Request('https://example.com/foo', { method: 'GET' })); expect(handler).toHaveBeenCalledTimes(1); - expect(startSpanSpy).not.toHaveBeenCalled(); + // Next.js 13 never emits `Middleware.execute`, so without this span there would be no middleware transaction. + expect(startSpanSpy).toHaveBeenCalledTimes(1); + expect(startSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'middleware GET', + op: 'http.server.middleware', + }), + expect.any(Function), + ); }); it('captures errors thrown by the middleware when a root span is already active', async () => {