Skip to content

Commit 515f551

Browse files
nicohrubecclaude
andauthored
feat(tanstackstart-react): Add server-side route parametrization (#21147)
Server transactions now use parametrized route names instead of raw URLs. `GET /users/123` becomes `GET /users/$id`, fixing high-cardinality transaction grouping. The framework provides no way to do this natively, so this is implemented using a build manifest approach that should hopefully work in most cases. Route patterns are extracted from `routeTree.gen.ts` at build time and presorted by number of static/dynamic segments, then matched against request URLs at runtime in `wrapFetchWithSentry`. Closes #18284 --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 0a8adc4 commit 515f551

13 files changed

Lines changed: 483 additions & 17 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@
1212

1313
Server and client traces are now automatically connected, allowing you to see the full request lifecycle from server-side rendering through client-side hydration in a single trace.
1414

15+
- **feat(tanstackstart-react): Add server-side route parametrization ([#21147](https://github.com/getsentry/sentry-javascript/pull/21147))**
16+
17+
Server transaction names are now parametrized automatically (e.g., `GET /users/123` becomes `GET /users/$userId`), improving transaction grouping in Sentry.
18+
1519
## 10.54.0
1620

1721
### Important Changes
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { createFileRoute } from '@tanstack/react-router';
2+
3+
export const Route = createFileRoute('/api/user/$id')({
4+
server: {
5+
handlers: {
6+
GET: async ({ params }) => {
7+
return new Response(JSON.stringify({ id: params.id }), {
8+
headers: { 'Content-Type': 'application/json' },
9+
});
10+
},
11+
},
12+
},
13+
});
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { createFileRoute } from '@tanstack/react-router';
2+
3+
export const Route = createFileRoute('/param/$id')({
4+
component: ParamPage,
5+
});
6+
7+
function ParamPage() {
8+
const { id } = Route.useParams();
9+
return (
10+
<div>
11+
<p id="param-value">Param: {id}</p>
12+
</div>
13+
);
14+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { createFileRoute } from '@tanstack/react-router';
2+
3+
export const Route = createFileRoute('/users/$userId')({
4+
component: UserPage,
5+
});
6+
7+
function UserPage() {
8+
const { userId } = Route.useParams();
9+
return (
10+
<div>
11+
<p id="user-id">User: {userId}</p>
12+
</div>
13+
);
14+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { Outlet, createFileRoute } from '@tanstack/react-router';
2+
3+
export const Route = createFileRoute('/users')({
4+
component: UsersLayout,
5+
});
6+
7+
function UsersLayout() {
8+
return (
9+
<div>
10+
<Outlet />
11+
</div>
12+
);
13+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { expect, test } from '@playwright/test';
2+
import { waitForTransaction } from '@sentry-internal/test-utils';
3+
4+
const usesManagedTunnelRoute =
5+
(process.env.E2E_TEST_TUNNEL_ROUTE_MODE ?? 'off') !== 'off' || process.env.E2E_TEST_CUSTOM_TUNNEL_ROUTE === '1';
6+
7+
test.skip(usesManagedTunnelRoute, 'Default e2e suites run only in the proxy variant');
8+
9+
test('should parametrize server and client transaction names for dynamic routes', async ({ page }) => {
10+
const serverTxPromise = waitForTransaction('tanstackstart-react', transactionEvent => {
11+
return (
12+
transactionEvent?.contexts?.trace?.op === 'http.server' &&
13+
typeof transactionEvent?.transaction === 'string' &&
14+
transactionEvent.transaction.includes('/param/')
15+
);
16+
});
17+
18+
const clientTxPromise = waitForTransaction('tanstackstart-react', transactionEvent => {
19+
return (
20+
transactionEvent?.contexts?.trace?.op === 'pageload' &&
21+
typeof transactionEvent?.transaction === 'string' &&
22+
transactionEvent.transaction.includes('/param/')
23+
);
24+
});
25+
26+
await page.goto('/param/42');
27+
28+
const serverTx = await serverTxPromise;
29+
const clientTx = await clientTxPromise;
30+
31+
expect(serverTx.transaction).toBe('GET /param/$id');
32+
expect(serverTx.transaction_info?.source).toBe('route');
33+
34+
expect(clientTx.transaction).toBe('/param/$id');
35+
expect(clientTx.transaction_info?.source).toBe('route');
36+
});
37+
38+
test('should parametrize server and client transaction names for nested dynamic routes', async ({ page }) => {
39+
const serverTxPromise = waitForTransaction('tanstackstart-react', transactionEvent => {
40+
return (
41+
transactionEvent?.contexts?.trace?.op === 'http.server' &&
42+
typeof transactionEvent?.transaction === 'string' &&
43+
transactionEvent.transaction.includes('/users/')
44+
);
45+
});
46+
47+
const clientTxPromise = waitForTransaction('tanstackstart-react', transactionEvent => {
48+
return (
49+
transactionEvent?.contexts?.trace?.op === 'pageload' &&
50+
typeof transactionEvent?.transaction === 'string' &&
51+
transactionEvent.transaction.includes('/users/')
52+
);
53+
});
54+
55+
await page.goto('/users/123');
56+
57+
const serverTx = await serverTxPromise;
58+
const clientTx = await clientTxPromise;
59+
60+
expect(serverTx.transaction).toBe('GET /users/$userId');
61+
expect(serverTx.transaction_info?.source).toBe('route');
62+
63+
expect(clientTx.transaction).toBe('/users/$userId');
64+
expect(clientTx.transaction_info?.source).toBe('route');
65+
});
66+
67+
test('should parametrize API route transaction names', async ({ baseURL }) => {
68+
const serverTxPromise = waitForTransaction('tanstackstart-react', transactionEvent => {
69+
return (
70+
transactionEvent?.contexts?.trace?.op === 'http.server' &&
71+
typeof transactionEvent?.transaction === 'string' &&
72+
transactionEvent.transaction.includes('/api/user/')
73+
);
74+
});
75+
76+
await fetch(`${baseURL}/api/user/456`);
77+
78+
const serverTx = await serverTxPromise;
79+
80+
expect(serverTx.transaction).toBe('GET /api/user/$id');
81+
expect(serverTx.transaction_info?.source).toBe('route');
82+
});
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { ATTR_HTTP_ROUTE } from '@opentelemetry/semantic-conventions';
2+
import {
3+
escapeStringForRegex,
4+
getActiveSpan,
5+
getCurrentScope,
6+
getRootSpan,
7+
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
8+
spanToJSON,
9+
updateSpanName,
10+
} from '@sentry/core';
11+
12+
function patternToRegex(pattern: string): RegExp {
13+
const segments = pattern
14+
.split('/')
15+
.map(segment => {
16+
if (segment.startsWith('$')) {
17+
return '[^/]+';
18+
}
19+
return escapeStringForRegex(segment);
20+
})
21+
.join('/');
22+
return new RegExp(`^${segments}$`);
23+
}
24+
25+
/**
26+
* Matches a URL pathname against a list of TanStack Start route patterns.
27+
* Patterns use `$param` syntax for dynamic segments (e.g., `/users/$id`).
28+
*
29+
* Patterns are expected to be pre-sorted by specificity (more segments first, static before dynamic).
30+
*/
31+
export function matchUrlToRoutePattern(pathname: string, patterns: string[]): string | undefined {
32+
const normalizedPathname = pathname.length > 1 ? pathname.replace(/\/$/, '') : pathname;
33+
for (const pattern of patterns) {
34+
if (patternToRegex(pattern).test(normalizedPathname)) {
35+
return pattern;
36+
}
37+
}
38+
return undefined;
39+
}
40+
41+
/**
42+
* Updates the active root span with a parametrized route name.
43+
*/
44+
export function updateSpanWithRouteParametrization(method: string, pathname: string, patterns: string[]): void {
45+
const matchedPattern = matchUrlToRoutePattern(pathname, patterns);
46+
if (!matchedPattern) {
47+
return;
48+
}
49+
50+
const activeSpan = getActiveSpan();
51+
if (!activeSpan) {
52+
return;
53+
}
54+
55+
const rootSpan = getRootSpan(activeSpan);
56+
const rootSpanData = spanToJSON(rootSpan).data;
57+
if (rootSpanData?.[ATTR_HTTP_ROUTE]) {
58+
return;
59+
}
60+
61+
const transactionName = `${method} ${matchedPattern}`;
62+
updateSpanName(rootSpan, transactionName);
63+
rootSpan.setAttribute(ATTR_HTTP_ROUTE, matchedPattern);
64+
rootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route');
65+
getCurrentScope().setTransactionName(transactionName);
66+
}

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,11 @@ import {
55
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
66
startSpan,
77
} from '@sentry/node';
8+
import { updateSpanWithRouteParametrization } from './routeParametrization';
89
import { extractServerFunctionSha256 } from './utils';
910

11+
declare const __SENTRY_ROUTE_PATTERNS__: string[] | undefined;
12+
1013
export type ServerEntry = {
1114
fetch: (request: Request, opts?: unknown) => Promise<Response> | Response;
1215
};
@@ -161,6 +164,10 @@ export function wrapFetchWithSentry(serverEntry: ServerEntry): ServerEntry {
161164
);
162165
}
163166

167+
if (typeof __SENTRY_ROUTE_PATTERNS__ !== 'undefined') {
168+
updateSpanWithRouteParametrization(method, url.pathname, __SENTRY_ROUTE_PATTERNS__);
169+
}
170+
164171
return injectMetaTagsInResponse(await target.apply(thisArg, args));
165172
} finally {
166173
await flushIfServerless();
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import * as fs from 'node:fs';
2+
import * as path from 'node:path';
3+
import type { Plugin } from 'vite';
4+
5+
/**
6+
* Extracts route patterns from TanStack Start's generated routeTree.gen.ts
7+
* and replaces `__SENTRY_ROUTE_PATTERNS__` references with the extracted patterns.
8+
*
9+
* The route tree file is read during `transform` rather than `config` because
10+
* TanStack Start generates it during the build.
11+
*/
12+
export function makeRoutePatternPlugin(): Plugin {
13+
let resolvedRoot = '';
14+
15+
return {
16+
name: 'sentry-tanstackstart-route-patterns',
17+
enforce: 'post',
18+
19+
configResolved(config) {
20+
resolvedRoot = config.root || process.cwd();
21+
},
22+
23+
transform(code, id) {
24+
// this is set in the `wrapFetchWithSentry` where the paths are getting replaced by their parametrized counterparts
25+
// so this extraction should only happen once during the build (for the `wrapFetchWithSentry` file)
26+
if (!code.includes('__SENTRY_ROUTE_PATTERNS__')) {
27+
return null;
28+
}
29+
30+
// extract the patterns from the route tree file
31+
const routeTreePath = path.resolve(resolvedRoot, 'src/routeTree.gen.ts');
32+
let patterns: string[] = [];
33+
try {
34+
if (fs.existsSync(routeTreePath)) {
35+
patterns = extractRoutePatterns(fs.readFileSync(routeTreePath, 'utf-8'));
36+
}
37+
} catch {
38+
// skip
39+
}
40+
41+
return {
42+
code: code.replace(/__SENTRY_ROUTE_PATTERNS__/g, JSON.stringify(patterns)),
43+
map: null,
44+
};
45+
},
46+
};
47+
}
48+
49+
/**
50+
* Extracts full route path patterns from the content of routeTree.gen.ts.
51+
*
52+
* Parses the `fullPaths` type union which contains the resolved full paths
53+
* (e.g., `fullPaths: '/' | '/page-a' | '/users/$userId'`).
54+
* This is more reliable than `path:` properties which can be relative for nested routes.
55+
*/
56+
export function extractRoutePatterns(content: string): string[] {
57+
const fullPathsMatch = content.match(/fullPaths:\s*([\s\S]*?)(?:\n\s*\w|\n\})/);
58+
if (!fullPathsMatch) {
59+
return [];
60+
}
61+
62+
const patterns: string[] = [];
63+
const pathRegex = /['"]([^'"]+)['"]/g;
64+
let match;
65+
while ((match = pathRegex.exec(fullPathsMatch[1] || '')) !== null) {
66+
if (match[1]) {
67+
patterns.push(match[1]);
68+
}
69+
}
70+
71+
return [...new Set(patterns)].sort((a, b) => {
72+
const aSegments = a.split('/');
73+
const bSegments = b.split('/');
74+
if (bSegments.length !== aSegments.length) {
75+
return bSegments.length - aSegments.length;
76+
}
77+
const aDynamic = aSegments.filter(s => s.startsWith('$')).length;
78+
const bDynamic = bSegments.filter(s => s.startsWith('$')).length;
79+
return aDynamic - bDynamic;
80+
});
81+
}

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

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { BuildTimeOptionsBase } from '@sentry/core';
22
import type { Plugin } from 'vite';
33
import { makeAutoInstrumentMiddlewarePlugin } from './autoInstrumentMiddleware';
4+
import { makeRoutePatternPlugin } from './routePatterns';
45
import { makeAddSentryVitePlugin, makeEnableSourceMapsVitePlugin } from './sourceMaps';
56
import type { TunnelRouteOptions } from './tunnelRoute';
67
import { makeTunnelRoutePlugin } from './tunnelRoute';
@@ -84,18 +85,18 @@ export interface SentryTanstackStartOptions extends BuildTimeOptionsBase {
8485
* @returns An array of Vite plugins
8586
*/
8687
export function sentryTanstackStart(options: SentryTanstackStartOptions = {}): Plugin[] {
87-
const tunnelRoutePlugin = options.tunnelRoute ? makeTunnelRoutePlugin(options.tunnelRoute, options.debug) : undefined;
88+
const plugins: Plugin[] = [makeRoutePatternPlugin()];
89+
90+
if (options.tunnelRoute) {
91+
plugins.push(makeTunnelRoutePlugin(options.tunnelRoute, options.debug));
92+
}
8893

8994
// only add build-time plugins in production builds
9095
if (process.env.NODE_ENV === 'development') {
91-
return tunnelRoutePlugin ? [tunnelRoutePlugin] : [];
96+
return plugins;
9297
}
9398

94-
const plugins: Plugin[] = [...makeAddSentryVitePlugin(options)];
95-
96-
if (tunnelRoutePlugin) {
97-
plugins.push(tunnelRoutePlugin);
98-
}
99+
plugins.push(...makeAddSentryVitePlugin(options));
99100

100101
// middleware auto-instrumentation
101102
if (options.autoInstrumentMiddleware !== false) {

0 commit comments

Comments
 (0)