Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
- 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
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
564a6519ccee8728c63fa51600a385619b351020a429a49deaa0ac2ee02a1642d1fa35eed3d20f8dd9eead5b1d6756d14dfa5a6b65f4daf73db237917c32afb24b1e7a6f5a11File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| 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'); | ||
| }); |
| 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 '[^/]+'; | ||
| } | ||
nicohrubec marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| return escapeStringForRegex(segment); | ||
| }) | ||
| .join('/'); | ||
| return new RegExp(`^${segments}$`); | ||
| } | ||
sentry[bot] marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| /** | ||
| * 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; | ||
| } | ||
| } | ||
cursor[bot] marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| 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 |
|---|---|---|
| @@ -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; | ||
| }; | ||
| @@ -161,6 +164,10 @@ export function wrapFetchWithSentry(serverEntry: ServerEntry): ServerEntry { | ||
| ); | ||
| } | ||
| if (typeof __SENTRY_ROUTE_PATTERNS__ !== 'undefined') { | ||
| updateSpanWithRouteParametrization(method, url.pathname, __SENTRY_ROUTE_PATTERNS__); | ||
| } | ||
nicohrubec marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| return injectMetaTagsInResponse(await target.apply(thisArg, args)); | ||
| } finally { | ||
| await flushIfServerless(); | ||
| 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) { | ||
| // 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)), | ||
nicohrubec marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| 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; | ||
| }); | ||
| } | ||
cursor[bot] marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.