[]): void {
return;
}
+ const { name, source } = getTransactionNameAndSource(location.pathname, lastMatch.id);
+
const spanContext: StartSpanOptions = {
- name: lastMatch.id,
+ name,
op: 'navigation',
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.remix',
- [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route',
+ [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source,
},
};
@@ -134,16 +171,17 @@ export function withSentry, R extends React.Co
_useEffect(() => {
const lastMatch = matches && matches[matches.length - 1];
if (lastMatch) {
- const routeName = lastMatch.id;
- getCurrentScope().setTransactionName(routeName);
+ const { name, source } = getTransactionNameAndSource(location.pathname, lastMatch.id);
+
+ getCurrentScope().setTransactionName(name);
const activeRootSpan = getActiveSpan();
if (activeRootSpan) {
const transaction = getRootSpan(activeRootSpan);
if (transaction) {
- transaction.updateName(routeName);
- transaction.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route');
+ transaction.updateName(name);
+ transaction.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source);
}
}
}
@@ -167,7 +205,7 @@ export function withSentry
, R extends React.Co
activeRootSpan.end();
}
- startNavigationSpan(matches);
+ startNavigationSpan(matches, location);
}
}, [location]);
diff --git a/packages/remix/src/client/remixRouteParameterization.ts b/packages/remix/src/client/remixRouteParameterization.ts
new file mode 100644
index 000000000000..1714a64d058c
--- /dev/null
+++ b/packages/remix/src/client/remixRouteParameterization.ts
@@ -0,0 +1,165 @@
+import { debug, GLOBAL_OBJ } from '@sentry/core';
+import type { RouteManifest } from '../config/remixRouteManifest';
+import { DEBUG_BUILD } from '../utils/debug-build';
+
+const globalWithInjectedManifest = GLOBAL_OBJ as typeof GLOBAL_OBJ & {
+ _sentryRemixRouteManifest: string | undefined;
+};
+
+// Performance caches
+let cachedManifest: RouteManifest | null = null;
+let cachedManifestString: string | undefined = undefined;
+const compiledRegexCache: Map = new Map();
+const routeResultCache: Map = new Map();
+
+/**
+ * Calculate specificity score for route matching. Lower scores = more specific routes.
+ */
+function getRouteSpecificity(routePath: string): number {
+ const segments = routePath.split('/').filter(Boolean);
+ let score = 0;
+
+ for (const segment of segments) {
+ if (segment.startsWith(':')) {
+ const paramName = segment.substring(1);
+ if (paramName.endsWith('*')) {
+ // Splat/catchall routes are least specific
+ score += 100;
+ } else {
+ // Dynamic segments are more specific than splats
+ score += 10;
+ }
+ }
+ // Static segments add 0 (most specific)
+ }
+
+ return score;
+}
+
+/**
+ * Get compiled regex from cache or create and cache it.
+ */
+function getCompiledRegex(regexString: string): RegExp | null {
+ if (compiledRegexCache.has(regexString)) {
+ return compiledRegexCache.get(regexString) ?? null;
+ }
+
+ try {
+ // eslint-disable-next-line @sentry-internal/sdk/no-regexp-constructor -- regex patterns are from build-time route manifest, not user input
+ const regex = new RegExp(regexString);
+ compiledRegexCache.set(regexString, regex);
+ return regex;
+ } catch (error) {
+ DEBUG_BUILD && debug.warn('Could not compile regex', { regexString, error });
+ return null;
+ }
+}
+
+/**
+ * Get and cache the route manifest from the global object.
+ * @returns The parsed route manifest or null if not available/invalid.
+ */
+function getManifest(): RouteManifest | null {
+ if (
+ !globalWithInjectedManifest?._sentryRemixRouteManifest ||
+ typeof globalWithInjectedManifest._sentryRemixRouteManifest !== 'string'
+ ) {
+ return null;
+ }
+
+ const currentManifestString = globalWithInjectedManifest._sentryRemixRouteManifest;
+
+ if (cachedManifest && cachedManifestString === currentManifestString) {
+ return cachedManifest;
+ }
+
+ compiledRegexCache.clear();
+ routeResultCache.clear();
+
+ let manifest: RouteManifest = {
+ staticRoutes: [],
+ dynamicRoutes: [],
+ };
+
+ try {
+ // The manifest string is JSON-stringified in the Vite plugin for safe injection into JavaScript.
+ // We parse once to convert the JSON string back to an object.
+ manifest = JSON.parse(currentManifestString);
+ if (!Array.isArray(manifest.staticRoutes) || !Array.isArray(manifest.dynamicRoutes)) {
+ return null;
+ }
+
+ cachedManifest = manifest;
+ cachedManifestString = currentManifestString;
+ return manifest;
+ } catch (error) {
+ DEBUG_BUILD && debug.warn('Could not extract route manifest');
+ return null;
+ }
+}
+
+/**
+ * Find matching routes from static and dynamic route collections.
+ * @param route - The route to match against.
+ * @param staticRoutes - Array of static route objects.
+ * @param dynamicRoutes - Array of dynamic route objects.
+ * @returns Array of matching parameterized route paths.
+ */
+function findMatchingRoutes(
+ route: string,
+ staticRoutes: RouteManifest['staticRoutes'],
+ dynamicRoutes: RouteManifest['dynamicRoutes'],
+): string[] {
+ const matches: string[] = [];
+
+ // Static routes don't need parameterization - return empty to keep source as 'url'
+ if (staticRoutes.some(r => r.path === route)) {
+ return matches;
+ }
+
+ // Check dynamic routes
+ for (const dynamicRoute of dynamicRoutes) {
+ if (dynamicRoute.regex) {
+ const regex = getCompiledRegex(dynamicRoute.regex);
+ if (regex?.test(route)) {
+ matches.push(dynamicRoute.path);
+ }
+ }
+ }
+
+ return matches;
+}
+
+/**
+ * Check if the route manifest is available (injected by the Vite plugin).
+ * @returns True if the manifest is available, false otherwise.
+ */
+export function hasManifest(): boolean {
+ return getManifest() !== null;
+}
+
+/**
+ * Parameterize a route using the route manifest.
+ *
+ * @param route - The route to parameterize.
+ * @returns The parameterized route or undefined if no parameterization is needed.
+ */
+export const maybeParameterizeRemixRoute = (route: string): string | undefined => {
+ const manifest = getManifest();
+ if (!manifest) {
+ return undefined;
+ }
+
+ if (routeResultCache.has(route)) {
+ return routeResultCache.get(route);
+ }
+
+ const { staticRoutes, dynamicRoutes } = manifest;
+ const matches = findMatchingRoutes(route, staticRoutes, dynamicRoutes);
+
+ const result = matches.sort((a, b) => getRouteSpecificity(a) - getRouteSpecificity(b))[0];
+
+ routeResultCache.set(route, result);
+
+ return result;
+};
diff --git a/packages/remix/src/config/createRemixRouteManifest.ts b/packages/remix/src/config/createRemixRouteManifest.ts
new file mode 100644
index 000000000000..b954061f4816
--- /dev/null
+++ b/packages/remix/src/config/createRemixRouteManifest.ts
@@ -0,0 +1,249 @@
+import * as fs from 'fs';
+import * as path from 'path';
+import type { RouteInfo, RouteManifest } from './remixRouteManifest';
+
+export type CreateRemixRouteManifestOptions = {
+ /**
+ * Path to the app directory (where routes folder is located)
+ */
+ appDirPath?: string;
+ /**
+ * The root directory of the project (defaults to process.cwd())
+ */
+ rootDir?: string;
+};
+
+let manifestCache: RouteManifest | null = null;
+let lastAppDirPath: string | null = null;
+
+/**
+ * Check if a file is a route file
+ */
+function isRouteFile(filename: string): boolean {
+ return filename.endsWith('.tsx') || filename.endsWith('.ts') || filename.endsWith('.jsx') || filename.endsWith('.js');
+}
+
+/**
+ * Convert Remix route file paths to parameterized paths at build time.
+ *
+ * Examples:
+ * - index.tsx -> /
+ * - users.tsx -> /users
+ * - users.$id.tsx -> /users/:id
+ * - users.$id.posts.$postId.tsx -> /users/:id/posts/:postId
+ * - $.tsx -> /:*
+ * - docs.$.tsx -> /docs/:*
+ * - users/$id.tsx (nested folder) -> /users/:id
+ * - users/$id/posts.tsx (nested folder) -> /users/:id/posts
+ * - users/index.tsx (nested folder) -> /users
+ * - _layout.tsx -> null (pathless layout route, not URL-addressable)
+ * - _auth.tsx -> null (pathless layout route, not URL-addressable)
+ *
+ * @param filename - The route filename or path (can include directory separators for nested routes)
+ * @returns Object containing the parameterized path and whether it's dynamic, or null for pathless layout routes
+ * @internal Exported for testing purposes
+ */
+export function convertRemixRouteToPath(filename: string): { path: string; isDynamic: boolean } | null {
+ // Remove file extension
+ const basename = filename.replace(/\.(tsx?|jsx?)$/, '');
+
+ // Handle root index route
+ if (basename === 'index' || basename === '_index') {
+ return { path: '/', isDynamic: false };
+ }
+
+ const normalizedBasename = basename.replace(/[/\\]/g, '.');
+ const segments = normalizedBasename.split('.');
+ const pathSegments: string[] = [];
+ let isDynamic = false;
+ let isIndexRoute = false;
+
+ for (let i = 0; i < segments.length; i++) {
+ const segment = segments[i];
+
+ if (!segment) {
+ continue;
+ }
+
+ if (segment.startsWith('_') && segment !== '_index') {
+ continue;
+ }
+
+ // Handle '_index' segments at the end (always skip - indicates an index route)
+ if (segment === '_index' && i === segments.length - 1) {
+ isIndexRoute = true;
+ continue;
+ }
+
+ // Handle 'index' segments at the end (skip only if there are path segments,
+ // otherwise root index is handled by the early return above)
+ if (segment === 'index' && i === segments.length - 1 && pathSegments.length > 0) {
+ isIndexRoute = true;
+ continue;
+ }
+
+ if (segment === '$') {
+ pathSegments.push(':*');
+ isDynamic = true;
+ continue;
+ }
+
+ if (segment.startsWith('$')) {
+ const paramName = segment.substring(1);
+ pathSegments.push(`:${paramName}`);
+ isDynamic = true;
+ } else if (segment !== 'index') {
+ pathSegments.push(segment);
+ }
+ }
+
+ // If all segments were skipped AND it's not an index route,
+ // it's a pathless layout route (like _layout.tsx, _auth.tsx) - exclude from manifest
+ if (pathSegments.length === 0 && !isIndexRoute) {
+ return null;
+ }
+
+ const path = pathSegments.length > 0 ? `/${pathSegments.join('/')}` : '/';
+ return { path, isDynamic };
+}
+
+/**
+ * Build a regex pattern for a dynamic route
+ */
+function buildRegexForDynamicRoute(routePath: string): { regex: string; paramNames: string[] } {
+ const segments = routePath.split('/').filter(Boolean);
+ const regexSegments: string[] = [];
+ const paramNames: string[] = [];
+
+ for (const segment of segments) {
+ if (segment.startsWith(':')) {
+ const paramName = segment.substring(1);
+
+ if (paramName.endsWith('*')) {
+ const cleanParamName = paramName.slice(0, -1);
+ paramNames.push(cleanParamName);
+ regexSegments.push('(.+)');
+ } else {
+ paramNames.push(paramName);
+ regexSegments.push('([^/]+)');
+ }
+ } else {
+ regexSegments.push(segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
+ }
+ }
+
+ const pattern = `^/${regexSegments.join('/')}$`;
+
+ return { regex: pattern, paramNames };
+}
+
+/**
+ * Scan the routes directory and build the manifest, recursively processing subdirectories
+ *
+ * @param routesDir - The directory to scan for route files
+ * @param prefix - Path prefix for nested routes (used internally for recursion)
+ * @returns Object containing arrays of dynamic and static routes
+ */
+function scanRoutesDirectory(
+ routesDir: string,
+ prefix: string = '',
+): { dynamicRoutes: RouteInfo[]; staticRoutes: RouteInfo[] } {
+ const dynamicRoutes: RouteInfo[] = [];
+ const staticRoutes: RouteInfo[] = [];
+
+ try {
+ if (!fs.existsSync(routesDir)) {
+ return { dynamicRoutes, staticRoutes };
+ }
+
+ const entries = fs.readdirSync(routesDir);
+
+ for (const entry of entries) {
+ const fullPath = path.join(routesDir, entry);
+ const stat = fs.lstatSync(fullPath);
+
+ if (stat.isDirectory()) {
+ const nestedPrefix = prefix ? `${prefix}/${entry}` : entry;
+ const nested = scanRoutesDirectory(fullPath, nestedPrefix);
+ dynamicRoutes.push(...nested.dynamicRoutes);
+ staticRoutes.push(...nested.staticRoutes);
+ } else if (stat.isFile() && isRouteFile(entry)) {
+ const routeName = prefix ? `${prefix}/${entry}` : entry;
+ const result = convertRemixRouteToPath(routeName);
+
+ // Skip pathless layout routes (e.g., _layout.tsx, _auth.tsx)
+ if (result === null) {
+ continue;
+ }
+
+ const { path: routePath, isDynamic } = result;
+
+ if (isDynamic) {
+ const { regex, paramNames } = buildRegexForDynamicRoute(routePath);
+ dynamicRoutes.push({
+ path: routePath,
+ regex,
+ paramNames,
+ });
+ } else {
+ staticRoutes.push({
+ path: routePath,
+ });
+ }
+ }
+ }
+ } catch (error) {
+ // eslint-disable-next-line no-console
+ console.warn('Error building Remix route manifest:', error);
+ }
+
+ return { dynamicRoutes, staticRoutes };
+}
+
+/**
+ * Scans Remix routes directory and generates a manifest containing all static
+ * and dynamic routes with their regex patterns for client-side route parameterization.
+ *
+ * @param options - Configuration options
+ * @param options.appDirPath - Path to the app directory (where routes folder is located)
+ * @param options.rootDir - The root directory of the project (defaults to process.cwd())
+ * @returns A RouteManifest containing arrays of dynamic and static routes
+ */
+export function createRemixRouteManifest(options?: CreateRemixRouteManifestOptions): RouteManifest {
+ const rootDir = options?.rootDir || process.cwd();
+ let appDirPath: string | undefined;
+
+ if (options?.appDirPath) {
+ appDirPath = options.appDirPath;
+ } else {
+ const maybeAppDirPath = path.join(rootDir, 'app');
+
+ if (fs.existsSync(maybeAppDirPath) && fs.lstatSync(maybeAppDirPath).isDirectory()) {
+ appDirPath = maybeAppDirPath;
+ }
+ }
+
+ if (!appDirPath) {
+ return {
+ dynamicRoutes: [],
+ staticRoutes: [],
+ };
+ }
+
+ if (manifestCache && lastAppDirPath === appDirPath) {
+ return manifestCache;
+ }
+
+ const routesDir = path.join(appDirPath, 'routes');
+ const { dynamicRoutes, staticRoutes } = scanRoutesDirectory(routesDir);
+
+ const manifest: RouteManifest = {
+ dynamicRoutes,
+ staticRoutes,
+ };
+
+ manifestCache = manifest;
+ lastAppDirPath = appDirPath;
+
+ return manifest;
+}
diff --git a/packages/remix/src/config/remixRouteManifest.ts b/packages/remix/src/config/remixRouteManifest.ts
new file mode 100644
index 000000000000..a29c3431945f
--- /dev/null
+++ b/packages/remix/src/config/remixRouteManifest.ts
@@ -0,0 +1,33 @@
+/**
+ * Information about a single route in the manifest
+ */
+export type RouteInfo = {
+ /**
+ * The parameterized route path, e.g. "/users/:id"
+ * This is what gets returned by the parameterization function.
+ */
+ path: string;
+ /**
+ * (Optional) The regex pattern for dynamic routes
+ */
+ regex?: string;
+ /**
+ * (Optional) The names of dynamic parameters in the route
+ */
+ paramNames?: string[];
+};
+
+/**
+ * The manifest containing all routes discovered in the app
+ */
+export type RouteManifest = {
+ /**
+ * List of all dynamic routes
+ */
+ dynamicRoutes: RouteInfo[];
+
+ /**
+ * List of all static routes
+ */
+ staticRoutes: RouteInfo[];
+};
diff --git a/packages/remix/src/config/vite.ts b/packages/remix/src/config/vite.ts
new file mode 100644
index 000000000000..83b033104530
--- /dev/null
+++ b/packages/remix/src/config/vite.ts
@@ -0,0 +1,174 @@
+import * as path from 'path';
+import type { Plugin } from 'vite';
+import { createRemixRouteManifest } from './createRemixRouteManifest';
+
+/**
+ * Escapes a JSON string for safe embedding in HTML script tags.
+ * JSON.stringify alone doesn't escape or