Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1055,8 +1055,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-express", "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-express", "bun-mysql"]'), matrix.test-application)
uses: oven-sh/setup-bun@v2
with:
bun-version: '1.3.14'
Expand All @@ -1067,7 +1067,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' }}
Expand Down Expand Up @@ -1193,8 +1193,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'
Comment thread
sentry-warden[bot] marked this conversation as resolved.
Comment thread
sentry-warden[bot] marked this conversation as resolved.
Comment thread
sentry-warden[bot] marked this conversation as resolved.
- 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' }}
Expand Down
52 changes: 52 additions & 0 deletions dev-packages/e2e-tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,58 @@ 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`. Read it with `getRuntime()` from
`@sentry-internal/test-utils`, which throws on any other value. Tests branch on it where the runtimes are expected to
differ (for example `platform` or `sdk.name`).
- **SDK per runtime**: each runtime inits its own SDK, the way a user of that runtime would: Node the framework SDK
(`instrument.mjs`), Bun `@sentry/bun` (`instrument.bun.mjs`), Deno `@sentry/deno` (`instrument.deno.mjs`) and
Cloudflare `@sentry/cloudflare`. The framework SDK only provides the framework wrappers on the other runtimes, so
values that come from its `init()` (`sdk.name`, default integrations) are Node-only.
- **Start commands**: `playwright.config.mjs` selects the start command with `getRuntime()`. Bun and Deno use the same build
as Node and only change the start command, for example
`bun --bun --preload ./instrument.bun.mjs ./node_modules/@react-router/serve/bin.cjs ./build/server/index.js` and
`deno run -A --preload=@sentry/deno/import --preload=./instrument.deno.mjs ./node_modules/@react-router/serve/bin.cjs ./build/server/index.js`.
The app's `deno.json` maps `@sentry/deno/import` to `node_modules`, because the e2e dependencies are `file:` tarballs
that `npm:@sentry/deno/import` does not resolve.
- **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 `<name>.<runtime>.<ext>` (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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { captureException } from '@sentry/cloudflare';
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<Response> {
let shellRendered = false;
const userAgent = request.headers.get('user-agent');
const nonce = btoa(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(16))));

const body = await renderToReadableStream(<ServerRouter context={routerContext} url={request.url} nonce={nonce} />, {
nonce,
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 = (error, { request }) => {
if (!request.signal.aborted) {
captureException(error, { mechanism: { type: 'react-router', handled: false } });
console.error(error);
}
};

export default Sentry.wrapSentryHandleRequest(handleRequest);
Original file line number Diff line number Diff line change
@@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
};

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"imports": {
"@sentry/deno/import": "./node_modules/@sentry/deno/build/import.mjs"
},
"nodeModulesDir": "manual"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import * as Sentry from '@sentry/bun';

Sentry.init({
dsn: 'https://username@domain/123',
environment: 'qa', // dynamic sampling bias to keep transactions
tracesSampleRate: 1.0,
tunnel: `http://localhost:3031/`, // proxy server
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import * as Sentry from '@sentry/deno';

Sentry.init({
dsn: 'https://username@domain/123',
environment: 'qa', // dynamic sampling bias to keep transactions
tracesSampleRate: 1.0,
tunnel: `http://localhost:3031/`, // proxy server
});
Original file line number Diff line number Diff line change
Expand Up @@ -9,30 +9,42 @@
"react-router": "^8.0.0",
"@react-router/node": "^8.0.0",
"@react-router/serve": "^8.0.0",
"@sentry/bun": "file:../../packed/sentry-bun-packed.tgz",
"@sentry/cloudflare": "file:../../packed/sentry-cloudflare-packed.tgz",
"@sentry/deno": "file:../../packed/sentry-deno-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",
"@react-router/dev": "^8.0.0",
"@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.bun.mjs ./node_modules/@react-router/serve/bin.cjs ./build/server/index.js",
"start:deno": "NODE_ENV=production deno run -A --preload=@sentry/deno/import --preload=./instrument.deno.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"
},
Expand Down Expand Up @@ -64,6 +76,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)"
}
]
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
import { getPlaywrightConfig } from '@sentry-internal/test-utils';
import { getPlaywrightConfig, getRuntime } from '@sentry-internal/test-utils';
import { fileURLToPath } from 'url';

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[getRuntime()],
port: 3030,
},
// Boot Redis before the tests run, outside the webServer startup-timeout window.
Expand Down
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
import { getRuntime } from '@sentry-internal/test-utils';

export const RUNTIME = getRuntime();

export const APP_NAME = 'react-router-8-framework';
Original file line number Diff line number Diff line change
@@ -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 }) => {
Expand All @@ -26,21 +26,26 @@ test.describe('server-side errors', () => {
},
],
},
// todo: should be 'GET /errors/server-loader'
transaction: 'GET /{*splat}',
// Express names the transaction on Node and Deno. On Bun, where Express is not instrumented under
// `bun run`, it stays the request path. On Cloudflare the error has no transaction.
// todo: should be 'GET /errors/server-loader' everywhere
...(RUNTIME === 'cloudflare'
? {}
: { transaction: RUNTIME === 'bun' ? 'GET /errors/server-loader' : 'GET /{*splat}' }),
request: {
url: expect.stringContaining('errors/server-loader'),
headers: expect.any(Object),
},
level: 'error',
platform: 'node',
// Only Node inits `@sentry/react-router`. Bun, Deno and Cloudflare init their runtime's own SDK.
platform: RUNTIME === 'cloudflare' || RUNTIME === 'deno' ? 'javascript' : 'node',
environment: 'qa',
sdk: {
integrations: expect.any(Array<string>),
name: 'sentry.javascript.react-router',
name: RUNTIME === 'node' ? 'sentry.javascript.react-router' : `sentry.javascript.${RUNTIME}`,
version: expect.any(String),
},
tags: { runtime: 'node' },
...(RUNTIME === 'node' ? { tags: { runtime: 'node' } } : {}),
contexts: {
trace: {
span_id: expect.any(String),
Expand Down Expand Up @@ -74,21 +79,25 @@ test.describe('server-side errors', () => {
},
],
},
// todo: should be 'POST /errors/server-action'
transaction: 'POST /{*splat}',
// Express names the transaction on Node and Deno. On Bun, where Express is not instrumented under
// `bun run`, it stays the request path. On Cloudflare the error has no transaction.
// todo: should be 'POST /errors/server-action' everywhere
...(RUNTIME === 'cloudflare'
? {}
: { transaction: 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' || RUNTIME === 'deno' ? 'javascript' : 'node',
environment: 'qa',
sdk: {
integrations: expect.any(Array<string>),
name: 'sentry.javascript.react-router',
name: RUNTIME === 'node' ? 'sentry.javascript.react-router' : `sentry.javascript.${RUNTIME}`,
version: expect.any(String),
},
tags: { runtime: 'node' },
...(RUNTIME === 'node' ? { tags: { runtime: 'node' } } : {}),
contexts: {
trace: {
span_id: expect.any(String),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import { expect, test } from '@playwright/test';
import type { SerializedStreamedSpan } from '@sentry-internal/test-utils';
import { getSpanOp, waitForStreamedSpan, waitForStreamedSpans } from '@sentry-internal/test-utils';
import { APP_NAME } from '../constants';
import { APP_NAME, RUNTIME } from '../constants';

test.describe('low-quality span filter', () => {
test('does not send a server span for /__manifest? requests', async ({ page }) => {
test.skip(
RUNTIME !== 'node',
"The filter is a default integration of `@sentry/react-router`'s `init()`, which only Node uses; the other runtimes init their own SDK",
);

const streamedSpans: SerializedStreamedSpan[] = [];

const navigationPromise = waitForStreamedSpan(APP_NAME, span => {
Expand Down
Loading