diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/.gitignore b/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/.gitignore new file mode 100644 index 000000000000..e637ae342782 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/.gitignore @@ -0,0 +1,35 @@ +# 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 + +# cloudflare +.wrangler diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/app/entry.client.tsx b/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/app/entry.client.tsx new file mode 100644 index 000000000000..8fc9f5510160 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/app/entry.client.tsx @@ -0,0 +1,23 @@ +import * as Sentry from '@sentry/react-router'; +import { startTransition, StrictMode } 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-cloudflare/app/entry.server.tsx b/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/app/entry.server.tsx new file mode 100644 index 000000000000..df56b2a3a8c0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/app/entry.server.tsx @@ -0,0 +1,51 @@ +import * as Sentry from '@sentry/react-router/cloudflare'; +import { isbot } from 'isbot'; +import { renderToReadableStream } from 'react-dom/server'; +import { type EntryContext, type HandleErrorFunction, ServerRouter } from 'react-router'; + +// workerd has no `renderToPipeableStream`, so this renders to a web stream instead. +async function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + routerContext: EntryContext, +): Promise { + 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();