Skip to content

Commit 6006c3a

Browse files
committed
feat(nuxt): Add Cloudflare Nitro plugin
1 parent 497b76e commit 6006c3a

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.26.0",
4751
"@sentry/core": "9.26.0",
52+
"@sentry/cloudflare": "9.26.0",
4853
"@sentry/node": "9.26.0",
4954
"@sentry/opentelemetry": "9.26.0",
5055
"@sentry/rollup-plugin": "3.4.0",
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,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 { addSentryTracingMetaTags, flushIfServerless } from '../utils';
8+
import { sentryCaptureErrorHook } from '../hooks/captureErrorHook';
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>) {

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

0 commit comments

Comments
 (0)