[]): 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..8ea8c7f85af6
--- /dev/null
+++ b/packages/remix/src/client/remixRouteParameterization.ts
@@ -0,0 +1,163 @@
+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 {
+ 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..014c188bd318
--- /dev/null
+++ b/packages/remix/src/config/createRemixRouteManifest.ts
@@ -0,0 +1,224 @@
+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
+ *
+ * @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
+ * @internal Exported for testing purposes
+ */
+export function convertRemixRouteToPath(filename: string): { path: string; isDynamic: boolean } {
+ // 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;
+
+ for (let i = 0; i < segments.length; i++) {
+ const segment = segments[i];
+
+ if (!segment) {
+ continue;
+ }
+
+ if (segment.startsWith('_') && segment !== '_index') {
+ continue;
+ }
+
+ if (segment === 'index' && i === segments.length - 1 && pathSegments.length > 0) {
+ 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);
+ }
+ }
+
+ const path = `/${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 { path: routePath, isDynamic } = convertRemixRouteToPath(routeName);
+
+ 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..84d88aa442bf
--- /dev/null
+++ b/packages/remix/src/config/vite.ts
@@ -0,0 +1,161 @@
+import * as path from 'path';
+import type { Plugin } from 'vite';
+import { createRemixRouteManifest } from './createRemixRouteManifest';
+
+export type SentryRemixVitePluginOptions = {
+ /**
+ * Path to the app directory (where routes folder is located).
+ * Can be relative to project root or absolute.
+ * Defaults to 'app' in the project root.
+ *
+ * @example './app'
+ * @example '/absolute/path/to/app'
+ */
+ appDirPath?: string;
+};
+
+// Global variable key used to store the route manifest
+const MANIFEST_GLOBAL_KEY = '_sentryRemixRouteManifest' as const;
+
+/**
+ * Vite plugin to inject Remix route manifest for Sentry client-side route parameterization.
+ *
+ * @param options - Plugin configuration options
+ * @returns Vite plugin
+ *
+ * @example
+ * ```typescript
+ * // vite.config.ts
+ * import { defineConfig } from 'vite';
+ * import { vitePlugin as remix } from '@remix-run/dev';
+ * import { sentryRemixVitePlugin } from '@sentry/remix';
+ *
+ * export default defineConfig({
+ * plugins: [
+ * remix(),
+ * sentryRemixVitePlugin({
+ * appDirPath: './app',
+ * }),
+ * ],
+ * });
+ * ```
+ */
+export function sentryRemixVitePlugin(options: SentryRemixVitePluginOptions = {}): Plugin {
+ let routeManifestJson: string = '';
+ let isDevMode = false;
+
+ return {
+ name: 'sentry-remix-route-manifest',
+ enforce: 'post',
+
+ configResolved(config) {
+ isDevMode = config.command === 'serve' || config.mode === 'development';
+
+ try {
+ const rootDir = config.root || process.cwd();
+
+ let resolvedAppDirPath = options.appDirPath;
+ if (resolvedAppDirPath && !path.isAbsolute(resolvedAppDirPath)) {
+ resolvedAppDirPath = path.resolve(rootDir, resolvedAppDirPath);
+ }
+
+ const manifest = createRemixRouteManifest({
+ appDirPath: resolvedAppDirPath,
+ rootDir,
+ });
+
+ routeManifestJson = JSON.stringify(manifest);
+
+ if (isDevMode) {
+ // eslint-disable-next-line no-console
+ console.log(
+ `[Sentry Remix] Found ${manifest.staticRoutes.length} static and ${manifest.dynamicRoutes.length} dynamic routes`,
+ );
+ }
+ } catch (error) {
+ // eslint-disable-next-line no-console
+ console.error('[Sentry Remix] Failed to generate route manifest:', error);
+ routeManifestJson = JSON.stringify({ dynamicRoutes: [], staticRoutes: [] });
+ }
+ },
+
+ transformIndexHtml: {
+ order: 'pre',
+ handler(html) {
+ if (!routeManifestJson) {
+ return html;
+ }
+
+ /**
+ * XSS Prevention: Double-stringify strategy prevents injection from malicious route filenames.
+ * routeManifestJson is already stringified once, stringifying again escapes special chars.
+ * Client-side code parses once to recover the manifest object.
+ */
+ const script = ``;
+
+ if (//i.test(html)) {
+ return html.replace(//i, match => `${match}\n ${script}`);
+ }
+
+ if (/]*>/i.test(html)) {
+ return html.replace(/]*>/i, match => `${match}\n${script}`);
+ }
+
+ return `${script}${html}`;
+ },
+ },
+
+ transform(code, id) {
+ if (!routeManifestJson) {
+ return null;
+ }
+
+ const isClientEntry =
+ /entry[.-]client\.[jt]sx?$/.test(id) ||
+ // Also handle Remix's default entry.client location
+ id.includes('/entry.client.') ||
+ id.includes('/entry-client.');
+
+ const isServerEntry =
+ /entry[.-]server\.[jt]sx?$/.test(id) ||
+ // Also handle Remix's default entry.server location
+ id.includes('/entry.server.') ||
+ id.includes('/entry-server.') ||
+ // Also handle Hydrogen/Cloudflare Workers server files
+ /(^|\/)server\.[jt]sx?$/.test(id);
+
+ if (isClientEntry) {
+ // XSS Prevention: Double-stringify strategy (same as transformIndexHtml above)
+ const injectedCode = `
+// Sentry Remix Route Manifest - Auto-injected
+if (typeof window !== 'undefined') {
+ window.${MANIFEST_GLOBAL_KEY} = window.${MANIFEST_GLOBAL_KEY} || ${JSON.stringify(routeManifestJson)};
+}
+${code}`;
+
+ return {
+ code: injectedCode,
+ map: null,
+ };
+ }
+
+ if (isServerEntry) {
+ // Inject into server entry for server-side transaction naming
+ // Use globalThis for Cloudflare Workers/Hydrogen compatibility
+ const injectedCode = `
+// Sentry Remix Route Manifest - Auto-injected
+if (typeof globalThis !== 'undefined') {
+ globalThis.${MANIFEST_GLOBAL_KEY} = globalThis.${MANIFEST_GLOBAL_KEY} || ${JSON.stringify(routeManifestJson)};
+}
+${code}`;
+
+ return {
+ code: injectedCode,
+ map: null,
+ };
+ }
+
+ return null;
+ },
+ };
+}
diff --git a/packages/remix/src/index.server.ts b/packages/remix/src/index.server.ts
index 4ba64d609317..e4eb8ad6236e 100644
--- a/packages/remix/src/index.server.ts
+++ b/packages/remix/src/index.server.ts
@@ -1,4 +1,9 @@
export * from './server';
export { captureRemixErrorBoundaryError, withSentry, ErrorBoundary, browserTracingIntegration } from './client';
+export { sentryRemixVitePlugin } from './config/vite';
+export { createRemixRouteManifest } from './config/createRemixRouteManifest';
+export type { CreateRemixRouteManifestOptions } from './config/createRemixRouteManifest';
+
export type { SentryMetaArgs } from './utils/types';
+export type { RouteManifest, RouteInfo } from './config/remixRouteManifest';
diff --git a/packages/remix/src/server/instrumentServer.ts b/packages/remix/src/server/instrumentServer.ts
index fda9b3f10b75..a0e803b243d8 100644
--- a/packages/remix/src/server/instrumentServer.ts
+++ b/packages/remix/src/server/instrumentServer.ts
@@ -149,14 +149,49 @@ function makeWrappedDocumentRequestFunction(instrumentTracing?: boolean) {
};
}
+/**
+ * Updates the root span name with the parameterized route name.
+ * This is necessary for runtimes like Cloudflare Workers/Hydrogen where
+ * the request handler is not wrapped by Remix's wrapRequestHandler.
+ */
+function updateSpanWithRoute(args: DataFunctionArgs, build: ServerBuild): void {
+ try {
+ const activeSpan = getActiveSpan();
+ const rootSpan = activeSpan && getRootSpan(activeSpan);
+
+ if (!rootSpan) {
+ return;
+ }
+
+ const routes = createRoutes(build.routes);
+ const url = new URL(args.request.url);
+ const [transactionName] = getTransactionName(routes, url);
+
+ // Preserve the HTTP method prefix if the span already has one
+ const method = args.request.method.toUpperCase();
+ const currentSpanName = spanToJSON(rootSpan).description;
+ const newSpanName = currentSpanName?.startsWith(method) ? `${method} ${transactionName}` : transactionName;
+
+ rootSpan.updateName(newSpanName);
+ } catch (e) {
+ DEBUG_BUILD && debug.warn('Failed to update span name with route', e);
+ }
+}
+
function makeWrappedDataFunction(
origFn: DataFunction,
id: string,
name: 'action' | 'loader',
instrumentTracing?: boolean,
+ build?: ServerBuild,
): DataFunction {
return async function (this: unknown, args: DataFunctionArgs): Promise {
if (instrumentTracing) {
+ // Update span name for Cloudflare Workers/Hydrogen environments
+ if (build) {
+ updateSpanWithRoute(args, build);
+ }
+
return startSpan(
{
op: `function.remix.${name}`,
@@ -177,20 +212,26 @@ function makeWrappedDataFunction(
}
const makeWrappedAction =
- (id: string, instrumentTracing?: boolean) =>
+ (id: string, instrumentTracing?: boolean, build?: ServerBuild) =>
(origAction: DataFunction): DataFunction => {
- return makeWrappedDataFunction(origAction, id, 'action', instrumentTracing);
+ return makeWrappedDataFunction(origAction, id, 'action', instrumentTracing, build);
};
const makeWrappedLoader =
- (id: string, instrumentTracing?: boolean) =>
+ (id: string, instrumentTracing?: boolean, build?: ServerBuild) =>
(origLoader: DataFunction): DataFunction => {
- return makeWrappedDataFunction(origLoader, id, 'loader', instrumentTracing);
+ return makeWrappedDataFunction(origLoader, id, 'loader', instrumentTracing, build);
};
-function makeWrappedRootLoader() {
+function makeWrappedRootLoader(instrumentTracing?: boolean, build?: ServerBuild) {
return function (origLoader: DataFunction): DataFunction {
return async function (this: unknown, args: DataFunctionArgs): Promise {
+ // Update span name for Cloudflare Workers/Hydrogen environments
+ // The root loader always runs, even for routes that don't have their own loaders
+ if (instrumentTracing && build) {
+ updateSpanWithRoute(args, build);
+ }
+
const res = await origLoader.call(this, args);
const traceAndBaggage = getTraceAndBaggage();
@@ -283,6 +324,13 @@ function wrapRequestHandler ServerBuild | Promise
[name, source] = getTransactionName(resolvedRoutes, url);
isolationScope.setTransactionName(name);
+
+ // Update the span name if we're running inside an existing span
+ const parentSpan = getActiveSpan();
+ if (parentSpan) {
+ const rootSpan = getRootSpan(parentSpan);
+ rootSpan?.updateName(name);
+ }
}
isolationScope.setSDKProcessingMetadata({ normalizedRequest });
@@ -365,18 +413,18 @@ function instrumentBuildCallback(
}
if (!(wrappedRoute.module.loader as WrappedFunction).__sentry_original__) {
- fill(wrappedRoute.module, 'loader', makeWrappedRootLoader());
+ fill(wrappedRoute.module, 'loader', makeWrappedRootLoader(options?.instrumentTracing, build));
}
}
const routeAction = wrappedRoute.module.action as undefined | WrappedFunction;
if (routeAction && !routeAction.__sentry_original__) {
- fill(wrappedRoute.module, 'action', makeWrappedAction(id, options?.instrumentTracing));
+ fill(wrappedRoute.module, 'action', makeWrappedAction(id, options?.instrumentTracing, build));
}
const routeLoader = wrappedRoute.module.loader as undefined | WrappedFunction;
if (routeLoader && !routeLoader.__sentry_original__) {
- fill(wrappedRoute.module, 'loader', makeWrappedLoader(id, options?.instrumentTracing));
+ fill(wrappedRoute.module, 'loader', makeWrappedLoader(id, options?.instrumentTracing, build));
}
routes[id] = wrappedRoute;
diff --git a/packages/remix/src/utils/utils.ts b/packages/remix/src/utils/utils.ts
index 5485cff5e0a3..591b2f4009af 100644
--- a/packages/remix/src/utils/utils.ts
+++ b/packages/remix/src/utils/utils.ts
@@ -1,14 +1,14 @@
import type { ActionFunctionArgs, LoaderFunctionArgs, ServerBuild } from '@remix-run/node';
import type { AgnosticRouteObject } from '@remix-run/router';
import type { Span, TransactionSource } from '@sentry/core';
-import { debug } from '@sentry/core';
+import { debug, GLOBAL_OBJ } from '@sentry/core';
import { DEBUG_BUILD } from './debug-build';
import { getRequestMatch, matchServerRoutes } from './vendor/response';
type ServerRouteManifest = ServerBuild['routes'];
/**
- *
+ * Store configured FormData keys as span attributes for Remix actions.
*/
export async function storeFormDataKeys(
args: LoaderFunctionArgs | ActionFunctionArgs,
@@ -43,13 +43,103 @@ export async function storeFormDataKeys(
}
}
+/**
+ * Converts Remix route IDs to parameterized paths at runtime.
+ * (e.g., "routes/users.$id" -> "/users/:id")
+ *
+ * @param routeId - The Remix route ID
+ * @returns The parameterized path
+ * @internal
+ */
+export function convertRemixRouteIdToPath(routeId: string): string {
+ // Remove the "routes/" prefix if present
+ const path = routeId.replace(/^routes\//, '');
+
+ // Handle root index route
+ if (path === 'index' || path === '_index') {
+ return '/';
+ }
+
+ // Split by dots to get segments
+ const segments = path.split('.');
+ const pathSegments: string[] = [];
+
+ for (let i = 0; i < segments.length; i++) {
+ const segment = segments[i];
+
+ if (!segment) {
+ continue;
+ }
+
+ // Skip layout route segments (prefixed with _)
+ if (segment.startsWith('_') && segment !== '_index') {
+ continue;
+ }
+
+ // Handle 'index' segments at the end (they don't add to the path)
+ if (segment === 'index' && i === segments.length - 1 && pathSegments.length > 0) {
+ continue;
+ }
+
+ // Handle splat routes (catch-all)
+ // Remix accesses splat params via params["*"] at runtime
+ if (segment === '$') {
+ pathSegments.push(':*');
+ continue;
+ }
+
+ // Handle dynamic segments (prefixed with $)
+ if (segment.startsWith('$')) {
+ const paramName = segment.substring(1);
+ pathSegments.push(`:${paramName}`);
+ } else if (segment !== 'index') {
+ // Static segment (skip remaining 'index' segments)
+ pathSegments.push(segment);
+ }
+ }
+
+ // Return with leading slash for consistency with client-side URL paths
+ const routePath = pathSegments.length > 0 ? `/${pathSegments.join('/')}` : '/';
+ return routePath;
+}
+
+/**
+ * Check if the Vite plugin manifest is available.
+ * @returns True if the manifest is available, false otherwise.
+ */
+function hasManifest(): boolean {
+ const globalWithInjectedManifest = GLOBAL_OBJ as typeof GLOBAL_OBJ & {
+ _sentryRemixRouteManifest: string | undefined;
+ };
+
+ return (
+ !!globalWithInjectedManifest?._sentryRemixRouteManifest &&
+ typeof globalWithInjectedManifest._sentryRemixRouteManifest === 'string'
+ );
+}
+
/**
* Get transaction name from routes and url
*/
export function getTransactionName(routes: AgnosticRouteObject[], url: URL): [string, TransactionSource] {
const matches = matchServerRoutes(routes, url.pathname);
const match = matches && getRequestMatch(url, matches);
- return match === null ? [url.pathname, 'url'] : [match.route.id || 'no-route-id', 'route'];
+
+ if (match === null) {
+ return [url.pathname, 'url'];
+ }
+
+ const routeId = match.route.id || 'no-route-id';
+
+ // Only use parameterized path if the Vite plugin manifest is available
+ // This ensures backward compatibility - without the plugin, we use the old behavior
+ if (hasManifest()) {
+ const parameterizedPath = convertRemixRouteIdToPath(routeId);
+ return [parameterizedPath, 'route'];
+ }
+
+ // Fallback to old behavior (route ID) when manifest is not available
+ return [routeId, 'route'];
}
/**
diff --git a/packages/remix/test/client/remixRouteParameterization.test.ts b/packages/remix/test/client/remixRouteParameterization.test.ts
new file mode 100644
index 000000000000..59eb2c1796c1
--- /dev/null
+++ b/packages/remix/test/client/remixRouteParameterization.test.ts
@@ -0,0 +1,416 @@
+import { GLOBAL_OBJ } from '@sentry/core';
+import { afterEach, describe, expect, it } from 'vitest';
+import { maybeParameterizeRemixRoute } from '../../src/client/remixRouteParameterization';
+import type { RouteManifest } from '../../src/config/remixRouteManifest';
+
+const globalWithInjectedManifest = GLOBAL_OBJ as typeof GLOBAL_OBJ & {
+ _sentryRemixRouteManifest: string | undefined;
+};
+
+describe('maybeParameterizeRemixRoute', () => {
+ const originalManifest = globalWithInjectedManifest._sentryRemixRouteManifest;
+
+ afterEach(() => {
+ globalWithInjectedManifest._sentryRemixRouteManifest = originalManifest;
+ });
+
+ describe('when no manifest is available', () => {
+ it('should return undefined', () => {
+ globalWithInjectedManifest._sentryRemixRouteManifest = undefined;
+
+ expect(maybeParameterizeRemixRoute('/users/123')).toBeUndefined();
+ expect(maybeParameterizeRemixRoute('/blog/my-post')).toBeUndefined();
+ expect(maybeParameterizeRemixRoute('/')).toBeUndefined();
+ });
+ });
+
+ describe('when manifest has static routes', () => {
+ it('should return undefined for static routes', () => {
+ const manifest: RouteManifest = {
+ staticRoutes: [{ path: '/' }, { path: '/about' }, { path: '/contact' }, { path: '/blog/posts' }],
+ dynamicRoutes: [],
+ };
+ globalWithInjectedManifest._sentryRemixRouteManifest = JSON.stringify(manifest);
+
+ expect(maybeParameterizeRemixRoute('/')).toBeUndefined();
+ expect(maybeParameterizeRemixRoute('/about')).toBeUndefined();
+ expect(maybeParameterizeRemixRoute('/contact')).toBeUndefined();
+ expect(maybeParameterizeRemixRoute('/blog/posts')).toBeUndefined();
+ });
+ });
+
+ describe('when manifest has dynamic routes', () => {
+ it('should return parameterized routes for matching dynamic routes', () => {
+ const manifest: RouteManifest = {
+ staticRoutes: [{ path: '/' }, { path: '/about' }],
+ dynamicRoutes: [
+ {
+ path: '/users/:id',
+ regex: '^/users/([^/]+)$',
+ paramNames: ['id'],
+ },
+ {
+ path: '/blog/:slug',
+ regex: '^/blog/([^/]+)$',
+ paramNames: ['slug'],
+ },
+ {
+ path: '/users/:userId/posts/:postId',
+ regex: '^/users/([^/]+)/posts/([^/]+)$',
+ paramNames: ['userId', 'postId'],
+ },
+ ],
+ };
+ globalWithInjectedManifest._sentryRemixRouteManifest = JSON.stringify(manifest);
+
+ expect(maybeParameterizeRemixRoute('/users/123')).toBe('/users/:id');
+ expect(maybeParameterizeRemixRoute('/users/john-doe')).toBe('/users/:id');
+ expect(maybeParameterizeRemixRoute('/blog/my-post')).toBe('/blog/:slug');
+ expect(maybeParameterizeRemixRoute('/blog/hello-world')).toBe('/blog/:slug');
+ expect(maybeParameterizeRemixRoute('/users/123/posts/456')).toBe('/users/:userId/posts/:postId');
+ expect(maybeParameterizeRemixRoute('/users/john/posts/my-post')).toBe('/users/:userId/posts/:postId');
+ });
+
+ it('should return undefined for static routes even when dynamic routes exist', () => {
+ const manifest: RouteManifest = {
+ staticRoutes: [{ path: '/' }, { path: '/about' }],
+ dynamicRoutes: [
+ {
+ path: '/users/:id',
+ regex: '^/users/([^/]+)$',
+ paramNames: ['id'],
+ },
+ ],
+ };
+ globalWithInjectedManifest._sentryRemixRouteManifest = JSON.stringify(manifest);
+
+ expect(maybeParameterizeRemixRoute('/')).toBeUndefined();
+ expect(maybeParameterizeRemixRoute('/about')).toBeUndefined();
+ });
+
+ it('should handle splat/catch-all routes', () => {
+ const manifest: RouteManifest = {
+ staticRoutes: [{ path: '/' }],
+ dynamicRoutes: [
+ {
+ path: '/docs/:*',
+ regex: '^/docs/(.+)$',
+ paramNames: ['*'],
+ },
+ {
+ path: '/:*',
+ regex: '^/(.+)$',
+ paramNames: ['*'],
+ },
+ ],
+ };
+ globalWithInjectedManifest._sentryRemixRouteManifest = JSON.stringify(manifest);
+
+ expect(maybeParameterizeRemixRoute('/docs/intro')).toBe('/docs/:*');
+ expect(maybeParameterizeRemixRoute('/docs/guide/getting-started')).toBe('/docs/:*');
+ expect(maybeParameterizeRemixRoute('/anything/else')).toBe('/:*');
+ });
+
+ it('should handle routes with special characters', () => {
+ const manifest: RouteManifest = {
+ staticRoutes: [],
+ dynamicRoutes: [
+ {
+ path: '/users/:id/settings',
+ regex: '^/users/([^/]+)/settings$',
+ paramNames: ['id'],
+ },
+ ],
+ };
+ globalWithInjectedManifest._sentryRemixRouteManifest = JSON.stringify(manifest);
+
+ expect(maybeParameterizeRemixRoute('/users/user-with-dashes/settings')).toBe('/users/:id/settings');
+ expect(maybeParameterizeRemixRoute('/users/user_with_underscores/settings')).toBe('/users/:id/settings');
+ expect(maybeParameterizeRemixRoute('/users/123/settings')).toBe('/users/:id/settings');
+ });
+
+ it('should return the first matching dynamic route when sorted by specificity', () => {
+ const manifest: RouteManifest = {
+ staticRoutes: [],
+ dynamicRoutes: [
+ {
+ path: '/:*',
+ regex: '^/(.+)$',
+ paramNames: ['*'],
+ },
+ {
+ path: '/users/:id',
+ regex: '^/users/([^/]+)$',
+ paramNames: ['id'],
+ },
+ ],
+ };
+ globalWithInjectedManifest._sentryRemixRouteManifest = JSON.stringify(manifest);
+
+ // Should prefer more specific route over catch-all
+ expect(maybeParameterizeRemixRoute('/users/123')).toBe('/users/:id');
+ expect(maybeParameterizeRemixRoute('/about/something')).toBe('/:*');
+ });
+
+ it('should return undefined for dynamic routes without regex', () => {
+ const manifest: RouteManifest = {
+ staticRoutes: [],
+ dynamicRoutes: [
+ {
+ path: '/users/:id',
+ paramNames: ['id'],
+ },
+ ],
+ };
+ globalWithInjectedManifest._sentryRemixRouteManifest = JSON.stringify(manifest);
+
+ expect(maybeParameterizeRemixRoute('/users/123')).toBeUndefined();
+ });
+
+ it('should handle invalid regex patterns gracefully', () => {
+ const manifest: RouteManifest = {
+ staticRoutes: [],
+ dynamicRoutes: [
+ {
+ path: '/users/:id',
+ regex: '[invalid-regex',
+ paramNames: ['id'],
+ },
+ ],
+ };
+ globalWithInjectedManifest._sentryRemixRouteManifest = JSON.stringify(manifest);
+
+ expect(maybeParameterizeRemixRoute('/users/123')).toBeUndefined();
+ });
+ });
+
+ describe('when route does not match any pattern', () => {
+ it('should return undefined for unknown routes', () => {
+ const manifest: RouteManifest = {
+ staticRoutes: [{ path: '/about' }],
+ dynamicRoutes: [
+ {
+ path: '/users/:id',
+ regex: '^/users/([^/]+)$',
+ paramNames: ['id'],
+ },
+ ],
+ };
+ globalWithInjectedManifest._sentryRemixRouteManifest = JSON.stringify(manifest);
+
+ expect(maybeParameterizeRemixRoute('/unknown')).toBeUndefined();
+ expect(maybeParameterizeRemixRoute('/posts/123')).toBeUndefined();
+ expect(maybeParameterizeRemixRoute('/users/123/extra')).toBeUndefined();
+ });
+ });
+
+ describe('edge cases', () => {
+ it('should handle empty route', () => {
+ const manifest: RouteManifest = {
+ staticRoutes: [],
+ dynamicRoutes: [],
+ };
+ globalWithInjectedManifest._sentryRemixRouteManifest = JSON.stringify(manifest);
+
+ expect(maybeParameterizeRemixRoute('')).toBeUndefined();
+ });
+
+ it('should handle root route', () => {
+ const manifest: RouteManifest = {
+ staticRoutes: [{ path: '/' }],
+ dynamicRoutes: [],
+ };
+ globalWithInjectedManifest._sentryRemixRouteManifest = JSON.stringify(manifest);
+
+ expect(maybeParameterizeRemixRoute('/')).toBeUndefined();
+ });
+
+ it('should handle complex nested dynamic routes', () => {
+ const manifest: RouteManifest = {
+ staticRoutes: [],
+ dynamicRoutes: [
+ {
+ path: '/api/v1/users/:id/posts/:postId/comments/:commentId',
+ regex: '^/api/v1/users/([^/]+)/posts/([^/]+)/comments/([^/]+)$',
+ paramNames: ['id', 'postId', 'commentId'],
+ },
+ ],
+ };
+ globalWithInjectedManifest._sentryRemixRouteManifest = JSON.stringify(manifest);
+
+ expect(maybeParameterizeRemixRoute('/api/v1/users/123/posts/456/comments/789')).toBe(
+ '/api/v1/users/:id/posts/:postId/comments/:commentId',
+ );
+ });
+ });
+
+ describe('realistic Remix patterns', () => {
+ it.each([
+ ['/', undefined],
+ ['/about', undefined],
+ ['/contact', undefined],
+ ['/blog/posts', undefined],
+
+ ['/users/123', '/users/:id'],
+ ['/users/john-doe', '/users/:id'],
+ ['/blog/my-post', '/blog/:slug'],
+ ['/blog/hello-world', '/blog/:slug'],
+ ['/users/123/posts/456', '/users/:userId/posts/:postId'],
+ ['/users/john/posts/my-post', '/users/:userId/posts/:postId'],
+
+ ['/docs/intro', '/docs/:*'],
+ ['/docs/guide/getting-started', '/docs/:*'],
+
+ ['/unknown-route', undefined],
+ ['/api/unknown', undefined],
+ ])('should handle route "%s" and return %s', (inputRoute, expectedRoute) => {
+ const manifest: RouteManifest = {
+ staticRoutes: [{ path: '/' }, { path: '/about' }, { path: '/contact' }, { path: '/blog/posts' }],
+ dynamicRoutes: [
+ {
+ path: '/users/:id',
+ regex: '^/users/([^/]+)$',
+ paramNames: ['id'],
+ },
+ {
+ path: '/blog/:slug',
+ regex: '^/blog/([^/]+)$',
+ paramNames: ['slug'],
+ },
+ {
+ path: '/users/:userId/posts/:postId',
+ regex: '^/users/([^/]+)/posts/([^/]+)$',
+ paramNames: ['userId', 'postId'],
+ },
+ {
+ path: '/docs/:*',
+ regex: '^/docs/(.+)$',
+ paramNames: ['*'],
+ },
+ ],
+ };
+ globalWithInjectedManifest._sentryRemixRouteManifest = JSON.stringify(manifest);
+
+ if (expectedRoute === undefined) {
+ expect(maybeParameterizeRemixRoute(inputRoute)).toBeUndefined();
+ } else {
+ expect(maybeParameterizeRemixRoute(inputRoute)).toBe(expectedRoute);
+ }
+ });
+ });
+
+ describe('route specificity and precedence', () => {
+ it('should prefer more specific routes over catch-all routes', () => {
+ const manifest: RouteManifest = {
+ staticRoutes: [],
+ dynamicRoutes: [
+ {
+ path: '/:parameter',
+ regex: '^/([^/]+)$',
+ paramNames: ['parameter'],
+ },
+ {
+ path: '/:*',
+ regex: '^/(.+)$',
+ paramNames: ['*'],
+ },
+ ],
+ };
+ globalWithInjectedManifest._sentryRemixRouteManifest = JSON.stringify(manifest);
+
+ // Single segment should match the specific route, not the catch-all
+ expect(maybeParameterizeRemixRoute('/123')).toBe('/:parameter');
+ expect(maybeParameterizeRemixRoute('/abc')).toBe('/:parameter');
+
+ // Multiple segments should match the catch-all
+ expect(maybeParameterizeRemixRoute('/123/456')).toBe('/:*');
+ });
+
+ it('should prefer routes with more static segments', () => {
+ const manifest: RouteManifest = {
+ staticRoutes: [],
+ dynamicRoutes: [
+ {
+ path: '/api/users/:id',
+ regex: '^/api/users/([^/]+)$',
+ paramNames: ['id'],
+ },
+ {
+ path: '/api/:resource/:id',
+ regex: '^/api/([^/]+)/([^/]+)$',
+ paramNames: ['resource', 'id'],
+ },
+ {
+ path: '/:*',
+ regex: '^/(.+)$',
+ paramNames: ['*'],
+ },
+ ],
+ };
+ globalWithInjectedManifest._sentryRemixRouteManifest = JSON.stringify(manifest);
+
+ // More specific route with static segments should win
+ expect(maybeParameterizeRemixRoute('/api/users/123')).toBe('/api/users/:id');
+
+ // Less specific but still targeted route should win over catch-all
+ expect(maybeParameterizeRemixRoute('/api/posts/456')).toBe('/api/:resource/:id');
+
+ // Unmatched patterns should fall back to catch-all
+ expect(maybeParameterizeRemixRoute('/some/other/path')).toBe('/:*');
+ });
+ });
+
+ describe('caching behavior', () => {
+ it('should cache route results', () => {
+ const manifest: RouteManifest = {
+ staticRoutes: [],
+ dynamicRoutes: [
+ {
+ path: '/users/:id',
+ regex: '^/users/([^/]+)$',
+ paramNames: ['id'],
+ },
+ ],
+ };
+ globalWithInjectedManifest._sentryRemixRouteManifest = JSON.stringify(manifest);
+
+ const result1 = maybeParameterizeRemixRoute('/users/123');
+ const result2 = maybeParameterizeRemixRoute('/users/123');
+
+ expect(result1).toBe(result2);
+ expect(result1).toBe('/users/:id');
+ });
+
+ it('should clear cache when manifest changes', () => {
+ const manifest1: RouteManifest = {
+ staticRoutes: [],
+ dynamicRoutes: [
+ {
+ path: '/users/:id',
+ regex: '^/users/([^/]+)$',
+ paramNames: ['id'],
+ },
+ ],
+ };
+ globalWithInjectedManifest._sentryRemixRouteManifest = JSON.stringify(manifest1);
+
+ expect(maybeParameterizeRemixRoute('/users/123')).toBe('/users/:id');
+
+ // Change manifest
+ const manifest2: RouteManifest = {
+ staticRoutes: [],
+ dynamicRoutes: [
+ {
+ path: '/members/:id',
+ regex: '^/members/([^/]+)$',
+ paramNames: ['id'],
+ },
+ ],
+ };
+ globalWithInjectedManifest._sentryRemixRouteManifest = JSON.stringify(manifest2);
+
+ expect(maybeParameterizeRemixRoute('/users/123')).toBeUndefined();
+ expect(maybeParameterizeRemixRoute('/members/123')).toBe('/members/:id');
+ });
+ });
+});
diff --git a/packages/remix/test/config/manifest/createRemixRouteManifest.test.ts b/packages/remix/test/config/manifest/createRemixRouteManifest.test.ts
new file mode 100644
index 000000000000..2af7e6d2ad00
--- /dev/null
+++ b/packages/remix/test/config/manifest/createRemixRouteManifest.test.ts
@@ -0,0 +1,136 @@
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import { afterAll, describe, expect, it } from 'vitest';
+import { createRemixRouteManifest } from '../../../src/config/createRemixRouteManifest';
+
+describe('createRemixRouteManifest', () => {
+ const tempDirs: string[] = [];
+
+ function createTestDir(): { tempDir: string; appDir: string; routesDir: string } {
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'remix-test-'));
+ const appDir = path.join(tempDir, 'app');
+ const routesDir = path.join(appDir, 'routes');
+ fs.mkdirSync(routesDir, { recursive: true });
+ tempDirs.push(tempDir);
+ return { tempDir, appDir, routesDir };
+ }
+
+ afterAll(() => {
+ // Clean up all temporary directories
+ tempDirs.forEach(dir => {
+ if (fs.existsSync(dir)) {
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+ });
+ });
+
+ describe('flat route structure', () => {
+ it('should handle basic flat routes', () => {
+ const { tempDir, routesDir } = createTestDir();
+
+ // Create test route files
+ fs.writeFileSync(path.join(routesDir, 'index.tsx'), '// index route');
+ fs.writeFileSync(path.join(routesDir, 'about.tsx'), '// about route');
+ fs.writeFileSync(path.join(routesDir, 'users.$id.tsx'), '// users dynamic route');
+
+ const manifest = createRemixRouteManifest({ rootDir: tempDir });
+
+ expect(manifest.staticRoutes).toHaveLength(2);
+ expect(manifest.staticRoutes).toContainEqual({ path: '/' });
+ expect(manifest.staticRoutes).toContainEqual({ path: '/about' });
+
+ expect(manifest.dynamicRoutes).toHaveLength(1);
+ expect(manifest.dynamicRoutes[0]).toMatchObject({
+ path: '/users/:id',
+ regex: '^/users/([^/]+)$',
+ paramNames: ['id'],
+ });
+
+ // Clean up
+ fs.unlinkSync(path.join(routesDir, 'index.tsx'));
+ fs.unlinkSync(path.join(routesDir, 'about.tsx'));
+ fs.unlinkSync(path.join(routesDir, 'users.$id.tsx'));
+ });
+ });
+
+ describe('nested route structure', () => {
+ it('should handle nested directory routes', () => {
+ const { tempDir, routesDir } = createTestDir();
+ const usersDir = path.join(routesDir, 'users');
+ fs.mkdirSync(usersDir, { recursive: true });
+
+ fs.writeFileSync(path.join(routesDir, 'index.tsx'), '// root index');
+ fs.writeFileSync(path.join(usersDir, '$id.tsx'), '// user id');
+ fs.writeFileSync(path.join(usersDir, 'index.tsx'), '// users index');
+
+ const manifest = createRemixRouteManifest({ rootDir: tempDir });
+
+ expect(manifest.staticRoutes).toContainEqual({ path: '/' });
+ expect(manifest.staticRoutes).toContainEqual({ path: '/users' });
+
+ expect(manifest.dynamicRoutes).toContainEqual(
+ expect.objectContaining({
+ path: '/users/:id',
+ regex: '^/users/([^/]+)$',
+ paramNames: ['id'],
+ }),
+ );
+ });
+
+ it('should handle deeply nested routes', () => {
+ const { tempDir, routesDir } = createTestDir();
+ const usersDir = path.join(routesDir, 'users');
+ const userIdDir = path.join(usersDir, '$id');
+ const postsDir = path.join(userIdDir, 'posts');
+
+ fs.mkdirSync(postsDir, { recursive: true });
+
+ fs.writeFileSync(path.join(userIdDir, 'index.tsx'), '// user index');
+ fs.writeFileSync(path.join(postsDir, '$postId.tsx'), '// post id');
+
+ const manifest = createRemixRouteManifest({ rootDir: tempDir });
+
+ // users/$id/index.tsx should map to /users/:id (dynamic route)
+ expect(manifest.dynamicRoutes).toContainEqual(
+ expect.objectContaining({
+ path: '/users/:id',
+ regex: '^/users/([^/]+)$',
+ paramNames: ['id'],
+ }),
+ );
+
+ // users/$id/posts/$postId.tsx should map to /users/:id/posts/:postId
+ expect(manifest.dynamicRoutes).toContainEqual(
+ expect.objectContaining({
+ path: '/users/:id/posts/:postId',
+ regex: '^/users/([^/]+)/posts/([^/]+)$',
+ paramNames: ['id', 'postId'],
+ }),
+ );
+ });
+
+ it('should handle mixed flat and nested routes', () => {
+ const { tempDir, routesDir } = createTestDir();
+ const usersDir = path.join(routesDir, 'users');
+ fs.mkdirSync(usersDir, { recursive: true });
+
+ fs.writeFileSync(path.join(routesDir, 'index.tsx'), '// root');
+ fs.writeFileSync(path.join(routesDir, 'about.tsx'), '// about');
+ fs.writeFileSync(path.join(usersDir, '$id.tsx'), '// user');
+
+ const manifest = createRemixRouteManifest({ rootDir: tempDir });
+
+ expect(manifest.staticRoutes).toContainEqual({ path: '/' });
+ expect(manifest.staticRoutes).toContainEqual({ path: '/about' });
+
+ expect(manifest.dynamicRoutes).toContainEqual(
+ expect.objectContaining({
+ path: '/users/:id',
+ regex: '^/users/([^/]+)$',
+ paramNames: ['id'],
+ }),
+ );
+ });
+ });
+});
diff --git a/packages/remix/test/config/vite.test.ts b/packages/remix/test/config/vite.test.ts
new file mode 100644
index 000000000000..1948cfcd06e8
--- /dev/null
+++ b/packages/remix/test/config/vite.test.ts
@@ -0,0 +1,585 @@
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import type { Plugin, ResolvedConfig } from 'vite';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { sentryRemixVitePlugin } from '../../src/config/vite';
+
+describe('sentryRemixVitePlugin', () => {
+ let tempDir: string;
+ let appDir: string;
+ let routesDir: string;
+ let consoleLogSpy: ReturnType;
+ let consoleErrorSpy: ReturnType;
+
+ beforeEach(() => {
+ // Create a temporary directory for test fixtures
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vite-plugin-test-'));
+ appDir = path.join(tempDir, 'app');
+ routesDir = path.join(appDir, 'routes');
+ fs.mkdirSync(routesDir, { recursive: true });
+
+ // Spy on console methods
+ consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
+ consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ // Clean up
+ if (fs.existsSync(tempDir)) {
+ fs.rmSync(tempDir, { recursive: true, force: true });
+ }
+ consoleLogSpy.mockRestore();
+ consoleErrorSpy.mockRestore();
+ });
+
+ describe('plugin configuration', () => {
+ it('should return a valid Vite plugin with correct name', () => {
+ const plugin = sentryRemixVitePlugin();
+
+ expect(plugin).toBeDefined();
+ expect(plugin.name).toBe('sentry-remix-route-manifest');
+ expect(plugin.enforce).toBe('post');
+ });
+
+ it('should accept custom appDirPath option', () => {
+ const plugin = sentryRemixVitePlugin({ appDirPath: '/custom/path' });
+
+ expect(plugin).toBeDefined();
+ });
+
+ it('should work with no options', () => {
+ const plugin = sentryRemixVitePlugin();
+
+ expect(plugin).toBeDefined();
+ });
+ });
+
+ describe('configResolved hook', () => {
+ it('should generate route manifest from routes directory', () => {
+ // Create test routes
+ fs.writeFileSync(path.join(routesDir, 'index.tsx'), '// index');
+ fs.writeFileSync(path.join(routesDir, 'about.tsx'), '// about');
+ fs.writeFileSync(path.join(routesDir, 'users.$id.tsx'), '// users');
+
+ const plugin = sentryRemixVitePlugin() as Plugin & {
+ configResolved: (config: ResolvedConfig) => void;
+ };
+
+ const mockConfig: Partial = {
+ root: tempDir,
+ command: 'build',
+ mode: 'production',
+ };
+
+ plugin.configResolved(mockConfig as ResolvedConfig);
+
+ // Should not log in production mode
+ expect(consoleLogSpy).not.toHaveBeenCalled();
+ expect(consoleErrorSpy).not.toHaveBeenCalled();
+ });
+
+ it('should log manifest info in development mode', () => {
+ fs.writeFileSync(path.join(routesDir, 'index.tsx'), '// index');
+ fs.writeFileSync(path.join(routesDir, 'users.$id.tsx'), '// users');
+
+ const plugin = sentryRemixVitePlugin() as Plugin & {
+ configResolved: (config: ResolvedConfig) => void;
+ };
+
+ const mockConfig: Partial = {
+ root: tempDir,
+ command: 'serve',
+ mode: 'development',
+ };
+
+ plugin.configResolved(mockConfig as ResolvedConfig);
+
+ expect(consoleLogSpy).toHaveBeenCalledWith(
+ expect.stringContaining('[Sentry Remix] Found 1 static and 1 dynamic routes'),
+ );
+ });
+
+ it('should handle errors gracefully and set empty manifest', () => {
+ const plugin = sentryRemixVitePlugin({ appDirPath: '/nonexistent/path' }) as Plugin & {
+ configResolved: (config: ResolvedConfig) => void;
+ };
+
+ const mockConfig: Partial = {
+ root: tempDir,
+ command: 'build',
+ mode: 'production',
+ };
+
+ // Should not throw
+ expect(() => plugin.configResolved(mockConfig as ResolvedConfig)).not.toThrow();
+
+ // Should log error but not crash
+ expect(consoleErrorSpy).not.toHaveBeenCalled(); // No error if directory doesn't exist
+ });
+
+ it('should use custom appDirPath when provided', () => {
+ const customAppDir = path.join(tempDir, 'custom-app');
+ const customRoutesDir = path.join(customAppDir, 'routes');
+ fs.mkdirSync(customRoutesDir, { recursive: true });
+ fs.writeFileSync(path.join(customRoutesDir, 'index.tsx'), '// index');
+
+ const plugin = sentryRemixVitePlugin({ appDirPath: customAppDir }) as Plugin & {
+ configResolved: (config: ResolvedConfig) => void;
+ };
+
+ const mockConfig: Partial = {
+ root: tempDir,
+ command: 'serve',
+ mode: 'development',
+ };
+
+ plugin.configResolved(mockConfig as ResolvedConfig);
+
+ expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('[Sentry Remix] Found 1 static'));
+ });
+ });
+
+ describe('transformIndexHtml hook', () => {
+ it('should inject manifest into HTML with tag', () => {
+ fs.writeFileSync(path.join(routesDir, 'index.tsx'), '// index');
+
+ const plugin = sentryRemixVitePlugin() as Plugin & {
+ configResolved: (config: ResolvedConfig) => void;
+ transformIndexHtml: {
+ order: string;
+ handler: (html: string) => string;
+ };
+ };
+
+ const mockConfig: Partial = {
+ root: tempDir,
+ command: 'build',
+ mode: 'production',
+ };
+
+ plugin.configResolved(mockConfig as ResolvedConfig);
+
+ const html = 'Test';
+ const result = plugin.transformIndexHtml.handler(html);
+
+ expect(result).toContain('');
+ expect(result).toContain('window._sentryRemixRouteManifest');
+ expect(result).toContain('