Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"name": "nitro-3-cloudflare",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"build": "vite build",
"preview": "wrangler dev --port 3030 --log-level=$(test $CI && echo 'none' || echo 'log')",
"clean": "npx rimraf node_modules pnpm-lock.yaml .output",
"test": "playwright test",
"test:build": "pnpm install && pnpm build",
"test:assert": "pnpm test"
},
"dependencies": {
"@sentry/cloudflare": "latest || *",
"@sentry/nitro": "latest || *"
},
"devDependencies": {
"@playwright/test": "~1.56.0",
"@sentry-internal/test-utils": "link:../../../test-utils",
"@sentry/core": "latest || *",
"nitro": "^3.0.260522-beta",
"rolldown": "latest",
"vite": "latest",
"wrangler": "^4.72.0"
},
"volta": {
"node": "22.20.0",
"extends": "../../package.json"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { getPlaywrightConfig } from '@sentry-internal/test-utils';

const config = getPlaywrightConfig({
startCommand: 'pnpm preview',
port: 3030,
});

export default config;
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { defineHandler } from 'nitro/h3';

export default defineHandler(() => ({ hello: 'world' }));
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { defineHandler } from 'nitro/h3';

export default defineHandler(() => {
throw new Error('This is a test error');
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { sentryCloudflareNitroPlugin } from '@sentry/nitro/cloudflare';
import { definePlugin } from 'nitro';

export default definePlugin(
sentryCloudflareNitroPlugin(() => ({
environment: 'qa',
traceLifecycle: 'static',
dsn: 'https://public@dsn.ingest.sentry.io/1337',
tunnel: 'http://localhost:3031/',
tracesSampleRate: 1.0,
})),
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { startEventProxyServer } from '@sentry-internal/test-utils';

startEventProxyServer({
port: 3031,
proxyServerName: 'nitro-3-cloudflare',
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { expect, test } from '@playwright/test';
import { waitForError } from '@sentry-internal/test-utils';

test('sends an error event from the Cloudflare Workers runtime', async ({ request }) => {
const errorEventPromise = waitForError('nitro-3-cloudflare', event => {
return !event.type && !!event.exception?.values?.some(v => v.value === 'This is a test error');
});

const res = await request.get('/api/test-error');
expect(res.status()).toBe(500);

const errorEvent = await errorEventPromise;
const values = errorEvent.exception?.values ?? [];

// h3 wraps the thrown error in an HTTPError, so both are reported and linked.
expect(values).toHaveLength(2);
expect(values.some(v => v.type === 'Error' && v.value === 'This is a test error')).toBe(true);
expect(
values.some(v => v.mechanism?.type === 'auto.function.nitro.captureErrorHook' && v.mechanism.handled === false),
).toBe(true);
expect(errorEvent.sdk?.name).toBe('sentry.javascript.cloudflare');
});

test('does not send 404 errors', async ({ request }) => {
const errorEvents: (string | undefined)[] = [];
const sentinelEventPromise = waitForError('nitro-3-cloudflare', event => {
if (event.type) {
return false;
}
errorEvents.push(event.exception?.values?.map(v => v.value).join(', '));
return !!event.exception?.values?.some(v => v.value === 'This is a test error');
});

const notFoundRes = await request.get('/api/non-existent-route');
expect(notFoundRes.status()).toBe(404);

// The sentinel error arrives after anything the 404 could have sent, so waiting for it
// makes the no-report assertion deterministic without a timeout.
const sentinelRes = await request.get('/api/test-error');
expect(sentinelRes.status()).toBe(500);

await sentinelEventPromise;

expect(errorEvents).toHaveLength(1);
});
Comment thread
cursor[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { expect, test } from '@playwright/test';
import { waitForTransaction } from '@sentry-internal/test-utils';

test('sends an http.server transaction from the Cloudflare Workers runtime', async ({ request }) => {
const transactionEventPromise = waitForTransaction('nitro-3-cloudflare', transactionEvent => {
return (
transactionEvent.contexts?.trace?.op === 'http.server' &&
(transactionEvent.request?.url?.endsWith('/api/') ?? false)
);
});

const res = await request.get('/api/');
expect(res.status()).toBe(200);
expect(await res.json()).toEqual({ hello: 'world' });

const transactionEvent = await transactionEventPromise;

expect(transactionEvent.contexts?.trace).toMatchObject({
op: 'http.server',
origin: 'auto.http.cloudflare',
});
expect(transactionEvent.sdk?.name).toBe('sentry.javascript.cloudflare');
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["server/**/*.ts", "vite.config.ts"]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { nitro } from 'nitro/vite';
import { defineConfig } from 'vite';

export default defineConfig({
plugins: [
nitro({
preset: 'cloudflare-module',
serverDir: './server',
cloudflare: {
deployConfig: false,
},
}),
],
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "nitro-3-cloudflare",
"main": "./.output/server/index.mjs",
"compatibility_date": "2026-06-29",
"compatibility_flags": ["nodejs_compat"],
"assets": {
"directory": "./.output/public",
"binding": "ASSETS",
},
}
86 changes: 86 additions & 0 deletions packages/nitro/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

- [Official Nitro SDK Docs](https://docs.sentry.io/platforms/javascript/guides/nitro/)
- [Example Nitro app](https://github.com/getsentry/sentry-javascript/tree/develop/dev-packages/e2e-tests/test-applications/nitro-3)
- [Example Nitro app on Cloudflare Workers](https://github.com/getsentry/sentry-javascript/tree/develop/dev-packages/e2e-tests/test-applications/nitro-3-cloudflare)

## Compatibility

Expand Down Expand Up @@ -111,6 +112,91 @@ NODE_OPTIONS='--import ./instrument.mjs' npx nitro dev

This works with any Nitro command (`nitro dev`, `nitro preview`, or a production start script).

## Cloudflare Workers

On a Workers preset such as `cloudflare-module`, use the Cloudflare plugin from `@sentry/nitro/cloudflare`. It
replaces the `instrument.mjs` and `--import` steps above.

Nitro registers every file in your server `plugins/` directory automatically, so creating the file is the only
registration step:

```ts
// server/plugins/sentry.ts
import { definePlugin } from 'nitro';
import { sentryCloudflareNitroPlugin } from '@sentry/nitro/cloudflare';

export default definePlugin(
sentryCloudflareNitroPlugin({
dsn: '__YOUR_DSN__',
tracesSampleRate: 1.0,
}),
);
```

Directory scanning is disabled until `serverDir` is set, so set it next to the preset. In `nitro.config.ts`:

```ts
import { defineNitroConfig } from 'nitro/config';

export default defineNitroConfig({
preset: 'cloudflare-module',
serverDir: './server',
});
```

Or in `vite.config.ts` when using Nitro as a Vite plugin:

```ts
import { defineConfig } from 'vite';
import { nitro } from 'nitro/vite';

export default defineConfig({
plugins: [
nitro({
preset: 'cloudflare-module',
serverDir: './server',
}),
],
});
```

The plugin enables the full default integration set of `@sentry/cloudflare`, which requires the `nodejs_compat`
compatibility flag in your `wrangler.jsonc`:

```jsonc
{
"compatibility_flags": ["nodejs_compat"],
}
```

Secrets are not available at build time on Workers, so read the DSN from the runtime config when it comes from an
environment binding. The runtime config only picks up keys declared in your Nitro config, so declare the key there
and set it on the Worker as `NITRO_SENTRY_DSN`:

```ts
// server/plugins/sentry.ts
import { definePlugin } from 'nitro';
import { useRuntimeConfig } from 'nitro/runtime-config';
import { sentryCloudflareNitroPlugin } from '@sentry/nitro/cloudflare';

export default definePlugin(sentryCloudflareNitroPlugin(() => ({ dsn: useRuntimeConfig().sentryDsn })));
```

```ts
// nitro.config.ts
import { defineNitroConfig } from 'nitro/config';

export default defineNitroConfig({
preset: 'cloudflare-module',
serverDir: './server',
runtimeConfig: { sentryDsn: '' },
});
```

The plugin gives each request its own isolation scope, flushes events through `waitUntil`, and reports unhandled
errors from Nitro's `error` hook. The build-time setup from `withSentryConfig` (source map upload and the h3 tracing
channels) is not wired up for the Workers path yet, so server spans come from the Cloudflare request wrapper only.

## Uploading Source Maps

The `withSentryConfig` function automatically configures source map uploading when the `authToken`, `org`, and `project`
Expand Down
16 changes: 16 additions & 0 deletions packages/nitro/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,23 @@
"exports": {
"./package.json": "./package.json",
".": {
"workerd": {
"types": "./build/types/cloudflare/index.d.ts",
"import": "./build/esm/cloudflare/index.js",
"default": "./build/esm/cloudflare/index.js"
},
"worker": {
"types": "./build/types/cloudflare/index.d.ts",
"import": "./build/esm/cloudflare/index.js",
"default": "./build/esm/cloudflare/index.js"
},
"types": "./build/types/index.d.ts",
"default": "./build/esm/index.js"
},
"./cloudflare": {
"types": "./build/types/cloudflare/index.d.ts",
"import": "./build/esm/cloudflare/index.js",
"default": "./build/esm/cloudflare/index.js"
}
},
"publishConfig": {
Expand All @@ -36,6 +51,7 @@
},
"dependencies": {
"@sentry/bundler-plugins": "^10.67.0",
"@sentry/cloudflare": "10.67.0",
"@sentry/conventions": "^0.19.0",
"@sentry/core": "10.67.0",
"@sentry/node": "10.67.0",
Expand Down
2 changes: 1 addition & 1 deletion packages/nitro/rollup.npm.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { makeBaseNPMConfig, makeNPMConfigVariants } from '@sentry-internal/rollu
export default [
...makeNPMConfigVariants(
makeBaseNPMConfig({
entrypoints: ['src/index.ts', 'src/runtime/plugins/server.ts'],
entrypoints: ['src/index.ts', 'src/cloudflare/index.ts', 'src/runtime/plugins/server.ts'],
packageSpecificConfig: {
external: [/^nitro/, /^h3/, /^srvx/, /^@sentry\/opentelemetry/, '@sentry/bundler-plugin-core'],
},
Expand Down
2 changes: 2 additions & 0 deletions packages/nitro/src/cloudflare/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from '@sentry/cloudflare';
export { sentryCloudflareNitroPlugin } from '../runtime/plugins/cloudflare';
Loading