Skip to content

Commit bc6725c

Browse files
authored
feat(nuxt): Add Cloudflare Nitro plugin (#15597)
A Nitro plugin which initializes Sentry when deployed to Cloudflare. 1. Remove the previous server config file: `sentry.server.config.ts` 2. Add a plugin in `server/plugins` (e.g. `server/plugins/sentry-cloudflare-setup.ts`) 3. Add this code in your plugin file ```javascript // server/plugins/sentry-cloudflare-setup.ts (filename does not matter) import { sentryCloudflareNitroPlugin } from '@sentry/nuxt/module/plugins' export default defineNitroPlugin(sentryCloudflareNitroPlugin({ dsn: 'https://dsn', tracesSampleRate: 1.0, })) ``` or with access to `nitroApp`: ```javascript export default defineNitroPlugin(sentryCloudflareNitroPlugin((nitroApp: NitroApp) => { return ({ dsn: 'https://417c51af5466942533c989cdec3036b8@o447951.ingest.us.sentry.io/4508873430466560', tracesSampleRate: 1.0, }) })) ```
1 parent 53c9c68 commit bc6725c

File tree

9 files changed

+282
-85
lines changed

9 files changed

+282
-85
lines changed

packages/core/src/utils/meta.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { SerializedTraceData } from '../types-hoist/tracing';
12
import { getTraceData } from './traceData';
23

34
/**
@@ -21,8 +22,8 @@ import { getTraceData } from './traceData';
2122
* ```
2223
*
2324
*/
24-
export function getTraceMetaTags(): string {
25-
return Object.entries(getTraceData())
25+
export function getTraceMetaTags(traceData?: SerializedTraceData): string {
26+
return Object.entries(traceData || getTraceData())
2627
.map(([key, value]) => `<meta name="${key}" content="${value}"/>`)
2728
.join('\n');
2829
}

packages/core/test/lib/utils/meta.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,4 +29,20 @@ describe('getTraceMetaTags', () => {
2929

3030
expect(getTraceMetaTags()).toBe('');
3131
});
32+
33+
it('uses provided traceData instead of calling getTraceData()', () => {
34+
const getTraceDataSpy = vi.spyOn(TraceDataModule, 'getTraceData');
35+
36+
const customTraceData = {
37+
'sentry-trace': 'ab12345678901234567890123456789012-1234567890abcdef-1',
38+
baggage:
39+
'sentry-environment=test,sentry-public_key=public12345,sentry-trace_id=ab12345678901234567890123456789012,sentry-sample_rate=0.5',
40+
};
41+
42+
expect(getTraceMetaTags(customTraceData))
43+
.toBe(`<meta name="sentry-trace" content="ab12345678901234567890123456789012-1234567890abcdef-1"/>
44+
<meta name="baggage" content="sentry-environment=test,sentry-public_key=public12345,sentry-trace_id=ab12345678901234567890123456789012,sentry-sample_rate=0.5"/>`);
45+
46+
expect(getTraceDataSpy).not.toHaveBeenCalled();
47+
});
3248
});

packages/nuxt/package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@
3333
"types": "./build/module/types.d.ts",
3434
"import": "./build/module/module.mjs",
3535
"require": "./build/module/module.cjs"
36+
},
37+
"./module/plugins": {
38+
"types": "./build/module/runtime/plugins/index.d.ts",
39+
"import": "./build/module/runtime/plugins/index.js"
3640
}
3741
},
3842
"publishConfig": {
@@ -45,6 +49,7 @@
4549
"@nuxt/kit": "^3.13.2",
4650
"@sentry/browser": "9.33.0",
4751
"@sentry/core": "9.33.0",
52+
"@sentry/cloudflare": "9.33.0",
4853
"@sentry/node": "9.33.0",
4954
"@sentry/rollup-plugin": "^3.5.0",
5055
"@sentry/vite-plugin": "^3.5.0",
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { captureException, getClient, getCurrentScope } from '@sentry/core';
2+
// eslint-disable-next-line import/no-extraneous-dependencies
3+
import { H3Error } from 'h3';
4+
import type { CapturedErrorContext } from 'nitropack';
5+
import { extractErrorContext, flushIfServerless } from '../utils';
6+
7+
/**
8+
* Hook that can be added in a Nitro plugin. It captures an error and sends it to Sentry.
9+
*/
10+
export async function sentryCaptureErrorHook(error: Error, errorContext: CapturedErrorContext): Promise<void> {
11+
const sentryClient = getClient();
12+
const sentryClientOptions = sentryClient?.getOptions();
13+
14+
if (
15+
sentryClientOptions &&
16+
'enableNitroErrorHandler' in sentryClientOptions &&
17+
sentryClientOptions.enableNitroErrorHandler === false
18+
) {
19+
return;
20+
}
21+
22+
// Do not handle 404 and 422
23+
if (error instanceof H3Error) {
24+
// Do not report if status code is 3xx or 4xx
25+
if (error.statusCode >= 300 && error.statusCode < 500) {
26+
return;
27+
}
28+
}
29+
30+
const { method, path } = {
31+
method: errorContext.event?._method ? errorContext.event._method : '',
32+
path: errorContext.event?._path ? errorContext.event._path : null,
33+
};
34+
35+
if (path) {
36+
getCurrentScope().setTransactionName(`${method} ${path}`);
37+
}
38+
39+
const structuredContext = extractErrorContext(errorContext);
40+
41+
captureException(error, {
42+
captureContext: { contexts: { nuxt: structuredContext } },
43+
mechanism: { handled: false },
44+
});
45+
46+
await flushIfServerless();
47+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
// fixme: Can this be exported like this?
2+
export { sentryCloudflareNitroPlugin } from './sentry-cloudflare.server';
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import type { ExecutionContext, IncomingRequestCfProperties } from '@cloudflare/workers-types';
2+
import type { CloudflareOptions } from '@sentry/cloudflare';
3+
import { setAsyncLocalStorageAsyncContextStrategy, wrapRequestHandler } from '@sentry/cloudflare';
4+
import { getDefaultIsolationScope, getIsolationScope, getTraceData, logger } from '@sentry/core';
5+
import type { H3Event } from 'h3';
6+
import type { NitroApp, NitroAppPlugin } from 'nitropack';
7+
import type { NuxtRenderHTMLContext } from 'nuxt/app';
8+
import { sentryCaptureErrorHook } from '../hooks/captureErrorHook';
9+
import { addSentryTracingMetaTags } from '../utils';
10+
11+
interface CfEventType {
12+
protocol: string;
13+
host: string;
14+
method: string;
15+
headers: Record<string, string>;
16+
context: {
17+
cf: {
18+
httpProtocol?: string;
19+
country?: string;
20+
// ...other CF properties
21+
};
22+
cloudflare: {
23+
context: ExecutionContext;
24+
request?: Record<string, unknown>;
25+
env?: Record<string, unknown>;
26+
};
27+
};
28+
}
29+
30+
function isEventType(event: unknown): event is CfEventType {
31+
if (event === null || typeof event !== 'object') return false;
32+
33+
return (
34+
// basic properties
35+
'protocol' in event &&
36+
'host' in event &&
37+
typeof event.protocol === 'string' &&
38+
typeof event.host === 'string' &&
39+
// context property
40+
'context' in event &&
41+
typeof event.context === 'object' &&
42+
event.context !== null &&
43+
// context.cf properties
44+
'cf' in event.context &&
45+
typeof event.context.cf === 'object' &&
46+
event.context.cf !== null &&
47+
// context.cloudflare properties
48+
'cloudflare' in event.context &&
49+
typeof event.context.cloudflare === 'object' &&
50+
event.context.cloudflare !== null &&
51+
'context' in event.context.cloudflare
52+
);
53+
}
54+
55+
/**
56+
* Sentry Cloudflare Nitro plugin for when using the "cloudflare-pages" preset in Nuxt.
57+
* This plugin automatically sets up Sentry error monitoring and performance tracking for Cloudflare Pages projects.
58+
*
59+
* Instead of adding a `sentry.server.config.ts` file, export this plugin in the `server/plugins` directory
60+
* with the necessary Sentry options to enable Sentry for your Cloudflare Pages project.
61+
*
62+
*
63+
* @example Basic usage
64+
* ```ts
65+
* // nitro/plugins/sentry.ts
66+
* import { defineNitroPlugin } from '#imports'
67+
* import { sentryCloudflareNitroPlugin } from '@sentry/nuxt/module/plugins'
68+
*
69+
* export default defineNitroPlugin(sentryCloudflareNitroPlugin({
70+
* dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0',
71+
* tracesSampleRate: 1.0,
72+
* }));
73+
* ```
74+
*
75+
* @example Dynamic configuration with nitroApp
76+
* ```ts
77+
* // nitro/plugins/sentry.ts
78+
* import { defineNitroPlugin } from '#imports'
79+
* import { sentryCloudflareNitroPlugin } from '@sentry/nuxt/module/plugins'
80+
*
81+
* export default defineNitroPlugin(sentryCloudflareNitroPlugin(nitroApp => ({
82+
* dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0',
83+
* debug: nitroApp.h3App.options.debug
84+
* })));
85+
* ```
86+
*/
87+
export const sentryCloudflareNitroPlugin =
88+
(optionsOrFn: CloudflareOptions | ((nitroApp: NitroApp) => CloudflareOptions)): NitroAppPlugin =>
89+
(nitroApp: NitroApp): void => {
90+
const traceDataMap = new WeakMap<object, ReturnType<typeof getTraceData>>();
91+
92+
nitroApp.localFetch = new Proxy(nitroApp.localFetch, {
93+
async apply(handlerTarget, handlerThisArg, handlerArgs: [string, unknown]) {
94+
setAsyncLocalStorageAsyncContextStrategy();
95+
96+
const cloudflareOptions = typeof optionsOrFn === 'function' ? optionsOrFn(nitroApp) : optionsOrFn;
97+
const pathname = handlerArgs[0];
98+
const event = handlerArgs[1];
99+
100+
if (!isEventType(event)) {
101+
logger.log("Nitro Cloudflare plugin did not detect a Cloudflare event type. Won't patch Cloudflare handler.");
102+
return handlerTarget.apply(handlerThisArg, handlerArgs);
103+
} else {
104+
// Usually, the protocol already includes ":"
105+
const url = `${event.protocol}${event.protocol.endsWith(':') ? '' : ':'}//${event.host}${pathname}`;
106+
const request = new Request(url, {
107+
method: event.method,
108+
headers: event.headers,
109+
cf: event.context.cf,
110+
}) as Request<unknown, IncomingRequestCfProperties<unknown>>;
111+
112+
const requestHandlerOptions = {
113+
options: cloudflareOptions,
114+
request,
115+
context: event.context.cloudflare.context,
116+
};
117+
118+
return wrapRequestHandler(requestHandlerOptions, () => {
119+
const isolationScope = getIsolationScope();
120+
const newIsolationScope =
121+
isolationScope === getDefaultIsolationScope() ? isolationScope.clone() : isolationScope;
122+
123+
const traceData = getTraceData();
124+
if (traceData && Object.keys(traceData).length > 0) {
125+
// Storing trace data in the WeakMap using event.context.cf as key for later use in HTML meta-tags
126+
traceDataMap.set(event.context.cf, traceData);
127+
logger.log('Stored trace data for later use in HTML meta-tags: ', traceData);
128+
}
129+
130+
logger.log(
131+
`Patched Cloudflare handler (\`nitroApp.localFetch\`). ${
132+
isolationScope === newIsolationScope ? 'Using existing' : 'Created new'
133+
} isolation scope.`,
134+
);
135+
136+
return handlerTarget.apply(handlerThisArg, handlerArgs);
137+
});
138+
}
139+
},
140+
});
141+
142+
// @ts-expect-error - 'render:html' is a valid hook name in the Nuxt context
143+
nitroApp.hooks.hook('render:html', (html: NuxtRenderHTMLContext, { event }: { event: H3Event }) => {
144+
const storedTraceData = event?.context?.cf ? traceDataMap.get(event.context.cf) : undefined;
145+
146+
if (storedTraceData && Object.keys(storedTraceData).length > 0) {
147+
logger.log('Using stored trace data for HTML meta-tags: ', storedTraceData);
148+
addSentryTracingMetaTags(html.head, storedTraceData);
149+
} else {
150+
addSentryTracingMetaTags(html.head);
151+
}
152+
});
153+
154+
nitroApp.hooks.hook('error', sentryCaptureErrorHook);
155+
};

packages/nuxt/src/runtime/plugins/sentry.server.ts

Lines changed: 5 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -1,96 +1,23 @@
1-
import {
2-
flush,
3-
getDefaultIsolationScope,
4-
getIsolationScope,
5-
GLOBAL_OBJ,
6-
logger,
7-
vercelWaitUntil,
8-
withIsolationScope,
9-
} from '@sentry/core';
10-
import * as SentryNode from '@sentry/node';
1+
import { getDefaultIsolationScope, getIsolationScope, logger, withIsolationScope } from '@sentry/core';
112
// eslint-disable-next-line import/no-extraneous-dependencies
12-
import { type EventHandler, H3Error } from 'h3';
3+
import { type EventHandler } from 'h3';
134
// eslint-disable-next-line import/no-extraneous-dependencies
145
import { defineNitroPlugin } from 'nitropack/runtime';
156
import type { NuxtRenderHTMLContext } from 'nuxt/app';
16-
import { addSentryTracingMetaTags, extractErrorContext } from '../utils';
7+
import { sentryCaptureErrorHook } from '../hooks/captureErrorHook';
8+
import { addSentryTracingMetaTags, flushIfServerless } from '../utils';
179

1810
export default defineNitroPlugin(nitroApp => {
1911
nitroApp.h3App.handler = patchEventHandler(nitroApp.h3App.handler);
2012

21-
nitroApp.hooks.hook('error', async (error, errorContext) => {
22-
const sentryClient = SentryNode.getClient();
23-
const sentryClientOptions = sentryClient?.getOptions();
24-
25-
if (
26-
sentryClientOptions &&
27-
'enableNitroErrorHandler' in sentryClientOptions &&
28-
sentryClientOptions.enableNitroErrorHandler === false
29-
) {
30-
return;
31-
}
32-
33-
// Do not handle 404 and 422
34-
if (error instanceof H3Error) {
35-
// Do not report if status code is 3xx or 4xx
36-
if (error.statusCode >= 300 && error.statusCode < 500) {
37-
return;
38-
}
39-
}
40-
41-
const { method, path } = {
42-
method: errorContext.event?._method ? errorContext.event._method : '',
43-
path: errorContext.event?._path ? errorContext.event._path : null,
44-
};
45-
46-
if (path) {
47-
SentryNode.getCurrentScope().setTransactionName(`${method} ${path}`);
48-
}
49-
50-
const structuredContext = extractErrorContext(errorContext);
51-
52-
SentryNode.captureException(error, {
53-
captureContext: { contexts: { nuxt: structuredContext } },
54-
mechanism: { handled: false },
55-
});
56-
57-
await flushIfServerless();
58-
});
13+
nitroApp.hooks.hook('error', sentryCaptureErrorHook);
5914

6015
// @ts-expect-error - 'render:html' is a valid hook name in the Nuxt context
6116
nitroApp.hooks.hook('render:html', (html: NuxtRenderHTMLContext) => {
6217
addSentryTracingMetaTags(html.head);
6318
});
6419
});
6520

66-
async function flushIfServerless(): Promise<void> {
67-
const isServerless =
68-
!!process.env.FUNCTIONS_WORKER_RUNTIME || // Azure Functions
69-
!!process.env.LAMBDA_TASK_ROOT || // AWS Lambda
70-
!!process.env.VERCEL ||
71-
!!process.env.NETLIFY;
72-
73-
// @ts-expect-error This is not typed
74-
if (GLOBAL_OBJ[Symbol.for('@vercel/request-context')]) {
75-
vercelWaitUntil(flushWithTimeout());
76-
} else if (isServerless) {
77-
await flushWithTimeout();
78-
}
79-
}
80-
81-
async function flushWithTimeout(): Promise<void> {
82-
const sentryClient = SentryNode.getClient();
83-
const isDebug = sentryClient ? sentryClient.getOptions().debug : false;
84-
85-
try {
86-
isDebug && logger.log('Flushing events...');
87-
await flush(2000);
88-
isDebug && logger.log('Done flushing events');
89-
} catch (e) {
90-
isDebug && logger.log('Error while flushing events:\n', e);
91-
}
92-
}
93-
9421
function patchEventHandler(handler: EventHandler): EventHandler {
9522
return new Proxy(handler, {
9623
async apply(handlerTarget, handlerThisArg, handlerArgs: Parameters<EventHandler>) {

0 commit comments

Comments
 (0)