-
-
Notifications
You must be signed in to change notification settings - Fork 1.8k
feat(tanstackstart-react): Add server-side route parametrization #21147
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
bece925
c51cb4a
055babe
fa882e4
f52fdb2
05d23c8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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'); | ||
| }); |
| 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, '\\$&'); | ||
| } | ||
|
|
||
| function patternToRegex(pattern: string): RegExp { | ||
| const segments = pattern | ||
| .split('/') | ||
| .map(segment => { | ||
| if (segment === '$') { | ||
| return '.+'; | ||
| } | ||
| if (segment.startsWith('$')) { | ||
| return '[^/]+'; | ||
| } | ||
|
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; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Splat routes not deprioritized vs named parameter routesLow Severity The sort comparator counts both splat segments ( Additional Locations (1)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 |
|---|---|---|
| @@ -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) { | ||
| 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)]; | ||
| } | ||
|
cursor[bot] marked this conversation as resolved.
|
||
| 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/$'); | ||
| }); | ||
| }); |


There was a problem hiding this comment.
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
escapeRegexfunction duplicatesescapeStringForRegexalready 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.Reviewed by Cursor Bugbot for commit 05d23c8. Configure here.