Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
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,39 @@
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 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/')
);
});

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

const serverTx = await serverTxPromise;

expect(serverTx.transaction).toBe('GET /param/$id');
});

test('should parametrize server 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/')
);
});

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

const serverTx = await serverTxPromise;

expect(serverTx.transaction).toBe('GET /users/$userId');
});
73 changes: 73 additions & 0 deletions packages/tanstackstart-react/src/server/routeParametrization.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { ATTR_HTTP_ROUTE } from '@opentelemetry/semantic-conventions';
import { getActiveSpan, getRootSpan, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, spanToJSON, updateSpanName } from '@sentry/core';

function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicated regex escape utility already exists in core

Low Severity

The local escapeRegex function duplicates escapeStringForRegex already exported from @sentry/core (which is already imported on line 2 of this file). Using the existing utility avoids maintaining two copies and ensures consistent escaping behavior across the codebase.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 05d23c8. Configure here.


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

/**
* 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 sorted by specificity: more segments first, static segments before dynamic.
*/
export function matchUrlToRoutePattern(pathname: string, patterns: string[]): string | undefined {
const sorted = [...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;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Splat routes not deprioritized vs named parameter routes

Low Severity

The sort comparator counts both splat segments ($) and named parameter segments ($param) equally via s.startsWith('$'), but splat maps to .+ (matches across /) while named params map to [^/]+ (single segment only). When both /files/$ and /files/$name exist with equal segment count and dynamic count, sort order depends on input order. If the splat appears first, a URL like /files/hello incorrectly matches the less-specific splat pattern instead of the named parameter pattern.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 05d23c8. Configure here.

});

for (const pattern of sorted) {
if (patternToRegex(pattern).test(pathname)) {
return pattern;
}
}
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;
}

updateSpanName(rootSpan, `${method} ${matchedPattern}`);
rootSpan.setAttribute(ATTR_HTTP_ROUTE, matchedPattern);
rootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route');
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { flushIfServerless } from '@sentry/core';
import { SEMANTIC_ATTRIBUTE_SENTRY_OP, 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 @@ -62,6 +65,10 @@ export function wrapFetchWithSentry(serverEntry: ServerEntry): ServerEntry {
);
}

if (typeof __SENTRY_ROUTE_PATTERNS__ !== 'undefined') {
updateSpanWithRouteParametrization(method, url.pathname, __SENTRY_ROUTE_PATTERNS__);
}

return await target.apply(thisArg, args);
} finally {
await flushIfServerless();
Expand Down
74 changes: 74 additions & 0 deletions packages/tanstackstart-react/src/vite/routePatterns.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
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.
*
* Reads the route tree lazily during `transform` to ensure it exists after TanStack Start generates it.
*/
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 22 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 '_'.
if (!code.includes('__SENTRY_ROUTE_PATTERNS__')) {
return null;
}

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)),
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.
*
* Only exported for testing.
*/
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]);
}
}

if (!patterns.includes('/')) {
patterns.push('/');
}

return [...new Set(patterns)];
}
Comment thread
cursor[bot] marked this conversation as resolved.
11 changes: 8 additions & 3 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 @@ -67,12 +68,16 @@ export interface SentryTanstackStartOptions extends BuildTimeOptionsBase {
export function sentryTanstackStart(options: SentryTanstackStartOptions = {}): Plugin[] {
const tunnelRoutePlugin = options.tunnelRoute ? makeTunnelRoutePlugin(options.tunnelRoute, options.debug) : undefined;

// only add build-time plugins in production builds
// In development, only add route patterns plugin and tunnel route
if (process.env.NODE_ENV === 'development') {
return tunnelRoutePlugin ? [tunnelRoutePlugin] : [];
const devPlugins: Plugin[] = [makeRoutePatternPlugin()];
if (tunnelRoutePlugin) {
devPlugins.push(tunnelRoutePlugin);
}
return devPlugins;
}

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

if (tunnelRoutePlugin) {
plugins.push(tunnelRoutePlugin);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest';
import { matchUrlToRoutePattern } from '../../src/server/routeParametrization';

describe('matchUrlToRoutePattern', () => {
const patterns = ['/', '/page-a', '/page-b/$id', '/users/$userId/posts/$postId', '/api/health'];

it('matches the root route', () => {
expect(matchUrlToRoutePattern('/', patterns)).toBe('/');
});

it('matches a static route', () => {
expect(matchUrlToRoutePattern('/page-a', patterns)).toBe('/page-a');
});

it('matches a single-param route', () => {
expect(matchUrlToRoutePattern('/page-b/42', patterns)).toBe('/page-b/$id');
});

it('matches a multi-param route', () => {
expect(matchUrlToRoutePattern('/users/123/posts/456', patterns)).toBe('/users/$userId/posts/$postId');
});

it('matches a static API route', () => {
expect(matchUrlToRoutePattern('/api/health', patterns)).toBe('/api/health');
});

it('returns undefined for unmatched paths', () => {
expect(matchUrlToRoutePattern('/unknown', patterns)).toBeUndefined();
});

it('returns undefined for partially matched paths', () => {
expect(matchUrlToRoutePattern('/page-b', patterns)).toBeUndefined();
});

it('prefers static over dynamic matches', () => {
const patternsWithOverlap = ['/page-b/$id', '/page-b/special'];
expect(matchUrlToRoutePattern('/page-b/special', patternsWithOverlap)).toBe('/page-b/special');
});

it('prefers more specific routes (more segments)', () => {
const patternsNested = ['/users/$id', '/users/$id/profile'];
expect(matchUrlToRoutePattern('/users/123/profile', patternsNested)).toBe('/users/$id/profile');
});

it('matches splat/catch-all routes across multiple segments', () => {
const patternsWithSplat = ['/', '/files/$'];
expect(matchUrlToRoutePattern('/files/a/b/c', patternsWithSplat)).toBe('/files/$');
expect(matchUrlToRoutePattern('/files/readme.txt', patternsWithSplat)).toBe('/files/$');
});

it('prefers specific routes over splat routes', () => {
const patternsWithSplat = ['/files/$', '/files/upload'];
expect(matchUrlToRoutePattern('/files/upload', patternsWithSplat)).toBe('/files/upload');
expect(matchUrlToRoutePattern('/files/a/b', patternsWithSplat)).toBe('/files/$');
});
});
Loading
Loading