Skip to content

Commit c4ca304

Browse files
JPeer264claude
andcommitted
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 <noreply@anthropic.com>
1 parent 4739836 commit c4ca304

34 files changed

Lines changed: 540 additions & 48 deletions

.github/workflows/build.yml

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1054,8 +1054,8 @@ jobs:
10541054
node-version-file: 'dev-packages/e2e-tests/test-applications/${{ matrix.test-application }}/package.json'
10551055
- name: Set up Bun
10561056
if:
1057-
contains(fromJSON('["node-exports-test-app","nextjs-16-bun", "elysia-bun", "elysia-bun-static", "hono-4",
1058-
"bun-bytecode", "bun-mysql"]'), matrix.test-application)
1057+
matrix.runtime == 'bun' || contains(fromJSON('["node-exports-test-app","nextjs-16-bun", "elysia-bun",
1058+
"elysia-bun-static", "hono-4", "bun-bytecode", "bun-mysql"]'), matrix.test-application)
10591059
uses: oven-sh/setup-bun@v2
10601060
with:
10611061
bun-version: '1.3.14'
@@ -1066,7 +1066,7 @@ jobs:
10661066
use-installer: true
10671067
token: ${{ secrets.GITHUB_TOKEN }}
10681068
- name: Set up Deno
1069-
if: matrix.test-application == 'deno' || matrix.test-application == 'hono-4'
1069+
if: matrix.runtime == 'deno' || matrix.test-application == 'deno' || matrix.test-application == 'hono-4'
10701070
uses: denoland/setup-deno@v2.0.5
10711071
with:
10721072
deno-version: ${{ matrix.deno-version || 'v2.8.3' }}
@@ -1192,8 +1192,13 @@ jobs:
11921192
uses: actions/setup-node@v7
11931193
with:
11941194
node-version-file: 'dev-packages/e2e-tests/test-applications/${{ matrix.test-application }}/package.json'
1195+
- name: Set up Bun
1196+
if: matrix.runtime == 'bun'
1197+
uses: oven-sh/setup-bun@v2
1198+
with:
1199+
bun-version: '1.3.14'
11951200
- name: Set up Deno
1196-
if: matrix.test-application == 'deno'
1201+
if: matrix.runtime == 'deno' || matrix.test-application == 'deno'
11971202
uses: denoland/setup-deno@v2.0.5
11981203
with:
11991204
deno-version: ${{ matrix.deno-version || 'v2.8.3' }}

dev-packages/e2e-tests/README.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,51 @@ Make sure to add a `test:build` and `test:assert` command to the new app's `pack
116116
Sentry packages are automatically resolved to the local build via pnpm overrides injected at test time, so no manual
117117
registry configuration is needed.
118118

119+
## Runtime variants (Bun, Deno, Cloudflare)
120+
121+
To test a framework on a runtime other than Node.js, add an `optionalVariants` entry to the existing test app instead of
122+
creating a new app. `react-router-8-framework` is the reference setup.
123+
124+
- **`RUNTIME` env var**: `node` (default), `bun`, `deno` or `cloudflare`. `tests/constants.ts` exports it, so tests can
125+
branch on it where the runtimes are expected to differ (for example `platform` or `sdk.name`).
126+
- **Start commands**: `playwright.config.mjs` selects the start command from `RUNTIME`. Bun and Deno use the same build
127+
as Node and only change the start command, for example
128+
`bun --bun --preload ./instrument.mjs ./node_modules/@react-router/serve/bin.cjs ./build/server/index.js` and
129+
`deno run -A --preload ./instrument.mjs ./node_modules/@react-router/serve/bin.cjs ./build/server/index.js`.
130+
- **Cloudflare**: the app has the Cloudflare dependencies installed all the time. The Cloudflare build has its own
131+
`vite.cloudflare.config.ts` with `@cloudflare/vite-plugin` and `sentryCloudflareVitePlugin` from
132+
`@sentry/cloudflare/vite`, and the variant's `build-command` passes it with `--config`. Node, Bun and Deno share
133+
`vite.config.ts`. The Worker entry lives in a separate file (for example `workers/app.ts`) and exports a plain
134+
handler: the Sentry plugin wraps it with `withSentry` and reads the init options from `instrument.server.ts` next to
135+
the entry. The start command runs `wrangler dev` on the build output. Code at module scope must not do I/O (for
136+
example open a database connection), because workerd does not allow it.
137+
- **Runtime-specific files**: name them `<name>.<runtime>.<ext>` (for example `entry.server.cloudflare.tsx`). When the
138+
framework does not let you configure a server entry, add `runtimeEntryPlugin` from
139+
`@sentry-internal/test-utils/vite` to the runtime's Vite config, for example
140+
`runtimeEntryPlugin('app/entry.server.tsx', 'cloudflare')`.
141+
- **Scripts**: put `RUNTIME` in a named script (`"test:assert:bun": "RUNTIME=bun pnpm test:assert"`), not in the
142+
`assert-command`. `yarn test:run` prefixes the command with `volta run`, which cannot run a leading env assignment.
143+
- **`runtime` matrix key**: set `"runtime": "bun"` or `"runtime": "deno"` on the variant. CI then installs that runtime
144+
for the job, so a new variant needs no change to `.github/workflows/build.yml`. A variant can also pin the version,
145+
for example `"deno-version": "v2.9.0"`.
146+
- **Bun**: under `bun run` the SDK cannot inject diagnostics channels into packages that stay outside the build (for
147+
example Express behind `react-router-serve`), so those produce no spans on Bun. Where a test depends on them, branch
148+
on `RUNTIME` and say why in a comment.
149+
150+
```json
151+
"sentryTest": {
152+
"optionalVariants": [
153+
{ "assert-command": "pnpm test:assert:bun", "runtime": "bun", "label": "my-app (bun)" },
154+
{ "assert-command": "pnpm test:assert:deno", "runtime": "deno", "label": "my-app (deno)" },
155+
{
156+
"build-command": "pnpm test:build:cloudflare",
157+
"assert-command": "pnpm test:assert:cloudflare",
158+
"label": "my-app (cloudflare)"
159+
}
160+
]
161+
}
162+
```
163+
119164
## Troubleshooting
120165

121166
### Common Issues
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import * as Sentry from '@sentry/react-router/cloudflare';
2+
import { isbot } from 'isbot';
3+
import { renderToReadableStream } from 'react-dom/server';
4+
import { type EntryContext, type HandleErrorFunction, ServerRouter } from 'react-router';
5+
6+
async function handleRequest(
7+
request: Request,
8+
responseStatusCode: number,
9+
responseHeaders: Headers,
10+
routerContext: EntryContext,
11+
): Promise<Response> {
12+
let shellRendered = false;
13+
const userAgent = request.headers.get('user-agent');
14+
15+
const body = await renderToReadableStream(<ServerRouter context={routerContext} url={request.url} />, {
16+
signal: request.signal,
17+
onError(error: unknown) {
18+
responseStatusCode = 500;
19+
// Errors thrown after the shell has flushed can't change the status code, so surface them.
20+
if (shellRendered) {
21+
// eslint-disable-next-line no-console
22+
console.error(error);
23+
}
24+
},
25+
});
26+
shellRendered = true;
27+
28+
if (userAgent && isbot(userAgent)) {
29+
await body.allReady;
30+
}
31+
32+
responseHeaders.set('Content-Type', 'text/html');
33+
34+
return new Response(Sentry.injectTraceMetaTags(body), {
35+
headers: responseHeaders,
36+
status: responseStatusCode,
37+
});
38+
}
39+
40+
export const handleError: HandleErrorFunction = Sentry.createSentryHandleError({ logErrors: true });
41+
42+
export default Sentry.wrapSentryHandleRequest(handleRequest);

dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes/performance/redis.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import Redis from 'ioredis';
22
import type { Route } from './+types/redis';
33

4-
const redis = new Redis();
4+
// workerd does not allow a socket connect at module scope, so the client is made in the loader.
5+
let redis: Redis | undefined;
56

67
export async function loader() {
8+
redis ??= new Redis();
79
const key = 'cache:greeting';
810
await redis.set(key, 'hello from react-router');
911
const value = await redis.get(key);

dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes/performance/with-middleware.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,11 @@ async function getUser() {
1111
};
1212
}
1313

14-
const authMiddleware: Route.MiddlewareFunction = async ({ request, context }, next) => {
15-
Sentry.startSpan({ name: 'authMiddleware', op: 'middleware.auth' }, async () => {
14+
const authMiddleware: Route.MiddlewareFunction = async ({ context }, next) => {
15+
return Sentry.startSpan({ name: 'authMiddleware', op: 'middleware.auth' }, async () => {
1616
const user: User = await getUser();
1717
context.set(userContext, user);
18-
await next();
18+
return next();
1919
});
2020
};
2121

dev-packages/e2e-tests/test-applications/react-router-8-framework/package.json

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,30 +9,40 @@
99
"react-router": "^8.0.0",
1010
"@react-router/node": "^8.0.0",
1111
"@react-router/serve": "^8.0.0",
12+
"@sentry/cloudflare": "file:../../packed/sentry-cloudflare-packed.tgz",
1213
"@sentry/react-router": "file:../../packed/sentry-react-router-packed.tgz",
1314
"ioredis": "^5.11.1",
1415
"isbot": "^5.1.43"
1516
},
1617
"devDependencies": {
18+
"@cloudflare/vite-plugin": "^1.35.0",
1719
"@types/react": "19.2.17",
1820
"@types/react-dom": "19.2.3",
1921
"@types/node": "^22",
2022
"@react-router/dev": "^8.0.0",
2123
"@playwright/test": "~1.63.0",
2224
"@sentry-internal/test-utils": "link:../../../test-utils",
2325
"typescript": "^5.6.3",
24-
"vite": "^7.3.2"
26+
"vite": "^7.3.2",
27+
"wrangler": "^4.120.0"
2528
},
2629
"scripts": {
2730
"build": "react-router build",
2831
"test:build-latest": "pnpm install && pnpm add react-router@latest && pnpm add @react-router/node@latest && pnpm add @react-router/serve@latest && pnpm build",
2932
"dev": "NODE_OPTIONS='--import ./instrument.mjs' react-router dev",
3033
"start": "NODE_ENV=production NODE_OPTIONS='--import ./instrument.mjs' react-router-serve ./build/server/index.js",
34+
"start:bun": "NODE_ENV=production bun --bun --preload ./instrument.mjs ./node_modules/@react-router/serve/bin.cjs ./build/server/index.js",
35+
"start:deno": "NODE_ENV=production deno run -A --preload ./instrument.mjs ./node_modules/@react-router/serve/bin.cjs ./build/server/index.js",
36+
"start:cloudflare": "wrangler dev --config ./build/server/wrangler.json --port 3030",
3137
"proxy": "node start-event-proxy.mjs",
3238
"typecheck": "react-router typegen && tsc",
3339
"clean": "npx rimraf node_modules pnpm-lock.yaml",
3440
"test:build": "pnpm install && pnpm build",
41+
"test:build:cloudflare": "pnpm install && react-router build --config vite.cloudflare.config.ts",
3542
"test:assert": "pnpm test:ts && pnpm test:playwright",
43+
"test:assert:bun": "RUNTIME=bun pnpm test:assert",
44+
"test:assert:deno": "RUNTIME=deno pnpm test:assert",
45+
"test:assert:cloudflare": "RUNTIME=cloudflare pnpm test:assert",
3646
"test:ts": "pnpm typecheck",
3747
"test:playwright": "playwright test"
3848
},
@@ -64,6 +74,24 @@
6474
"build-command": "pnpm test:build-latest",
6575
"label": "react-router-8-framework (latest)"
6676
}
77+
],
78+
"optionalVariants": [
79+
{
80+
"assert-command": "pnpm test:assert:bun",
81+
"runtime": "bun",
82+
"label": "react-router-8-framework (bun)"
83+
},
84+
{
85+
"assert-command": "pnpm test:assert:deno",
86+
"runtime": "deno",
87+
"deno-version": "v2.9.0",
88+
"label": "react-router-8-framework (deno)"
89+
},
90+
{
91+
"build-command": "pnpm test:build:cloudflare",
92+
"assert-command": "pnpm test:assert:cloudflare",
93+
"label": "react-router-8-framework (cloudflare)"
94+
}
6795
]
6896
}
6997
}

dev-packages/e2e-tests/test-applications/react-router-8-framework/playwright.config.mjs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,18 @@
11
import { getPlaywrightConfig } from '@sentry-internal/test-utils';
22
import { fileURLToPath } from 'url';
33

4+
const RUNTIME = process.env.RUNTIME || 'node';
5+
6+
const startCommands = {
7+
node: 'PORT=3030 pnpm start',
8+
bun: 'PORT=3030 pnpm start:bun',
9+
deno: 'PORT=3030 pnpm start:deno',
10+
cloudflare: 'pnpm start:cloudflare',
11+
};
12+
413
const config = getPlaywrightConfig(
514
{
6-
startCommand: `PORT=3030 pnpm start`,
15+
startCommand: startCommands[RUNTIME],
716
port: 3030,
817
},
918
// Boot Redis before the tests run, outside the webServer startup-timeout window.
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,5 @@
1+
export type Runtime = 'node' | 'bun' | 'deno' | 'cloudflare';
2+
3+
export const RUNTIME = (process.env.RUNTIME || 'node') as Runtime;
4+
15
export const APP_NAME = 'react-router-8-framework';

dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/errors/errors.server.test.ts

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { expect, test } from '@playwright/test';
22
import { waitForError } from '@sentry-internal/test-utils';
3-
import { APP_NAME } from '../constants';
3+
import { APP_NAME, RUNTIME } from '../constants';
44

55
test.describe('server-side errors', () => {
66
test('captures error thrown in server loader', async ({ page }) => {
@@ -26,21 +26,23 @@ test.describe('server-side errors', () => {
2626
},
2727
],
2828
},
29-
// todo: should be 'GET /errors/server-loader'
30-
transaction: 'GET /{*splat}',
29+
// Express names the transaction on Node and Deno. Without an Express layer (Cloudflare, and Bun, where
30+
// Express is not instrumented under `bun run`) it stays the request path.
31+
// todo: should be 'GET /errors/server-loader' everywhere
32+
transaction: RUNTIME === 'cloudflare' || RUNTIME === 'bun' ? 'GET /errors/server-loader' : 'GET /{*splat}',
3133
request: {
3234
url: expect.stringContaining('errors/server-loader'),
3335
headers: expect.any(Object),
3436
},
3537
level: 'error',
36-
platform: 'node',
38+
platform: RUNTIME === 'cloudflare' ? 'javascript' : 'node',
3739
environment: 'qa',
3840
sdk: {
3941
integrations: expect.any(Array<string>),
40-
name: 'sentry.javascript.react-router',
42+
name: RUNTIME === 'cloudflare' ? 'sentry.javascript.cloudflare' : 'sentry.javascript.react-router',
4143
version: expect.any(String),
4244
},
43-
tags: { runtime: 'node' },
45+
...(RUNTIME === 'cloudflare' ? {} : { tags: { runtime: 'node' } }),
4446
contexts: {
4547
trace: {
4648
span_id: expect.any(String),
@@ -74,21 +76,23 @@ test.describe('server-side errors', () => {
7476
},
7577
],
7678
},
77-
// todo: should be 'POST /errors/server-action'
78-
transaction: 'POST /{*splat}',
79+
// Express names the transaction on Node and Deno. Without an Express layer (Cloudflare, and Bun, where
80+
// Express is not instrumented under `bun run`) it stays the request path.
81+
// todo: should be 'POST /errors/server-action' everywhere
82+
transaction: RUNTIME === 'cloudflare' || RUNTIME === 'bun' ? 'POST /errors/server-action.data' : 'POST /{*splat}',
7983
request: {
8084
url: expect.stringContaining('errors/server-action'),
8185
headers: expect.any(Object),
8286
},
8387
level: 'error',
84-
platform: 'node',
88+
platform: RUNTIME === 'cloudflare' ? 'javascript' : 'node',
8589
environment: 'qa',
8690
sdk: {
8791
integrations: expect.any(Array<string>),
88-
name: 'sentry.javascript.react-router',
92+
name: RUNTIME === 'cloudflare' ? 'sentry.javascript.cloudflare' : 'sentry.javascript.react-router',
8993
version: expect.any(String),
9094
},
91-
tags: { runtime: 'node' },
95+
...(RUNTIME === 'cloudflare' ? {} : { tags: { runtime: 'node' } }),
9296
contexts: {
9397
trace: {
9498
span_id: expect.any(String),

dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/navigation.client.test.ts

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { expect, test } from '@playwright/test';
22
import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils';
3-
import { APP_NAME } from '../constants';
3+
import { APP_NAME, RUNTIME } from '../constants';
44

55
test.describe('client - navigation performance', () => {
66
test('should create navigation span', async ({ page }) => {
@@ -88,10 +88,14 @@ test.describe('client - navigation performance', () => {
8888
'sentry.op': { value: 'navigation', type: 'string' },
8989
'sentry.origin': { value: 'auto.navigation.react_router', type: 'string' },
9090
'url.template': { value: '/performance', type: 'string' },
91-
// the initial pageload to `/performance` gets 301-redirected to a trailing slash by react-router-serve
92-
'url.path': { value: '/performance/', type: 'string' },
91+
// the initial pageload to `/performance` gets 301-redirected to a trailing slash by react-router-serve, workerd does not
92+
'url.path': { value: RUNTIME === 'cloudflare' ? '/performance' : '/performance/', type: 'string' },
9393
'url.full': {
94-
value: expect.stringMatching(/^https?:\/\/localhost:\d+\/performance\/\?query=test$/),
94+
value: expect.stringMatching(
95+
RUNTIME === 'cloudflare'
96+
? /^https?:\/\/localhost:\d+\/performance\?query=test$/
97+
: /^https?:\/\/localhost:\d+\/performance\/\?query=test$/,
98+
),
9599
type: 'string',
96100
},
97101
});
@@ -166,9 +170,16 @@ test.describe('client - navigation performance', () => {
166170
'sentry.op': { value: 'navigation', type: 'string' },
167171
'sentry.origin': { value: 'auto.navigation.react_router', type: 'string' },
168172
'url.template': { value: '/performance', type: 'string' },
169-
// react-router-serve 301-redirects the bare index route to a trailing slash
170-
'url.path': { value: '/performance/', type: 'string' },
171-
'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/performance\/$/), type: 'string' },
173+
// react-router-serve 301-redirects the bare index route to a trailing slash, workerd does not
174+
'url.path': { value: RUNTIME === 'cloudflare' ? '/performance' : '/performance/', type: 'string' },
175+
'url.full': {
176+
value: expect.stringMatching(
177+
RUNTIME === 'cloudflare'
178+
? /^https?:\/\/localhost:\d+\/performance$/
179+
: /^https?:\/\/localhost:\d+\/performance\/$/,
180+
),
181+
type: 'string',
182+
},
172183
});
173184
});
174185
});

0 commit comments

Comments
 (0)