diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/entry.server.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/entry.server.tsx
index 178a8ed4e377..b646f036b3ad 100644
--- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/entry.server.tsx
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/entry.server.tsx
@@ -1,3 +1,4 @@
+import '../instrument.mjs';
import { createReadableStreamFromReadable } from '@react-router/node';
import * as Sentry from '@sentry/react-router';
import { renderToPipeableStream } from 'react-dom/server';
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes.ts
index 2754c153ad2f..9f0dd2325c42 100644
--- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes.ts
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes.ts
@@ -16,6 +16,7 @@ export default [
route('error-middleware', 'routes/performance/error-middleware.tsx'),
route('lazy-route', 'routes/performance/lazy-route.tsx'),
route('fetcher-test', 'routes/performance/fetcher-test.tsx'),
- route('redis', 'routes/performance/redis.tsx'),
+ route('db-ioredis', 'routes/performance/db-ioredis.tsx'),
+ route('db-mysql', 'routes/performance/db-mysql.tsx'),
]),
] satisfies RouteConfig;
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/app/routes/db-ioredis.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes/performance/db-ioredis.tsx
similarity index 55%
rename from dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/app/routes/db-ioredis.tsx
rename to dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes/performance/db-ioredis.tsx
index 8774bf6d1555..ba48126c0c3d 100644
--- a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/app/routes/db-ioredis.tsx
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes/performance/db-ioredis.tsx
@@ -1,9 +1,6 @@
import Redis from 'ioredis';
+import type { Route } from './+types/db-ioredis';
-// Page route (not a loader-only resource route): `@sentry/react-router` only
-// renames the `http.server` transaction to the matched route (`GET /db-ioredis`)
-// for rendered routes, so the orchestrion-injected ioredis spans land on a
-// per-route transaction rather than the Express catch-all `GET /{*splat}`.
export async function loader() {
const redis = new Redis({
// Don't keep retrying forever if Redis goes away (e.g. on test teardown)
@@ -20,6 +17,6 @@ export async function loader() {
}
}
-export default function DbIoredis() {
+export default function DbIoredis(_props: Route.ComponentProps) {
return
db-ioredis
;
}
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/app/routes/db-mysql.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes/performance/db-mysql.tsx
similarity index 78%
rename from dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/app/routes/db-mysql.tsx
rename to dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes/performance/db-mysql.tsx
index d7031799b70e..85fa18d37a3f 100644
--- a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/app/routes/db-mysql.tsx
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes/performance/db-mysql.tsx
@@ -1,4 +1,5 @@
import mysql from 'mysql';
+import type { Route } from './+types/db-mysql';
const connection = mysql.createConnection({
user: 'root',
@@ -15,6 +16,6 @@ export function loader() {
});
}
-export default function DbMysql() {
+export default function DbMysql(_props: Route.ComponentProps) {
return db-mysql
;
}
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes/performance/redis.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes/performance/redis.tsx
deleted file mode 100644
index cba8275fcf63..000000000000
--- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes/performance/redis.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-import Redis from 'ioredis';
-import type { Route } from './+types/redis';
-
-const redis = new Redis();
-
-export async function loader() {
- const key = 'cache:greeting';
- await redis.set(key, 'hello from react-router');
- const value = await redis.get(key);
-
- return { value };
-}
-
-export default function RedisPage({ loaderData }: Route.ComponentProps) {
- const { value } = loaderData;
- return (
-
- );
-}
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/docker-compose.yml b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/docker-compose.yml
index a2cb7ab5f088..489862f006f7 100644
--- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/docker-compose.yml
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/docker-compose.yml
@@ -10,3 +10,21 @@ services:
interval: 1s
timeout: 3s
retries: 30
+
+ db:
+ image: mysql:8.0
+ restart: always
+ container_name: e2e-tests-react-router-7-instrumentation-mysql
+ # The `mysql` 2.x driver doesn't speak MySQL 8's default
+ # `caching_sha2_password` auth, so force the legacy plugin.
+ command: ['--default-authentication-plugin=mysql_native_password']
+ ports:
+ - '3306:3306'
+ environment:
+ MYSQL_ROOT_PASSWORD: docker
+ healthcheck:
+ test: ['CMD-SHELL', 'mysqladmin ping -h 127.0.0.1 -uroot -pdocker']
+ interval: 2s
+ timeout: 3s
+ retries: 30
+ start_period: 10s
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/global-setup.mjs b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/global-setup.mjs
index aaf07f256cd3..560a7875fe60 100644
--- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/global-setup.mjs
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/global-setup.mjs
@@ -7,12 +7,17 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
export default async function globalSetup() {
// Each run copies this app to a fresh temp dir, so `docker compose` doesn't
// recognize a leftover container from a previous (e.g. interrupted) run as
- // part of the same project - but the container name is fixed, so the daemon
- // still refuses to create a new one. Force-remove any stale leftover first.
- try {
- execSync('docker rm -f e2e-tests-react-router-7-instrumentation-redis', { stdio: 'ignore' });
- } catch {
- // no stale container to remove
+ // part of the same project - but the container names are fixed, so the daemon
+ // still refuses to create new ones. Force-remove any stale leftovers first.
+ for (const container of [
+ 'e2e-tests-react-router-7-instrumentation-redis',
+ 'e2e-tests-react-router-7-instrumentation-mysql',
+ ]) {
+ try {
+ execSync(`docker rm -f ${container}`, { stdio: 'ignore' });
+ } catch {
+ // no stale container to remove
+ }
}
execSync('docker compose up -d --wait', { cwd: __dirname, stdio: 'inherit' });
}
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/instrument.mjs b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/instrument.mjs
index bf39b453fb97..00a6d2952286 100644
--- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/instrument.mjs
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/instrument.mjs
@@ -1,7 +1,5 @@
import * as Sentry from '@sentry/react-router';
-// Initialize Sentry early (before the server starts)
-// The server instrumentations are created in entry.server.tsx
Sentry.init({
traceLifecycle: 'static',
dsn: 'https://username@domain/123',
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/package.json b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/package.json
index 41a5cec4c2ee..56a3736fafd2 100644
--- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/package.json
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/package.json
@@ -3,12 +3,14 @@
"version": "0.1.0",
"type": "module",
"private": true,
+ "//": "Need to use ioredis 5.10.1 because that's the last version before they support tracing channels, so the orchestrion build-time transform injects the diagnostics_channel publishers.",
"dependencies": {
"@react-router/node": "^7",
"@react-router/serve": "^7",
"@sentry/react-router": "file:../../packed/sentry-react-router-packed.tgz",
- "ioredis": "^5.4.1",
+ "ioredis": "5.10.1",
"isbot": "^5.1.17",
+ "mysql": "^2.18.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router": "^7"
@@ -17,6 +19,7 @@
"@playwright/test": "~1.56.0",
"@react-router/dev": "^7",
"@sentry-internal/test-utils": "link:../../../test-utils",
+ "@types/mysql": "^2.15.26",
"@types/node": "^20",
"@types/react": "18.3.1",
"@types/react-dom": "18.3.1",
@@ -25,8 +28,8 @@
},
"scripts": {
"build": "react-router build",
- "dev": "NODE_OPTIONS='--import ./instrument.mjs' react-router dev",
- "start": "NODE_OPTIONS='--import ./instrument.mjs' react-router-serve ./build/server/index.js",
+ "dev": "react-router dev",
+ "start": "react-router-serve ./build/server/index.js",
"proxy": "node start-event-proxy.mjs",
"typecheck": "react-router typegen && tsc",
"clean": "npx rimraf node_modules pnpm-lock.yaml",
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/build-injection.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/build-injection.test.ts
new file mode 100644
index 000000000000..d66ff8436650
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/build-injection.test.ts
@@ -0,0 +1,37 @@
+import { readFileSync, readdirSync } from 'node:fs';
+import path from 'node:path';
+import { expect, test } from '@playwright/test';
+
+function readBundle(dir: string): string {
+ const root = path.join(process.cwd(), dir);
+ return readdirSync(root, { recursive: true })
+ .filter((file): file is string => typeof file === 'string' && file.endsWith('.js'))
+ .map(file => readFileSync(path.join(root, file), 'utf8'))
+ .join('\n');
+}
+
+test.describe('Orchestrion build-time injection', () => {
+ const serverBundle = readBundle('build/server');
+ const clientBundle = readBundle('build/client');
+
+ test('force-bundles instrumented dependencies', () => {
+ expect(serverBundle).not.toMatch(/(from\s*["']mysql["']|require\(["']mysql["']\))/);
+ expect(serverBundle).not.toMatch(/(from\s*["']ioredis["']|require\(["']ioredis["']\))/);
+ });
+
+ test('injects diagnostics-channel publishers into the server build', () => {
+ expect(serverBundle).toContain('__SENTRY_ORCHESTRION__');
+ expect(serverBundle).toMatch(/tracingChannel\(["']orchestrion:mysql:query["']\)/);
+ expect(serverBundle).toMatch(/tracingChannel\(["']orchestrion:ioredis:command["']\)/);
+ expect(serverBundle).toMatch(/tracingChannel\(["']orchestrion:ioredis:connect["']\)/);
+ });
+
+ test('does not inject diagnostics-channel publishers into the client build', () => {
+ // The client build may carry the inert `__SENTRY_ORCHESTRION__.bundler = []` detection marker
+ // (it's environment-agnostic and harmless in a browser), but the actual `tracingChannel` publishers
+ // and their channel names must never leak — `applyToEnvironment` keeps the transform server-only, so
+ // a browser never hits a `diagnostics_channel` call that would throw.
+ expect(clientBundle).not.toMatch(/tracingChannel\(/);
+ expect(clientBundle).not.toMatch(/orchestrion:[a-z]/);
+ });
+});
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/db.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/db.server.test.ts
new file mode 100644
index 000000000000..7812077a547d
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/db.server.test.ts
@@ -0,0 +1,114 @@
+import { expect, test } from '@playwright/test';
+import { waitForTransaction } from '@sentry-internal/test-utils';
+import { APP_NAME } from '../constants';
+
+// Orchestrion injects `diagnostics_channel` publishers at build time, so these spans only exist in the
+// bundled server build. `react-router dev` serves an unbundled SSR pipeline where the transform never
+// runs, so the assertions are meaningless there — skip them in the dev run.
+const isDev = process.env.TEST_ENV === 'development';
+
+test.describe('server - orchestrion build-time db instrumentation', () => {
+ test.skip(isDev, 'orchestrion only injects into the bundled server build, not the dev server');
+
+ test('instruments ioredis automatically via orchestrion', async ({ page }) => {
+ const transactionEventPromise = waitForTransaction(APP_NAME, transactionEvent => {
+ return (
+ transactionEvent.contexts?.trace?.op === 'http.server' &&
+ transactionEvent.transaction === 'GET /performance/db-ioredis'
+ );
+ });
+
+ await page.goto('/performance/db-ioredis');
+
+ const transactionEvent = await transactionEventPromise;
+ const spans = transactionEvent.spans || [];
+
+ // The server transaction must come from the native instrumentation API (not the legacy handler),
+ // proving the orchestrion-injected db spans share context with the React Router server span.
+ expect(transactionEvent.contexts?.trace?.origin).toBe('auto.http.react_router.instrumentation_api');
+
+ expect(spans).toContainEqual(
+ expect.objectContaining({
+ op: 'db',
+ origin: 'auto.db.redis',
+ description: 'set test-key [1 other arguments]',
+ status: 'ok',
+ data: expect.objectContaining({
+ 'db.system': 'redis',
+ 'db.statement': 'set test-key [1 other arguments]',
+ }),
+ }),
+ );
+ expect(spans).toContainEqual(
+ expect.objectContaining({
+ op: 'db',
+ origin: 'auto.db.redis',
+ description: 'get test-key',
+ status: 'ok',
+ data: expect.objectContaining({
+ 'db.system': 'redis',
+ 'db.statement': 'get test-key',
+ }),
+ }),
+ );
+
+ // Each command maps to exactly one span (no offline-queue duplicate).
+ const setSpans = spans.filter(span => span.description === 'set test-key [1 other arguments]');
+ expect(setSpans).toHaveLength(1);
+
+ // Every db span nests under the native instrumentation-API http.server transaction.
+ const rootSpanId = transactionEvent.contexts?.trace?.span_id;
+ const spanIds = new Set([rootSpanId, ...spans.map(span => span.span_id)]);
+ const dbSpans = spans.filter(span => span.op === 'db');
+ expect(dbSpans.every(span => typeof span.parent_span_id === 'string' && spanIds.has(span.parent_span_id))).toBe(
+ true,
+ );
+ });
+
+ test('instruments mysql automatically via orchestrion', async ({ page }) => {
+ const transactionEventPromise = waitForTransaction(APP_NAME, transactionEvent => {
+ return (
+ transactionEvent.contexts?.trace?.op === 'http.server' &&
+ transactionEvent.transaction === 'GET /performance/db-mysql'
+ );
+ });
+
+ await page.goto('/performance/db-mysql');
+
+ const transactionEvent = await transactionEventPromise;
+ const spans = transactionEvent.spans || [];
+
+ expect(spans).toContainEqual(
+ expect.objectContaining({
+ op: 'db',
+ origin: 'auto.db.mysql',
+ description: 'SELECT 1 + 1 AS solution',
+ status: 'ok',
+ data: expect.objectContaining({
+ 'db.system': 'mysql',
+ 'db.statement': 'SELECT 1 + 1 AS solution',
+ 'db.user': 'root',
+ 'db.connection_string': expect.any(String),
+ 'net.peer.name': expect.any(String),
+ 'net.peer.port': 3306,
+ }),
+ }),
+ );
+ expect(spans).toContainEqual(
+ expect.objectContaining({
+ op: 'db',
+ origin: 'auto.db.mysql',
+ description: 'SELECT NOW()',
+ status: 'ok',
+ data: expect.objectContaining({
+ 'db.system': 'mysql',
+ 'db.statement': 'SELECT NOW()',
+ 'db.user': 'root',
+ 'db.connection_string': expect.any(String),
+ 'net.peer.name': expect.any(String),
+ 'net.peer.port': 3306,
+ }),
+ }),
+ );
+ });
+});
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/redis.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/redis.server.test.ts
deleted file mode 100644
index eb60915bd89c..000000000000
--- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/redis.server.test.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-import { expect, test } from '@playwright/test';
-import { waitForTransaction } from '@sentry-internal/test-utils';
-import { APP_NAME } from '../constants';
-
-test.describe('server - redis db spans (instrumentation API)', () => {
- test('OTel db.query spans nest under the native instrumentation-API http.server transaction', async ({ page }) => {
- const txPromise = waitForTransaction(APP_NAME, async transactionEvent => {
- return (
- transactionEvent.transaction === 'GET /performance/redis' &&
- (transactionEvent.spans?.some(span => span.op === 'db.query') ?? false)
- );
- });
-
- await page.goto('/performance/redis');
-
- const transaction = await txPromise;
-
- // The server transaction must come from the native instrumentation API (not the legacy handler),
- // proving auto-instrumented OTel spans still share context with the React Router server span.
- expect(transaction.contexts?.trace?.op).toBe('http.server');
- expect(transaction.contexts?.trace?.origin).toBe('auto.http.react_router.instrumentation_api');
-
- // Collect every span id in the transaction (root + children) so we can verify nesting.
- const rootSpanId = transaction.contexts?.trace?.span_id;
- const spanIds = new Set([rootSpanId, ...(transaction.spans ?? []).map(span => span.span_id)]);
-
- const redisSpans = transaction.spans!.filter(span => span.op === 'db.query');
-
- // loader runs SET then GET => at least two redis command spans
- expect(redisSpans.length).toBeGreaterThanOrEqual(2);
-
- // every redis span nests under the native instrumentation-API http.server transaction
- const allNested = redisSpans.every(
- span => typeof span.parent_span_id === 'string' && spanIds.has(span.parent_span_id),
- );
- expect(allNested).toBe(true);
- });
-});
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/vite.config.ts b/dev-packages/e2e-tests/test-applications/react-router-8-framework/vite.config.ts
index 68ba30d69397..912a22c538fe 100644
--- a/dev-packages/e2e-tests/test-applications/react-router-8-framework/vite.config.ts
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/vite.config.ts
@@ -1,6 +1,9 @@
import { reactRouter } from '@react-router/dev/vite';
+import { sentryReactRouter } from '@sentry/react-router';
import { defineConfig } from 'vite';
-export default defineConfig({
- plugins: [reactRouter()],
+export default defineConfig(config => {
+ return {
+ plugins: [reactRouter(), sentryReactRouter({ sourcemaps: { disable: true } }, config)],
+ };
});
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/.gitignore b/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/.gitignore
deleted file mode 100644
index ebb991370034..000000000000
--- a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/.gitignore
+++ /dev/null
@@ -1,32 +0,0 @@
-# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
-
-# dependencies
-/node_modules
-/.pnp
-.pnp.js
-
-# testing
-/coverage
-
-# production
-/build
-
-# misc
-.DS_Store
-.env.local
-.env.development.local
-.env.test.local
-.env.production.local
-
-npm-debug.log*
-yarn-debug.log*
-yarn-error.log*
-
-/test-results/
-/playwright-report/
-/playwright/.cache/
-
-!*.d.ts
-
-# react router
-.react-router
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/app/app.css b/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/app/app.css
deleted file mode 100644
index b31c3a9d0ddf..000000000000
--- a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/app/app.css
+++ /dev/null
@@ -1,6 +0,0 @@
-html,
-body {
- @media (prefers-color-scheme: dark) {
- color-scheme: dark;
- }
-}
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/app/entry.client.tsx b/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/app/entry.client.tsx
deleted file mode 100644
index 83f29c042f0b..000000000000
--- a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/app/entry.client.tsx
+++ /dev/null
@@ -1,23 +0,0 @@
-import * as Sentry from '@sentry/react-router';
-import { StrictMode, startTransition } from 'react';
-import { hydrateRoot } from 'react-dom/client';
-import { HydratedRouter } from 'react-router/dom';
-
-Sentry.init({
- traceLifecycle: 'static',
- environment: 'qa', // dynamic sampling bias to keep transactions
- dsn: 'https://username@domain/123',
- tunnel: `http://localhost:3031/`, // proxy server
- integrations: [Sentry.reactRouterTracingIntegration()],
- tracesSampleRate: 1.0,
- tracePropagationTargets: [/^\//],
-});
-
-startTransition(() => {
- hydrateRoot(
- document,
-
-
- ,
- );
-});
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/app/entry.server.tsx b/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/app/entry.server.tsx
deleted file mode 100644
index b646f036b3ad..000000000000
--- a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/app/entry.server.tsx
+++ /dev/null
@@ -1,21 +0,0 @@
-import '../instrument.mjs';
-import { createReadableStreamFromReadable } from '@react-router/node';
-import * as Sentry from '@sentry/react-router';
-import { renderToPipeableStream } from 'react-dom/server';
-import { ServerRouter } from 'react-router';
-import { type HandleErrorFunction } from 'react-router';
-
-const ABORT_DELAY = 5_000;
-
-const handleRequest = Sentry.createSentryHandleRequest({
- streamTimeout: ABORT_DELAY,
- ServerRouter,
- renderToPipeableStream,
- createReadableStreamFromReadable,
-});
-
-export default handleRequest;
-
-export const handleError: HandleErrorFunction = Sentry.createSentryHandleError({ logErrors: true });
-
-export const instrumentations = [Sentry.createSentryServerInstrumentation()];
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/app/root.tsx b/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/app/root.tsx
deleted file mode 100644
index fc20ee4f6895..000000000000
--- a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/app/root.tsx
+++ /dev/null
@@ -1,55 +0,0 @@
-import { Links, Meta, Outlet, Scripts, ScrollRestoration, isRouteErrorResponse } from 'react-router';
-import type { Route } from './+types/root';
-import stylesheet from './app.css?url';
-
-export const links: Route.LinksFunction = () => [{ rel: 'stylesheet', href: stylesheet }];
-
-export function Layout({ children }: { children: React.ReactNode }) {
- return (
-
-
-
-
-
-
-
-
- {children}
-
-
-
-
- );
-}
-
-export default function App() {
- return ;
-}
-
-export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
- let message = 'Oops!';
- let details = 'An unexpected error occurred.';
- let stack: string | undefined;
-
- if (isRouteErrorResponse(error)) {
- message = error.status === 404 ? '404' : 'Error';
- details = error.status === 404 ? 'The requested page could not be found.' : error.statusText || details;
- } else if (error && error instanceof Error) {
- if (import.meta.env.DEV) {
- details = error.message;
- stack = error.stack;
- }
- }
-
- return (
-
- {message}
- {details}
- {stack && (
-
- {stack}
-
- )}
-
- );
-}
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/app/routes.ts b/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/app/routes.ts
deleted file mode 100644
index 560647eefa08..000000000000
--- a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/app/routes.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import { type RouteConfig, index, route } from '@react-router/dev/routes';
-
-export default [
- index('routes/home.tsx'),
- route('db-mysql', 'routes/db-mysql.tsx'),
- route('db-ioredis', 'routes/db-ioredis.tsx'),
-] satisfies RouteConfig;
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/app/routes/home.tsx b/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/app/routes/home.tsx
deleted file mode 100644
index 8d968568dcb7..000000000000
--- a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/app/routes/home.tsx
+++ /dev/null
@@ -1,7 +0,0 @@
-export function meta() {
- return [{ title: 'React Router 8 Orchestrion' }];
-}
-
-export default function Home() {
- return home
;
-}
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/docker-compose.yml b/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/docker-compose.yml
deleted file mode 100644
index f62a56205dc9..000000000000
--- a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/docker-compose.yml
+++ /dev/null
@@ -1,31 +0,0 @@
-services:
- db:
- image: mysql:8.0
- restart: always
- container_name: e2e-tests-react-router-8-orchestrion-mysql
- # The `mysql` 2.x driver doesn't speak MySQL 8's default
- # `caching_sha2_password` auth, so force the legacy plugin.
- command: ['--default-authentication-plugin=mysql_native_password']
- ports:
- - '3306:3306'
- environment:
- MYSQL_ROOT_PASSWORD: docker
- healthcheck:
- test: ['CMD-SHELL', 'mysqladmin ping -h 127.0.0.1 -uroot -pdocker']
- interval: 2s
- timeout: 3s
- retries: 30
- start_period: 10s
-
- redis:
- image: redis:7
- restart: always
- container_name: e2e-tests-react-router-8-orchestrion-redis
- ports:
- - '6379:6379'
- healthcheck:
- test: ['CMD', 'redis-cli', 'ping']
- interval: 2s
- timeout: 3s
- retries: 30
- start_period: 5s
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/global-setup.mjs b/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/global-setup.mjs
deleted file mode 100644
index fddc0ab46143..000000000000
--- a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/global-setup.mjs
+++ /dev/null
@@ -1,27 +0,0 @@
-import { execSync } from 'child_process';
-import { dirname } from 'path';
-import { fileURLToPath } from 'url';
-
-const __dirname = dirname(fileURLToPath(import.meta.url));
-
-// Boot MySQL and Redis here (rather than in the `start` script) so the cold
-// image pulls happen outside Playwright's webServer startup-timeout window.
-// `--wait` blocks until the healthchecks in docker-compose.yml pass, so the app
-// can connect immediately.
-export default async function globalSetup() {
- // Each run copies this app to a fresh temp dir, so `docker compose` doesn't
- // recognize a leftover container from a previous (e.g. interrupted) run as
- // part of the same project - but the container names are fixed, so the daemon
- // still refuses to create new ones. Force-remove any stale leftovers first.
- for (const container of [
- 'e2e-tests-react-router-8-orchestrion-mysql',
- 'e2e-tests-react-router-8-orchestrion-redis',
- ]) {
- try {
- execSync(`docker rm -f ${container}`, { stdio: 'ignore' });
- } catch {
- // no stale container to remove
- }
- }
- execSync('docker compose up -d --wait', { cwd: __dirname, stdio: 'inherit' });
-}
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/global-teardown.mjs b/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/global-teardown.mjs
deleted file mode 100644
index 2742279431ad..000000000000
--- a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/global-teardown.mjs
+++ /dev/null
@@ -1,12 +0,0 @@
-import { execSync } from 'child_process';
-import { dirname } from 'path';
-import { fileURLToPath } from 'url';
-
-const __dirname = dirname(fileURLToPath(import.meta.url));
-
-export default async function globalTeardown() {
- execSync('docker compose down --volumes', {
- cwd: __dirname,
- stdio: 'inherit',
- });
-}
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/instrument.mjs b/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/instrument.mjs
deleted file mode 100644
index 67b6ca2ff092..000000000000
--- a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/instrument.mjs
+++ /dev/null
@@ -1,9 +0,0 @@
-import * as Sentry from '@sentry/react-router';
-
-Sentry.init({
- traceLifecycle: 'static',
- dsn: 'https://username@domain/123',
- environment: 'qa', // dynamic sampling bias to keep transactions
- tracesSampleRate: 1.0,
- tunnel: 'http://localhost:3031/', // proxy server
-});
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/package.json b/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/package.json
deleted file mode 100644
index cc390c344d24..000000000000
--- a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/package.json
+++ /dev/null
@@ -1,51 +0,0 @@
-{
- "name": "react-router-8-orchestrion",
- "version": "0.1.0",
- "type": "module",
- "private": true,
- "//": "Need to use ioredis 5.10.1 because that's the last version before they support tracing channels",
- "dependencies": {
- "react": "^19.2.7",
- "react-dom": "^19.2.7",
- "react-router": "^8.0.0",
- "@react-router/node": "^8.0.0",
- "@react-router/serve": "^8.0.0",
- "@sentry/react-router": "file:../../packed/sentry-react-router-packed.tgz",
- "@sentry/server-utils": "file:../../packed/sentry-server-utils-packed.tgz",
- "ioredis": "5.10.1",
- "mysql": "^2.18.1"
- },
- "devDependencies": {
- "@types/react": "19.2.17",
- "@types/react-dom": "19.2.3",
- "@types/node": "^22",
- "@react-router/dev": "^8.0.0",
- "@playwright/test": "~1.58.0",
- "@sentry-internal/test-utils": "link:../../../test-utils",
- "typescript": "^5.6.3",
- "vite": "^7.3.2"
- },
- "scripts": {
- "build": "react-router build",
- "test:build-latest": "pnpm install && pnpm add react-router@latest && pnpm add @react-router/node@latest && pnpm add @react-router/serve@latest && pnpm build",
- "dev": "react-router dev",
- "start": "NODE_ENV=production react-router-serve ./build/server/index.js",
- "proxy": "node start-event-proxy.mjs",
- "clean": "npx rimraf node_modules pnpm-lock.yaml",
- "test:build": "pnpm install && pnpm build",
- "test:assert": "pnpm test",
- "test": "playwright test"
- },
- "volta": {
- "extends": "../../package.json",
- "node": "22.22.0"
- },
- "sentryTest": {
- "variants": [
- {
- "build-command": "pnpm test:build-latest",
- "label": "react-router-8-orchestrion (latest)"
- }
- ]
- }
-}
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/playwright.config.mjs
deleted file mode 100644
index ba35892417a8..000000000000
--- a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/playwright.config.mjs
+++ /dev/null
@@ -1,16 +0,0 @@
-import { getPlaywrightConfig } from '@sentry-internal/test-utils';
-import { fileURLToPath } from 'url';
-
-const config = getPlaywrightConfig(
- {
- startCommand: `PORT=3030 pnpm start`,
- port: 3030,
- },
- // Boot MySQL and Redis before the tests run, outside the webServer startup-timeout window.
- {
- globalSetup: fileURLToPath(new URL('./global-setup.mjs', import.meta.url)),
- globalTeardown: fileURLToPath(new URL('./global-teardown.mjs', import.meta.url)),
- },
-);
-
-export default config;
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/public/favicon.ico b/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/public/favicon.ico
deleted file mode 100644
index 5dbdfcddcb14..000000000000
Binary files a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/public/favicon.ico and /dev/null differ
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/react-router.config.ts b/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/react-router.config.ts
deleted file mode 100644
index 51e8967770b3..000000000000
--- a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/react-router.config.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-import type { Config } from '@react-router/dev/config';
-
-export default {
- ssr: true,
-} satisfies Config;
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/start-event-proxy.mjs
deleted file mode 100644
index b7b1bbc72a53..000000000000
--- a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/start-event-proxy.mjs
+++ /dev/null
@@ -1,6 +0,0 @@
-import { startEventProxyServer } from '@sentry-internal/test-utils';
-
-startEventProxyServer({
- port: 3031,
- proxyServerName: 'react-router-8-orchestrion',
-});
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/tests/db.test.ts b/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/tests/db.test.ts
deleted file mode 100644
index 824ed4101bf0..000000000000
--- a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/tests/db.test.ts
+++ /dev/null
@@ -1,88 +0,0 @@
-import { expect, test } from '@playwright/test';
-import { waitForTransaction } from '@sentry-internal/test-utils';
-
-test('Instruments ioredis automatically via orchestrion', async ({ baseURL }) => {
- const transactionEventPromise = waitForTransaction('react-router-8-orchestrion', transactionEvent => {
- return transactionEvent.contexts?.trace?.op === 'http.server' && transactionEvent.transaction === 'GET /db-ioredis';
- });
-
- await fetch(`${baseURL}/db-ioredis`);
-
- const transactionEvent = await transactionEventPromise;
-
- const spans = transactionEvent.spans || [];
-
- expect(spans).toContainEqual(
- expect.objectContaining({
- op: 'db',
- origin: 'auto.db.redis',
- description: 'set test-key [1 other arguments]',
- status: 'ok',
- data: expect.objectContaining({
- 'db.system': 'redis',
- 'db.statement': 'set test-key [1 other arguments]',
- }),
- }),
- );
- expect(spans).toContainEqual(
- expect.objectContaining({
- op: 'db',
- origin: 'auto.db.redis',
- description: 'get test-key',
- status: 'ok',
- data: expect.objectContaining({
- 'db.system': 'redis',
- 'db.statement': 'get test-key',
- }),
- }),
- );
-
- // Each command maps to exactly one span (no offline-queue duplicate).
- const setSpans = spans.filter(span => span.description === 'set test-key [1 other arguments]');
- expect(setSpans).toHaveLength(1);
-});
-
-test('Instruments mysql automatically via orchestrion', async ({ baseURL }) => {
- const transactionEventPromise = waitForTransaction('react-router-8-orchestrion', transactionEvent => {
- return transactionEvent.contexts?.trace?.op === 'http.server' && transactionEvent.transaction === 'GET /db-mysql';
- });
-
- await fetch(`${baseURL}/db-mysql`);
-
- const transactionEvent = await transactionEventPromise;
-
- const spans = transactionEvent.spans || [];
-
- expect(spans).toContainEqual(
- expect.objectContaining({
- op: 'db',
- origin: 'auto.db.mysql',
- description: 'SELECT 1 + 1 AS solution',
- status: 'ok',
- data: expect.objectContaining({
- 'db.system': 'mysql',
- 'db.statement': 'SELECT 1 + 1 AS solution',
- 'db.user': 'root',
- 'db.connection_string': expect.any(String),
- 'net.peer.name': expect.any(String),
- 'net.peer.port': 3306,
- }),
- }),
- );
- expect(spans).toContainEqual(
- expect.objectContaining({
- op: 'db',
- origin: 'auto.db.mysql',
- description: 'SELECT NOW()',
- status: 'ok',
- data: expect.objectContaining({
- 'db.system': 'mysql',
- 'db.statement': 'SELECT NOW()',
- 'db.user': 'root',
- 'db.connection_string': expect.any(String),
- 'net.peer.name': expect.any(String),
- 'net.peer.port': 3306,
- }),
- }),
- );
-});
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/tsconfig.json b/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/tsconfig.json
deleted file mode 100644
index a16df276e8bc..000000000000
--- a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/tsconfig.json
+++ /dev/null
@@ -1,20 +0,0 @@
-{
- "compilerOptions": {
- "lib": ["DOM", "DOM.Iterable", "ES2022"],
- "types": ["node", "vite/client"],
- "target": "ES2022",
- "module": "ES2022",
- "moduleResolution": "bundler",
- "jsx": "react-jsx",
- "rootDirs": [".", "./.react-router/types"],
- "baseUrl": ".",
-
- "esModuleInterop": true,
- "verbatimModuleSyntax": true,
- "noEmit": true,
- "resolveJsonModule": true,
- "skipLibCheck": true,
- "strict": true
- },
- "include": ["**/*", "**/.server/**/*", "**/.client/**/*", ".react-router/types/**/*"]
-}
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/vite.config.ts b/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/vite.config.ts
deleted file mode 100644
index 494f3d41f2ce..000000000000
--- a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/vite.config.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import { reactRouter } from '@react-router/dev/vite';
-import { sentryOrchestrionPlugin } from '@sentry/server-utils/orchestrion/vite';
-import { defineConfig } from 'vite';
-
-export default defineConfig({
- plugins: [
- reactRouter(),
- // Runs the orchestrion code transform over the SSR server bundle and
- // force-bundles the instrumented deps (mysql, ioredis, …) so the
- // diagnostics-channel calls are actually injected at build time.
- sentryOrchestrionPlugin(),
- ],
-});
diff --git a/packages/react-router/package.json b/packages/react-router/package.json
index 54d85e4ed55b..94bc5f2f5134 100644
--- a/packages/react-router/package.json
+++ b/packages/react-router/package.json
@@ -52,6 +52,7 @@
"@sentry/core": "10.67.0",
"@sentry/node": "10.67.0",
"@sentry/react": "10.67.0",
+ "@sentry/server-utils": "10.67.0",
"@sentry/bundler-plugins": "10.67.0",
"glob": "^13.0.6"
},
diff --git a/packages/react-router/src/vite/plugin.ts b/packages/react-router/src/vite/plugin.ts
index 4a66a2575987..602ca6f4c412 100644
--- a/packages/react-router/src/vite/plugin.ts
+++ b/packages/react-router/src/vite/plugin.ts
@@ -1,3 +1,4 @@
+import { sentryOrchestrionPlugin } from '@sentry/server-utils/orchestrion/vite';
import type { ConfigEnv, Plugin } from 'vite';
import { makeConfigInjectorPlugin } from './makeConfigInjectorPlugin';
import { makeCustomSentryVitePlugins } from './makeCustomSentryVitePlugins';
@@ -22,6 +23,7 @@ export async function sentryReactRouter(
plugins.push(makeServerBuildCapturePlugin());
if (process.env.NODE_ENV !== 'development' && viteConfig.command === 'build' && viteConfig.mode !== 'development') {
+ plugins.push(sentryOrchestrionPlugin({ buildTimeInstrumentation: options.buildTimeInstrumentation }));
plugins.push(makeEnableSourceMapsPlugin(options));
plugins.push(...(await makeCustomSentryVitePlugins(options)));
}
diff --git a/packages/react-router/test/vite/plugin.test.ts b/packages/react-router/test/vite/plugin.test.ts
index 52306eb0dbd1..52cbecceb9a9 100644
--- a/packages/react-router/test/vite/plugin.test.ts
+++ b/packages/react-router/test/vite/plugin.test.ts
@@ -17,11 +17,21 @@ vi.mock('../../src/vite/makeEnableSourceMapsPlugin');
vi.mock('../../src/vite/makeConfigInjectorPlugin');
vi.mock('../../src/vite/makeServerBuildCapturePlugin');
+// Stub the orchestrion plugin so these stay pure wiring tests (no apm code transformer pulled in).
+// Mirror the real plugin's contract: `buildTimeInstrumentation: false` yields the inert variant.
+const orchestrionVite = vi.fn((options?: { buildTimeInstrumentation?: boolean }) => ({
+ name: options?.buildTimeInstrumentation === false ? 'sentry-orchestrion-disabled' : 'sentry-orchestrion-vite',
+}));
+vi.mock('@sentry/server-utils/orchestrion/vite', () => ({
+ sentryOrchestrionPlugin: (options?: { buildTimeInstrumentation?: boolean }) => orchestrionVite(options),
+}));
+
describe('sentryReactRouter', () => {
const mockPlugins = [{ name: 'test-plugin' }];
const mockSourceMapsPlugin = { name: 'source-maps-plugin' };
const mockConfigInjectorPlugin = { name: 'sentry-config-injector' };
const mockServerBuildCapturePlugin = { name: 'sentry-react-router-server-build-capture' };
+ const mockOrchestrionPlugin = { name: 'sentry-orchestrion-vite' };
beforeEach(() => {
vi.clearAllMocks();
@@ -73,6 +83,7 @@ describe('sentryReactRouter', () => {
expect(result).toEqual([
mockConfigInjectorPlugin,
mockServerBuildCapturePlugin,
+ mockOrchestrionPlugin,
mockSourceMapsPlugin,
...mockPlugins,
]);
@@ -102,4 +113,39 @@ describe('sentryReactRouter', () => {
process.env.NODE_ENV = originalNodeEnv;
});
+
+ it('adds the orchestrion plugin to the server build by default', async () => {
+ const originalNodeEnv = process.env.NODE_ENV;
+ process.env.NODE_ENV = 'production';
+
+ const result = await sentryReactRouter({}, { command: 'build', mode: 'production' });
+ expect(orchestrionVite).toHaveBeenCalledWith({ buildTimeInstrumentation: undefined });
+ expect(result.map(plugin => plugin?.name)).toContain('sentry-orchestrion-vite');
+
+ process.env.NODE_ENV = originalNodeEnv;
+ });
+
+ it('adds an inert orchestrion plugin when `buildTimeInstrumentation` is `false`', async () => {
+ const originalNodeEnv = process.env.NODE_ENV;
+ process.env.NODE_ENV = 'production';
+
+ const result = await sentryReactRouter(
+ { buildTimeInstrumentation: false },
+ { command: 'build', mode: 'production' },
+ );
+ const pluginNames = result.map(plugin => plugin?.name);
+ expect(orchestrionVite).toHaveBeenCalledWith({ buildTimeInstrumentation: false });
+ expect(pluginNames).toContain('sentry-orchestrion-disabled');
+ expect(pluginNames).not.toContain('sentry-orchestrion-vite');
+
+ process.env.NODE_ENV = originalNodeEnv;
+ });
+
+ it('does not add the orchestrion plugin to the dev server (serve command)', async () => {
+ const result = await sentryReactRouter({}, { command: 'serve', mode: 'production' });
+ expect(orchestrionVite).not.toHaveBeenCalled();
+ const pluginNames = result.map(plugin => plugin?.name);
+ expect(pluginNames).not.toContain('sentry-orchestrion-vite');
+ expect(pluginNames).not.toContain('sentry-orchestrion-disabled');
+ });
});