From 881c9f35d54e3f4092c0b8a005fe188c904e3eea Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 4 Nov 2026 11:35:14 +0100 Subject: [PATCH 1/2] fix(nextjs): Drop tunnel requests under the Next.request proxy root span Since Next.js 16.4.3 a proxy invoked separately from the render runs under a Next.request root span. The tunnel filter only matched Middleware.execute, so only the child was dropped and tunnel requests through the proxy were still sent as transactions. Next.request carries only the pathname in http.target, so the span streaming ignoreSpans entry matches it on the tunnel path alone. Co-Authored-By: Claude Opus 5.5 --- .../nextjs-16-adapter/next.config.ts | 1 + .../nextjs-16-adapter/proxy.ts | 6 +++- .../tests/proxy-tunnel.test.ts | 29 ++++++++++++++++ .../utils/dropMiddlewareTunnelRequests.ts | 20 ++++++++--- .../dropMiddlewareTunnelRequests.test.ts | 34 +++++++++++++++++++ 5 files changed, 85 insertions(+), 5 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-adapter/tests/proxy-tunnel.test.ts diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/next.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/next.config.ts index 6fb632853c16..660eddbbfe1f 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/next.config.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/next.config.ts @@ -7,4 +7,5 @@ const nextConfig: NextConfig = { export default withSentryConfig(nextConfig, { silent: true, + tunnelRoute: '/monitoring', }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/proxy.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/proxy.ts index e104e3864459..b5cda0cfaa92 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/proxy.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/proxy.ts @@ -3,6 +3,10 @@ import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; export async function proxy(request: NextRequest) { + if (request.nextUrl.pathname !== '/api/proxy-isolation') { + return NextResponse.next(); + } + const requestId = request.nextUrl.searchParams.get('id'); Sentry.addBreadcrumb({ message: `proxy breadcrumb ${requestId}` }); @@ -16,5 +20,5 @@ export async function proxy(request: NextRequest) { } export const config = { - matcher: ['/api/proxy-isolation'], + matcher: ['/api/proxy-isolation', '/api/proxy-ok', '/monitoring'], }; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/tests/proxy-tunnel.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/tests/proxy-tunnel.test.ts new file mode 100644 index 000000000000..1e3b3cda4bd1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/tests/proxy-tunnel.test.ts @@ -0,0 +1,29 @@ +import { expect, test } from '@playwright/test'; +import { waitForStreamedSpan, waitForStreamedSpans } from '@sentry-internal/test-utils'; + +test('drops the `Next.request` root span of tunnel requests through the proxy', async ({ request }) => { + // The callback never returns true, so this promise is deliberately left unsettled. The assertions read the array. + const segmentSpans: Awaited>[] = []; + void waitForStreamedSpans('nextjs-16-adapter', spans => { + segmentSpans.push(...spans.filter(span => span.is_segment)); + return false; + }); + + const proxyOkSpanPromise = waitForStreamedSpan('nextjs-16-adapter', span => { + return ( + span.is_segment && + span.attributes['next.span_type']?.value === 'Next.request' && + span.attributes['http.target']?.value === '/api/proxy-ok' + ); + }); + + const tunnelResponse = await request.post('/monitoring?o=123&p=456', { data: '{}' }); + expect(tunnelResponse.headers()['x-middleware-next']).toBe('1'); + + await request.get('/api/proxy-ok'); + await proxyOkSpanPromise; + + expect(segmentSpans.filter(span => String(span.attributes['http.target']?.value).startsWith('/monitoring'))).toEqual( + [], + ); +}); diff --git a/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts b/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts index f2d4774f3c54..8baa168fa031 100644 --- a/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts +++ b/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts @@ -34,8 +34,10 @@ export function dropMiddlewareTunnelRequests( return; } - // Only filter middleware spans, HTTP fetch spans, or BaseServer.handleRequest spans - const isMiddleware = attrs?.[ATTR_NEXT_SPAN_TYPE] === 'Middleware.execute'; + // Only filter middleware spans, HTTP fetch spans, or BaseServer.handleRequest spans. Since Next.js 16.4.3, a proxy + // invoked separately from the render runs under a `Next.request` root span, which has to be dropped too. + const isMiddleware = + attrs?.[ATTR_NEXT_SPAN_TYPE] === 'Middleware.execute' || attrs?.[ATTR_NEXT_SPAN_TYPE] === 'Next.request'; // The fetch span could be originating from rewrites re-writing a tunnel request // So we want to filter it out const isFetchSpan = attrs?.[SENTRY_ORIGIN] === 'auto.http.node_fetch'; @@ -114,8 +116,18 @@ export function getTunnelRouteIgnoreSpans(): NonNullable { }); }); + describe('Next.request spans', () => { + it('marks the proxy root span for dropping when http.target matches tunnel path', () => { + globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + const span = createMockSpan(); + + dropMiddlewareTunnelRequests(span as any, { + 'next.span_type': 'Next.request', + 'http.target': '/monitoring', + }); + + expect(span.setAttribute).toHaveBeenCalledWith(TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION, true); + }); + }); + describe('unrelated spans', () => { it('does not process spans without matching span type or origin', () => { globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; @@ -211,6 +225,26 @@ describe('getTunnelRouteIgnoreSpans', () => { }, ); + it.each([ + ['rewrite', '/monitoring'], + ['rewrite', '/monitoring/'], + ['handler', '/monitoring'], + ] as const)('ignores a Next.request proxy root span in %s mode for %s', (mode, target) => { + globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + globalWithInjectedValues._sentryTunnelRouteMode = mode; + + expect(isIgnored(nextRequestSpanAttributes('Next.request', target))).toBe(true); + }); + + it.each(['/monitoring/dashboard', '/monitoring-dashboard', '/api/monitoring'])( + 'does not ignore a Next.request proxy root span for %s', + target => { + globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + + expect(isIgnored(nextRequestSpanAttributes('Next.request', target))).toBe(false); + }, + ); + it('does not ignore a request span for a sub-path in handler mode', () => { globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; globalWithInjectedValues._sentryTunnelRouteMode = 'handler'; From 927e8de90410551930be6ddcee7da13f3bd6e3a8 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 4 Nov 2026 12:48:29 +0100 Subject: [PATCH 2/2] test(nextjs): Use conventions constant for http.target in proxy tunnel e2e test Co-Authored-By: Claude Opus 5.5 --- .../test-applications/nextjs-16-adapter/package.json | 1 + .../nextjs-16-adapter/tests/proxy-tunnel.test.ts | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/package.json index 4411d949fef3..f26999360e44 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/package.json @@ -20,6 +20,7 @@ "devDependencies": { "@playwright/test": "1.64.1", "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz", + "@sentry/conventions": "0.30.0", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/tests/proxy-tunnel.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/tests/proxy-tunnel.test.ts index 1e3b3cda4bd1..4c8807745ea0 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/tests/proxy-tunnel.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/tests/proxy-tunnel.test.ts @@ -1,4 +1,5 @@ import { expect, test } from '@playwright/test'; +import { HTTP_TARGET } from '@sentry/conventions/attributes'; import { waitForStreamedSpan, waitForStreamedSpans } from '@sentry-internal/test-utils'; test('drops the `Next.request` root span of tunnel requests through the proxy', async ({ request }) => { @@ -13,7 +14,7 @@ test('drops the `Next.request` root span of tunnel requests through the proxy', return ( span.is_segment && span.attributes['next.span_type']?.value === 'Next.request' && - span.attributes['http.target']?.value === '/api/proxy-ok' + span.attributes[HTTP_TARGET]?.value === '/api/proxy-ok' ); }); @@ -23,7 +24,7 @@ test('drops the `Next.request` root span of tunnel requests through the proxy', await request.get('/api/proxy-ok'); await proxyOkSpanPromise; - expect(segmentSpans.filter(span => String(span.attributes['http.target']?.value).startsWith('/monitoring'))).toEqual( + expect(segmentSpans.filter(span => String(span.attributes[HTTP_TARGET]?.value).startsWith('/monitoring'))).toEqual( [], ); });