diff --git a/packages/nuxt/package.json b/packages/nuxt/package.json index 9b6a1dca5f72..3e5930b51adf 100644 --- a/packages/nuxt/package.json +++ b/packages/nuxt/package.json @@ -33,6 +33,10 @@ "types": "./build/module/types.d.ts", "import": "./build/module/module.mjs", "require": "./build/module/module.cjs" + }, + "./module/plugins": { + "types": "./build/module/runtime/plugins/index.d.ts", + "import": "./build/module/runtime/plugins/index.js" } }, "publishConfig": { @@ -45,6 +49,7 @@ "@nuxt/kit": "^3.13.2", "@sentry/browser": "9.3.0", "@sentry/core": "9.3.0", + "@sentry/cloudflare": "9.3.0", "@sentry/node": "9.3.0", "@sentry/opentelemetry": "9.3.0", "@sentry/rollup-plugin": "3.1.2", diff --git a/packages/nuxt/src/runtime/hooks/captureErrorHook.ts b/packages/nuxt/src/runtime/hooks/captureErrorHook.ts new file mode 100644 index 000000000000..4a588ec58a45 --- /dev/null +++ b/packages/nuxt/src/runtime/hooks/captureErrorHook.ts @@ -0,0 +1,46 @@ +import * as SentryNode from '@sentry/node'; +import { H3Error } from 'h3'; +import { extractErrorContext, flushIfServerless } from '../utils'; +import type { CapturedErrorContext } from 'nitropack'; + +/** + * Hook that can be added in a Nitro plugin. It captures an error and sends it to Sentry. + */ +export async function sentryCaptureErrorHook(error: Error, errorContext: CapturedErrorContext): Promise { + const sentryClient = SentryNode.getClient(); + const sentryClientOptions = sentryClient?.getOptions(); + + if ( + sentryClientOptions && + 'enableNitroErrorHandler' in sentryClientOptions && + sentryClientOptions.enableNitroErrorHandler === false + ) { + return; + } + + // Do not handle 404 and 422 + if (error instanceof H3Error) { + // Do not report if status code is 3xx or 4xx + if (error.statusCode >= 300 && error.statusCode < 500) { + return; + } + } + + const { method, path } = { + method: errorContext.event?._method ? errorContext.event._method : '', + path: errorContext.event?._path ? errorContext.event._path : null, + }; + + if (path) { + SentryNode.getCurrentScope().setTransactionName(`${method} ${path}`); + } + + const structuredContext = extractErrorContext(errorContext); + + SentryNode.captureException(error, { + captureContext: { contexts: { nuxt: structuredContext } }, + mechanism: { handled: false }, + }); + + await flushIfServerless(); +} diff --git a/packages/nuxt/src/runtime/plugins/index.ts b/packages/nuxt/src/runtime/plugins/index.ts new file mode 100644 index 000000000000..5c04178922b3 --- /dev/null +++ b/packages/nuxt/src/runtime/plugins/index.ts @@ -0,0 +1,2 @@ +// fixme: Can this be exported like this? +export { cloudflareNitroPlugin } from './sentry-cloudflare.server'; diff --git a/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts b/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts new file mode 100644 index 000000000000..5f62c1473f1b --- /dev/null +++ b/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts @@ -0,0 +1,70 @@ +import { wrapRequestHandler, setAsyncLocalStorageAsyncContextStrategy } from '@sentry/cloudflare'; +import type { NitroApp, NitroAppPlugin } from 'nitropack'; +import type { CloudflareOptions } from '@sentry/cloudflare'; +import type { ExecutionContext } from '@cloudflare/workers-types'; +import type { NuxtRenderHTMLContext } from 'nuxt/app'; +import { addSentryTracingMetaTags } from '../utils'; +import { sentryCaptureErrorHook } from '../hooks/captureErrorHook'; + +interface CfEventType { + protocol: string; + host: string; + context: { + cloudflare: { + context: ExecutionContext; + }; + }; +} + +function isEventType(event: unknown): event is CfEventType { + return ( + event !== null && + typeof event === 'object' && + 'protocol' in event && + 'host' in event && + 'context' in event && + typeof event.protocol === 'string' && + typeof event.host === 'string' && + typeof event.context === 'object' && + event?.context !== null && + 'cloudflare' in event.context && + typeof event.context.cloudflare === 'object' && + event?.context.cloudflare !== null && + 'context' in event?.context?.cloudflare + ); +} + +export const cloudflareNitroPlugin = + (sentryOptions: CloudflareOptions): NitroAppPlugin => + (nitroApp: NitroApp): void => { + nitroApp.localFetch = new Proxy(nitroApp.localFetch, { + async apply(handlerTarget, handlerThisArg, handlerArgs: [string, unknown]) { + // fixme: is this the correct spot? + setAsyncLocalStorageAsyncContextStrategy(); + + const pathname = handlerArgs[0]; + const event = handlerArgs[1]; + + if (isEventType(event)) { + const requestHandlerOptions = { + options: sentryOptions, + request: { ...event, url: `${event.protocol}//${event.host}${pathname}` }, + context: event.context.cloudflare.context, + }; + + // todo: wrap in isolation scope (like regular handler) + return wrapRequestHandler(requestHandlerOptions, () => handlerTarget.apply(handlerThisArg, handlerArgs)); + } + + return handlerTarget.apply(handlerThisArg, handlerArgs); + }, + }); + + // @ts-expect-error - 'render:html' is a valid hook name in the Nuxt context + nitroApp.hooks.hook('render:html', (html: NuxtRenderHTMLContext) => { + // fixme: it's attaching the html meta tag but it's not connecting the trace + addSentryTracingMetaTags(html.head); + }); + + nitroApp.hooks.hook('error', sentryCaptureErrorHook); + }; diff --git a/packages/nuxt/src/runtime/plugins/sentry.server.ts b/packages/nuxt/src/runtime/plugins/sentry.server.ts index f65ac64b9982..9e2c0dc718c4 100644 --- a/packages/nuxt/src/runtime/plugins/sentry.server.ts +++ b/packages/nuxt/src/runtime/plugins/sentry.server.ts @@ -1,59 +1,14 @@ -import { - GLOBAL_OBJ, - flush, - getDefaultIsolationScope, - getIsolationScope, - logger, - vercelWaitUntil, - withIsolationScope, -} from '@sentry/core'; -import * as SentryNode from '@sentry/node'; -import { type EventHandler, H3Error } from 'h3'; +import { getDefaultIsolationScope, getIsolationScope, logger, withIsolationScope } from '@sentry/core'; +import { type EventHandler } from 'h3'; import { defineNitroPlugin } from 'nitropack/runtime'; import type { NuxtRenderHTMLContext } from 'nuxt/app'; -import { addSentryTracingMetaTags, extractErrorContext } from '../utils'; +import { addSentryTracingMetaTags, flushIfServerless } from '../utils'; +import { sentryCaptureErrorHook } from '../hooks/captureErrorHook'; export default defineNitroPlugin(nitroApp => { nitroApp.h3App.handler = patchEventHandler(nitroApp.h3App.handler); - nitroApp.hooks.hook('error', async (error, errorContext) => { - const sentryClient = SentryNode.getClient(); - const sentryClientOptions = sentryClient?.getOptions(); - - if ( - sentryClientOptions && - 'enableNitroErrorHandler' in sentryClientOptions && - sentryClientOptions.enableNitroErrorHandler === false - ) { - return; - } - - // Do not handle 404 and 422 - if (error instanceof H3Error) { - // Do not report if status code is 3xx or 4xx - if (error.statusCode >= 300 && error.statusCode < 500) { - return; - } - } - - const { method, path } = { - method: errorContext.event?._method ? errorContext.event._method : '', - path: errorContext.event?._path ? errorContext.event._path : null, - }; - - if (path) { - SentryNode.getCurrentScope().setTransactionName(`${method} ${path}`); - } - - const structuredContext = extractErrorContext(errorContext); - - SentryNode.captureException(error, { - captureContext: { contexts: { nuxt: structuredContext } }, - mechanism: { handled: false }, - }); - - await flushIfServerless(); - }); + nitroApp.hooks.hook('error', sentryCaptureErrorHook); // @ts-expect-error - 'render:html' is a valid hook name in the Nuxt context nitroApp.hooks.hook('render:html', (html: NuxtRenderHTMLContext) => { @@ -61,34 +16,6 @@ export default defineNitroPlugin(nitroApp => { }); }); -async function flushIfServerless(): Promise { - const isServerless = - !!process.env.FUNCTIONS_WORKER_RUNTIME || // Azure Functions - !!process.env.LAMBDA_TASK_ROOT || // AWS Lambda - !!process.env.VERCEL || - !!process.env.NETLIFY; - - // @ts-expect-error This is not typed - if (GLOBAL_OBJ[Symbol.for('@vercel/request-context')]) { - vercelWaitUntil(flushWithTimeout()); - } else if (isServerless) { - await flushWithTimeout(); - } -} - -async function flushWithTimeout(): Promise { - const sentryClient = SentryNode.getClient(); - const isDebug = sentryClient ? sentryClient.getOptions().debug : false; - - try { - isDebug && logger.log('Flushing events...'); - await flush(2000); - isDebug && logger.log('Done flushing events'); - } catch (e) { - isDebug && logger.log('Error while flushing events:\n', e); - } -} - function patchEventHandler(handler: EventHandler): EventHandler { return new Proxy(handler, { async apply(handlerTarget, handlerThisArg, handlerArgs: Parameters) { diff --git a/packages/nuxt/src/runtime/utils.ts b/packages/nuxt/src/runtime/utils.ts index 23bac2486b74..9b4de1261b2e 100644 --- a/packages/nuxt/src/runtime/utils.ts +++ b/packages/nuxt/src/runtime/utils.ts @@ -1,9 +1,11 @@ import type { ClientOptions, Context } from '@sentry/core'; +import { flush, GLOBAL_OBJ, logger, vercelWaitUntil } from '@sentry/core'; import { captureException, dropUndefinedKeys, getClient, getTraceMetaTags } from '@sentry/core'; import type { VueOptions } from '@sentry/vue/src/types'; import type { CapturedErrorContext } from 'nitropack'; import type { NuxtRenderHTMLContext } from 'nuxt/app'; import type { ComponentPublicInstance } from 'vue'; +import * as SentryNode from '@sentry/node'; /** * Extracts the relevant context information from the error context (H3Event in Nitro Error) @@ -79,3 +81,35 @@ export function reportNuxtError(options: { }); }); } + +async function flushWithTimeout(): Promise { + const sentryClient = SentryNode.getClient(); + const isDebug = sentryClient ? sentryClient.getOptions().debug : false; + + try { + isDebug && logger.log('Flushing events...'); + await flush(2000); + isDebug && logger.log('Done flushing events'); + } catch (e) { + isDebug && logger.log('Error while flushing events:\n', e); + } +} + +/** + * Flushes if in a serverless environment + */ +export async function flushIfServerless(): Promise { + const isServerless = + !!process.env.FUNCTIONS_WORKER_RUNTIME || // Azure Functions + !!process.env.LAMBDA_TASK_ROOT || // AWS Lambda + !!process.env.CF_PAGES || // Cloudflare + !!process.env.VERCEL || + !!process.env.NETLIFY; + + // @ts-expect-error This is not typed + if (GLOBAL_OBJ[Symbol.for('@vercel/request-context')]) { + vercelWaitUntil(flushWithTimeout()); + } else if (isServerless) { + await flushWithTimeout(); + } +}