Skip to content

Commit bc63ab4

Browse files
committed
feat(nuxt): Add Cloudflare Nitro plugin
1 parent fa6590b commit bc63ab4

File tree

6 files changed

+162
-78
lines changed

6 files changed

+162
-78
lines changed

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.3.0",
4751
"@sentry/core": "9.3.0",
52+
"@sentry/cloudflare": "9.3.0",
4853
"@sentry/node": "9.3.0",
4954
"@sentry/opentelemetry": "9.3.0",
5055
"@sentry/rollup-plugin": "3.1.2",
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import * as SentryNode from '@sentry/node';
2+
import { H3Error } from 'h3';
3+
import { extractErrorContext, flushIfServerless } from '../utils';
4+
import type { CapturedErrorContext } from 'nitropack';
5+
6+
/**
7+
* Hook that can be added in a Nitro plugin. It captures an error and sends it to Sentry.
8+
*/
9+
export async function sentryCaptureErrorHook(error: Error, errorContext: CapturedErrorContext): Promise<void> {
10+
const sentryClient = SentryNode.getClient();
11+
const sentryClientOptions = sentryClient?.getOptions();
12+
13+
if (
14+
sentryClientOptions &&
15+
'enableNitroErrorHandler' in sentryClientOptions &&
16+
sentryClientOptions.enableNitroErrorHandler === false
17+
) {
18+
return;
19+
}
20+
21+
// Do not handle 404 and 422
22+
if (error instanceof H3Error) {
23+
// Do not report if status code is 3xx or 4xx
24+
if (error.statusCode >= 300 && error.statusCode < 500) {
25+
return;
26+
}
27+
}
28+
29+
const { method, path } = {
30+
method: errorContext.event?._method ? errorContext.event._method : '',
31+
path: errorContext.event?._path ? errorContext.event._path : null,
32+
};
33+
34+
if (path) {
35+
SentryNode.getCurrentScope().setTransactionName(`${method} ${path}`);
36+
}
37+
38+
const structuredContext = extractErrorContext(errorContext);
39+
40+
SentryNode.captureException(error, {
41+
captureContext: { contexts: { nuxt: structuredContext } },
42+
mechanism: { handled: false },
43+
});
44+
45+
await flushIfServerless();
46+
}
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 { cloudflareNitroPlugin } from './sentry-cloudflare.server';
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { wrapRequestHandler, setAsyncLocalStorageAsyncContextStrategy } from '@sentry/cloudflare';
2+
import type { NitroApp, NitroAppPlugin } from 'nitropack';
3+
import type { CloudflareOptions } from '@sentry/cloudflare';
4+
import type { ExecutionContext } from '@cloudflare/workers-types';
5+
import type { NuxtRenderHTMLContext } from 'nuxt/app';
6+
import { addSentryTracingMetaTags } from '../utils';
7+
import { sentryCaptureErrorHook } from '../hooks/captureErrorHook';
8+
9+
interface CfEventType {
10+
protocol: string;
11+
host: string;
12+
context: {
13+
cloudflare: {
14+
context: ExecutionContext;
15+
};
16+
};
17+
}
18+
19+
function isEventType(event: unknown): event is CfEventType {
20+
return (
21+
event !== null &&
22+
typeof event === 'object' &&
23+
'protocol' in event &&
24+
'host' in event &&
25+
'context' in event &&
26+
typeof event.protocol === 'string' &&
27+
typeof event.host === 'string' &&
28+
typeof event.context === 'object' &&
29+
event?.context !== null &&
30+
'cloudflare' in event.context &&
31+
typeof event.context.cloudflare === 'object' &&
32+
event?.context.cloudflare !== null &&
33+
'context' in event?.context?.cloudflare
34+
);
35+
}
36+
37+
export const cloudflareNitroPlugin =
38+
(sentryOptions: CloudflareOptions): NitroAppPlugin =>
39+
(nitroApp: NitroApp): void => {
40+
nitroApp.localFetch = new Proxy(nitroApp.localFetch, {
41+
async apply(handlerTarget, handlerThisArg, handlerArgs: [string, unknown]) {
42+
// fixme: is this the correct spot?
43+
setAsyncLocalStorageAsyncContextStrategy();
44+
45+
const pathname = handlerArgs[0];
46+
const event = handlerArgs[1];
47+
48+
if (isEventType(event)) {
49+
const requestHandlerOptions = {
50+
options: sentryOptions,
51+
request: { ...event, url: `${event.protocol}//${event.host}${pathname}` },
52+
context: event.context.cloudflare.context,
53+
};
54+
55+
// todo: wrap in isolation scope (like regular handler)
56+
return wrapRequestHandler(requestHandlerOptions, () => handlerTarget.apply(handlerThisArg, handlerArgs));
57+
}
58+
59+
return handlerTarget.apply(handlerThisArg, handlerArgs);
60+
},
61+
});
62+
63+
// @ts-expect-error - 'render:html' is a valid hook name in the Nuxt context
64+
nitroApp.hooks.hook('render:html', (html: NuxtRenderHTMLContext) => {
65+
// fixme: it's attaching the html meta tag but it's not connecting the trace
66+
addSentryTracingMetaTags(html.head);
67+
});
68+
69+
nitroApp.hooks.hook('error', sentryCaptureErrorHook);
70+
};

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

Lines changed: 5 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -1,94 +1,21 @@
1-
import {
2-
GLOBAL_OBJ,
3-
flush,
4-
getDefaultIsolationScope,
5-
getIsolationScope,
6-
logger,
7-
vercelWaitUntil,
8-
withIsolationScope,
9-
} from '@sentry/core';
10-
import * as SentryNode from '@sentry/node';
11-
import { type EventHandler, H3Error } from 'h3';
1+
import { getDefaultIsolationScope, getIsolationScope, logger, withIsolationScope } from '@sentry/core';
2+
import { type EventHandler } from 'h3';
123
import { defineNitroPlugin } from 'nitropack/runtime';
134
import type { NuxtRenderHTMLContext } from 'nuxt/app';
14-
import { addSentryTracingMetaTags, extractErrorContext } from '../utils';
5+
import { addSentryTracingMetaTags, flushIfServerless } from '../utils';
6+
import { sentryCaptureErrorHook } from '../hooks/captureErrorHook';
157

168
export default defineNitroPlugin(nitroApp => {
179
nitroApp.h3App.handler = patchEventHandler(nitroApp.h3App.handler);
1810

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

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

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

packages/nuxt/src/runtime/utils.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import type { ClientOptions, Context } from '@sentry/core';
2+
import { flush, GLOBAL_OBJ, logger, vercelWaitUntil } from '@sentry/core';
23
import { captureException, dropUndefinedKeys, getClient, getTraceMetaTags } from '@sentry/core';
34
import type { VueOptions } from '@sentry/vue/src/types';
45
import type { CapturedErrorContext } from 'nitropack';
56
import type { NuxtRenderHTMLContext } from 'nuxt/app';
67
import type { ComponentPublicInstance } from 'vue';
8+
import * as SentryNode from '@sentry/node';
79

810
/**
911
* Extracts the relevant context information from the error context (H3Event in Nitro Error)
@@ -79,3 +81,35 @@ export function reportNuxtError(options: {
7981
});
8082
});
8183
}
84+
85+
async function flushWithTimeout(): Promise<void> {
86+
const sentryClient = SentryNode.getClient();
87+
const isDebug = sentryClient ? sentryClient.getOptions().debug : false;
88+
89+
try {
90+
isDebug && logger.log('Flushing events...');
91+
await flush(2000);
92+
isDebug && logger.log('Done flushing events');
93+
} catch (e) {
94+
isDebug && logger.log('Error while flushing events:\n', e);
95+
}
96+
}
97+
98+
/**
99+
* Flushes if in a serverless environment
100+
*/
101+
export async function flushIfServerless(): Promise<void> {
102+
const isServerless =
103+
!!process.env.FUNCTIONS_WORKER_RUNTIME || // Azure Functions
104+
!!process.env.LAMBDA_TASK_ROOT || // AWS Lambda
105+
!!process.env.CF_PAGES || // Cloudflare
106+
!!process.env.VERCEL ||
107+
!!process.env.NETLIFY;
108+
109+
// @ts-expect-error This is not typed
110+
if (GLOBAL_OBJ[Symbol.for('@vercel/request-context')]) {
111+
vercelWaitUntil(flushWithTimeout());
112+
} else if (isServerless) {
113+
await flushWithTimeout();
114+
}
115+
}

0 commit comments

Comments
 (0)