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 (