{
+ let shellRendered = false;
+ const userAgent = request.headers.get('user-agent');
+
+ const body = await renderToReadableStream(, {
+ signal: request.signal,
+ onError(error: unknown) {
+ responseStatusCode = 500;
+ // Errors thrown after the shell has flushed can't change the status code, so surface them.
+ if (shellRendered) {
+ // eslint-disable-next-line no-console
+ console.error(error);
+ }
+ },
+ });
+ shellRendered = true;
+
+ // Bots need complete markup rather than a streamed shell.
+ if (userAgent && isbot(userAgent)) {
+ await body.allReady;
+ }
+
+ responseHeaders.set('Content-Type', 'text/html');
+
+ return new Response(Sentry.injectTraceMetaTags(body), {
+ headers: responseHeaders,
+ status: responseStatusCode,
+ });
+}
+
+export const handleError: HandleErrorFunction = (error, { request }) => {
+ // React Router aborts interrupted requests, don't report those.
+ if (!request.signal.aborted) {
+ Sentry.captureException(error);
+ // eslint-disable-next-line no-console
+ console.error(error);
+ }
+};
+
+export default Sentry.wrapSentryHandleRequest(handleRequest);
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/app/root.tsx b/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/app/root.tsx
new file mode 100644
index 000000000000..c09b53b99d46
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/app/root.tsx
@@ -0,0 +1,23 @@
+import { Links, Meta, Outlet, Scripts, ScrollRestoration } from 'react-router';
+
+export function Layout({ children }: { children: React.ReactNode }) {
+ return (
+
+
+
+
+
+
+
+
+ {children}
+
+
+
+
+ );
+}
+
+export default function App() {
+ return ;
+}
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/app/routes.ts b/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/app/routes.ts
new file mode 100644
index 000000000000..744ffc9485de
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/app/routes.ts
@@ -0,0 +1,6 @@
+import { index, prefix, route, type RouteConfig } from '@react-router/dev/routes';
+
+export default [
+ index('routes/home.tsx'),
+ ...prefix('performance', [route('db-mysql', 'routes/performance/db-mysql.tsx')]),
+] satisfies RouteConfig;
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/app/routes/home.tsx b/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/app/routes/home.tsx
new file mode 100644
index 000000000000..7e59685f3987
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/app/routes/home.tsx
@@ -0,0 +1,10 @@
+import { Link } from 'react-router';
+
+export default function Home() {
+ return (
+
+
react-router-8-cloudflare
+ db-mysql
+
+ );
+}
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/app/routes/performance/db-mysql.tsx b/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/app/routes/performance/db-mysql.tsx
new file mode 100644
index 000000000000..ea2ac1fcea61
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/app/routes/performance/db-mysql.tsx
@@ -0,0 +1,40 @@
+import mysql from 'mysql';
+import type { Route } from './+types/db-mysql';
+
+// These queries produce `db` spans from the build-time orchestrion transform alone — workerd can't
+// monkey-patch requires, so there's no OTel hook involved.
+export async function loader(): Promise<{ status: string }> {
+ // Connect inside the loader: workerd forbids I/O in global scope.
+ const connection = mysql.createConnection({
+ host: '127.0.0.1',
+ port: 3306,
+ user: 'root',
+ password: 'docker',
+ });
+
+ // Swallow socket-level errors so they don't fail the request for reasons unrelated to the spans.
+ connection.on('error', () => {
+ // no-op
+ });
+
+ try {
+ // The nested query runs in a fresh async context (mysql dispatches callbacks from its socket
+ // handler), so it only lands on this transaction if the subscriber restored the parent span.
+ await new Promise((resolve, reject) => {
+ connection.query('SELECT 1 + 1 AS solution', err1 => {
+ if (err1) return reject(err1);
+ connection.query('SELECT NOW()', err2 => {
+ if (err2) return reject(err2);
+ resolve();
+ });
+ });
+ });
+ return { status: 'ok' };
+ } finally {
+ connection.end();
+ }
+}
+
+export default function DbMysql(_props: Route.ComponentProps) {
+ return db-mysql
;
+}
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/docker-compose.yml b/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/docker-compose.yml
new file mode 100644
index 000000000000..c03828e36b43
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/docker-compose.yml
@@ -0,0 +1,18 @@
+services:
+ db:
+ image: mysql:8.0
+ restart: always
+ container_name: e2e-tests-react-router-8-cloudflare-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-8-cloudflare/global-setup.mjs b/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/global-setup.mjs
new file mode 100644
index 000000000000..b0695e3731f7
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/global-setup.mjs
@@ -0,0 +1,13 @@
+import { execSync } from 'child_process';
+import { dirname } from 'path';
+import { fileURLToPath } from 'url';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+
+export default async function globalSetup() {
+ // `--wait` blocks until the healthcheck passes, so the first request can connect.
+ execSync('docker compose up -d --wait', {
+ cwd: __dirname,
+ stdio: 'inherit',
+ });
+}
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/global-teardown.mjs b/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/global-teardown.mjs
new file mode 100644
index 000000000000..2742279431ad
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/global-teardown.mjs
@@ -0,0 +1,12 @@
+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-cloudflare/package.json b/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/package.json
new file mode 100644
index 000000000000..a4ffaf1a31fc
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/package.json
@@ -0,0 +1,45 @@
+{
+ "name": "react-router-8-cloudflare",
+ "version": "0.1.0",
+ "type": "module",
+ "private": true,
+ "dependencies": {
+ "@sentry/cloudflare": "file:../../packed/sentry-cloudflare-packed.tgz",
+ "@sentry/react-router": "file:../../packed/sentry-react-router-packed.tgz",
+ "isbot": "^5.1.17",
+ "mysql": "^2.18.1",
+ "react": "^19.2.0",
+ "react-dom": "^19.2.0",
+ "react-router": "^8"
+ },
+ "devDependencies": {
+ "@cloudflare/vite-plugin": "^1.35.0",
+ "@cloudflare/workers-types": "^4.20260504.0",
+ "@playwright/test": "~1.56.0",
+ "@react-router/dev": "^8",
+ "@sentry-internal/test-utils": "link:../../../test-utils",
+ "@types/mysql": "^2.15.26",
+ "@types/react": "^19.2.0",
+ "@types/react-dom": "^19.2.0",
+ "typescript": "^5.9.0",
+ "vite": "7.3.2",
+ "wrangler": "^4.120.0"
+ },
+ "scripts": {
+ "build": "react-router build",
+ "dev": "react-router dev",
+ "preview": "wrangler dev --var \"E2E_TEST_DSN:$E2E_TEST_DSN\" --port 3030",
+ "proxy": "node start-event-proxy.mjs",
+ "typecheck": "react-router typegen && tsc",
+ "clean": "npx rimraf node_modules pnpm-lock.yaml",
+ "test:build": "pnpm install && pnpm build",
+ "test:assert": "pnpm typecheck && TEST_ENV=production playwright test"
+ },
+ "volta": {
+ "node": "24.15.0",
+ "extends": "../../package.json"
+ },
+ "sentryTest": {
+ "optional": true
+ }
+}
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/playwright.config.mjs
new file mode 100644
index 000000000000..579222812424
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/playwright.config.mjs
@@ -0,0 +1,14 @@
+import { getPlaywrightConfig } from '@sentry-internal/test-utils';
+
+const config = getPlaywrightConfig(
+ {
+ startCommand: 'pnpm preview',
+ port: 3030,
+ },
+ {
+ globalSetup: './global-setup.mjs',
+ globalTeardown: './global-teardown.mjs',
+ },
+);
+
+export default config;
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/public/favicon.ico b/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/public/favicon.ico
new file mode 100644
index 000000000000..5dbdfcddcb14
Binary files /dev/null and b/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/public/favicon.ico differ
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/react-router.config.ts b/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/react-router.config.ts
new file mode 100644
index 000000000000..51e8967770b3
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/react-router.config.ts
@@ -0,0 +1,5 @@
+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-cloudflare/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/start-event-proxy.mjs
new file mode 100644
index 000000000000..d78c58fa1714
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/start-event-proxy.mjs
@@ -0,0 +1,6 @@
+import { startEventProxyServer } from '@sentry-internal/test-utils';
+
+startEventProxyServer({
+ port: 3031,
+ proxyServerName: 'react-router-8-cloudflare',
+});
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/tests/db.test.ts b/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/tests/db.test.ts
new file mode 100644
index 000000000000..5b2acfe85b61
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/tests/db.test.ts
@@ -0,0 +1,43 @@
+import { expect, test } from '@playwright/test';
+import { waitForTransaction } from '@sentry-internal/test-utils';
+
+test('a real mysql query emits a db span with orchestrion-channel attributes', async ({ request }) => {
+ const transactionPromise = waitForTransaction('react-router-8-cloudflare', transactionEvent => {
+ return (
+ transactionEvent.contexts?.trace?.op === 'http.server' &&
+ (transactionEvent.spans?.some(span => span.op === 'db') ?? false)
+ );
+ });
+
+ const res = await request.get('/performance/db-mysql');
+ expect(res.status()).toBe(200);
+
+ const transactionEvent = await transactionPromise;
+ const dbSpans = transactionEvent.spans!.filter(span => span.op === 'db');
+
+ const firstQuery = dbSpans.find(span => span.description === 'SELECT 1 + 1 AS solution');
+ expect(firstQuery).toBeDefined();
+ expect(firstQuery!.data?.['sentry.origin']).toBe('auto.db.mysql');
+ expect(firstQuery!.data?.['db.system']).toBe('mysql');
+ expect(firstQuery!.data?.['db.statement']).toBe('SELECT 1 + 1 AS solution');
+ expect(firstQuery!.data?.['net.peer.name']).toBe('127.0.0.1');
+ expect(firstQuery!.data?.['net.peer.port']).toBe(3306);
+ expect(firstQuery!.data?.['db.user']).toBe('root');
+});
+
+test('a nested query lands on the same transaction (async context restored)', async ({ request }) => {
+ const transactionPromise = waitForTransaction('react-router-8-cloudflare', transactionEvent => {
+ return (
+ transactionEvent.contexts?.trace?.op === 'http.server' &&
+ (transactionEvent.spans?.filter(span => span.op === 'db').length ?? 0) >= 2
+ );
+ });
+
+ const res = await request.get('/performance/db-mysql');
+ expect(res.status()).toBe(200);
+
+ const transactionEvent = await transactionPromise;
+ const descriptions = transactionEvent.spans!.filter(span => span.op === 'db').map(span => span.description);
+ expect(descriptions).toContain('SELECT 1 + 1 AS solution');
+ expect(descriptions).toContain('SELECT NOW()');
+});
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/tsconfig.json b/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/tsconfig.json
new file mode 100644
index 000000000000..573bcd3ad911
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "compilerOptions": {
+ "lib": ["DOM", "DOM.Iterable", "ES2022"],
+ // workers-types rather than node: the server runs in workerd.
+ "types": ["@cloudflare/workers-types", "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-cloudflare/vite.config.ts b/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/vite.config.ts
new file mode 100644
index 000000000000..86a05f2a7814
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/vite.config.ts
@@ -0,0 +1,13 @@
+import { cloudflare } from '@cloudflare/vite-plugin';
+import { reactRouter } from '@react-router/dev/vite';
+import { sentryReactRouter } from '@sentry/react-router';
+import { defineConfig } from 'vite';
+
+export default defineConfig(async config => ({
+ plugins: [
+ cloudflare({ viteEnvironment: { name: 'ssr' } }),
+ reactRouter(),
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ ...((await sentryReactRouter({ sourcemaps: { disable: true } }, config)) as any[]),
+ ],
+}));
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/workers/app.ts b/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/workers/app.ts
new file mode 100644
index 000000000000..d80e3741947c
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/workers/app.ts
@@ -0,0 +1,27 @@
+import * as Sentry from '@sentry/cloudflare';
+import { createRequestHandler } from 'react-router';
+
+const requestHandler = createRequestHandler(() => import('virtual:react-router/server-build'), import.meta.env.MODE);
+
+interface Env {
+ E2E_TEST_DSN: string;
+}
+
+// `withSentry` is what reads the build-time orchestrion marker; without it the injected
+// `diagnostics_channel` publishers would fire with nobody subscribed.
+export default Sentry.withSentry(
+ (env: Env) => ({
+ traceLifecycle: 'static',
+ dsn: env.E2E_TEST_DSN,
+ tunnel: 'http://localhost:3031/',
+ tracesSampleRate: 1.0,
+ environment: 'qa', // dynamic sampling bias to keep transactions
+ }),
+ {
+ // No load context: React Router 8 takes a `RouterContextProvider`, not v7's `{ cloudflare }`
+ // object, and nothing here reads bindings from a loader.
+ async fetch(request) {
+ return requestHandler(request);
+ },
+ } satisfies ExportedHandler,
+);
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/wrangler.jsonc b/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/wrangler.jsonc
new file mode 100644
index 000000000000..32521671d64e
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/wrangler.jsonc
@@ -0,0 +1,13 @@
+{
+ "$schema": "node_modules/wrangler/config-schema.json",
+ "name": "react-router-8-cloudflare",
+ "compatibility_date": "2026-06-29",
+ "compatibility_flags": ["nodejs_compat"],
+ "main": "./workers/app.ts",
+ "assets": {
+ "directory": "./build/client",
+ },
+ "observability": {
+ "enabled": true,
+ },
+}