From d138670e4e6421c93731746e8b52accb0dbd1ba5 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Wed, 23 Sep 2026 06:59:09 +0200 Subject: [PATCH] test(e2e): Add Bun, Deno and Cloudflare variants to react-router-8-framework Runs the same Playwright suite on Bun, Deno and Cloudflare (local workerd) as optional variants of the existing app, instead of one app per runtime. A variant can set a `runtime` matrix key (`bun` or `deno`), and CI then installs that runtime for the job. The Cloudflare build uses its own Vite config with `@cloudflare/vite-plugin` and `sentryCloudflareVitePlugin`, and swaps in a workerd server entry with the new `runtimeEntryPlugin` from `@sentry-internal/test-utils/vite`. Getting the variants green needed these SDK fixes: - react-router: resolve the Cloudflare entry under the `workerd` condition, so `Sentry.flush` and `Sentry.startSpan` exist in a Worker. - react-router: export `createSentryHandleError` and `lowQualityTransactionsFilterIntegration` from `/cloudflare`. - react-router: do not write trace meta tags into prerendered pages, which otherwise pinned every visitor of the static page to one build-time trace. - react-router: name an index route after its nearest ancestor path, or `/`. It has no `path` of its own, so without an Express layer (Bun, Cloudflare) the index page got no route name. - cloudflare: set the request as the transaction name, like Node does. - server-utils: write bare builtin imports of force-bundled dependencies with the `node:` prefix, which Deno before 2.9 needs. Under `bun run` Express is not instrumented, so on Bun (as on Cloudflare) the error transaction stays the request path and the meta tag names the http.server segment. The Bun variant relies on the diagnostics channel fixes of the Bun auto-instrumentation suites PR it is based on. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/build.yml | 13 +++-- dev-packages/e2e-tests/README.md | 45 ++++++++++++++++ .../app/entry.server.cloudflare.tsx | 42 +++++++++++++++ .../app/routes/performance/redis.tsx | 4 +- .../routes/performance/with-middleware.tsx | 6 +-- .../react-router-8-framework/package.json | 30 ++++++++++- .../playwright.config.mjs | 11 +++- .../tests/constants.ts | 4 ++ .../tests/errors/errors.server.test.ts | 26 ++++++---- .../performance/navigation.client.test.ts | 25 ++++++--- .../tests/performance/pageload.client.test.ts | 15 ++++-- .../performance/performance.server.test.ts | 12 +++-- .../performance/trace-propagation.test.ts | 9 +++- .../vite.cloudflare.config.ts | 22 ++++++++ .../react-router-8-framework/workers/app.ts | 9 ++++ .../workers/instrument.server.ts | 10 ++++ .../react-router-8-framework/wrangler.jsonc | 10 ++++ dev-packages/test-utils/package.json | 10 ++++ dev-packages/test-utils/rollup.npm.config.mjs | 2 +- dev-packages/test-utils/src/vite.ts | 52 +++++++++++++++++++ .../src/wrapRequestHandlerWithInit.ts | 3 ++ packages/cloudflare/test/request.test.ts | 23 ++++++++ packages/react-router/package.json | 5 ++ packages/react-router/src/cloudflare/index.ts | 7 +++ .../src/server/getMetaTagTransformer.ts | 4 +- .../react-router/src/server/serverBuild.ts | 31 ++++++++++- .../src/server/wrapSentryHandleRequest.ts | 13 ++++- .../src/vite/makeServerBuildCapturePlugin.ts | 5 +- .../test/server/getMetaTagTransformer.test.ts | 30 +++++++++++ .../test/server/serverBuild.test.ts | 35 ++++++++++++- .../server/wrapSentryHandleRequest.test.ts | 39 ++++++++++++++ .../vite/makeServerBuildCapturePlugin.test.ts | 2 +- .../src/orchestrion/bundler/vite.ts | 20 ++++++- .../test/orchestrion/bundler.test.ts | 14 +++++ 34 files changed, 540 insertions(+), 48 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/react-router-8-framework/app/entry.server.cloudflare.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-8-framework/vite.cloudflare.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-8-framework/workers/app.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-8-framework/workers/instrument.server.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-8-framework/wrangler.jsonc create mode 100644 dev-packages/test-utils/src/vite.ts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 729e22999414..e1a1a888b0d8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1028,8 +1028,8 @@ jobs: node-version-file: 'dev-packages/e2e-tests/test-applications/${{ matrix.test-application }}/package.json' - name: Set up Bun if: - contains(fromJSON('["node-exports-test-app","nextjs-16-bun", "elysia-bun", "elysia-bun-static", "hono-4", - "bun-bytecode", "bun-mysql"]'), matrix.test-application) + matrix.runtime == 'bun' || contains(fromJSON('["node-exports-test-app","nextjs-16-bun", "elysia-bun", + "elysia-bun-static", "hono-4", "bun-bytecode", "bun-mysql"]'), matrix.test-application) uses: oven-sh/setup-bun@v2 with: bun-version: '1.3.14' @@ -1040,7 +1040,7 @@ jobs: use-installer: true token: ${{ secrets.GITHUB_TOKEN }} - name: Set up Deno - if: matrix.test-application == 'deno' || matrix.test-application == 'hono-4' + if: matrix.runtime == 'deno' || matrix.test-application == 'deno' || matrix.test-application == 'hono-4' uses: denoland/setup-deno@v2.0.5 with: deno-version: ${{ matrix.deno-version || 'v2.8.3' }} @@ -1166,8 +1166,13 @@ jobs: uses: actions/setup-node@v7 with: node-version-file: 'dev-packages/e2e-tests/test-applications/${{ matrix.test-application }}/package.json' + - name: Set up Bun + if: matrix.runtime == 'bun' + uses: oven-sh/setup-bun@v2 + with: + bun-version: '1.3.14' - name: Set up Deno - if: matrix.test-application == 'deno' + if: matrix.runtime == 'deno' || matrix.test-application == 'deno' uses: denoland/setup-deno@v2.0.5 with: deno-version: ${{ matrix.deno-version || 'v2.8.3' }} diff --git a/dev-packages/e2e-tests/README.md b/dev-packages/e2e-tests/README.md index 4a398a2b9002..3f797508ba6d 100644 --- a/dev-packages/e2e-tests/README.md +++ b/dev-packages/e2e-tests/README.md @@ -116,6 +116,51 @@ Make sure to add a `test:build` and `test:assert` command to the new app's `pack Sentry packages are automatically resolved to the local build via pnpm overrides injected at test time, so no manual registry configuration is needed. +## Runtime variants (Bun, Deno, Cloudflare) + +To test a framework on a runtime other than Node.js, add an `optionalVariants` entry to the existing test app instead of +creating a new app. `react-router-8-framework` is the reference setup. + +- **`RUNTIME` env var**: `node` (default), `bun`, `deno` or `cloudflare`. `tests/constants.ts` exports it, so tests can + branch on it where the runtimes are expected to differ (for example `platform` or `sdk.name`). +- **Start commands**: `playwright.config.mjs` selects the start command from `RUNTIME`. Bun and Deno use the same build + as Node and only change the start command, for example + `bun --bun --preload ./instrument.mjs ./node_modules/@react-router/serve/bin.cjs ./build/server/index.js` and + `deno run -A --preload ./instrument.mjs ./node_modules/@react-router/serve/bin.cjs ./build/server/index.js`. +- **Cloudflare**: the app has the Cloudflare dependencies installed all the time. The Cloudflare build has its own + `vite.cloudflare.config.ts` with `@cloudflare/vite-plugin` and `sentryCloudflareVitePlugin` from + `@sentry/cloudflare/vite`, and the variant's `build-command` passes it with `--config`. Node, Bun and Deno share + `vite.config.ts`. The Worker entry lives in a separate file (for example `workers/app.ts`) and exports a plain + handler: the Sentry plugin wraps it with `withSentry` and reads the init options from `instrument.server.ts` next to + the entry. The start command runs `wrangler dev` on the build output. Code at module scope must not do I/O (for + example open a database connection), because workerd does not allow it. +- **Runtime-specific files**: name them `..` (for example `entry.server.cloudflare.tsx`). When the + framework does not let you configure a server entry, add `runtimeEntryPlugin` from + `@sentry-internal/test-utils/vite` to the runtime's Vite config, for example + `runtimeEntryPlugin('app/entry.server.tsx', 'cloudflare')`. +- **Scripts**: put `RUNTIME` in a named script (`"test:assert:bun": "RUNTIME=bun pnpm test:assert"`), not in the + `assert-command`. `yarn test:run` prefixes the command with `volta run`, which cannot run a leading env assignment. +- **`runtime` matrix key**: set `"runtime": "bun"` or `"runtime": "deno"` on the variant. CI then installs that runtime + for the job, so a new variant needs no change to `.github/workflows/build.yml`. A variant can also pin the version, + for example `"deno-version": "v2.9.0"`. +- **Bun**: under `bun run` the SDK cannot inject diagnostics channels into packages that stay outside the build (for + example Express behind `react-router-serve`), so those produce no spans on Bun. Where a test depends on them, branch + on `RUNTIME` and say why in a comment. + +```json +"sentryTest": { + "optionalVariants": [ + { "assert-command": "pnpm test:assert:bun", "runtime": "bun", "label": "my-app (bun)" }, + { "assert-command": "pnpm test:assert:deno", "runtime": "deno", "label": "my-app (deno)" }, + { + "build-command": "pnpm test:build:cloudflare", + "assert-command": "pnpm test:assert:cloudflare", + "label": "my-app (cloudflare)" + } + ] +} +``` + ## Troubleshooting ### Common Issues diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/entry.server.cloudflare.tsx b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/entry.server.cloudflare.tsx new file mode 100644 index 000000000000..cc9ba04a07b4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/entry.server.cloudflare.tsx @@ -0,0 +1,42 @@ +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'; + +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; + + 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 = Sentry.createSentryHandleError({ logErrors: true }); + +export default Sentry.wrapSentryHandleRequest(handleRequest); diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes/performance/redis.tsx b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes/performance/redis.tsx index cba8275fcf63..cf3eb5dbd4be 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes/performance/redis.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes/performance/redis.tsx @@ -1,9 +1,11 @@ import Redis from 'ioredis'; import type { Route } from './+types/redis'; -const redis = new Redis(); +// workerd does not allow a socket connect at module scope, so the client is made in the loader. +let redis: Redis | undefined; export async function loader() { + redis ??= new Redis(); const key = 'cache:greeting'; await redis.set(key, 'hello from react-router'); const value = await redis.get(key); diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes/performance/with-middleware.tsx b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes/performance/with-middleware.tsx index c86f78e17164..0c8f45eee61d 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes/performance/with-middleware.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes/performance/with-middleware.tsx @@ -11,11 +11,11 @@ async function getUser() { }; } -const authMiddleware: Route.MiddlewareFunction = async ({ request, context }, next) => { - Sentry.startSpan({ name: 'authMiddleware', op: 'middleware.auth' }, async () => { +const authMiddleware: Route.MiddlewareFunction = async ({ context }, next) => { + return Sentry.startSpan({ name: 'authMiddleware', op: 'middleware.auth' }, async () => { const user: User = await getUser(); context.set(userContext, user); - await next(); + return next(); }); }; diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/package.json b/dev-packages/e2e-tests/test-applications/react-router-8-framework/package.json index d4d993061d6f..55a78a9f66c7 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-framework/package.json +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/package.json @@ -9,11 +9,13 @@ "react-router": "^8.0.0", "@react-router/node": "^8.0.0", "@react-router/serve": "^8.0.0", + "@sentry/cloudflare": "file:../../packed/sentry-cloudflare-packed.tgz", "@sentry/react-router": "file:../../packed/sentry-react-router-packed.tgz", "ioredis": "^5.11.1", "isbot": "^5.1.43" }, "devDependencies": { + "@cloudflare/vite-plugin": "^1.35.0", "@types/react": "19.2.17", "@types/react-dom": "19.2.3", "@types/node": "^22", @@ -21,18 +23,26 @@ "@playwright/test": "~1.63.0", "@sentry-internal/test-utils": "link:../../../test-utils", "typescript": "^5.6.3", - "vite": "^7.3.2" + "vite": "^7.3.2", + "wrangler": "^4.120.0" }, "scripts": { "build": "react-router build", "test:build-latest": "pnpm install && pnpm add react-router@latest && pnpm add @react-router/node@latest && pnpm add @react-router/serve@latest && pnpm build", "dev": "NODE_OPTIONS='--import ./instrument.mjs' react-router dev", "start": "NODE_ENV=production NODE_OPTIONS='--import ./instrument.mjs' react-router-serve ./build/server/index.js", + "start:bun": "NODE_ENV=production bun --bun --preload ./instrument.mjs ./node_modules/@react-router/serve/bin.cjs ./build/server/index.js", + "start:deno": "NODE_ENV=production deno run -A --preload ./instrument.mjs ./node_modules/@react-router/serve/bin.cjs ./build/server/index.js", + "start:cloudflare": "wrangler dev --config ./build/server/wrangler.json --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:build:cloudflare": "pnpm install && react-router build --config vite.cloudflare.config.ts", "test:assert": "pnpm test:ts && pnpm test:playwright", + "test:assert:bun": "RUNTIME=bun pnpm test:assert", + "test:assert:deno": "RUNTIME=deno pnpm test:assert", + "test:assert:cloudflare": "RUNTIME=cloudflare pnpm test:assert", "test:ts": "pnpm typecheck", "test:playwright": "playwright test" }, @@ -64,6 +74,24 @@ "build-command": "pnpm test:build-latest", "label": "react-router-8-framework (latest)" } + ], + "optionalVariants": [ + { + "assert-command": "pnpm test:assert:bun", + "runtime": "bun", + "label": "react-router-8-framework (bun)" + }, + { + "assert-command": "pnpm test:assert:deno", + "runtime": "deno", + "deno-version": "v2.9.0", + "label": "react-router-8-framework (deno)" + }, + { + "build-command": "pnpm test:build:cloudflare", + "assert-command": "pnpm test:assert:cloudflare", + "label": "react-router-8-framework (cloudflare)" + } ] } } diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/react-router-8-framework/playwright.config.mjs index 70e62c2b9e3b..4b1dd49f81ad 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-framework/playwright.config.mjs +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/playwright.config.mjs @@ -1,9 +1,18 @@ import { getPlaywrightConfig } from '@sentry-internal/test-utils'; import { fileURLToPath } from 'url'; +const RUNTIME = process.env.RUNTIME || 'node'; + +const startCommands = { + node: 'PORT=3030 pnpm start', + bun: 'PORT=3030 pnpm start:bun', + deno: 'PORT=3030 pnpm start:deno', + cloudflare: 'pnpm start:cloudflare', +}; + const config = getPlaywrightConfig( { - startCommand: `PORT=3030 pnpm start`, + startCommand: startCommands[RUNTIME], port: 3030, }, // Boot Redis before the tests run, outside the webServer startup-timeout window. diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/constants.ts b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/constants.ts index f6971b89941e..4df42e848317 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/constants.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/constants.ts @@ -1 +1,5 @@ +export type Runtime = 'node' | 'bun' | 'deno' | 'cloudflare'; + +export const RUNTIME = (process.env.RUNTIME || 'node') as Runtime; + export const APP_NAME = 'react-router-8-framework'; diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/errors/errors.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/errors/errors.server.test.ts index acb11603fedc..dbbca4477eec 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/errors/errors.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/errors/errors.server.test.ts @@ -1,6 +1,6 @@ import { expect, test } from '@playwright/test'; import { waitForError } from '@sentry-internal/test-utils'; -import { APP_NAME } from '../constants'; +import { APP_NAME, RUNTIME } from '../constants'; test.describe('server-side errors', () => { test('captures error thrown in server loader', async ({ page }) => { @@ -26,21 +26,23 @@ test.describe('server-side errors', () => { }, ], }, - // todo: should be 'GET /errors/server-loader' - transaction: 'GET /{*splat}', + // Express names the transaction on Node and Deno. Without an Express layer (Cloudflare, and Bun, where + // Express is not instrumented under `bun run`) it stays the request path. + // todo: should be 'GET /errors/server-loader' everywhere + transaction: RUNTIME === 'cloudflare' || RUNTIME === 'bun' ? 'GET /errors/server-loader' : 'GET /{*splat}', request: { url: expect.stringContaining('errors/server-loader'), headers: expect.any(Object), }, level: 'error', - platform: 'node', + platform: RUNTIME === 'cloudflare' ? 'javascript' : 'node', environment: 'qa', sdk: { integrations: expect.any(Array), - name: 'sentry.javascript.react-router', + name: RUNTIME === 'cloudflare' ? 'sentry.javascript.cloudflare' : 'sentry.javascript.react-router', version: expect.any(String), }, - tags: { runtime: 'node' }, + ...(RUNTIME === 'cloudflare' ? {} : { tags: { runtime: 'node' } }), contexts: { trace: { span_id: expect.any(String), @@ -74,21 +76,23 @@ test.describe('server-side errors', () => { }, ], }, - // todo: should be 'POST /errors/server-action' - transaction: 'POST /{*splat}', + // Express names the transaction on Node and Deno. Without an Express layer (Cloudflare, and Bun, where + // Express is not instrumented under `bun run`) it stays the request path. + // todo: should be 'POST /errors/server-action' everywhere + transaction: RUNTIME === 'cloudflare' || RUNTIME === 'bun' ? 'POST /errors/server-action.data' : 'POST /{*splat}', request: { url: expect.stringContaining('errors/server-action'), headers: expect.any(Object), }, level: 'error', - platform: 'node', + platform: RUNTIME === 'cloudflare' ? 'javascript' : 'node', environment: 'qa', sdk: { integrations: expect.any(Array), - name: 'sentry.javascript.react-router', + name: RUNTIME === 'cloudflare' ? 'sentry.javascript.cloudflare' : 'sentry.javascript.react-router', version: expect.any(String), }, - tags: { runtime: 'node' }, + ...(RUNTIME === 'cloudflare' ? {} : { tags: { runtime: 'node' } }), contexts: { trace: { span_id: expect.any(String), diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/navigation.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/navigation.client.test.ts index e203c87b9664..765cdd890d06 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/navigation.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/navigation.client.test.ts @@ -1,6 +1,6 @@ import { expect, test } from '@playwright/test'; import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; -import { APP_NAME } from '../constants'; +import { APP_NAME, RUNTIME } from '../constants'; test.describe('client - navigation performance', () => { test('should create navigation span', async ({ page }) => { @@ -88,10 +88,14 @@ test.describe('client - navigation performance', () => { 'sentry.op': { value: 'navigation', type: 'string' }, 'sentry.origin': { value: 'auto.navigation.react_router', type: 'string' }, 'url.template': { value: '/performance', type: 'string' }, - // the initial pageload to `/performance` gets 301-redirected to a trailing slash by react-router-serve - 'url.path': { value: '/performance/', type: 'string' }, + // the initial pageload to `/performance` gets 301-redirected to a trailing slash by react-router-serve, workerd does not + 'url.path': { value: RUNTIME === 'cloudflare' ? '/performance' : '/performance/', type: 'string' }, 'url.full': { - value: expect.stringMatching(/^https?:\/\/localhost:\d+\/performance\/\?query=test$/), + value: expect.stringMatching( + RUNTIME === 'cloudflare' + ? /^https?:\/\/localhost:\d+\/performance\?query=test$/ + : /^https?:\/\/localhost:\d+\/performance\/\?query=test$/, + ), type: 'string', }, }); @@ -166,9 +170,16 @@ test.describe('client - navigation performance', () => { 'sentry.op': { value: 'navigation', type: 'string' }, 'sentry.origin': { value: 'auto.navigation.react_router', type: 'string' }, 'url.template': { value: '/performance', type: 'string' }, - // react-router-serve 301-redirects the bare index route to a trailing slash - 'url.path': { value: '/performance/', type: 'string' }, - 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/performance\/$/), type: 'string' }, + // react-router-serve 301-redirects the bare index route to a trailing slash, workerd does not + 'url.path': { value: RUNTIME === 'cloudflare' ? '/performance' : '/performance/', type: 'string' }, + 'url.full': { + value: expect.stringMatching( + RUNTIME === 'cloudflare' + ? /^https?:\/\/localhost:\d+\/performance$/ + : /^https?:\/\/localhost:\d+\/performance\/$/, + ), + type: 'string', + }, }); }); }); diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/pageload.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/pageload.client.test.ts index 15ece1778f40..1458c4b693e0 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/pageload.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/pageload.client.test.ts @@ -1,6 +1,6 @@ import { expect, test } from '@playwright/test'; import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; -import { APP_NAME } from '../constants'; +import { APP_NAME, RUNTIME } from '../constants'; test.describe('client - pageload performance', () => { test('should send pageload span', async ({ page }) => { @@ -30,9 +30,16 @@ test.describe('client - pageload performance', () => { 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'sentry.sdk.integrations': { value: expect.arrayContaining([expect.any(String)]), type: 'array' }, 'url.template': { value: '/performance', type: 'string' }, - // react-router-serve 301-redirects the bare index route to a trailing slash - 'url.path': { value: '/performance/', type: 'string' }, - 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/performance\/$/), type: 'string' }, + // react-router-serve 301-redirects the bare index route to a trailing slash, workerd does not + 'url.path': { value: RUNTIME === 'cloudflare' ? '/performance' : '/performance/', type: 'string' }, + 'url.full': { + value: expect.stringMatching( + RUNTIME === 'cloudflare' + ? /^https?:\/\/localhost:\d+\/performance$/ + : /^https?:\/\/localhost:\d+\/performance\/$/, + ), + type: 'string', + }, }); }); diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/performance.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/performance.server.test.ts index 432b7c60f5f0..cf9aacf133a8 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/performance.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/performance.server.test.ts @@ -1,6 +1,6 @@ import { expect, test } from '@playwright/test'; import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; -import { APP_NAME } from '../constants'; +import { APP_NAME, RUNTIME } from '../constants'; test.describe('server - performance', () => { test('should send server span on pageload', async ({ page }) => { @@ -26,7 +26,10 @@ test.describe('server - performance', () => { 'sentry.origin': { value: 'auto.http.react_router.request_handler', type: 'string' }, 'sentry.segment.name.source': { value: 'route', type: 'string' }, 'sentry.environment': { value: 'qa', type: 'string' }, - 'sentry.sdk.name': { value: 'sentry.javascript.react-router', type: 'string' }, + 'sentry.sdk.name': { + value: RUNTIME === 'cloudflare' ? 'sentry.javascript.cloudflare' : 'sentry.javascript.react-router', + type: 'string', + }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'sentry.sdk.integrations': { value: expect.arrayContaining([expect.any(String)]), type: 'array' }, 'url.full': { value: expect.stringContaining('/performance'), type: 'string' }, @@ -56,7 +59,10 @@ test.describe('server - performance', () => { 'sentry.origin': { value: 'auto.http.react_router.request_handler', type: 'string' }, 'sentry.segment.name.source': { value: 'route', type: 'string' }, 'sentry.environment': { value: 'qa', type: 'string' }, - 'sentry.sdk.name': { value: 'sentry.javascript.react-router', type: 'string' }, + 'sentry.sdk.name': { + value: RUNTIME === 'cloudflare' ? 'sentry.javascript.cloudflare' : 'sentry.javascript.react-router', + type: 'string', + }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'sentry.sdk.integrations': { value: expect.arrayContaining([expect.any(String)]), type: 'array' }, 'url.full': { value: expect.stringContaining('/performance/with/some-param'), type: 'string' }, diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/trace-propagation.test.ts b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/trace-propagation.test.ts index 9ac6ccb807b1..b2991b7f0dc0 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/trace-propagation.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/trace-propagation.test.ts @@ -1,7 +1,7 @@ import { expect, test } from '@playwright/test'; import type { SerializedStreamedSpan } from '@sentry-internal/test-utils'; import { getSpanOp, waitForStreamedSpans } from '@sentry-internal/test-utils'; -import { APP_NAME } from '../constants'; +import { APP_NAME, RUNTIME } from '../constants'; test.describe('Trace propagation', () => { test('should inject metatags in ssr pageload', async ({ page }) => { @@ -51,7 +51,12 @@ test.describe('Trace propagation', () => { const requestHandlerSpan = streamedSpans.find(span => span.span_id === handlerSpanId); expect(requestHandlerSpan).toBeDefined(); - expect(getSpanOp(requestHandlerSpan!)).toBe('handler'); + // On Node and Deno the Express request handler span is active while the page renders. Without an Express + // layer (Cloudflare, and Bun, where Express is not instrumented under `bun run`) the tag names the + // http.server segment itself. + expect(getSpanOp(requestHandlerSpan!)).toBe( + RUNTIME === 'cloudflare' || RUNTIME === 'bun' ? 'http.server' : 'handler', + ); expect(requestHandlerSpan!.trace_id).toBe(traceId); }); diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/vite.cloudflare.config.ts b/dev-packages/e2e-tests/test-applications/react-router-8-framework/vite.cloudflare.config.ts new file mode 100644 index 000000000000..c6e7922f6ad5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/vite.cloudflare.config.ts @@ -0,0 +1,22 @@ +import { cloudflare } from '@cloudflare/vite-plugin'; +import { reactRouter } from '@react-router/dev/vite'; +import { runtimeEntryPlugin } from '@sentry-internal/test-utils/vite'; +import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite'; +import { sentryReactRouter } from '@sentry/react-router/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig(async config => ({ + plugins: [ + // workerd has no `renderToPipeableStream`, so the Worker needs its own server entry. + runtimeEntryPlugin('app/entry.server.tsx', 'cloudflare'), + cloudflare({ viteEnvironment: { name: 'ssr' } }), + sentryCloudflareVitePlugin(), + reactRouter(), + ...((await sentryReactRouter( + // Both Sentry plugins inject the orchestrion snippet, and injecting it twice fails the build. + { sourcemaps: { disable: true }, buildTimeInstrumentation: false }, + config, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + )) as any[]), + ], +})); diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/workers/app.ts b/dev-packages/e2e-tests/test-applications/react-router-8-framework/workers/app.ts new file mode 100644 index 000000000000..e14713f82cde --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/workers/app.ts @@ -0,0 +1,9 @@ +import { createRequestHandler } from 'react-router'; + +const requestHandler = createRequestHandler(() => import('virtual:react-router/server-build'), import.meta.env.MODE); + +export default { + async fetch(request: Request) { + return requestHandler(request); + }, +}; diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/workers/instrument.server.ts b/dev-packages/e2e-tests/test-applications/react-router-8-framework/workers/instrument.server.ts new file mode 100644 index 000000000000..55e06b6aa2d1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/workers/instrument.server.ts @@ -0,0 +1,10 @@ +import { defineCloudflareOptions } from '@sentry/cloudflare'; +import { lowQualityTransactionsFilterIntegration } from '@sentry/react-router/cloudflare'; + +export default defineCloudflareOptions({ + dsn: 'https://username@domain/123', + environment: 'qa', // dynamic sampling bias to keep transactions + tracesSampleRate: 1.0, + tunnel: 'http://localhost:3031/', // proxy server + integrations: [lowQualityTransactionsFilterIntegration()], +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/wrangler.jsonc b/dev-packages/e2e-tests/test-applications/react-router-8-framework/wrangler.jsonc new file mode 100644 index 000000000000..e9cb6bee7cd7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/wrangler.jsonc @@ -0,0 +1,10 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "react-router-8-framework", + "compatibility_date": "2026-06-29", + "compatibility_flags": ["nodejs_compat"], + "main": "./workers/app.ts", + "assets": { + "directory": "./build/client", + }, +} diff --git a/dev-packages/test-utils/package.json b/dev-packages/test-utils/package.json index b989ca5d90cc..97f380d38caf 100644 --- a/dev-packages/test-utils/package.json +++ b/dev-packages/test-utils/package.json @@ -33,6 +33,16 @@ "types": "./build/types/cli.d.ts", "default": "./build/cjs/cli.js" } + }, + "./vite": { + "import": { + "types": "./build/types/vite.d.ts", + "default": "./build/esm/vite.js" + }, + "require": { + "types": "./build/types/vite.d.ts", + "default": "./build/cjs/vite.js" + } } }, "sideEffects": false, diff --git a/dev-packages/test-utils/rollup.npm.config.mjs b/dev-packages/test-utils/rollup.npm.config.mjs index 3c774d19e5a6..587748d5eee5 100644 --- a/dev-packages/test-utils/rollup.npm.config.mjs +++ b/dev-packages/test-utils/rollup.npm.config.mjs @@ -2,7 +2,7 @@ import { makeBaseNPMConfig, makeNPMConfigVariants } from '@sentry-internal/rollu export default makeNPMConfigVariants( makeBaseNPMConfig({ - entrypoints: ['src/index.ts', 'src/cli.ts'], + entrypoints: ['src/index.ts', 'src/cli.ts', 'src/vite.ts'], packageSpecificConfig: { output: { // set exports to 'named' or 'auto' so that rollup doesn't warn diff --git a/dev-packages/test-utils/src/vite.ts b/dev-packages/test-utils/src/vite.ts new file mode 100644 index 000000000000..1b59fe93d3a6 --- /dev/null +++ b/dev-packages/test-utils/src/vite.ts @@ -0,0 +1,52 @@ +import { isAbsolute, resolve } from 'node:path'; + +interface ResolvedId { + id: string; +} + +interface ResolveContext { + resolve(source: string, importer?: string, options?: Record): Promise; +} + +/** The subset of a Vite plugin that {@link runtimeEntryPlugin} returns, so this package needs no `vite` dependency. */ +export interface RuntimeEntryPlugin { + name: string; + enforce: 'pre'; + configResolved(config: { root: string }): void; + resolveId( + this: ResolveContext, + source: string, + importer: string | undefined, + options: Record, + ): Promise; +} + +/** + * Makes Vite load `.` in place of `file`, for a server entry that the framework + * does not let you configure. `file` is relative to the Vite root. + * + * @example + * ```ts + * // vite.cloudflare.config.ts, loads `app/entry.server.cloudflare.tsx` in place of `app/entry.server.tsx` + * import { runtimeEntryPlugin } from '@sentry-internal/test-utils/vite'; + * + * export default defineConfig({ + * plugins: [runtimeEntryPlugin('app/entry.server.tsx', 'cloudflare'), cloudflare(), reactRouter()], + * }); + * ``` + */ +export function runtimeEntryPlugin(file: string, runtime: string): RuntimeEntryPlugin { + let entry = resolve(file); + + return { + name: 'sentry-test-runtime-entry', + enforce: 'pre', + configResolved(config) { + entry = isAbsolute(file) ? file : resolve(config.root, file); + }, + async resolveId(source, importer, options) { + const resolved = await this.resolve(source, importer, { ...options, skipSelf: true }); + return resolved?.id === entry ? entry.replace(/(\.[^./]+)$/, `.${runtime}$1`) : null; + }, + }; +} diff --git a/packages/cloudflare/src/wrapRequestHandlerWithInit.ts b/packages/cloudflare/src/wrapRequestHandlerWithInit.ts index 18f2ce63ddb2..e1f1cf645c3e 100644 --- a/packages/cloudflare/src/wrapRequestHandlerWithInit.ts +++ b/packages/cloudflare/src/wrapRequestHandlerWithInit.ts @@ -125,6 +125,9 @@ export function wrapRequestHandlerWithInit( addCloudResourceContext(isolationScope); addRequest(isolationScope, request); + // Errors thrown before a framework names the route (e.g. in a loader) would otherwise have no + // `transaction`. Frameworks overwrite this once they know the parameterized route. + isolationScope.setTransactionName(rawName); if (request.cf) { addCultureContext(isolationScope, request.cf); diff --git a/packages/cloudflare/test/request.test.ts b/packages/cloudflare/test/request.test.ts index baf9282f9e15..07d4567682c7 100644 --- a/packages/cloudflare/test/request.test.ts +++ b/packages/cloudflare/test/request.test.ts @@ -211,6 +211,29 @@ describe('withSentry', () => { }); }); + test('sets the request as the transaction name for events', async () => { + let sentryEvent: Event = {}; + await wrapRequestHandler( + { + options: { + ...MOCK_OPTIONS, + beforeSend(event) { + sentryEvent = event; + return null; + }, + }, + request: new Request('https://example.com/users/42?tab=posts', { method: 'POST' }), + context: createMockExecutionContext(), + }, + () => { + SentryCore.captureMessage('transaction name'); + return new Response('test'); + }, + ); + + expect(sentryEvent.transaction).toBe('POST /users/42'); + }); + test('adds culture context', async () => { const mockRequest = new Request('https://example.com') as any; mockRequest.cf = { diff --git a/packages/react-router/package.json b/packages/react-router/package.json index 1ec8a5122ce5..dccc81379f2b 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -20,6 +20,11 @@ "./package.json": "./package.json", ".": { "types": "./build/types/index.types.d.ts", + "workerd": { + "import": "./build/esm/cloudflare/index.js", + "require": "./build/cjs/cloudflare/index.js", + "default": "./build/esm/cloudflare/index.js" + }, "browser": { "import": "./build/esm/index.client.js", "require": "./build/cjs/index.client.js" diff --git a/packages/react-router/src/cloudflare/index.ts b/packages/react-router/src/cloudflare/index.ts index e1e349306043..48c43c956b74 100644 --- a/packages/react-router/src/cloudflare/index.ts +++ b/packages/react-router/src/cloudflare/index.ts @@ -1,8 +1,11 @@ import { getTraceMetaTags } from '@sentry/core'; +import { isPrerenderRequest } from '../server/serverBuild'; export * from '../client'; export { wrapSentryHandleRequest } from '../server/wrapSentryHandleRequest'; +export { createSentryHandleError, type SentryHandleErrorOptions } from '../server/createSentryHandleError'; +export { lowQualityTransactionsFilterIntegration } from '../server/integration/lowQualityTransactionsFilterIntegration'; /** * Injects Sentry trace meta tags into the HTML response by transforming the ReadableStream. @@ -11,6 +14,10 @@ export { wrapSentryHandleRequest } from '../server/wrapSentryHandleRequest'; * @returns A new ReadableStream with Sentry trace meta tags injected into the head section */ export function injectTraceMetaTags(body: ReadableStream): ReadableStream { + if (isPrerenderRequest()) { + return body; + } + const headClosingTag = ''; const reader = body.getReader(); diff --git a/packages/react-router/src/server/getMetaTagTransformer.ts b/packages/react-router/src/server/getMetaTagTransformer.ts index 376bf8919691..3e6eb924d539 100644 --- a/packages/react-router/src/server/getMetaTagTransformer.ts +++ b/packages/react-router/src/server/getMetaTagTransformer.ts @@ -1,6 +1,7 @@ import type { PassThrough } from 'node:stream'; import { Transform } from 'node:stream'; import { getTraceMetaTags } from '@sentry/core'; +import { isPrerenderRequest } from './serverBuild'; /** * Injects Sentry trace meta tags into the HTML response by piping through a transform stream. @@ -10,6 +11,7 @@ import { getTraceMetaTags } from '@sentry/core'; */ export function getMetaTagTransformer(body: PassThrough): Transform { const headClosingTag = ''; + const injectMetaTags = !isPrerenderRequest(); // A single streaming decoder carries incomplete multi-byte sequences across chunk // boundaries. Decoding each chunk on its own (e.g. `Buffer.toString()`) would flush a // split character as U+FFFD, corrupting the response (see @@ -18,7 +20,7 @@ export function getMetaTagTransformer(body: PassThrough): Transform { const htmlMetaTagTransformer = new Transform({ transform(chunk, _encoding, callback) { const html = Buffer.isBuffer(chunk) ? decoder.decode(chunk, { stream: true }) : String(chunk); - if (html.includes(headClosingTag)) { + if (injectMetaTags && html.includes(headClosingTag)) { const modifiedHtml = html.replace(headClosingTag, `${getTraceMetaTags()}${headClosingTag}`); callback(null, modifiedHtml); return; diff --git a/packages/react-router/src/server/serverBuild.ts b/packages/react-router/src/server/serverBuild.ts index 3b46c745b4b7..1dd606f3ed1c 100644 --- a/packages/react-router/src/server/serverBuild.ts +++ b/packages/react-router/src/server/serverBuild.ts @@ -1,7 +1,7 @@ -import { GLOBAL_OBJ } from '@sentry/core'; +import { getIsolationScope, GLOBAL_OBJ, parseStringToURLObject } from '@sentry/core'; /** - * Subset of ServerBuild shape for middleware name lookup. + * Subset of ServerBuild shape for middleware name lookup and prerender detection. * The official React Router types don't expose `middleware` on route modules yet. * @internal */ @@ -14,6 +14,8 @@ interface ServerBuildLike { }; } >; + /** The paths React Router prerenders to static HTML at build time. */ + prerender?: string[]; } /** @internal */ @@ -53,6 +55,31 @@ export function getMiddlewareName(routeId: string, index: number): string | unde return middlewareFn?.name || undefined; } +function withoutTrailingSlash(path: string): string { + return path.length > 1 && path.endsWith('/') ? path.slice(0, -1) : path; +} + +/** + * Whether the current request renders a path from the build's `prerender` list. + * + * In production these paths are served as static files and never reach the request handler, so a + * render of one is the build-time prerender. Trace meta tags must not be written into that HTML: + * every visitor of the static page would continue the same trace. + * + * @internal + */ +export function isPrerenderRequest(): boolean { + const prerender = _serverBuild?.prerender; + if (!prerender?.length) return false; + + const url = getIsolationScope().getScopeData().sdkProcessingMetadata.normalizedRequest?.url; + const pathname = url ? parseStringToURLObject(url)?.pathname : undefined; + if (!pathname) return false; + + const requestPath = withoutTrailingSlash(pathname); + return prerender.some(path => withoutTrailingSlash(path) === requestPath); +} + /** @internal */ export function registerServerBuildGlobal(): void { (GLOBAL_OBJ as GlobalObjWithBuildCapture)[GLOBAL_KEY] = setServerBuild; diff --git a/packages/react-router/src/server/wrapSentryHandleRequest.ts b/packages/react-router/src/server/wrapSentryHandleRequest.ts index 8a4af55ce62e..f40d56cad735 100644 --- a/packages/react-router/src/server/wrapSentryHandleRequest.ts +++ b/packages/react-router/src/server/wrapSentryHandleRequest.ts @@ -8,6 +8,7 @@ import { } from '@sentry/core'; import { flushIfServerless } from '@sentry/core/server'; import type { AppLoadContext, EntryContext, RouterContextProvider } from 'react-router'; +import { registerServerBuildGlobal } from './serverBuild'; import { isInstrumentationApiUsed } from './serverGlobals'; type OriginalHandleRequestWithoutMiddleware = ( @@ -53,6 +54,11 @@ export function wrapSentryHandleRequest( export function wrapSentryHandleRequest( originalHandle: OriginalHandleRequestWithoutMiddleware | OriginalHandleRequestWithMiddleware, ): OriginalHandleRequestWithoutMiddleware | OriginalHandleRequestWithMiddleware { + // `entry.server` is evaluated before the server build module, so the build's capture call at the + // end of that module finds this. Runtimes without the Node server integration (Cloudflare) only + // register it here. + registerServerBuildGlobal(); + return async function sentryInstrumentedHandleRequest( request: Request, responseStatusCode: number, @@ -60,8 +66,11 @@ export function wrapSentryHandleRequest( routerContext: EntryContext, loadContext: AppLoadContext | RouterContextProvider, ) { - const parameterizedPath = - routerContext?.staticHandlerContext?.matches?.[routerContext.staticHandlerContext.matches.length - 1]?.route.path; + const matches = routerContext?.staticHandlerContext?.matches; + // An index route has no `path` of its own and renders at its nearest ancestor's path, or at `/`. + const parameterizedPath = matches?.length + ? ([...matches].reverse().find(match => match.route.path)?.route.path ?? '/') + : undefined; const activeSpan = getActiveSpan(); const rootSpan = activeSpan ? getRootSpan(activeSpan) : undefined; diff --git a/packages/react-router/src/vite/makeServerBuildCapturePlugin.ts b/packages/react-router/src/vite/makeServerBuildCapturePlugin.ts index b22fc715d782..8811dd13780a 100644 --- a/packages/react-router/src/vite/makeServerBuildCapturePlugin.ts +++ b/packages/react-router/src/vite/makeServerBuildCapturePlugin.ts @@ -23,10 +23,11 @@ export function makeServerBuildCapturePlugin(): Plugin { return null; } - // `routes` is a module-scope export in the virtual:react-router/server-build module + // `routes` and `prerender` are module-scope exports in the virtual:react-router/server-build + // module. `prerender` is guarded with `typeof` because older React Router versions don't declare it. const injectedCode = `${code} if (typeof globalThis !== 'undefined' && typeof globalThis["${GLOBAL_KEY}"] === 'function') { - globalThis["${GLOBAL_KEY}"]({ routes }); + globalThis["${GLOBAL_KEY}"]({ routes, prerender: typeof prerender !== 'undefined' ? prerender : undefined }); } `; diff --git a/packages/react-router/test/server/getMetaTagTransformer.test.ts b/packages/react-router/test/server/getMetaTagTransformer.test.ts index 6b2fbe10ee31..bec0e32c2ea1 100644 --- a/packages/react-router/test/server/getMetaTagTransformer.test.ts +++ b/packages/react-router/test/server/getMetaTagTransformer.test.ts @@ -2,6 +2,7 @@ import { getTraceMetaTags } from '@sentry/core'; import { PassThrough } from 'stream'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import { getMetaTagTransformer } from '../../src/server/getMetaTagTransformer'; +import { isPrerenderRequest } from '../../src/server/serverBuild'; vi.mock('@sentry/core', () => ({ SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN: 'sentry.origin', @@ -10,6 +11,10 @@ vi.mock('@sentry/core', () => ({ getTraceMetaTags: vi.fn(), })); +vi.mock('../../src/server/serverBuild', () => ({ + isPrerenderRequest: vi.fn(() => false), +})); + describe('getMetaTagTransformer', () => { beforeEach(() => { vi.clearAllMocks(); @@ -43,6 +48,31 @@ describe('getMetaTagTransformer', () => { transformer.end(); })); + test('should not inject meta tags into a prerendered page', () => + new Promise((resolve, reject) => { + vi.mocked(isPrerenderRequest).mockReturnValueOnce(true); + const bodyStream = new PassThrough(); + const transformer = getMetaTagTransformer(bodyStream); + + let outputData = ''; + bodyStream.on('data', chunk => { + outputData += chunk.toString(); + }); + + bodyStream.on('end', () => { + try { + expect(outputData).toBe('Test'); + expect(getTraceMetaTags).not.toHaveBeenCalled(); + resolve(); + } catch (e) { + reject(e); + } + }); + + transformer.write('Test'); + transformer.end(); + })); + test('should not modify chunks without head closing tag', () => new Promise((resolve, reject) => { const bodyStream = new PassThrough(); diff --git a/packages/react-router/test/server/serverBuild.test.ts b/packages/react-router/test/server/serverBuild.test.ts index 80eb7e2028b7..d18f9d40f799 100644 --- a/packages/react-router/test/server/serverBuild.test.ts +++ b/packages/react-router/test/server/serverBuild.test.ts @@ -1,8 +1,9 @@ -import { GLOBAL_OBJ } from '@sentry/core'; +import { getIsolationScope, GLOBAL_OBJ } from '@sentry/core'; import { afterEach, describe, expect, it } from 'vitest'; import { _resetServerBuild, getMiddlewareName, + isPrerenderRequest, isServerBuildLike, registerServerBuildGlobal, setServerBuild, @@ -61,6 +62,38 @@ describe('serverBuild', () => { }); }); + describe('isPrerenderRequest', () => { + afterEach(() => { + getIsolationScope().setSDKProcessingMetadata({ normalizedRequest: undefined }); + }); + + it('is true when the current request path is in the prerender list, with or without a trailing slash', () => { + setServerBuild({ routes: {}, prerender: ['/performance/static', '/about/'] }); + + getIsolationScope().setSDKProcessingMetadata({ + normalizedRequest: { url: 'http://localhost/performance/static/' }, + }); + expect(isPrerenderRequest()).toBe(true); + + getIsolationScope().setSDKProcessingMetadata({ normalizedRequest: { url: 'http://localhost/about' } }); + expect(isPrerenderRequest()).toBe(true); + }); + + it('is false for other paths, without a prerender list, or without a request', () => { + setServerBuild({ routes: {}, prerender: ['/performance/static'] }); + expect(isPrerenderRequest()).toBe(false); + + getIsolationScope().setSDKProcessingMetadata({ normalizedRequest: { url: 'http://localhost/performance' } }); + expect(isPrerenderRequest()).toBe(false); + + setServerBuild({ routes: {} }); + getIsolationScope().setSDKProcessingMetadata({ + normalizedRequest: { url: 'http://localhost/performance/static' }, + }); + expect(isPrerenderRequest()).toBe(false); + }); + }); + describe('registerServerBuildGlobal', () => { it('should register a global callback that calls setServerBuild', () => { registerServerBuildGlobal(); diff --git a/packages/react-router/test/server/wrapSentryHandleRequest.test.ts b/packages/react-router/test/server/wrapSentryHandleRequest.test.ts index c48bedef8181..6fa1f2ef1026 100644 --- a/packages/react-router/test/server/wrapSentryHandleRequest.test.ts +++ b/packages/react-router/test/server/wrapSentryHandleRequest.test.ts @@ -74,6 +74,45 @@ describe('wrapSentryHandleRequest', () => { }); }); + test('should name an index route after its nearest ancestor path, or `/`', async () => { + const mockRootSpan = { setAttributes: vi.fn() }; + + (getActiveSpan as unknown as ReturnType).mockReturnValue({}); + (getRootSpan as unknown as ReturnType).mockReturnValue(mockRootSpan); + + const wrappedHandler = wrapSentryHandleRequest(vi.fn().mockResolvedValue('test')); + + const rootIndexContext = { + staticHandlerContext: { matches: [{ route: { path: '' } }, { route: { index: true } }] }, + } as any; + await wrappedHandler(new Request('https://nacho.queso/'), 200, new Headers(), rootIndexContext, {} as any); + + expect(mockRootSpan.setAttributes).toHaveBeenLastCalledWith({ + [HTTP_ROUTE]: '/', + [SENTRY_SEGMENT_NAME_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react_router.request_handler', + }); + + const nestedIndexContext = { + staticHandlerContext: { + matches: [{ route: { path: '' } }, { route: { path: 'dashboard' } }, { route: { index: true } }], + }, + } as any; + await wrappedHandler( + new Request('https://nacho.queso/dashboard'), + 200, + new Headers(), + nestedIndexContext, + {} as any, + ); + + expect(mockRootSpan.setAttributes).toHaveBeenLastCalledWith({ + [HTTP_ROUTE]: '/dashboard', + [SENTRY_SEGMENT_NAME_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react_router.request_handler', + }); + }); + test('should not set span attributes when parameterized path does not exist', async () => { const mockActiveSpan = {}; const mockRootSpan = { setAttributes: vi.fn() }; diff --git a/packages/react-router/test/vite/makeServerBuildCapturePlugin.test.ts b/packages/react-router/test/vite/makeServerBuildCapturePlugin.test.ts index ab77da695dc0..f1adc360477f 100644 --- a/packages/react-router/test/vite/makeServerBuildCapturePlugin.test.ts +++ b/packages/react-router/test/vite/makeServerBuildCapturePlugin.test.ts @@ -45,7 +45,7 @@ describe('makeServerBuildCapturePlugin', () => { expect(result).not.toBeNull(); expect(result.code).toContain(SERVER_BUILD_CODE); expect(result.code).toContain('__sentrySetServerBuild'); - expect(result.code).toContain('({ routes })'); + expect(result.code).toContain("({ routes, prerender: typeof prerender !== 'undefined' ? prerender : undefined })"); expect(result.map).toBeNull(); }); }); diff --git a/packages/server-utils/src/orchestrion/bundler/vite.ts b/packages/server-utils/src/orchestrion/bundler/vite.ts index 0b8f66d961f4..8f96edfb80b0 100644 --- a/packages/server-utils/src/orchestrion/bundler/vite.ts +++ b/packages/server-utils/src/orchestrion/bundler/vite.ts @@ -1,5 +1,6 @@ import codeTransformer from '@apm-js-collab/code-transformer-bundler-plugins/vite'; -import type { ConfigEnv, Plugin, ResolvedConfig, UserConfig } from 'vite'; +import { builtinModules } from 'node:module'; +import type { ConfigEnv, Plugin, ResolvedConfig, Rollup, UserConfig } from 'vite'; export type { Plugin as VitePlugin } from 'vite'; import { instrumentedModuleNames } from '../config'; @@ -34,6 +35,20 @@ function ssrOnlyTransform(transform: Plugin['transform']): Plugin['transform'] { return transform; } +const BARE_BUILTINS = new Set(builtinModules.filter(name => !name.startsWith('node:'))); + +/** + * Wraps `output.paths` so bare Node builtin imports are written with the `node:` prefix. The + * force-bundled CJS dependencies call `require('events')` and similar, which Rollup keeps as a + * bare `import "events"`. Node and Bun load that, but Deno before 2.9 does not. + */ +function prefixBuiltinPaths(paths: Rollup.OutputOptions['paths']): NonNullable { + return id => { + const path = typeof paths === 'function' ? paths(id) : (paths?.[id] ?? id); + return path === id && BARE_BUILTINS.has(id) ? `node:${id}` : path; + }; +} + /** * Vite plugin that runs the orchestrion code transform on the bundled output. * @@ -118,6 +133,9 @@ export function sentryOrchestrionPlugin(options: PluginOptions = {}): Plugin { return { resolve: { noExternal: noExternalModules() } }; }, + outputOptions(outputOptions: Rollup.OutputOptions): Rollup.OutputOptions { + return { ...outputOptions, paths: prefixBuiltinPaths(outputOptions.paths) }; + }, configResolved(config: ResolvedConfig): void { // Nothing is force-bundled in `serve`, so an externalized module is expected there. if (config.command === 'serve') { diff --git a/packages/server-utils/test/orchestrion/bundler.test.ts b/packages/server-utils/test/orchestrion/bundler.test.ts index b04fa7d1f160..989a3d26ab38 100644 --- a/packages/server-utils/test/orchestrion/bundler.test.ts +++ b/packages/server-utils/test/orchestrion/bundler.test.ts @@ -324,6 +324,20 @@ describe('sentryOrchestrionPlugin (vite)', () => { expect(transform.call({}, 'code', 'id', { ssr: true })).toBe('transformed'); }); + it('writes bare builtin imports with the node: prefix and keeps other paths', () => { + const outputOptions = vitePlugin().outputOptions as (options: unknown) => { paths: (id: string) => string }; + + const { paths } = outputOptions({}); + expect(paths('events')).toBe('node:events'); + expect(paths('node:net')).toBe('node:net'); + expect(paths('ioredis')).toBe('ioredis'); + + const { paths: userPaths } = outputOptions({ paths: { events: 'events-polyfill', lodash: 'lodash-es' } }); + expect(userPaths('events')).toBe('events-polyfill'); + expect(userPaths('lodash')).toBe('lodash-es'); + expect(userPaths('net')).toBe('node:net'); + }); + it('gates resolveId on the ssr flag and falls back to self-resolution', async () => { const plugin = vitePlugin(); const resolveId = plugin.resolveId as (