Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
564a651
feat(tanstackstart-react): Add server-side route parametrization
nicohrubec May 23, 2026
9ccee87
fix: Extract route patterns from fullPaths type union instead of path…
nicohrubec May 23, 2026
28c63fa
test: Add E2E test for nested parametrized routes
nicohrubec May 23, 2026
51600a3
fix: Handle splat/catch-all routes in URL pattern matching
nicohrubec May 23, 2026
85619b3
perf: Cache compiled route pattern regexes across requests
nicohrubec May 23, 2026
51020a4
fix: Remove unnecessary caching complexity from route matching
nicohrubec May 23, 2026
29a49de
fix: Use escapeStringForRegex from core, deprioritize splat routes in…
nicohrubec May 26, 2026
aa0ac2e
test: Remove redundant static route matching test
nicohrubec May 26, 2026
e02a164
test: Strengthen route parametrization tests
nicohrubec May 26, 2026
2d1fa35
ref: Remove splat route handling to simplify matching logic
nicohrubec May 26, 2026
eed3d20
ref: Clean up plugin assembly in sentryTanstackStart
nicohrubec May 26, 2026
f8dd9ee
docs: Clarify routePatterns plugin JSDoc
nicohrubec May 26, 2026
ad5b1d6
fix: Fall back to empty array instead of ['/'] when route tree is una…
nicohrubec May 26, 2026
756d14d
ref: Move pattern sorting to build time, add setTransactionName
nicohrubec May 26, 2026
fa5a6b6
ci: trigger CI
nicohrubec May 26, 2026
5f4daf7
test: Use toBe instead of toMatchObject in E2E assertions
nicohrubec May 26, 2026
3db2379
fix: Support double-quoted paths in routeTree.gen.ts
nicohrubec May 26, 2026
17c32af
ci: retrigger
nicohrubec May 26, 2026
b24b1e7
docs: Add changelog entry for route parametrization
nicohrubec May 26, 2026
a6f5a11
fix: Strip trailing slashes before route pattern matching
nicohrubec May 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@

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.

- **feat(tanstackstart-react): Add server-side route parametrization ([#21147](https://github.com/getsentry/sentry-javascript/pull/21147))**

Server transaction names are now parametrized automatically (e.g., `GET /users/123` becomes `GET /users/$userId`), improving transaction grouping in Sentry.

## 10.54.0

### Important Changes
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { createFileRoute } from '@tanstack/react-router';

export const Route = createFileRoute('/api/user/$id')({
server: {
handlers: {
GET: async ({ params }) => {
return new Response(JSON.stringify({ id: params.id }), {
headers: { 'Content-Type': 'application/json' },
});
},
},
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { createFileRoute } from '@tanstack/react-router';

export const Route = createFileRoute('/param/$id')({
component: ParamPage,
});

function ParamPage() {
const { id } = Route.useParams();
return (
<div>
<p id="param-value">Param: {id}</p>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { createFileRoute } from '@tanstack/react-router';

export const Route = createFileRoute('/users/$userId')({
component: UserPage,
});

function UserPage() {
const { userId } = Route.useParams();
return (
<div>
<p id="user-id">User: {userId}</p>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Outlet, createFileRoute } from '@tanstack/react-router';

export const Route = createFileRoute('/users')({
component: UsersLayout,
});

function UsersLayout() {
return (
<div>
<Outlet />
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { expect, test } from '@playwright/test';
import { waitForTransaction } from '@sentry-internal/test-utils';

const usesManagedTunnelRoute =
(process.env.E2E_TEST_TUNNEL_ROUTE_MODE ?? 'off') !== 'off' || process.env.E2E_TEST_CUSTOM_TUNNEL_ROUTE === '1';

test.skip(usesManagedTunnelRoute, 'Default e2e suites run only in the proxy variant');

test('should parametrize server and client transaction names for dynamic routes', async ({ page }) => {
const serverTxPromise = waitForTransaction('tanstackstart-react', transactionEvent => {
return (
transactionEvent?.contexts?.trace?.op === 'http.server' &&
typeof transactionEvent?.transaction === 'string' &&
transactionEvent.transaction.includes('/param/')
);
});

const clientTxPromise = waitForTransaction('tanstackstart-react', transactionEvent => {
return (
transactionEvent?.contexts?.trace?.op === 'pageload' &&
typeof transactionEvent?.transaction === 'string' &&
transactionEvent.transaction.includes('/param/')
);
});

await page.goto('/param/42');

const serverTx = await serverTxPromise;
const clientTx = await clientTxPromise;

expect(serverTx.transaction).toBe('GET /param/$id');
expect(serverTx.transaction_info?.source).toBe('route');

expect(clientTx.transaction).toBe('/param/$id');
expect(clientTx.transaction_info?.source).toBe('route');
});

test('should parametrize server and client transaction names for nested dynamic routes', async ({ page }) => {
const serverTxPromise = waitForTransaction('tanstackstart-react', transactionEvent => {
return (
transactionEvent?.contexts?.trace?.op === 'http.server' &&
typeof transactionEvent?.transaction === 'string' &&
transactionEvent.transaction.includes('/users/')
);
});

const clientTxPromise = waitForTransaction('tanstackstart-react', transactionEvent => {
return (
transactionEvent?.contexts?.trace?.op === 'pageload' &&
typeof transactionEvent?.transaction === 'string' &&
transactionEvent.transaction.includes('/users/')
);
});

await page.goto('/users/123');

const serverTx = await serverTxPromise;
const clientTx = await clientTxPromise;

expect(serverTx.transaction).toBe('GET /users/$userId');
expect(serverTx.transaction_info?.source).toBe('route');

expect(clientTx.transaction).toBe('/users/$userId');
expect(clientTx.transaction_info?.source).toBe('route');
});

test('should parametrize API route transaction names', async ({ baseURL }) => {
const serverTxPromise = waitForTransaction('tanstackstart-react', transactionEvent => {
return (
transactionEvent?.contexts?.trace?.op === 'http.server' &&
typeof transactionEvent?.transaction === 'string' &&
transactionEvent.transaction.includes('/api/user/')
);
});

await fetch(`${baseURL}/api/user/456`);

const serverTx = await serverTxPromise;

expect(serverTx.transaction).toBe('GET /api/user/$id');
expect(serverTx.transaction_info?.source).toBe('route');
});
66 changes: 66 additions & 0 deletions packages/tanstackstart-react/src/server/routeParametrization.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { ATTR_HTTP_ROUTE } from '@opentelemetry/semantic-conventions';
import {
escapeStringForRegex,
getActiveSpan,
getCurrentScope,
getRootSpan,
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
spanToJSON,
updateSpanName,
} from '@sentry/core';

function patternToRegex(pattern: string): RegExp {
const segments = pattern
.split('/')
.map(segment => {
if (segment.startsWith('$')) {
return '[^/]+';
}
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
nicohrubec marked this conversation as resolved.
return escapeStringForRegex(segment);
})
.join('/');
return new RegExp(`^${segments}$`);
}
Comment thread
sentry[bot] marked this conversation as resolved.

/**
* Matches a URL pathname against a list of TanStack Start route patterns.
* Patterns use `$param` syntax for dynamic segments (e.g., `/users/$id`).
*
* Patterns are expected to be pre-sorted by specificity (more segments first, static before dynamic).
*/
export function matchUrlToRoutePattern(pathname: string, patterns: string[]): string | undefined {
const normalizedPathname = pathname.length > 1 ? pathname.replace(/\/$/, '') : pathname;
for (const pattern of patterns) {
if (patternToRegex(pattern).test(normalizedPathname)) {
return pattern;
}
}
Comment thread
cursor[bot] marked this conversation as resolved.
return undefined;
}

/**
* Updates the active root span with a parametrized route name.
*/
export function updateSpanWithRouteParametrization(method: string, pathname: string, patterns: string[]): void {
const matchedPattern = matchUrlToRoutePattern(pathname, patterns);
if (!matchedPattern) {
return;
}

const activeSpan = getActiveSpan();
if (!activeSpan) {
return;
}

const rootSpan = getRootSpan(activeSpan);
const rootSpanData = spanToJSON(rootSpan).data;
if (rootSpanData?.[ATTR_HTTP_ROUTE]) {
return;
}

const transactionName = `${method} ${matchedPattern}`;
updateSpanName(rootSpan, transactionName);
rootSpan.setAttribute(ATTR_HTTP_ROUTE, matchedPattern);
rootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route');
getCurrentScope().setTransactionName(transactionName);
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@ import {
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
startSpan,
} from '@sentry/node';
import { updateSpanWithRouteParametrization } from './routeParametrization';
import { extractServerFunctionSha256 } from './utils';

declare const __SENTRY_ROUTE_PATTERNS__: string[] | undefined;

export type ServerEntry = {
fetch: (request: Request, opts?: unknown) => Promise<Response> | Response;
};
Expand Down Expand Up @@ -161,6 +164,10 @@ export function wrapFetchWithSentry(serverEntry: ServerEntry): ServerEntry {
);
}

if (typeof __SENTRY_ROUTE_PATTERNS__ !== 'undefined') {
updateSpanWithRouteParametrization(method, url.pathname, __SENTRY_ROUTE_PATTERNS__);
}
Comment thread
nicohrubec marked this conversation as resolved.

return injectMetaTagsInResponse(await target.apply(thisArg, args));
} finally {
await flushIfServerless();
Expand Down
81 changes: 81 additions & 0 deletions packages/tanstackstart-react/src/vite/routePatterns.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import type { Plugin } from 'vite';

/**
* Extracts route patterns from TanStack Start's generated routeTree.gen.ts
* and replaces `__SENTRY_ROUTE_PATTERNS__` references with the extracted patterns.
*
* The route tree file is read during `transform` rather than `config` because
* TanStack Start generates it during the build.
*/
export function makeRoutePatternPlugin(): Plugin {
let resolvedRoot = '';

return {
name: 'sentry-tanstackstart-route-patterns',
enforce: 'post',

configResolved(config) {
resolvedRoot = config.root || process.cwd();
},

transform(code, id) {

Check warning on line 23 in packages/tanstackstart-react/src/vite/routePatterns.ts

View workflow job for this annotation

GitHub Actions / Lint

eslint(no-unused-vars)

Parameter 'id' is declared but never used. Unused parameters should start with a '_'.
// this is set in the `wrapFetchWithSentry` where the paths are getting replaced by their parametrized counterparts
// so this extraction should only happen once during the build (for the `wrapFetchWithSentry` file)
if (!code.includes('__SENTRY_ROUTE_PATTERNS__')) {
return null;
}

// extract the patterns from the route tree file
const routeTreePath = path.resolve(resolvedRoot, 'src/routeTree.gen.ts');
let patterns: string[] = [];
try {
if (fs.existsSync(routeTreePath)) {
patterns = extractRoutePatterns(fs.readFileSync(routeTreePath, 'utf-8'));
}
} catch {
// skip
}

return {
code: code.replace(/__SENTRY_ROUTE_PATTERNS__/g, JSON.stringify(patterns)),
Comment thread
nicohrubec marked this conversation as resolved.
map: null,
};
},
};
}

/**
* Extracts full route path patterns from the content of routeTree.gen.ts.
*
* Parses the `fullPaths` type union which contains the resolved full paths
* (e.g., `fullPaths: '/' | '/page-a' | '/users/$userId'`).
* This is more reliable than `path:` properties which can be relative for nested routes.
*/
export function extractRoutePatterns(content: string): string[] {
const fullPathsMatch = content.match(/fullPaths:\s*([\s\S]*?)(?:\n\s*\w|\n\})/);
if (!fullPathsMatch) {
return [];
}

const patterns: string[] = [];
const pathRegex = /['"]([^'"]+)['"]/g;
let match;
while ((match = pathRegex.exec(fullPathsMatch[1] || '')) !== null) {
if (match[1]) {
patterns.push(match[1]);
}
}

return [...new Set(patterns)].sort((a, b) => {
const aSegments = a.split('/');
const bSegments = b.split('/');
if (bSegments.length !== aSegments.length) {
return bSegments.length - aSegments.length;
}
const aDynamic = aSegments.filter(s => s.startsWith('$')).length;
const bDynamic = bSegments.filter(s => s.startsWith('$')).length;
return aDynamic - bDynamic;
});
}
Comment thread
cursor[bot] marked this conversation as resolved.
15 changes: 8 additions & 7 deletions packages/tanstackstart-react/src/vite/sentryTanstackStart.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { BuildTimeOptionsBase } from '@sentry/core';
import type { Plugin } from 'vite';
import { makeAutoInstrumentMiddlewarePlugin } from './autoInstrumentMiddleware';
import { makeRoutePatternPlugin } from './routePatterns';
import { makeAddSentryVitePlugin, makeEnableSourceMapsVitePlugin } from './sourceMaps';
import type { TunnelRouteOptions } from './tunnelRoute';
import { makeTunnelRoutePlugin } from './tunnelRoute';
Expand Down Expand Up @@ -84,18 +85,18 @@ export interface SentryTanstackStartOptions extends BuildTimeOptionsBase {
* @returns An array of Vite plugins
*/
export function sentryTanstackStart(options: SentryTanstackStartOptions = {}): Plugin[] {
const tunnelRoutePlugin = options.tunnelRoute ? makeTunnelRoutePlugin(options.tunnelRoute, options.debug) : undefined;
const plugins: Plugin[] = [makeRoutePatternPlugin()];

if (options.tunnelRoute) {
plugins.push(makeTunnelRoutePlugin(options.tunnelRoute, options.debug));
}

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

const plugins: Plugin[] = [...makeAddSentryVitePlugin(options)];

if (tunnelRoutePlugin) {
plugins.push(tunnelRoutePlugin);
}
plugins.push(...makeAddSentryVitePlugin(options));

// middleware auto-instrumentation
if (options.autoInstrumentMiddleware !== false) {
Expand Down
Loading
Loading