diff --git a/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts b/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts index 9eb2e70c8a43..ce54e8e25f85 100644 --- a/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts +++ b/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts @@ -9,10 +9,10 @@ const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { }; /** - * Drops spans for tunnel requests from middleware or fetch instrumentation. - * This catches both: - * 1. Requests to the local tunnel route (before rewrite) - * 2. Requests to Sentry ingest (after rewrite) + * Drops spans for tunnel requests from middleware, fetch instrumentation, or BaseServer.handleRequest. + * This catches: + * 1. Requests to the local tunnel route (before rewrite) via middleware or BaseServer.handleRequest + * 2. Requests to Sentry ingest (after rewrite) via fetch spans */ export function dropMiddlewareTunnelRequests(span: Span, attrs: SpanAttributes | undefined): void { // When the user brings their own OTel setup (skipOpenTelemetrySetup: true), we should not @@ -21,14 +21,15 @@ export function dropMiddlewareTunnelRequests(span: Span, attrs: SpanAttributes | return; } - // Only filter middleware spans or HTTP fetch spans + // Only filter middleware spans, HTTP fetch spans, or BaseServer.handleRequest spans const isMiddleware = attrs?.[ATTR_NEXT_SPAN_TYPE] === 'Middleware.execute'; // The fetch span could be originating from rewrites re-writing a tunnel request // So we want to filter it out const isFetchSpan = attrs?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] === 'auto.http.otel.node_fetch'; + const isBaseServerHandleRequest = attrs?.[ATTR_NEXT_SPAN_TYPE] === 'BaseServer.handleRequest'; - // If the span is not a middleware span or a fetch span, return - if (!isMiddleware && !isFetchSpan) { + // If the span is not a middleware span, fetch span, or BaseServer.handleRequest span, return + if (!isMiddleware && !isFetchSpan && !isBaseServerHandleRequest) { return; } @@ -58,7 +59,7 @@ function isTunnelRouteSpan(spanAttributes: Record): boolean { // Extract pathname from the target (e.g., "/tunnel?o=123&p=456" -> "/tunnel") const pathname = httpTarget.split('?')[0] || ''; - return pathname.startsWith(tunnelPath); + return pathname === tunnelPath || pathname.startsWith(`${tunnelPath}/`); } return false; diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index 343cfd8bb218..0483ab6448ff 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -48,7 +48,6 @@ export { startSpan, startSpanManual, startInactiveSpan } from '../common/utils/n const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryRewriteFramesDistDir?: string; - _sentryRewritesTunnelPath?: string; _sentryRelease?: string; }; @@ -207,16 +206,6 @@ export function init(options: NodeOptions): NodeClient | undefined { return null; } - // Filter out transactions for requests to the tunnel route - if ( - (globalWithInjectedValues._sentryRewritesTunnelPath && - event.transaction === `POST ${globalWithInjectedValues._sentryRewritesTunnelPath}`) || - (process.env._sentryRewritesTunnelPath && - event.transaction === `POST ${process.env._sentryRewritesTunnelPath}`) - ) { - return null; - } - // Filter out requests to resolve source maps for stack frames in dev mode if (event.transaction?.match(/\/__nextjs_original-stack-frame/)) { return null; diff --git a/packages/nextjs/test/utils/dropMiddlewareTunnelRequests.test.ts b/packages/nextjs/test/utils/dropMiddlewareTunnelRequests.test.ts new file mode 100644 index 000000000000..31624e3bffc6 --- /dev/null +++ b/packages/nextjs/test/utils/dropMiddlewareTunnelRequests.test.ts @@ -0,0 +1,159 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { dropMiddlewareTunnelRequests } from '../../src/common/utils/dropMiddlewareTunnelRequests'; +import { TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION } from '../../src/common/span-attributes-with-logic-attached'; + +const globalWithInjectedValues = global as typeof global & { + _sentryRewritesTunnelPath?: string; +}; + +vi.mock('@sentry/core', async requireActual => { + return { + ...(await requireActual()), + getClient: () => ({ + getOptions: () => ({}), + }), + }; +}); + +vi.mock('@sentry/opentelemetry', () => ({ + isSentryRequestSpan: () => false, +})); + +function createMockSpan(): { setAttribute: ReturnType; attributes: Record } { + const attributes: Record = {}; + return { + attributes, + setAttribute: vi.fn((key: string, value: unknown) => { + attributes[key] = value; + }), + }; +} + +beforeEach(() => { + globalWithInjectedValues._sentryRewritesTunnelPath = undefined; +}); + +describe('dropMiddlewareTunnelRequests', () => { + describe('BaseServer.handleRequest spans', () => { + it('marks BaseServer.handleRequest span for dropping when http.target matches tunnel path', () => { + globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + const span = createMockSpan(); + + dropMiddlewareTunnelRequests(span as any, { + 'next.span_type': 'BaseServer.handleRequest', + 'http.target': '/monitoring?o=123&p=456', + }); + + expect(span.setAttribute).toHaveBeenCalledWith(TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION, true); + }); + + it('marks BaseServer.handleRequest span for dropping when http.target exactly matches tunnel path', () => { + globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + const span = createMockSpan(); + + dropMiddlewareTunnelRequests(span as any, { + 'next.span_type': 'BaseServer.handleRequest', + 'http.target': '/monitoring', + }); + + expect(span.setAttribute).toHaveBeenCalledWith(TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION, true); + }); + + it('does not mark BaseServer.handleRequest span for dropping when http.target does not match tunnel path', () => { + globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + const span = createMockSpan(); + + dropMiddlewareTunnelRequests(span as any, { + 'next.span_type': 'BaseServer.handleRequest', + 'http.target': '/api/users', + }); + + expect(span.setAttribute).not.toHaveBeenCalled(); + }); + + it('does not mark BaseServer.handleRequest span for dropping when http.target shares tunnel path prefix', () => { + globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + const span = createMockSpan(); + + dropMiddlewareTunnelRequests(span as any, { + 'next.span_type': 'BaseServer.handleRequest', + 'http.target': '/monitoring-dashboard', + }); + + expect(span.setAttribute).not.toHaveBeenCalled(); + }); + + it('does not mark BaseServer.handleRequest span when no tunnel path is configured', () => { + const span = createMockSpan(); + + dropMiddlewareTunnelRequests(span as any, { + 'next.span_type': 'BaseServer.handleRequest', + 'http.target': '/monitoring', + }); + + expect(span.setAttribute).not.toHaveBeenCalled(); + }); + + it('handles BaseServer.handleRequest span with basePath prefix in http.target', () => { + globalWithInjectedValues._sentryRewritesTunnelPath = '/basepath/monitoring'; + const span = createMockSpan(); + + dropMiddlewareTunnelRequests(span as any, { + 'next.span_type': 'BaseServer.handleRequest', + 'http.target': '/basepath/monitoring?o=123&p=456', + }); + + expect(span.setAttribute).toHaveBeenCalledWith(TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION, true); + }); + }); + + describe('Middleware.execute spans', () => { + it('marks middleware span for dropping when http.target matches tunnel path', () => { + globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + const span = createMockSpan(); + + dropMiddlewareTunnelRequests(span as any, { + 'next.span_type': 'Middleware.execute', + 'http.target': '/monitoring?o=123&p=456', + }); + + 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'; + const span = createMockSpan(); + + dropMiddlewareTunnelRequests(span as any, { + 'next.span_type': 'SomeOtherSpanType', + 'http.target': '/monitoring', + }); + + expect(span.setAttribute).not.toHaveBeenCalled(); + }); + }); + + describe('skipOpenTelemetrySetup', () => { + it('does not process spans when skipOpenTelemetrySetup is true', async () => { + const core = await import('@sentry/core'); + const originalGetClient = core.getClient; + vi.spyOn(core, 'getClient').mockReturnValueOnce({ + getOptions: () => ({ skipOpenTelemetrySetup: true }), + } as any); + + globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + const span = createMockSpan(); + + dropMiddlewareTunnelRequests(span as any, { + 'next.span_type': 'BaseServer.handleRequest', + 'http.target': '/monitoring', + }); + + expect(span.setAttribute).not.toHaveBeenCalled(); + + vi.mocked(core.getClient).mockRestore(); + }); + }); +});