Skip to content

Commit bece925

Browse files
nicohrubecclaude
andcommitted
feat(tanstackstart-react): Add server-side route parametrization
Extracts route patterns from routeTree.gen.ts at build time and matches URLs at runtime to parametrize server transaction names (e.g., `GET /users/123` becomes `GET /users/$id`). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d75440c commit bece925

9 files changed

Lines changed: 319 additions & 12 deletions

File tree

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: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
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 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+
await page.goto('/param/42');
19+
20+
const serverTx = await serverTxPromise;
21+
22+
expect(serverTx.transaction).toBe('GET /param/$id');
23+
});
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { ATTR_HTTP_ROUTE } from '@opentelemetry/semantic-conventions';
2+
import { getActiveSpan, getRootSpan, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, spanToJSON, updateSpanName } from '@sentry/core';
3+
4+
function escapeRegex(str: string): string {
5+
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
6+
}
7+
8+
function patternToRegex(pattern: string): RegExp {
9+
const segments = pattern
10+
.split('/')
11+
.map(segment => {
12+
if (segment.startsWith('$')) {
13+
return '[^/]+';
14+
}
15+
return escapeRegex(segment);
16+
})
17+
.join('/');
18+
return new RegExp(`^${segments}$`);
19+
}
20+
21+
/**
22+
* Matches a URL pathname against a list of TanStack Start route patterns.
23+
* Patterns use `$param` syntax for dynamic segments (e.g., `/users/$id`).
24+
*
25+
* Patterns are sorted by specificity: more segments first, static segments before dynamic.
26+
*/
27+
export function matchUrlToRoutePattern(pathname: string, patterns: string[]): string | undefined {
28+
const sorted = [...patterns].sort((a, b) => {
29+
const aSegments = a.split('/');
30+
const bSegments = b.split('/');
31+
if (bSegments.length !== aSegments.length) {
32+
return bSegments.length - aSegments.length;
33+
}
34+
const aDynamic = aSegments.filter(s => s.startsWith('$')).length;
35+
const bDynamic = bSegments.filter(s => s.startsWith('$')).length;
36+
return aDynamic - bDynamic;
37+
});
38+
39+
for (const pattern of sorted) {
40+
if (patternToRegex(pattern).test(pathname)) {
41+
return pattern;
42+
}
43+
}
44+
return undefined;
45+
}
46+
47+
/**
48+
* Updates the active root span with a parametrized route name.
49+
*/
50+
export function updateSpanWithRouteParametrization(method: string, pathname: string, patterns: string[]): void {
51+
const matchedPattern = matchUrlToRoutePattern(pathname, patterns);
52+
if (!matchedPattern) {
53+
return;
54+
}
55+
56+
const activeSpan = getActiveSpan();
57+
if (!activeSpan) {
58+
return;
59+
}
60+
61+
const rootSpan = getRootSpan(activeSpan);
62+
const rootSpanData = spanToJSON(rootSpan).data;
63+
if (rootSpanData?.[ATTR_HTTP_ROUTE]) {
64+
return;
65+
}
66+
67+
updateSpanName(rootSpan, `${method} ${matchedPattern}`);
68+
rootSpan.setAttribute(ATTR_HTTP_ROUTE, matchedPattern);
69+
rootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route');
70+
}

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import { flushIfServerless } from '@sentry/core';
22
import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startSpan } from '@sentry/node';
3+
import { updateSpanWithRouteParametrization } from './routeParametrization';
34
import { extractServerFunctionSha256 } from './utils';
45

6+
declare const __SENTRY_ROUTE_PATTERNS__: string[] | undefined;
7+
58
export type ServerEntry = {
69
fetch: (request: Request, opts?: unknown) => Promise<Response> | Response;
710
};
@@ -62,6 +65,10 @@ export function wrapFetchWithSentry(serverEntry: ServerEntry): ServerEntry {
6265
);
6366
}
6467

68+
if (typeof __SENTRY_ROUTE_PATTERNS__ !== 'undefined') {
69+
updateSpanWithRouteParametrization(method, url.pathname, __SENTRY_ROUTE_PATTERNS__);
70+
}
71+
6572
return await target.apply(thisArg, args);
6673
} finally {
6774
await flushIfServerless();
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
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+
* Reads the route tree lazily during `transform` to ensure it exists after TanStack Start generates it.
10+
*/
11+
export function makeRoutePatternPlugin(): Plugin {
12+
let resolvedRoot = '';
13+
14+
return {
15+
name: 'sentry-tanstackstart-route-patterns',
16+
enforce: 'post',
17+
18+
configResolved(config) {
19+
resolvedRoot = config.root || process.cwd();
20+
},
21+
22+
transform(code, id) {
23+
if (!code.includes('__SENTRY_ROUTE_PATTERNS__')) {
24+
return null;
25+
}
26+
27+
const routeTreePath = path.resolve(resolvedRoot, 'src/routeTree.gen.ts');
28+
let patterns: string[] = ['/'];
29+
try {
30+
if (fs.existsSync(routeTreePath)) {
31+
patterns = extractRoutePatterns(fs.readFileSync(routeTreePath, 'utf-8'));
32+
}
33+
} catch {
34+
// skip
35+
}
36+
37+
return {
38+
code: code.replace(/__SENTRY_ROUTE_PATTERNS__/g, JSON.stringify(patterns)),
39+
map: null,
40+
};
41+
},
42+
};
43+
}
44+
45+
/**
46+
* Extracts route path patterns from the content of routeTree.gen.ts.
47+
*
48+
* Matches patterns like: `path: '/page-b/$id'`
49+
*
50+
* Only exported for testing.
51+
*/
52+
export function extractRoutePatterns(content: string): string[] {
53+
const patterns: string[] = [];
54+
const regex = /path:\s*['"]([^'"]+)['"]/g;
55+
let match;
56+
while ((match = regex.exec(content)) !== null) {
57+
const pattern = match[1];
58+
if (pattern && pattern !== '/') {
59+
patterns.push(pattern);
60+
}
61+
}
62+
patterns.push('/');
63+
return [...new Set(patterns)];
64+
}

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

Lines changed: 8 additions & 3 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';
@@ -67,12 +68,16 @@ export interface SentryTanstackStartOptions extends BuildTimeOptionsBase {
6768
export function sentryTanstackStart(options: SentryTanstackStartOptions = {}): Plugin[] {
6869
const tunnelRoutePlugin = options.tunnelRoute ? makeTunnelRoutePlugin(options.tunnelRoute, options.debug) : undefined;
6970

70-
// only add build-time plugins in production builds
71+
// In development, only add route patterns plugin and tunnel route
7172
if (process.env.NODE_ENV === 'development') {
72-
return tunnelRoutePlugin ? [tunnelRoutePlugin] : [];
73+
const devPlugins: Plugin[] = [makeRoutePatternPlugin()];
74+
if (tunnelRoutePlugin) {
75+
devPlugins.push(tunnelRoutePlugin);
76+
}
77+
return devPlugins;
7378
}
7479

75-
const plugins: Plugin[] = [...makeAddSentryVitePlugin(options)];
80+
const plugins: Plugin[] = [makeRoutePatternPlugin(), ...makeAddSentryVitePlugin(options)];
7681

7782
if (tunnelRoutePlugin) {
7883
plugins.push(tunnelRoutePlugin);
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { matchUrlToRoutePattern } from '../../src/server/routeParametrization';
3+
4+
describe('matchUrlToRoutePattern', () => {
5+
const patterns = ['/', '/page-a', '/page-b/$id', '/users/$userId/posts/$postId', '/api/health'];
6+
7+
it('matches the root route', () => {
8+
expect(matchUrlToRoutePattern('/', patterns)).toBe('/');
9+
});
10+
11+
it('matches a static route', () => {
12+
expect(matchUrlToRoutePattern('/page-a', patterns)).toBe('/page-a');
13+
});
14+
15+
it('matches a single-param route', () => {
16+
expect(matchUrlToRoutePattern('/page-b/42', patterns)).toBe('/page-b/$id');
17+
});
18+
19+
it('matches a multi-param route', () => {
20+
expect(matchUrlToRoutePattern('/users/123/posts/456', patterns)).toBe('/users/$userId/posts/$postId');
21+
});
22+
23+
it('matches a static API route', () => {
24+
expect(matchUrlToRoutePattern('/api/health', patterns)).toBe('/api/health');
25+
});
26+
27+
it('returns undefined for unmatched paths', () => {
28+
expect(matchUrlToRoutePattern('/unknown', patterns)).toBeUndefined();
29+
});
30+
31+
it('returns undefined for partially matched paths', () => {
32+
expect(matchUrlToRoutePattern('/page-b', patterns)).toBeUndefined();
33+
});
34+
35+
it('prefers static over dynamic matches', () => {
36+
const patternsWithOverlap = ['/page-b/$id', '/page-b/special'];
37+
expect(matchUrlToRoutePattern('/page-b/special', patternsWithOverlap)).toBe('/page-b/special');
38+
});
39+
40+
it('prefers more specific routes (more segments)', () => {
41+
const patternsNested = ['/users/$id', '/users/$id/profile'];
42+
expect(matchUrlToRoutePattern('/users/123/profile', patternsNested)).toBe('/users/$id/profile');
43+
});
44+
});
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { extractRoutePatterns } from '../../src/vite/routePatterns';
3+
4+
describe('extractRoutePatterns', () => {
5+
it('extracts route patterns from routeTree.gen.ts content', () => {
6+
const content = `
7+
const PageARoute = PageARouteImport.update({
8+
id: '/page-a',
9+
path: '/page-a',
10+
getParentRoute: () => rootRouteImport,
11+
})
12+
const IndexRoute = IndexRouteImport.update({
13+
id: '/',
14+
path: '/',
15+
getParentRoute: () => rootRouteImport,
16+
})
17+
const PageBIdRoute = PageBIdRouteImport.update({
18+
id: '/page-b/$id',
19+
path: '/page-b/$id',
20+
getParentRoute: () => rootRouteImport,
21+
})
22+
`;
23+
const patterns = extractRoutePatterns(content);
24+
expect(patterns).toContain('/page-a');
25+
expect(patterns).toContain('/page-b/$id');
26+
expect(patterns).toContain('/');
27+
});
28+
29+
it('always includes the root route', () => {
30+
const patterns = extractRoutePatterns('');
31+
expect(patterns).toEqual(['/']);
32+
});
33+
34+
it('handles nested routes', () => {
35+
const content = `
36+
const UsersIdRoute = UsersIdRouteImport.update({
37+
id: '/users/$userId',
38+
path: '/users/$userId',
39+
})
40+
const UsersIdPostsRoute = UsersIdPostsRouteImport.update({
41+
id: '/users/$userId/posts/$postId',
42+
path: '/users/$userId/posts/$postId',
43+
})
44+
`;
45+
const patterns = extractRoutePatterns(content);
46+
expect(patterns).toContain('/users/$userId');
47+
expect(patterns).toContain('/users/$userId/posts/$postId');
48+
});
49+
});

0 commit comments

Comments
 (0)