{
+ 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..fd15a015710e
--- /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.72.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,
+ },
+}
diff --git a/packages/react-router/src/vite/detectCloudflare.ts b/packages/react-router/src/vite/detectCloudflare.ts
new file mode 100644
index 000000000000..8a8ff4ccf9b1
--- /dev/null
+++ b/packages/react-router/src/vite/detectCloudflare.ts
@@ -0,0 +1,14 @@
+import * as fs from 'fs';
+import * as path from 'path';
+
+const WRANGLER_CONFIG_NAMES = ['wrangler.json', 'wrangler.jsonc', 'wrangler.toml'];
+
+/**
+ * Whether this build targets Cloudflare (Workers or Shopify Oxygen).
+ *
+ * React Router exposes no adapter or preset to branch on, and `sentryReactRouter` only receives
+ * Vite's `ConfigEnv`. A wrangler config is the remaining signal — a worker can't deploy without one.
+ */
+export function detectCloudflare(): boolean {
+ return WRANGLER_CONFIG_NAMES.some(name => fs.existsSync(path.join(process.cwd(), name)));
+}
diff --git a/packages/react-router/src/vite/plugin.ts b/packages/react-router/src/vite/plugin.ts
index 602ca6f4c412..4a23acfd6a61 100644
--- a/packages/react-router/src/vite/plugin.ts
+++ b/packages/react-router/src/vite/plugin.ts
@@ -1,5 +1,6 @@
import { sentryOrchestrionPlugin } from '@sentry/server-utils/orchestrion/vite';
import type { ConfigEnv, Plugin } from 'vite';
+import { detectCloudflare } from './detectCloudflare';
import { makeConfigInjectorPlugin } from './makeConfigInjectorPlugin';
import { makeCustomSentryVitePlugins } from './makeCustomSentryVitePlugins';
import { makeEnableSourceMapsPlugin } from './makeEnableSourceMapsPlugin';
@@ -23,7 +24,14 @@ 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(
+ sentryOrchestrionPlugin({
+ buildTimeInstrumentation: options.buildTimeInstrumentation,
+ // On Cloudflare, subscribers are wired via a build-time marker the SDK reads at runtime;
+ // on Node they register at init.
+ ...(detectCloudflare() ? { injectChannelSubscribers: true } : {}),
+ }),
+ );
plugins.push(makeEnableSourceMapsPlugin(options));
plugins.push(...(await makeCustomSentryVitePlugins(options)));
}
diff --git a/packages/react-router/test/vite/detectCloudflare.test.ts b/packages/react-router/test/vite/detectCloudflare.test.ts
new file mode 100644
index 000000000000..958259bf686b
--- /dev/null
+++ b/packages/react-router/test/vite/detectCloudflare.test.ts
@@ -0,0 +1,37 @@
+import * as fs from 'fs';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { detectCloudflare } from '../../src/vite/detectCloudflare';
+
+vi.mock('fs');
+
+describe('detectCloudflare', () => {
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it.each(['wrangler.json', 'wrangler.jsonc', 'wrangler.toml'])('detects Cloudflare from %s', configName => {
+ vi.spyOn(fs, 'existsSync').mockImplementation(filePath => filePath.toString().endsWith(configName));
+
+ expect(detectCloudflare()).toBe(true);
+ });
+
+ it('resolves the wrangler config against the current working directory', () => {
+ const existsSync = vi.spyOn(fs, 'existsSync').mockReturnValue(false);
+
+ detectCloudflare();
+
+ expect(existsSync).toHaveBeenCalledWith(`${process.cwd()}/wrangler.json`);
+ });
+
+ it('returns false when no wrangler config is present', () => {
+ vi.spyOn(fs, 'existsSync').mockReturnValue(false);
+
+ expect(detectCloudflare()).toBe(false);
+ });
+
+ it('does not treat an unrelated wrangler-prefixed file as a config', () => {
+ vi.spyOn(fs, 'existsSync').mockImplementation(filePath => filePath.toString().endsWith('wrangler.d.ts'));
+
+ expect(detectCloudflare()).toBe(false);
+ });
+});
diff --git a/packages/react-router/test/vite/plugin.test.ts b/packages/react-router/test/vite/plugin.test.ts
index 52cbecceb9a9..1ffd053c937a 100644
--- a/packages/react-router/test/vite/plugin.test.ts
+++ b/packages/react-router/test/vite/plugin.test.ts
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { detectCloudflare } from '../../src/vite/detectCloudflare';
import { makeConfigInjectorPlugin } from '../../src/vite/makeConfigInjectorPlugin';
import { makeCustomSentryVitePlugins } from '../../src/vite/makeCustomSentryVitePlugins';
import { makeEnableSourceMapsPlugin } from '../../src/vite/makeEnableSourceMapsPlugin';
@@ -16,14 +17,18 @@ vi.mock('../../src/vite/makeCustomSentryVitePlugins');
vi.mock('../../src/vite/makeEnableSourceMapsPlugin');
vi.mock('../../src/vite/makeConfigInjectorPlugin');
vi.mock('../../src/vite/makeServerBuildCapturePlugin');
+vi.mock('../../src/vite/detectCloudflare');
// 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',
-}));
+const orchestrionVite = vi.fn(
+ (options?: { buildTimeInstrumentation?: boolean; injectChannelSubscribers?: boolean }) => ({
+ name: options?.buildTimeInstrumentation === false ? 'sentry-orchestrion-disabled' : 'sentry-orchestrion-vite',
+ }),
+);
vi.mock('@sentry/server-utils/orchestrion/vite', () => ({
- sentryOrchestrionPlugin: (options?: { buildTimeInstrumentation?: boolean }) => orchestrionVite(options),
+ sentryOrchestrionPlugin: (options?: { buildTimeInstrumentation?: boolean; injectChannelSubscribers?: boolean }) =>
+ orchestrionVite(options),
}));
describe('sentryReactRouter', () => {
@@ -39,6 +44,7 @@ describe('sentryReactRouter', () => {
vi.mocked(makeEnableSourceMapsPlugin).mockReturnValue(mockSourceMapsPlugin);
vi.mocked(makeConfigInjectorPlugin).mockReturnValue(mockConfigInjectorPlugin);
vi.mocked(makeServerBuildCapturePlugin).mockReturnValue(mockServerBuildCapturePlugin);
+ vi.mocked(detectCloudflare).mockReturnValue(false);
});
afterEach(() => {
@@ -141,6 +147,50 @@ describe('sentryReactRouter', () => {
process.env.NODE_ENV = originalNodeEnv;
});
+ it('injects channel subscribers when building for Cloudflare', async () => {
+ const originalNodeEnv = process.env.NODE_ENV;
+ process.env.NODE_ENV = 'production';
+ vi.mocked(detectCloudflare).mockReturnValue(true);
+
+ const result = await sentryReactRouter({}, { command: 'build', mode: 'production' });
+
+ expect(orchestrionVite).toHaveBeenCalledWith({
+ buildTimeInstrumentation: undefined,
+ injectChannelSubscribers: true,
+ });
+ expect(result.map(plugin => plugin?.name)).toContain('sentry-orchestrion-vite');
+
+ process.env.NODE_ENV = originalNodeEnv;
+ });
+
+ it('does not inject channel subscribers on non-Cloudflare builds', async () => {
+ const originalNodeEnv = process.env.NODE_ENV;
+ process.env.NODE_ENV = 'production';
+
+ await sentryReactRouter({}, { command: 'build', mode: 'production' });
+
+ expect(orchestrionVite).toHaveBeenCalledWith(
+ expect.not.objectContaining({ injectChannelSubscribers: expect.anything() }),
+ );
+
+ process.env.NODE_ENV = originalNodeEnv;
+ });
+
+ it('keeps the orchestrion plugin inert on Cloudflare when `buildTimeInstrumentation` is `false`', async () => {
+ const originalNodeEnv = process.env.NODE_ENV;
+ process.env.NODE_ENV = 'production';
+ vi.mocked(detectCloudflare).mockReturnValue(true);
+
+ const result = await sentryReactRouter(
+ { buildTimeInstrumentation: false },
+ { command: 'build', mode: 'production' },
+ );
+
+ expect(result.map(plugin => plugin?.name)).toContain('sentry-orchestrion-disabled');
+
+ 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();