From 5fd138c3888b43600b6ca30153809c0c7e766d3a Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 5 Aug 2026 16:52:50 +0200 Subject: [PATCH 1/3] feat(react-router)!: Enable orchestrion instrumentation on Cloudflare `sentryReactRouter()` now enables orchestrion's channel-subscriber injection when building for Cloudflare, so bundled workers get `db` spans with no manual setup. Unlike the other meta-framework SDKs, React Router exposes no adapter or preset to branch on, and `sentryReactRouter` only receives Vite's `ConfigEnv`. Detection probes for a wrangler config instead: a worker can't deploy without one, it covers both the Workers scaffold and Shopify Oxygen, and no Node app has one. Ref: getsentry/sentry-javascript#22632 Co-Authored-By: Claude Opus 5 (1M context) --- .../react-router-8-cloudflare/.gitignore | 35 +++++++++++ .../app/entry.client.tsx | 23 +++++++ .../app/entry.server.tsx | 52 ++++++++++++++++ .../react-router-8-cloudflare/app/root.tsx | 23 +++++++ .../react-router-8-cloudflare/app/routes.ts | 6 ++ .../app/routes/home.tsx | 10 +++ .../app/routes/performance/db-mysql.tsx | 48 +++++++++++++++ .../docker-compose.yml | 18 ++++++ .../global-setup.mjs | 14 +++++ .../global-teardown.mjs | 12 ++++ .../react-router-8-cloudflare/package.json | 45 ++++++++++++++ .../playwright.config.mjs | 14 +++++ .../public/favicon.ico | Bin 0 -> 15086 bytes .../react-router.config.ts | 5 ++ .../start-event-proxy.mjs | 6 ++ .../tests/db.test.ts | 43 +++++++++++++ .../react-router-8-cloudflare/tsconfig.json | 22 +++++++ .../react-router-8-cloudflare/vite.config.ts | 13 ++++ .../react-router-8-cloudflare/workers/app.ts | 27 ++++++++ .../react-router-8-cloudflare/wrangler.jsonc | 13 ++++ .../react-router/src/vite/detectCloudflare.ts | 22 +++++++ packages/react-router/src/vite/plugin.ts | 11 +++- .../test/vite/detectCloudflare.test.ts | 37 +++++++++++ .../react-router/test/vite/plugin.test.ts | 58 ++++++++++++++++-- 24 files changed, 552 insertions(+), 5 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/app/entry.client.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/app/entry.server.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/app/root.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/app/routes.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/app/routes/home.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/app/routes/performance/db-mysql.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/docker-compose.yml create mode 100644 dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/global-setup.mjs create mode 100644 dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/global-teardown.mjs create mode 100644 dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/package.json create mode 100644 dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/playwright.config.mjs create mode 100644 dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/public/favicon.ico create mode 100644 dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/react-router.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/tests/db.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/tsconfig.json create mode 100644 dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/vite.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/workers/app.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/wrangler.jsonc create mode 100644 packages/react-router/src/vite/detectCloudflare.ts create mode 100644 packages/react-router/test/vite/detectCloudflare.test.ts 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..132be25cd04b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/app/entry.server.tsx @@ -0,0 +1,52 @@ +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` (a Node streams API), so this uses the web-streams +// renderer and injects the trace meta tags by transforming the response 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..a298fffba490 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/app/routes/performance/db-mysql.tsx @@ -0,0 +1,48 @@ +import mysql from 'mysql'; +import type { Route } from './+types/db-mysql'; + +// The `@sentry/react-router` orchestrion transform injects the `orchestrion:mysql:query` diagnostics +// channel into the bundled `mysql` package at build time. On Cloudflare the transform also registers +// the matching subscriber factory on the global marker, which `@sentry/cloudflare` reads in the +// `withSentry` wrap — so these queries produce `db` spans with no OTel require-hook, which wouldn't +// work in workerd anyway. +export async function loader(): Promise<{ status: string }> { + // The connection is created inside the loader: workerd forbids I/O in global scope, and mysql + // opens its socket lazily on the first query. Explicit host/port because workerd's default + // resolution differs from Node's. + const connection = mysql.createConnection({ + host: '127.0.0.1', + port: 3306, + user: 'root', + password: 'docker', + }); + + // Swallow connection-level errors so a socket hiccup doesn't become an uncaught exception that + // fails the request unrelated to the spans. + connection.on('error', () => { + // no-op + }); + + try { + // The second query is NESTED inside the first's callback. mysql dispatches that callback from + // its socket data handler (a fresh async context), so the nested query's span only lands on this + // request's http.server transaction if the channel subscriber restored the parent span across + // that async boundary. + 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..9ba25cd71638 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/global-setup.mjs @@ -0,0 +1,14 @@ +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() { + // Start MySQL via Docker Compose. `--wait` blocks until the healthcheck in + // docker-compose.yml passes, so the worker can connect on the first request. + 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 0000000000000000000000000000000000000000..5dbdfcddcb14182535f6d32d1c900681321b1aa3 GIT binary patch literal 15086 zcmeI33v3ic7{|AFEmuJ-;v>ep_G*NPi6KM`qNryCe1PIJ8siIN1WZ(7qVa)RVtmC% z)Ch?tN+afMKm;5@rvorJk zcXnoOc4q51HBQnQH_jn!cAg&XI1?PlX>Kl^k8qq0;zkha`kY$Fxt#=KNJAE9CMdpW zqr4#g8`nTw191(+H4xW8Tmyru2I^3=J1G3emPxkPXA=3{vvuvse_WWSshqaqls^-m zgB7q8&Vk*aYRe?sn$n53dGH#%3y%^vxv{pL*-h0Z4bmb_(k6{FL7HWIz(V*HT#IcS z-wE{)+0x1U!RUPt3gB97%p}@oHxF4|6S*+Yw=_tLtxZ~`S=z6J?O^AfU>7qOX`JNBbV&8+bO0%@fhQitKIJ^O^ zpgIa__qD_y07t@DFlBJ)8SP_#^j{6jpaXt{U%=dx!qu=4u7^21lWEYHPPY5U3TcoQ zX_7W+lvZi>TapNk_X>k-KO%MC9iZp>1E`N34gHKd9tK&){jq2~7OsJ>!G0FzxQFw6G zm&Vb(2#-T|rM|n3>uAsG_hnbvUKFf3#ay@u4uTzia~NY%XgCHfx4^To4BDU@)HlV? z@EN=g^ymETa1sQK{kRwyE4Ax8?wT&GvaG@ASO}{&a17&^v`y z!oPdiSiia^oov(Z)QhG2&|FgE{M9_4hJROGbnj>#$~ZF$-G^|zPj*QApltKe?;u;uKHJ~-V!=VLkg7Kgct)l7u39f@%VG8e3f$N-B zAu3a4%ZGf)r+jPAYCSLt73m_J3}p>}6Tx0j(wg4vvKhP!DzgiWANiE;Ppvp}P2W@m z-VbYn+NXFF?6ngef5CfY6ZwKnWvNV4z6s^~yMXw2i5mv}jC$6$46g?G|CPAu{W5qF zDobS=zb2ILX9D827g*NtGe5w;>frjanY{f)hrBP_2ehBt1?`~ypvg_Ot4x1V+43P@Ve8>qd)9NX_jWdLo`Zfy zoeam9)@Dpym{4m@+LNxXBPjPKA7{3a&H+~xQvr>C_A;7=JrfK~$M2pCh>|xLz>W6SCs4qC|#V`)# z)0C|?$o>jzh<|-cpf

K7osU{Xp5PG4-K+L2G=)c3f&}H&M3wo7TlO_UJjQ-Oq&_ zjAc9=nNIYz{c3zxOiS5UfcE1}8#iI4@uy;$Q7>}u`j+OU0N<*Ezx$k{x_27+{s2Eg z`^=rhtIzCm!_UcJ?Db~Lh-=_))PT3{Q0{Mwdq;0>ZL%l3+;B&4!&xm#%HYAK|;b456Iv&&f$VQHf` z>$*K9w8T+paVwc7fLfMlhQ4)*zL_SG{~v4QR;IuX-(oRtYAhWOlh`NLoX0k$RUYMi z2Y!bqpdN}wz8q`-%>&Le@q|jFw92ErW-hma-le?S z-@OZt2EEUm4wLsuEMkt4zlyy29_3S50JAcQHTtgTC{P~%-mvCTzrjXOc|{}N`Cz`W zSj7CrXfa7lcsU0J(0uSX6G`54t^7}+OLM0n(|g4waOQ}bd3%!XLh?NX9|8G_|06Ie zD5F1)w5I~!et7lA{G^;uf7aqT`KE&2qx9|~O;s6t!gb`+zVLJyT2T)l*8l(j literal 0 HcmV?d00001 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..2899547d12df --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ES2022"], + // `@cloudflare/workers-types` rather than `node`: the server runs in workerd, and it's what + // provides `ExportedHandler` for the worker entry. + "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..3cfab9d47e05 --- /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: the `@sentry/react-router` Vite +// plugin registers each transformed package's channel-subscriber factory there, and +// `getBaseDefaultIntegrations` instantiates them at `init()`. Without this wrap the injected +// `diagnostics_channel` publishers would fire with nobody listening. +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 + }), + { + async fetch(request, env, ctx) { + return requestHandler(request, { cloudflare: { env, ctx } }); + }, + } 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..7b3ae2798cd7 --- /dev/null +++ b/packages/react-router/src/vite/detectCloudflare.ts @@ -0,0 +1,22 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +// Wrangler's own config filenames, in its discovery precedence. A React Router app deployed to +// workerd always has one: it's how both the official Workers scaffold (`@cloudflare/vite-plugin`) +// and Shopify Oxygen declare the worker entry and compatibility flags. +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 — unlike SvelteKit (`kit.adapter.name`), + * Astro (`config.adapter.name`) or Nuxt (`nitro.preset`) — and `sentryReactRouter` only receives + * Vite's `ConfigEnv`, which carries no platform information. The presence of a wrangler config is + * the signal that's left, and it's a reliable one: a worker can't be deployed without it. + * + * Probed from `process.cwd()` rather than the Vite root, which isn't known until `configResolved` + * — too late, since the orchestrion plugin's options are fixed when it's constructed. + */ +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..90fd95637817 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,15 @@ 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 that `@sentry/cloudflare` + // reads at runtime (both `withSentry` and `wrapRequestHandler` do, so Workers and Shopify + // Oxygen are covered); 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(); From db614f742d8f6150e1a41eeaba54f70215302e89 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Thu, 6 Aug 2026 11:58:00 +0200 Subject: [PATCH 2/3] fix(react-router): Drop RR7 load context from Cloudflare e2e worker entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit React Router 8 replaced v7's augmentable `AppLoadContext` with `RouterContextProvider`, so the `{ cloudflare: { env, ctx } }` shape from Cloudflare's framework guide no longer typechecks. Nothing in the app reads the bindings from a loader — the DSN comes from the `withSentry` env argument — so the handler takes the request alone. Co-Authored-By: Claude Opus 5 (1M context) --- .../react-router-8-cloudflare/workers/app.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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 index 3cfab9d47e05..dddfe6bb8669 100644 --- 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 @@ -20,8 +20,12 @@ export default Sentry.withSentry( environment: 'qa', // dynamic sampling bias to keep transactions }), { - async fetch(request, env, ctx) { - return requestHandler(request, { cloudflare: { env, ctx } }); + // No load context is passed: React Router 8 replaced v7's augmentable `AppLoadContext` with + // `RouterContextProvider`, so the `{ cloudflare: { env, ctx } }` shape from Cloudflare's docs no + // longer typechecks. Nothing here reads the bindings from a loader — the DSN comes from the + // `env` argument above — so the handler takes the request alone. + async fetch(request) { + return requestHandler(request); }, } satisfies ExportedHandler, ); From a37f65b5a67aaf8464e012e7b200eb494588b013 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Thu, 6 Aug 2026 13:14:05 +0200 Subject: [PATCH 3/3] ref(react-router): Trim comments in Cloudflare orchestrion wiring Cut the explanatory comments back to the non-obvious reasoning, dropping the parts that restate the code or re-describe the transform pipeline. Co-Authored-By: Claude Opus 5 (1M context) --- .../app/entry.server.tsx | 3 +-- .../app/routes/performance/db-mysql.tsx | 20 ++++++------------- .../global-setup.mjs | 3 +-- .../react-router-8-cloudflare/tsconfig.json | 3 +-- .../react-router-8-cloudflare/workers/app.ts | 12 ++++------- .../react-router/src/vite/detectCloudflare.ts | 12 ++--------- packages/react-router/src/vite/plugin.ts | 5 ++--- 7 files changed, 17 insertions(+), 41 deletions(-) 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 index 132be25cd04b..df56b2a3a8c0 100644 --- 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 @@ -3,8 +3,7 @@ import { isbot } from 'isbot'; import { renderToReadableStream } from 'react-dom/server'; import { type EntryContext, type HandleErrorFunction, ServerRouter } from 'react-router'; -// workerd has no `renderToPipeableStream` (a Node streams API), so this uses the web-streams -// renderer and injects the trace meta tags by transforming the response stream instead. +// workerd has no `renderToPipeableStream`, so this renders to a web stream instead. async function handleRequest( request: Request, responseStatusCode: number, 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 index a298fffba490..ea2ac1fcea61 100644 --- 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 @@ -1,15 +1,10 @@ import mysql from 'mysql'; import type { Route } from './+types/db-mysql'; -// The `@sentry/react-router` orchestrion transform injects the `orchestrion:mysql:query` diagnostics -// channel into the bundled `mysql` package at build time. On Cloudflare the transform also registers -// the matching subscriber factory on the global marker, which `@sentry/cloudflare` reads in the -// `withSentry` wrap — so these queries produce `db` spans with no OTel require-hook, which wouldn't -// work in workerd anyway. +// 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 }> { - // The connection is created inside the loader: workerd forbids I/O in global scope, and mysql - // opens its socket lazily on the first query. Explicit host/port because workerd's default - // resolution differs from Node's. + // Connect inside the loader: workerd forbids I/O in global scope. const connection = mysql.createConnection({ host: '127.0.0.1', port: 3306, @@ -17,17 +12,14 @@ export async function loader(): Promise<{ status: string }> { password: 'docker', }); - // Swallow connection-level errors so a socket hiccup doesn't become an uncaught exception that - // fails the request unrelated to the spans. + // Swallow socket-level errors so they don't fail the request for reasons unrelated to the spans. connection.on('error', () => { // no-op }); try { - // The second query is NESTED inside the first's callback. mysql dispatches that callback from - // its socket data handler (a fresh async context), so the nested query's span only lands on this - // request's http.server transaction if the channel subscriber restored the parent span across - // that async boundary. + // 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); 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 index 9ba25cd71638..b0695e3731f7 100644 --- 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 @@ -5,8 +5,7 @@ import { fileURLToPath } from 'url'; const __dirname = dirname(fileURLToPath(import.meta.url)); export default async function globalSetup() { - // Start MySQL via Docker Compose. `--wait` blocks until the healthcheck in - // docker-compose.yml passes, so the worker can connect on the first request. + // `--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/tsconfig.json b/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/tsconfig.json index 2899547d12df..573bcd3ad911 100644 --- 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 @@ -1,8 +1,7 @@ { "compilerOptions": { "lib": ["DOM", "DOM.Iterable", "ES2022"], - // `@cloudflare/workers-types` rather than `node`: the server runs in workerd, and it's what - // provides `ExportedHandler` for the worker entry. + // workers-types rather than node: the server runs in workerd. "types": ["@cloudflare/workers-types", "vite/client"], "target": "ES2022", "module": "ES2022", 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 index dddfe6bb8669..d80e3741947c 100644 --- 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 @@ -7,10 +7,8 @@ interface Env { E2E_TEST_DSN: string; } -// `withSentry` is what reads the build-time orchestrion marker: the `@sentry/react-router` Vite -// plugin registers each transformed package's channel-subscriber factory there, and -// `getBaseDefaultIntegrations` instantiates them at `init()`. Without this wrap the injected -// `diagnostics_channel` publishers would fire with nobody listening. +// `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', @@ -20,10 +18,8 @@ export default Sentry.withSentry( environment: 'qa', // dynamic sampling bias to keep transactions }), { - // No load context is passed: React Router 8 replaced v7's augmentable `AppLoadContext` with - // `RouterContextProvider`, so the `{ cloudflare: { env, ctx } }` shape from Cloudflare's docs no - // longer typechecks. Nothing here reads the bindings from a loader — the DSN comes from the - // `env` argument above — so the handler takes the request alone. + // 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); }, diff --git a/packages/react-router/src/vite/detectCloudflare.ts b/packages/react-router/src/vite/detectCloudflare.ts index 7b3ae2798cd7..8a8ff4ccf9b1 100644 --- a/packages/react-router/src/vite/detectCloudflare.ts +++ b/packages/react-router/src/vite/detectCloudflare.ts @@ -1,21 +1,13 @@ import * as fs from 'fs'; import * as path from 'path'; -// Wrangler's own config filenames, in its discovery precedence. A React Router app deployed to -// workerd always has one: it's how both the official Workers scaffold (`@cloudflare/vite-plugin`) -// and Shopify Oxygen declare the worker entry and compatibility flags. 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 — unlike SvelteKit (`kit.adapter.name`), - * Astro (`config.adapter.name`) or Nuxt (`nitro.preset`) — and `sentryReactRouter` only receives - * Vite's `ConfigEnv`, which carries no platform information. The presence of a wrangler config is - * the signal that's left, and it's a reliable one: a worker can't be deployed without it. - * - * Probed from `process.cwd()` rather than the Vite root, which isn't known until `configResolved` - * — too late, since the orchestrion plugin's options are fixed when it's constructed. + * 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 90fd95637817..4a23acfd6a61 100644 --- a/packages/react-router/src/vite/plugin.ts +++ b/packages/react-router/src/vite/plugin.ts @@ -27,9 +27,8 @@ export async function sentryReactRouter( plugins.push( sentryOrchestrionPlugin({ buildTimeInstrumentation: options.buildTimeInstrumentation, - // On Cloudflare, subscribers are wired via a build-time marker that `@sentry/cloudflare` - // reads at runtime (both `withSentry` and `wrapRequestHandler` do, so Workers and Shopify - // Oxygen are covered); on Node they register at init. + // On Cloudflare, subscribers are wired via a build-time marker the SDK reads at runtime; + // on Node they register at init. ...(detectCloudflare() ? { injectChannelSubscribers: true } : {}), }), );