Skip to content

Commit 0b5bae1

Browse files
committed
feat(nitro): Add a Cloudflare Workers plugin
1 parent fefe0d2 commit 0b5bae1

7 files changed

Lines changed: 277 additions & 1 deletion

File tree

‎packages/nitro/README.md‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,50 @@ NODE_OPTIONS='--import ./instrument.mjs' npx nitro dev
111111

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

114+
## Cloudflare Workers
115+
116+
On a Workers preset such as `cloudflare-module`, register the Cloudflare plugin from `@sentry/nitro/cloudflare`. It
117+
replaces the `instrument.mjs` and `--import` steps above.
118+
119+
```ts
120+
// server/plugins/sentry.ts
121+
import { definePlugin } from 'nitro';
122+
import { sentryCloudflareNitroPlugin } from '@sentry/nitro/cloudflare';
123+
124+
export default definePlugin(
125+
sentryCloudflareNitroPlugin({
126+
dsn: '__YOUR_DSN__',
127+
tracesSampleRate: 1.0,
128+
}),
129+
);
130+
```
131+
132+
Register the plugin in your Nitro config, and note that `serverDir` has to be set for Nitro to pick up files in
133+
`server/plugins`:
134+
135+
```ts
136+
import { defineNitroConfig } from 'nitro/config';
137+
138+
export default defineNitroConfig({
139+
preset: 'cloudflare-module',
140+
serverDir: './server',
141+
});
142+
```
143+
144+
Secrets are not available at build time on Workers, so read the DSN from the runtime config when it comes from an
145+
environment binding:
146+
147+
```ts
148+
import { definePlugin } from 'nitro';
149+
import { useRuntimeConfig } from 'nitro/runtime-config';
150+
import { sentryCloudflareNitroPlugin } from '@sentry/nitro/cloudflare';
151+
152+
export default definePlugin(sentryCloudflareNitroPlugin(() => ({ dsn: useRuntimeConfig().sentryDsn })));
153+
```
154+
155+
The plugin gives each request its own isolation scope, flushes events through `waitUntil`, and reports unhandled
156+
errors from Nitro's `error` hook.
157+
114158
## Uploading Source Maps
115159

116160
The `withSentryConfig` function automatically configures source map uploading when the `authToken`, `org`, and `project`

‎packages/nitro/package.json‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,16 @@
2525
"./package.json": "./package.json",
2626
".": {
2727
"types": "./build/types/index.d.ts",
28+
"worker": {
29+
"import": "./build/esm/cloudflare/index.js",
30+
"default": "./build/esm/cloudflare/index.js"
31+
},
2832
"default": "./build/esm/index.js"
33+
},
34+
"./cloudflare": {
35+
"types": "./build/types/cloudflare/index.d.ts",
36+
"import": "./build/esm/cloudflare/index.js",
37+
"default": "./build/esm/cloudflare/index.js"
2938
}
3039
},
3140
"publishConfig": {
@@ -36,6 +45,7 @@
3645
},
3746
"dependencies": {
3847
"@sentry/bundler-plugins": "^10.67.0",
48+
"@sentry/cloudflare": "10.67.0",
3949
"@sentry/conventions": "^0.19.0",
4050
"@sentry/core": "10.67.0",
4151
"@sentry/node": "10.67.0",

‎packages/nitro/rollup.npm.config.mjs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { makeBaseNPMConfig, makeNPMConfigVariants } from '@sentry-internal/rollu
33
export default [
44
...makeNPMConfigVariants(
55
makeBaseNPMConfig({
6-
entrypoints: ['src/index.ts', 'src/runtime/plugins/server.ts'],
6+
entrypoints: ['src/index.ts', 'src/cloudflare/index.ts', 'src/runtime/plugins/server.ts'],
77
packageSpecificConfig: {
88
external: [/^nitro/, /^h3/, /^srvx/, /^@sentry\/opentelemetry/, '@sentry/bundler-plugin-core'],
99
},
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from '@sentry/cloudflare';
2+
export { sentryCloudflareNitroPlugin } from '../runtime/plugins/cloudflare';
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import type { CloudflareOptions } from '@sentry/cloudflare';
2+
import { getDefaultIntegrations, setAsyncLocalStorageAsyncContextStrategy } from '@sentry/cloudflare';
3+
import { wrapRequestHandler } from '@sentry/cloudflare/request';
4+
import { debug } from '@sentry/core';
5+
import type { NitroApp, NitroAppPlugin, ServerRequest } from 'nitro/types';
6+
import { DEBUG_BUILD } from '../../common/debug-build';
7+
import { captureErrorHook } from '../hooks/captureErrorHook';
8+
9+
type NitroAppWithHooks = NitroApp & { hooks: NonNullable<NitroApp['hooks']> };
10+
11+
/**
12+
* Sentry plugin for Nitro apps running on Cloudflare Workers.
13+
*
14+
* Register it through Nitro's `plugins` option. It is the only registration a Workers build
15+
* needs, and it gives each request its own isolation scope, flushes through `waitUntil`, and
16+
* reports unhandled errors from Nitro's `error` hook.
17+
*
18+
* @example Basic usage
19+
* ```ts
20+
* // server/plugins/sentry.ts
21+
* import { definePlugin } from 'nitro';
22+
* import { sentryCloudflareNitroPlugin } from '@sentry/nitro/cloudflare';
23+
*
24+
* export default definePlugin(
25+
* sentryCloudflareNitroPlugin({
26+
* dsn: '__PUBLIC_DSN__',
27+
* tracesSampleRate: 1.0,
28+
* }),
29+
* );
30+
* ```
31+
*
32+
* @example Reading configuration from the runtime config
33+
* ```ts
34+
* // server/plugins/sentry.ts
35+
* import { definePlugin } from 'nitro';
36+
* import { useRuntimeConfig } from 'nitro/runtime-config';
37+
* import { sentryCloudflareNitroPlugin } from '@sentry/nitro/cloudflare';
38+
*
39+
* export default definePlugin(
40+
* sentryCloudflareNitroPlugin(() => ({ dsn: useRuntimeConfig().sentryDsn })),
41+
* );
42+
* ```
43+
*/
44+
export const sentryCloudflareNitroPlugin =
45+
(optionsOrFn: CloudflareOptions | ((nitroApp: NitroApp) => CloudflareOptions)): NitroAppPlugin =>
46+
(nitroApp: NitroAppWithHooks): void => {
47+
const userOptions = typeof optionsOrFn === 'function' ? optionsOrFn(nitroApp) : optionsOrFn;
48+
49+
const options: CloudflareOptions = {
50+
defaultIntegrations: getDefaultIntegrations(userOptions),
51+
...userOptions,
52+
};
53+
54+
setAsyncLocalStorageAsyncContextStrategy();
55+
56+
const innerFetch = nitroApp.fetch.bind(nitroApp);
57+
58+
nitroApp.fetch = (request: Request): Response | Promise<Response> => {
59+
const context = (request as ServerRequest).runtime?.cloudflare?.context;
60+
61+
if (!context) {
62+
DEBUG_BUILD &&
63+
debug.log(
64+
'[@sentry/nitro] No Cloudflare execution context found on the request. Skipping request instrumentation.',
65+
);
66+
return innerFetch(request);
67+
}
68+
69+
return wrapRequestHandler({ options, request, context }, () => innerFetch(request));
70+
};
71+
72+
nitroApp.hooks.hook('error', captureErrorHook);
73+
};
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import { readFileSync } from 'node:fs';
2+
import { dirname, resolve } from 'node:path';
3+
import { beforeEach, describe, expect, it, vi } from 'vitest';
4+
import { sentryCloudflareNitroPlugin } from '../../../src/runtime/plugins/cloudflare';
5+
6+
const mocks = vi.hoisted(() => ({
7+
wrapRequestHandler: vi.fn((_wrapperOptions: unknown, handler: () => unknown) => handler()),
8+
setAsyncLocalStorageAsyncContextStrategy: vi.fn(),
9+
getDefaultIntegrations: vi.fn(() => [{ name: 'CloudflareDefault' }]),
10+
}));
11+
12+
vi.mock('@sentry/cloudflare', () => ({
13+
getDefaultIntegrations: mocks.getDefaultIntegrations,
14+
setAsyncLocalStorageAsyncContextStrategy: mocks.setAsyncLocalStorageAsyncContextStrategy,
15+
}));
16+
17+
vi.mock('@sentry/cloudflare/request', () => ({
18+
wrapRequestHandler: mocks.wrapRequestHandler,
19+
}));
20+
21+
const executionContext = { waitUntil: vi.fn(), passThroughOnException: vi.fn() };
22+
23+
function createNitroApp(): any {
24+
return {
25+
fetch: vi.fn(() => new Response('ok')),
26+
hooks: { hook: vi.fn() },
27+
};
28+
}
29+
30+
function createCloudflareRequest(url = 'https://example.com/'): Request {
31+
return Object.assign(new Request(url), {
32+
runtime: { name: 'cloudflare', cloudflare: { context: executionContext, env: {} } },
33+
});
34+
}
35+
36+
describe('sentryCloudflareNitroPlugin', () => {
37+
beforeEach(() => {
38+
vi.clearAllMocks();
39+
});
40+
41+
it('routes requests carrying a Cloudflare execution context through `wrapRequestHandler`', async () => {
42+
const nitroApp = createNitroApp();
43+
const innerFetch = nitroApp.fetch;
44+
45+
sentryCloudflareNitroPlugin({ dsn: 'https://public@example.ingest.sentry.io/1' })(nitroApp);
46+
const request = createCloudflareRequest();
47+
await nitroApp.fetch(request);
48+
49+
expect(mocks.wrapRequestHandler).toHaveBeenCalledTimes(1);
50+
expect(mocks.wrapRequestHandler.mock.calls[0]![0]).toMatchObject({ request, context: executionContext });
51+
expect(innerFetch).toHaveBeenCalledWith(request);
52+
});
53+
54+
// Without an execution context there is nothing to hang the flush on, which is the case in
55+
// `nitro dev` and on every non-Workers preset.
56+
it('passes the request through untouched when there is no execution context', async () => {
57+
const nitroApp = createNitroApp();
58+
const innerFetch = nitroApp.fetch;
59+
60+
sentryCloudflareNitroPlugin({ dsn: 'https://public@example.ingest.sentry.io/1' })(nitroApp);
61+
const request = new Request('https://example.com/');
62+
await nitroApp.fetch(request);
63+
64+
expect(mocks.wrapRequestHandler).not.toHaveBeenCalled();
65+
expect(innerFetch).toHaveBeenCalledWith(request);
66+
});
67+
68+
it('sets the async context strategy and registers the error hook', () => {
69+
const nitroApp = createNitroApp();
70+
71+
sentryCloudflareNitroPlugin({ dsn: 'https://public@example.ingest.sentry.io/1' })(nitroApp);
72+
73+
expect(mocks.setAsyncLocalStorageAsyncContextStrategy).toHaveBeenCalledTimes(1);
74+
expect(nitroApp.hooks.hook).toHaveBeenCalledWith('error', expect.any(Function));
75+
});
76+
77+
it('accepts a function so options can be read once the app exists', async () => {
78+
const nitroApp = createNitroApp();
79+
const optionsFn = vi.fn(() => ({ dsn: 'https://public@example.ingest.sentry.io/2' }));
80+
81+
sentryCloudflareNitroPlugin(optionsFn)(nitroApp);
82+
await nitroApp.fetch(createCloudflareRequest());
83+
84+
expect(optionsFn).toHaveBeenCalledWith(nitroApp);
85+
expect(mocks.wrapRequestHandler.mock.calls[0]![0]).toMatchObject({
86+
options: { dsn: 'https://public@example.ingest.sentry.io/2' },
87+
});
88+
});
89+
90+
it('defaults to the `nodejs_compat` integrations but lets explicit options win', async () => {
91+
const nitroApp = createNitroApp();
92+
93+
sentryCloudflareNitroPlugin({ dsn: 'https://public@example.ingest.sentry.io/1' })(nitroApp);
94+
await nitroApp.fetch(createCloudflareRequest());
95+
96+
expect((mocks.wrapRequestHandler.mock.calls[0]![0] as any).options.defaultIntegrations).toEqual([
97+
{ name: 'CloudflareDefault' },
98+
]);
99+
100+
vi.clearAllMocks();
101+
const otherApp = createNitroApp();
102+
sentryCloudflareNitroPlugin({ dsn: 'https://public@example.ingest.sentry.io/1', defaultIntegrations: false })(
103+
otherApp,
104+
);
105+
await otherApp.fetch(createCloudflareRequest());
106+
107+
expect((mocks.wrapRequestHandler.mock.calls[0]![0] as any).options.defaultIntegrations).toBe(false);
108+
});
109+
});
110+
111+
// See #22519 for the same class of bug reached through a barrel re-export.
112+
describe('the `@sentry/nitro/cloudflare` module graph', () => {
113+
it('never reaches `@sentry/node`', () => {
114+
const entry = resolve(__dirname, '../../../src/cloudflare/index.ts');
115+
const seen = new Set<string>();
116+
const offenders: string[] = [];
117+
118+
const visit = (file: string): void => {
119+
if (seen.has(file)) {
120+
return;
121+
}
122+
seen.add(file);
123+
124+
const source = readFileSync(file, 'utf8');
125+
const specifiers = [...source.matchAll(/from\s+'([^']+)'/g)].map(match => match[1]!);
126+
127+
for (const specifier of specifiers) {
128+
if (specifier === '@sentry/node' || specifier.startsWith('@sentry/node/')) {
129+
offenders.push(file);
130+
}
131+
if (specifier.startsWith('.')) {
132+
visit(resolve(dirname(file), `${specifier}.ts`));
133+
}
134+
}
135+
};
136+
137+
visit(entry);
138+
139+
expect(seen.size).toBeGreaterThan(1);
140+
expect(offenders).toEqual([]);
141+
});
142+
});

‎yarn.lock‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7504,6 +7504,11 @@
75047504
resolved "https://registry.yarnpkg.com/@sentry/conventions/-/conventions-0.19.0.tgz#da81dd1b8c4f2f42aca2d74251a07ff9f8718e42"
75057505
integrity sha512-nYciB7Pt/Ujf6pJSt1FnHeJBp908P5Ibod4FMlTzASJcqU1RvixI5EFzkbe8gvUxP3XAMoWDgeWeG8agCjKYVA==
75067506

7507+
"@sentry/conventions@^0.16.0":
7508+
version "0.16.0"
7509+
resolved "https://registry.yarnpkg.com/@sentry/conventions/-/conventions-0.16.0.tgz#3b58d15714cf44dca1518496c00749eec5525009"
7510+
integrity sha512-fO9PLmHdVURcSPUpWCItWAtgKiMwGdJHbovoSEyLplX5sxs2ugvI4CBPTrkkgqhObnZOD0CnWBKDzSVQYBKEyQ==
7511+
75077512
"@sentry/node-cpu-profiler@^2.4.3":
75087513
version "2.4.3"
75097514
resolved "https://registry.yarnpkg.com/@sentry/node-cpu-profiler/-/node-cpu-profiler-2.4.3.tgz#1119aa07fb34672435fea3e386ade7dfe73fdf34"

0 commit comments

Comments
 (0)