Skip to content
Merged
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
17 changes: 14 additions & 3 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1038,6 +1038,8 @@ jobs:
REACT_APP_E2E_TEST_DSN: 'https://username@domain/123'
E2E_TEST_SENTRY_ORG_SLUG: 'sentry-javascript-sdks'
E2E_TEST_SENTRY_PROJECT: 'sentry-javascript-e2e-tests'
# The `runtime` of a runtime variant, see dev-packages/e2e-tests/README.md
RUNTIME: ${{ matrix.runtime }}
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.job_build.outputs.e2e-matrix) }}
Expand All @@ -1056,7 +1058,7 @@ jobs:
- name: Set up Bun
if:
matrix.test-application == 'node-exports-test-app' || contains(matrix.test-application, 'bun') ||
contains(matrix.label, 'bun')
matrix.runtime == 'bun'
uses: oven-sh/setup-bun@v2
with:
bun-version: '1.3.14'
Expand All @@ -1067,7 +1069,7 @@ jobs:
use-installer: true
token: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Deno
if: contains(matrix.test-application, 'deno') || contains(matrix.label, 'deno')
if: contains(matrix.test-application, 'deno') || matrix.runtime == 'deno'
uses: denoland/setup-deno@v2.0.5
with:
deno-version: ${{ matrix.deno-version || 'v2.8.3' }}
Expand Down Expand Up @@ -1177,6 +1179,8 @@ jobs:
# Used by test apps that deploy a real Cloudflare Worker, e.g. cloudflare-workers-send-to-sentry
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
# The `runtime` of a runtime variant, see dev-packages/e2e-tests/README.md
RUNTIME: ${{ matrix.runtime }}
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.job_build.outputs.e2e-matrix-optional) }}
Expand All @@ -1193,8 +1197,15 @@ 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.test-application == 'node-exports-test-app' || contains(matrix.test-application, 'bun') ||
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: contains(matrix.test-application, 'deno') || matrix.runtime == 'deno'
uses: denoland/setup-deno@v2.0.5
with:
deno-version: ${{ matrix.deno-version || 'v2.8.3' }}
Expand Down
54 changes: 54 additions & 0 deletions dev-packages/e2e-tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ For example, if you have the following variants in your test app's `package.json

If you run `yarn test:run nextjs-pages-dir --variant 13`, it will match against the very first matching variant, which is `nextjs-pages-dir (next@13)`. If you need to target the second variant in the example, you need to be more specific and use `--variant 13-canary`.

A variant runs its `build-command` and `assert-command`. Without them, it runs the app's `test:build` and `test:assert` scripts, like the app's default job.

## How they work

We build our packages, pack them into tarballs (`yarn build:tarball`), and create symlinks in the `packed/` directory
Expand Down Expand Up @@ -134,6 +136,58 @@ Apps like `cloudflare-workers-send-to-sentry` deploy the built app as a real Wor
- Set `"sentryTest": { "optional": true }` in `package.json`, because only the optional CI job has the Cloudflare
secrets.

## 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`**: set `"runtime": "bun"`, `"deno"` or `"cloudflare"` on the variant. For such a variant, CI installs Bun
or Deno, and the runner and CI set the `RUNTIME` env var for the build and the assert command. Without `runtime`,
`RUNTIME` is empty or not set, which means `node`. So a runtime variant can use the default `test:build` and
`test:assert` scripts, with no `test:assert:<runtime>` script, and a new variant needs no change to
`.github/workflows/build.yml`.
- **`RUNTIME` env var**: read it with `getRuntime()` from `@sentry-internal/test-utils`, which throws on any value other
than `node`, `bun`, `deno` or `cloudflare`. 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`, which the runner uses in place of `vite.config.ts` (see runtime-specific files). List the
Sentry plugins after all other plugins: they set no `enforce`, so their position decides when their hooks run, and a
sourcemap upload has to see the final output. 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**: a file that has the runtime as a part of its name replaces the existing file without that
part, for example `app/entry.server.cloudflare.tsx` replaces `app/entry.server.tsx` and `vite.cloudflare.config.ts`
replaces `vite.config.ts`. For a variant with `runtime`, the runner copies these files over the others in the
temporary copy of the app, before the build. This needs no framework or bundler config. A build in the app folder
itself does not get these files, so run a variant with `yarn test:run <app> --variant <runtime>`.
- **Deno version**: a variant can pin the Deno 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": [
{ "runtime": "bun", "label": "my-app (bun)" },
{ "runtime": "deno", "label": "my-app (deno)" },
{ "runtime": "cloudflare", "label": "my-app (cloudflare)" }
]
}
```

## Troubleshooting

### Common Issues
Expand Down
5 changes: 5 additions & 0 deletions dev-packages/e2e-tests/ciCopyToTemp.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/* eslint-disable no-console */

import { copyToTemp } from './lib/copyToTemp';
import { applyRuntimeFiles } from './lib/runtimeFiles';

async function run(): Promise<void> {
const originalPath = process.argv[2];
Expand All @@ -13,6 +14,10 @@ async function run(): Promise<void> {
console.log(`Copying ${originalPath} to ${tmpDirPath}...`);

await copyToTemp(originalPath, tmpDirPath);

if (process.env.RUNTIME) {
applyRuntimeFiles(tmpDirPath, process.env.RUNTIME);
}
}

run().catch(error => {
Expand Down
33 changes: 33 additions & 0 deletions dev-packages/e2e-tests/lib/runtimeFiles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/* eslint-disable no-console */
import { copyFileSync, existsSync, readdirSync } from 'fs';
import { join } from 'path';

/**
* Copies each file in `dir` that has `runtime` as a part of its name over the existing file without that part, for
* example `app/entry.server.cloudflare.tsx` over `app/entry.server.tsx`.
*/
export function applyRuntimeFiles(dir: string, runtime: string): void {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (entry.name === 'node_modules' || entry.name.startsWith('.')) {
continue;
}

const path = join(dir, entry.name);
if (entry.isDirectory()) {
applyRuntimeFiles(path, runtime);
continue;
}

const segments = entry.name.split('.');
const index = segments.indexOf(runtime);
if (index <= 0 || index === segments.length - 1) {
continue;
}

const target = join(dir, segments.filter((_, i) => i !== index).join('.'));
if (existsSync(target)) {
copyFileSync(path, target);
console.log(`Copied ${path} to ${target} for ${runtime}`);
}
}
}
15 changes: 13 additions & 2 deletions dev-packages/e2e-tests/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { sync as globSync } from 'glob';
import { tmpdir } from 'os';
import { join, resolve } from 'path';
import { copyToTemp } from './lib/copyToTemp';
import { applyRuntimeFiles } from './lib/runtimeFiles';
import { syncPackedTarballSymlinks } from './lib/syncPackedTarballSymlinks';
import { addPnpmOverrides } from './lib/pnpmOverrides';

Expand All @@ -14,6 +15,8 @@ interface SentryTestVariant {
'assert-command'?: string;
label?: string;
skip?: boolean;
/** Runtime of the variant (`bun`, `deno` or `cloudflare`). Sets `RUNTIME` and selects the runtime-specific files. */
runtime?: string;
}

interface PackageJson {
Expand Down Expand Up @@ -89,6 +92,7 @@ async function getVariantBuildCommand(
testLabel: string;
matchedVariantLabel?: string;
skip?: boolean;
runtime?: string;
}> {
try {
const packageJsonContent = await readFile(packageJsonPath, 'utf-8');
Expand All @@ -108,6 +112,7 @@ async function getVariantBuildCommand(
testLabel: matchingVariant.label || testAppPath,
matchedVariantLabel: matchingVariant.label,
skip: matchingVariant.skip,
runtime: matchingVariant.runtime,
};
}

Expand Down Expand Up @@ -232,23 +237,29 @@ async function run(): Promise<void> {
// at all - breaks the build with `unable to open database file`. Give each app its own.
const sentryConfigDir = join(tmpDirPath, '.tmp_sentry_home');
await mkdir(sentryConfigDir, { recursive: true });
const appEnv = { ...env, SENTRY_CONFIG_DIR: sentryConfigDir };

const cwd = tmpDirPath;
// Resolve variant if needed
const { buildCommand, assertCommand, testLabel, matchedVariantLabel } = variantLabel
const { buildCommand, assertCommand, testLabel, matchedVariantLabel, runtime } = variantLabel
? await getVariantBuildCommand(join(tmpDirPath, 'package.json'), variantLabel, testAppPath)
: {
buildCommand: 'pnpm test:build',
assertCommand: 'pnpm test:assert',
testLabel: testAppPath,
runtime: undefined,
};

// Print which variant we're using if found
if (matchedVariantLabel) {
console.log(`\n\nUsing variant: "${matchedVariantLabel}"\n\n`);
}

if (runtime) {
applyRuntimeFiles(tmpDirPath, runtime);
}

const appEnv = { ...env, SENTRY_CONFIG_DIR: sentryConfigDir, ...(runtime ? { RUNTIME: runtime } : {}) };

console.log(`Building ${testLabel} in ${tmpDirPath}...`);
await asyncExec(`volta run ${buildCommand}`, { env: appEnv, cwd });

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,12 @@
"label": "hono-4-legacy (node)"
},
{
"runtime": "bun",
"assert-command": "RUNTIME=bun pnpm test:assert",
"label": "hono-4-legacy (bun)"
},
{
"runtime": "deno",
"assert-command": "RUNTIME=deno pnpm test:assert",
"label": "hono-4-legacy (deno)"
}
Expand Down
2 changes: 2 additions & 0 deletions dev-packages/e2e-tests/test-applications/hono-4/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,12 @@
"sentryTest": {
"variants": [
{
"runtime": "bun",
"assert-command": "pnpm test:assert:bun",
"label": "hono-4 (bun)"
},
{
"runtime": "deno",
"assert-command": "pnpm test:assert:deno",
"label": "hono-4 (deno)"
},
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
});
Loading
Loading