Skip to content

Commit 79241b0

Browse files
authored
fix(nextjs): Skip tracing for tunnel requests (#19861)
Fix Next.js tunnel route span filtering by extending `dropMiddlewareTunnelRequests` to also drop `BaseServer.handleRequest` spans that match the tunnel path, replacing a fragile transaction-name string comparison in the event processor with the early, attribute-based `TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION` mechanism already used for middleware and fetch spans. closes https://linear.app/getsentry/issue/JS-1952/nextjs-automatically-filter-tunnel-route-spans closes #19840
1 parent 938ab2d commit 79241b0

File tree

3 files changed

+168
-19
lines changed

3 files changed

+168
-19
lines changed

packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@ const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & {
99
};
1010

1111
/**
12-
* Drops spans for tunnel requests from middleware or fetch instrumentation.
13-
* This catches both:
14-
* 1. Requests to the local tunnel route (before rewrite)
15-
* 2. Requests to Sentry ingest (after rewrite)
12+
* Drops spans for tunnel requests from middleware, fetch instrumentation, or BaseServer.handleRequest.
13+
* This catches:
14+
* 1. Requests to the local tunnel route (before rewrite) via middleware or BaseServer.handleRequest
15+
* 2. Requests to Sentry ingest (after rewrite) via fetch spans
1616
*/
1717
export function dropMiddlewareTunnelRequests(span: Span, attrs: SpanAttributes | undefined): void {
1818
// When the user brings their own OTel setup (skipOpenTelemetrySetup: true), we should not
@@ -21,14 +21,15 @@ export function dropMiddlewareTunnelRequests(span: Span, attrs: SpanAttributes |
2121
return;
2222
}
2323

24-
// Only filter middleware spans or HTTP fetch spans
24+
// Only filter middleware spans, HTTP fetch spans, or BaseServer.handleRequest spans
2525
const isMiddleware = attrs?.[ATTR_NEXT_SPAN_TYPE] === 'Middleware.execute';
2626
// The fetch span could be originating from rewrites re-writing a tunnel request
2727
// So we want to filter it out
2828
const isFetchSpan = attrs?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] === 'auto.http.otel.node_fetch';
29+
const isBaseServerHandleRequest = attrs?.[ATTR_NEXT_SPAN_TYPE] === 'BaseServer.handleRequest';
2930

30-
// If the span is not a middleware span or a fetch span, return
31-
if (!isMiddleware && !isFetchSpan) {
31+
// If the span is not a middleware span, fetch span, or BaseServer.handleRequest span, return
32+
if (!isMiddleware && !isFetchSpan && !isBaseServerHandleRequest) {
3233
return;
3334
}
3435

@@ -58,7 +59,7 @@ function isTunnelRouteSpan(spanAttributes: Record<string, unknown>): boolean {
5859
// Extract pathname from the target (e.g., "/tunnel?o=123&p=456" -> "/tunnel")
5960
const pathname = httpTarget.split('?')[0] || '';
6061

61-
return pathname.startsWith(tunnelPath);
62+
return pathname === tunnelPath || pathname.startsWith(`${tunnelPath}/`);
6263
}
6364

6465
return false;

packages/nextjs/src/server/index.ts

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,6 @@ export { startSpan, startSpanManual, startInactiveSpan } from '../common/utils/n
4848

4949
const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & {
5050
_sentryRewriteFramesDistDir?: string;
51-
_sentryRewritesTunnelPath?: string;
5251
_sentryRelease?: string;
5352
};
5453

@@ -207,16 +206,6 @@ export function init(options: NodeOptions): NodeClient | undefined {
207206
return null;
208207
}
209208

210-
// Filter out transactions for requests to the tunnel route
211-
if (
212-
(globalWithInjectedValues._sentryRewritesTunnelPath &&
213-
event.transaction === `POST ${globalWithInjectedValues._sentryRewritesTunnelPath}`) ||
214-
(process.env._sentryRewritesTunnelPath &&
215-
event.transaction === `POST ${process.env._sentryRewritesTunnelPath}`)
216-
) {
217-
return null;
218-
}
219-
220209
// Filter out requests to resolve source maps for stack frames in dev mode
221210
if (event.transaction?.match(/\/__nextjs_original-stack-frame/)) {
222211
return null;
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
import { dropMiddlewareTunnelRequests } from '../../src/common/utils/dropMiddlewareTunnelRequests';
3+
import { TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION } from '../../src/common/span-attributes-with-logic-attached';
4+
5+
const globalWithInjectedValues = global as typeof global & {
6+
_sentryRewritesTunnelPath?: string;
7+
};
8+
9+
vi.mock('@sentry/core', async requireActual => {
10+
return {
11+
...(await requireActual<any>()),
12+
getClient: () => ({
13+
getOptions: () => ({}),
14+
}),
15+
};
16+
});
17+
18+
vi.mock('@sentry/opentelemetry', () => ({
19+
isSentryRequestSpan: () => false,
20+
}));
21+
22+
function createMockSpan(): { setAttribute: ReturnType<typeof vi.fn>; attributes: Record<string, unknown> } {
23+
const attributes: Record<string, unknown> = {};
24+
return {
25+
attributes,
26+
setAttribute: vi.fn((key: string, value: unknown) => {
27+
attributes[key] = value;
28+
}),
29+
};
30+
}
31+
32+
beforeEach(() => {
33+
globalWithInjectedValues._sentryRewritesTunnelPath = undefined;
34+
});
35+
36+
describe('dropMiddlewareTunnelRequests', () => {
37+
describe('BaseServer.handleRequest spans', () => {
38+
it('marks BaseServer.handleRequest span for dropping when http.target matches tunnel path', () => {
39+
globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring';
40+
const span = createMockSpan();
41+
42+
dropMiddlewareTunnelRequests(span as any, {
43+
'next.span_type': 'BaseServer.handleRequest',
44+
'http.target': '/monitoring?o=123&p=456',
45+
});
46+
47+
expect(span.setAttribute).toHaveBeenCalledWith(TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION, true);
48+
});
49+
50+
it('marks BaseServer.handleRequest span for dropping when http.target exactly matches tunnel path', () => {
51+
globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring';
52+
const span = createMockSpan();
53+
54+
dropMiddlewareTunnelRequests(span as any, {
55+
'next.span_type': 'BaseServer.handleRequest',
56+
'http.target': '/monitoring',
57+
});
58+
59+
expect(span.setAttribute).toHaveBeenCalledWith(TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION, true);
60+
});
61+
62+
it('does not mark BaseServer.handleRequest span for dropping when http.target does not match tunnel path', () => {
63+
globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring';
64+
const span = createMockSpan();
65+
66+
dropMiddlewareTunnelRequests(span as any, {
67+
'next.span_type': 'BaseServer.handleRequest',
68+
'http.target': '/api/users',
69+
});
70+
71+
expect(span.setAttribute).not.toHaveBeenCalled();
72+
});
73+
74+
it('does not mark BaseServer.handleRequest span for dropping when http.target shares tunnel path prefix', () => {
75+
globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring';
76+
const span = createMockSpan();
77+
78+
dropMiddlewareTunnelRequests(span as any, {
79+
'next.span_type': 'BaseServer.handleRequest',
80+
'http.target': '/monitoring-dashboard',
81+
});
82+
83+
expect(span.setAttribute).not.toHaveBeenCalled();
84+
});
85+
86+
it('does not mark BaseServer.handleRequest span when no tunnel path is configured', () => {
87+
const span = createMockSpan();
88+
89+
dropMiddlewareTunnelRequests(span as any, {
90+
'next.span_type': 'BaseServer.handleRequest',
91+
'http.target': '/monitoring',
92+
});
93+
94+
expect(span.setAttribute).not.toHaveBeenCalled();
95+
});
96+
97+
it('handles BaseServer.handleRequest span with basePath prefix in http.target', () => {
98+
globalWithInjectedValues._sentryRewritesTunnelPath = '/basepath/monitoring';
99+
const span = createMockSpan();
100+
101+
dropMiddlewareTunnelRequests(span as any, {
102+
'next.span_type': 'BaseServer.handleRequest',
103+
'http.target': '/basepath/monitoring?o=123&p=456',
104+
});
105+
106+
expect(span.setAttribute).toHaveBeenCalledWith(TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION, true);
107+
});
108+
});
109+
110+
describe('Middleware.execute spans', () => {
111+
it('marks middleware span for dropping when http.target matches tunnel path', () => {
112+
globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring';
113+
const span = createMockSpan();
114+
115+
dropMiddlewareTunnelRequests(span as any, {
116+
'next.span_type': 'Middleware.execute',
117+
'http.target': '/monitoring?o=123&p=456',
118+
});
119+
120+
expect(span.setAttribute).toHaveBeenCalledWith(TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION, true);
121+
});
122+
});
123+
124+
describe('unrelated spans', () => {
125+
it('does not process spans without matching span type or origin', () => {
126+
globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring';
127+
const span = createMockSpan();
128+
129+
dropMiddlewareTunnelRequests(span as any, {
130+
'next.span_type': 'SomeOtherSpanType',
131+
'http.target': '/monitoring',
132+
});
133+
134+
expect(span.setAttribute).not.toHaveBeenCalled();
135+
});
136+
});
137+
138+
describe('skipOpenTelemetrySetup', () => {
139+
it('does not process spans when skipOpenTelemetrySetup is true', async () => {
140+
const core = await import('@sentry/core');
141+
const originalGetClient = core.getClient;
142+
vi.spyOn(core, 'getClient').mockReturnValueOnce({
143+
getOptions: () => ({ skipOpenTelemetrySetup: true }),
144+
} as any);
145+
146+
globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring';
147+
const span = createMockSpan();
148+
149+
dropMiddlewareTunnelRequests(span as any, {
150+
'next.span_type': 'BaseServer.handleRequest',
151+
'http.target': '/monitoring',
152+
});
153+
154+
expect(span.setAttribute).not.toHaveBeenCalled();
155+
156+
vi.mocked(core.getClient).mockRestore();
157+
});
158+
});
159+
});

0 commit comments

Comments
 (0)