Skip to content

Commit 54e995d

Browse files
authored
fix(tanstackstart-react): Drop server transactions for tunnel route requests (#21769)
The server SDK was turning every incoming tunnel route request into an `http.server` transaction. This drops tunnel traffic for both the static and streamed (`traceLifecycle: 'stream'`) span lifecycles. closes #21555
1 parent b534db4 commit 54e995d

9 files changed

Lines changed: 186 additions & 7 deletions

File tree

dev-packages/e2e-tests/test-applications/tanstackstart-react/instrument.server.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ Sentry.init({
55
dsn: process.env.E2E_TEST_DSN,
66
tunnel: `http://localhost:3031/`, // proxy server
77
tracesSampleRate: 1,
8+
traceLifecycle: process.env.E2E_TEST_STREAMED_SPANS === '1' ? 'stream' : 'static',
89
transportOptions: {
910
// We expect the app to send a lot of events in a short time
1011
bufferSize: 1000,

dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"test:assert:proxy": "pnpm test",
1818
"test:assert": "pnpm test:assert:proxy",
1919
"test:assert:tunnel-generated": "E2E_TEST_TUNNEL_ROUTE_MODE=dynamic E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm test",
20+
"test:assert:tunnel-streamed": "E2E_TEST_TUNNEL_ROUTE_MODE=dynamic E2E_TEST_DSN=http://public@localhost:3031/1337 E2E_TEST_STREAMED_SPANS=1 pnpm test",
2021
"test:assert:tunnel-static": "E2E_TEST_TUNNEL_ROUTE_MODE=static E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm test",
2122
"test:assert:tunnel-custom": "E2E_TEST_CUSTOM_TUNNEL_ROUTE=1 pnpm test",
2223
"test:assert:tunnel-object": "E2E_TEST_TUNNEL_ROUTE_MODE=object E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm test"
@@ -52,6 +53,11 @@
5253
"build-command": "pnpm test:build:tunnel-generated",
5354
"assert-command": "pnpm test:assert:tunnel-generated"
5455
},
56+
{
57+
"label": "tanstackstart-react (tunnel-streamed)",
58+
"build-command": "pnpm test:build:tunnel-generated",
59+
"assert-command": "pnpm test:assert:tunnel-streamed"
60+
},
5561
{
5662
"label": "tanstackstart-react (tunnel-static)",
5763
"build-command": "pnpm test:build:tunnel-static",

dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/tunnel.test.ts

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { expect, test } from '@playwright/test';
2-
import { waitForError } from '@sentry-internal/test-utils';
2+
import { getSpanOp, waitForError, waitForStreamedSpan, waitForTransaction } from '@sentry-internal/test-utils';
33

44
const tunnelRouteMode =
55
process.env.E2E_TEST_TUNNEL_ROUTE_MODE ?? (process.env.E2E_TEST_CUSTOM_TUNNEL_ROUTE === '1' ? 'custom' : 'off');
6+
const useStreamedSpans = process.env.E2E_TEST_STREAMED_SPANS === '1';
67
const expectedTunnelPathMatcher =
78
tunnelRouteMode === 'static'
89
? '/monitor'
@@ -60,3 +61,68 @@ test('Sends client-side errors through the configured tunnel route', async ({ pa
6061
expect(errorEvent.exception?.values?.[0]?.value).toBe('Sentry Client Test Error');
6162
expect(errorEvent.transaction).toBe('/');
6263
});
64+
65+
function pathnameMatchesTunnelRoute(pathname: string): boolean {
66+
return typeof expectedTunnelPathMatcher === 'string'
67+
? pathname === expectedTunnelPathMatcher
68+
: expectedTunnelPathMatcher.test(pathname);
69+
}
70+
71+
// A server `http.server` transaction can arrive either as a classic transaction event (static trace
72+
// lifecycle) or as a Span v2 segment span (`traceLifecycle: 'stream'`). These helpers wait on whichever
73+
// format the current run produces, so the assertion below covers both lifecycles with one test body.
74+
function waitForServerHttpEvent(matchesPathname: (pathname: string) => boolean): Promise<unknown> {
75+
if (useStreamedSpans) {
76+
return waitForStreamedSpan('tanstackstart-react', span => {
77+
return getSpanOp(span) === 'http.server' && matchesPathname((span.name ?? '').split(' ')[1] ?? '');
78+
});
79+
}
80+
81+
return waitForTransaction('tanstackstart-react', transactionEvent => {
82+
return (
83+
transactionEvent?.contexts?.trace?.op === 'http.server' &&
84+
matchesPathname((transactionEvent.transaction ?? '').split(' ')[1] ?? '')
85+
);
86+
});
87+
}
88+
89+
test('Does not create a server transaction for the tunnel route', async ({ page }) => {
90+
// The incoming POST to the tunnel route must not be turned into an `http.server`
91+
// transaction by the server SDK — tunnel traffic is plumbing, not application requests.
92+
const tunnelServerEventPromise = waitForServerHttpEvent(pathnameMatchesTunnelRoute);
93+
94+
await page.goto('/');
95+
const pageOrigin = new URL(page.url()).origin;
96+
97+
await page.locator('html[data-hydrated="true"]').waitFor();
98+
await expect(page.locator('button').filter({ hasText: 'Break the client' })).toBeVisible();
99+
100+
const managedTunnelResponsePromise = page.waitForResponse(response => {
101+
const responseUrl = new URL(response.url());
102+
103+
return (
104+
responseUrl.origin === pageOrigin &&
105+
response.request().method() === 'POST' &&
106+
pathnameMatchesTunnelRoute(responseUrl.pathname)
107+
);
108+
});
109+
110+
await page.locator('button').filter({ hasText: 'Break the client' }).click();
111+
112+
// Ensure the tunnel POST was fully handled server-side before we issue the anchor request below.
113+
await managedTunnelResponsePromise;
114+
115+
// Anchor on a regular server transaction issued *after* the tunnel POST. The Node transport flushes
116+
// transactions/spans in FIFO order, so a (buggy) tunnel-route transaction would always arrive before
117+
// this anchor. Racing the two lets us assert the absence of a tunnel transaction without idling on a timeout.
118+
const anchorServerEventPromise = waitForServerHttpEvent(pathname => pathname.includes('/api/user/'));
119+
120+
await page.request.get('/api/user/456');
121+
122+
const winner = await Promise.race([
123+
tunnelServerEventPromise.then(() => 'tunnel' as const),
124+
anchorServerEventPromise.then(() => 'anchor' as const),
125+
]);
126+
127+
expect(winner).toBe('anchor');
128+
});

packages/tanstackstart-react/src/client/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,10 @@ export function createSentryTunnelRoute(_options: CreateSentryTunnelRouteOptions
5555
},
5656
};
5757
}
58+
59+
/**
60+
* No-op stub for client-side builds.
61+
* The actual implementation is server-only, but this stub is needed so the managed tunnel route module
62+
* can statically import it (the call is server-guarded and tree-shaken from the client bundle).
63+
*/
64+
export function registerSentryServerTunnelRoute(_path: string): void {}

packages/tanstackstart-react/src/index.types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,4 @@ export declare const tanstackRouterBrowserTracingIntegration: typeof clientSdk.t
4444
export declare const sentryGlobalRequestMiddleware: typeof serverSdk.sentryGlobalRequestMiddleware;
4545
export declare const sentryGlobalFunctionMiddleware: typeof serverSdk.sentryGlobalFunctionMiddleware;
4646
export declare const createSentryTunnelRoute: typeof serverSdk.createSentryTunnelRoute;
47+
export declare const registerSentryServerTunnelRoute: typeof serverSdk.registerSentryServerTunnelRoute;

packages/tanstackstart-react/src/server/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ export { init } from './sdk';
99
export { wrapFetchWithSentry } from './wrapFetchWithSentry';
1010
export { wrapMiddlewaresWithSentry } from './middleware';
1111
export { sentryGlobalRequestMiddleware, sentryGlobalFunctionMiddleware } from './globalMiddleware';
12-
export { createSentryTunnelRoute } from './tunnelRoute';
12+
export { createSentryTunnelRoute, registerSentryServerTunnelRoute } from './tunnelRoute';
1313

1414
/**
1515
* A no-op stub of the replay integration for the server. Router setup code is shared between client and server,

packages/tanstackstart-react/src/server/tunnelRoute.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,31 @@
1-
import { dsnToString, getClient, handleTunnelRequest } from '@sentry/core';
1+
import { dsnToString, escapeStringForRegex, getClient, handleTunnelRequest } from '@sentry/core';
2+
3+
const registeredTunnelRoutePaths = new Set<string>();
4+
5+
/**
6+
* Drops the incoming `http.server` transaction for a tunnel route by matching its request path (the
7+
* `http.target` attribute, set at span creation, so it works for static and streamed lifecycles).
8+
* Called at server startup for the managed route and from the handler (self-registration) otherwise.
9+
*/
10+
export function registerSentryServerTunnelRoute(path: string): void {
11+
// Dedupe: the route module can be re-evaluated (HMR, multiple import paths).
12+
if (registeredTunnelRoutePaths.has(path)) {
13+
return;
14+
}
15+
16+
const client = getClient();
17+
if (!client) {
18+
return;
19+
}
20+
21+
registeredTunnelRoutePaths.add(path);
22+
23+
const options = client.getOptions();
24+
options.ignoreSpans = [
25+
...(options.ignoreSpans ?? []),
26+
{ attributes: { 'http.target': new RegExp(`^${escapeStringForRegex(path)}(?:[/?#]|$)`) } },
27+
];
28+
}
229

330
export interface CreateSentryTunnelRouteOptions {
431
allowedDsns?: string[];
@@ -33,6 +60,12 @@ export function createSentryTunnelRoute(options: CreateSentryTunnelRouteOptions)
3360
return {
3461
handlers: {
3562
POST: async ({ request }) => {
63+
// Self-register the path so the tunnel route's own transaction is dropped: at span-end for the
64+
// static path, and at span-start for subsequent streamed requests. The first streamed request
65+
// still leaks here (this runs after its span was sampled); managed routes avoid even that via
66+
// the startup registration.
67+
registerSentryServerTunnelRoute(new URL(request.url).pathname);
68+
3669
const allowedDsnsFromOptions = options.allowedDsns?.length ? options.allowedDsns : undefined;
3770

3871
const allowedDsns =

packages/tanstackstart-react/src/vite/tunnelRoute.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,14 @@ export function makeTunnelRoutePlugin(options: TunnelRouteOptions, debug?: boole
178178
}
179179

180180
return `import { createFileRoute } from '@tanstack/react-router';
181+
import { registerSentryServerTunnelRoute } from '@sentry/tanstackstart-react';
182+
183+
// Server-only: drop the tunnel route's own transaction. Registered synchronously at module evaluation
184+
// (before any request) so streamed spans are filtered from the first request. The SSR guard tree-shakes
185+
// it (and the import) out of the client bundle.
186+
if (import.meta.env.SSR) {
187+
registerSentryServerTunnelRoute(${serializedTunnelRoute});
188+
}
181189
182190
export const Route = createFileRoute(${serializedTunnelRoute})({
183191
server: {

packages/tanstackstart-react/test/server/tunnelRoute.test.ts

Lines changed: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,11 @@ vi.mock('@sentry/core', async importOriginal => {
1212
};
1313
});
1414

15-
const { createSentryTunnelRoute } = await import('../../src/server/tunnelRoute');
15+
const { createSentryTunnelRoute, registerSentryServerTunnelRoute } = await import('../../src/server/tunnelRoute');
1616

1717
describe('createSentryTunnelRoute', () => {
1818
afterEach(() => {
19-
vi.clearAllMocks();
19+
vi.resetAllMocks();
2020
});
2121

2222
it('returns a server route config with only a POST handler', () => {
@@ -49,10 +49,12 @@ describe('createSentryTunnelRoute', () => {
4949
});
5050

5151
it('derives the allowed DSN from the active server Sentry client when allowedDsns is omitted', async () => {
52-
const request = new Request('http://localhost:3000/monitoring', { method: 'POST', body: 'envelope' });
52+
const request = new Request('http://localhost:3000/derive-dsn', { method: 'POST', body: 'envelope' });
5353
const response = new Response('ok', { status: 200 });
5454

55-
getClientSpy.mockReturnValueOnce({
55+
// `getClient` is called both by the path self-registration and the DSN derivation.
56+
getClientSpy.mockReturnValue({
57+
getOptions: () => ({}),
5658
getDsn: () => ({
5759
protocol: 'http',
5860
publicKey: 'public',
@@ -77,6 +79,21 @@ describe('createSentryTunnelRoute', () => {
7779
expect(result).toBe(response);
7880
});
7981

82+
it('self-registers the request path so the streamed-span sampler can drop it', async () => {
83+
const request = new Request('http://localhost:3000/handler-selfreg', { method: 'POST', body: 'envelope' });
84+
const options: { ignoreSpans?: unknown[] } = {};
85+
getClientSpy.mockReturnValue({ getOptions: () => options, getDsn: () => undefined });
86+
handleTunnelRequestSpy.mockResolvedValueOnce(new Response('ok', { status: 200 }));
87+
88+
await createSentryTunnelRoute({ allowedDsns: ['https://public@o0.ingest.sentry.io/0'] }).handlers.POST({ request });
89+
90+
const matcher = options.ignoreSpans?.find(
91+
(entry): entry is { attributes: { 'http.target': RegExp } } =>
92+
!!(entry as { attributes?: { 'http.target'?: unknown } })?.attributes?.['http.target'],
93+
);
94+
expect(matcher?.attributes['http.target'].test('/handler-selfreg')).toBe(true);
95+
});
96+
8097
it('returns 500 when allowedDsns is omitted and no active server Sentry client DSN exists', async () => {
8198
const request = new Request('http://localhost:3000/monitoring', { method: 'POST', body: 'envelope' });
8299

@@ -90,3 +107,43 @@ describe('createSentryTunnelRoute', () => {
90107
await expect(result.text()).resolves.toContain('Tunnel route requires Sentry server SDK initialized with a DSN');
91108
});
92109
});
110+
111+
describe('registerSentryServerTunnelRoute', () => {
112+
afterEach(() => {
113+
vi.resetAllMocks();
114+
});
115+
116+
it('adds an http.target ignoreSpans matcher for the tunnel route path so the transaction is dropped at span start', () => {
117+
const options: { ignoreSpans?: unknown[] } = { ignoreSpans: [/existing/] };
118+
getClientSpy.mockReturnValue({ getOptions: () => options });
119+
120+
// Unique path per test to avoid the module-level dedupe Set across tests.
121+
registerSentryServerTunnelRoute('/abcd1234');
122+
123+
expect(options.ignoreSpans).toHaveLength(2);
124+
const matcher = options.ignoreSpans?.[1] as { attributes: { 'http.target': RegExp } };
125+
const pattern = matcher.attributes['http.target'];
126+
127+
expect(pattern.test('/abcd1234')).toBe(true);
128+
expect(pattern.test('/abcd1234?o=1&p=2')).toBe(true);
129+
expect(pattern.test('/abcd1234/')).toBe(true);
130+
// Must not match an unrelated route that merely shares the prefix.
131+
expect(pattern.test('/abcd1234extra')).toBe(false);
132+
});
133+
134+
it('does not register the same tunnel route path twice', () => {
135+
const options: { ignoreSpans?: unknown[] } = {};
136+
getClientSpy.mockReturnValue({ getOptions: () => options });
137+
138+
registerSentryServerTunnelRoute('/dedupe-me');
139+
registerSentryServerTunnelRoute('/dedupe-me');
140+
141+
expect(options.ignoreSpans).toHaveLength(1);
142+
});
143+
144+
it('is a no-op when there is no active client', () => {
145+
getClientSpy.mockReturnValue(undefined);
146+
147+
expect(() => registerSentryServerTunnelRoute('/no-client')).not.toThrow();
148+
});
149+
});

0 commit comments

Comments
 (0)