diff --git a/packages/core/src/utils/meta.ts b/packages/core/src/utils/meta.ts index 7db802582eef..89bf7514822f 100644 --- a/packages/core/src/utils/meta.ts +++ b/packages/core/src/utils/meta.ts @@ -1,3 +1,4 @@ +import type { SerializedTraceData } from '../types-hoist/tracing'; import { getTraceData } from './traceData'; /** @@ -21,8 +22,8 @@ import { getTraceData } from './traceData'; * ``` * */ -export function getTraceMetaTags(): string { - return Object.entries(getTraceData()) +export function getTraceMetaTags(traceData?: SerializedTraceData): string { + return Object.entries(traceData || getTraceData()) .map(([key, value]) => ``) .join('\n'); } diff --git a/packages/core/test/lib/utils/meta.test.ts b/packages/core/test/lib/utils/meta.test.ts index 19fb68ef0e7d..71cdce3e6eee 100644 --- a/packages/core/test/lib/utils/meta.test.ts +++ b/packages/core/test/lib/utils/meta.test.ts @@ -29,4 +29,20 @@ describe('getTraceMetaTags', () => { expect(getTraceMetaTags()).toBe(''); }); + + it('uses provided traceData instead of calling getTraceData()', () => { + const getTraceDataSpy = vi.spyOn(TraceDataModule, 'getTraceData'); + + const customTraceData = { + 'sentry-trace': 'ab12345678901234567890123456789012-1234567890abcdef-1', + baggage: + 'sentry-environment=test,sentry-public_key=public12345,sentry-trace_id=ab12345678901234567890123456789012,sentry-sample_rate=0.5', + }; + + expect(getTraceMetaTags(customTraceData)) + .toBe(` +`); + + expect(getTraceDataSpy).not.toHaveBeenCalled(); + }); }); diff --git a/packages/nuxt/package.json b/packages/nuxt/package.json index 78a0385b7ed5..6fb0ae45465a 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.33.0", "@sentry/core": "9.33.0", + "@sentry/cloudflare": "9.33.0", "@sentry/node": "9.33.0", "@sentry/rollup-plugin": "^3.5.0", "@sentry/vite-plugin": "^3.5.0", diff --git a/packages/nuxt/src/runtime/hooks/captureErrorHook.ts b/packages/nuxt/src/runtime/hooks/captureErrorHook.ts new file mode 100644 index 000000000000..3b2e82ee6044 --- /dev/null +++ b/packages/nuxt/src/runtime/hooks/captureErrorHook.ts @@ -0,0 +1,47 @@ +import { captureException, getClient, getCurrentScope } from '@sentry/core'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { H3Error } from 'h3'; +import type { CapturedErrorContext } from 'nitropack'; +import { extractErrorContext, flushIfServerless } from '../utils'; + +/** + * 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 = 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) { + getCurrentScope().setTransactionName(`${method} ${path}`); + } + + const structuredContext = extractErrorContext(errorContext); + + 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..dbe41b848a0c --- /dev/null +++ b/packages/nuxt/src/runtime/plugins/index.ts @@ -0,0 +1,2 @@ +// fixme: Can this be exported like this? +export { sentryCloudflareNitroPlugin } 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..9d10e9bd86d0 --- /dev/null +++ b/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts @@ -0,0 +1,155 @@ +import type { ExecutionContext, IncomingRequestCfProperties } from '@cloudflare/workers-types'; +import type { CloudflareOptions } from '@sentry/cloudflare'; +import { setAsyncLocalStorageAsyncContextStrategy, wrapRequestHandler } from '@sentry/cloudflare'; +import { getDefaultIsolationScope, getIsolationScope, getTraceData, logger } from '@sentry/core'; +import type { H3Event } from 'h3'; +import type { NitroApp, NitroAppPlugin } from 'nitropack'; +import type { NuxtRenderHTMLContext } from 'nuxt/app'; +import { sentryCaptureErrorHook } from '../hooks/captureErrorHook'; +import { addSentryTracingMetaTags } from '../utils'; + +interface CfEventType { + protocol: string; + host: string; + method: string; + headers: Record; + context: { + cf: { + httpProtocol?: string; + country?: string; + // ...other CF properties + }; + cloudflare: { + context: ExecutionContext; + request?: Record; + env?: Record; + }; + }; +} + +function isEventType(event: unknown): event is CfEventType { + if (event === null || typeof event !== 'object') return false; + + return ( + // basic properties + 'protocol' in event && + 'host' in event && + typeof event.protocol === 'string' && + typeof event.host === 'string' && + // context property + 'context' in event && + typeof event.context === 'object' && + event.context !== null && + // context.cf properties + 'cf' in event.context && + typeof event.context.cf === 'object' && + event.context.cf !== null && + // context.cloudflare properties + 'cloudflare' in event.context && + typeof event.context.cloudflare === 'object' && + event.context.cloudflare !== null && + 'context' in event.context.cloudflare + ); +} + +/** + * Sentry Cloudflare Nitro plugin for when using the "cloudflare-pages" preset in Nuxt. + * This plugin automatically sets up Sentry error monitoring and performance tracking for Cloudflare Pages projects. + * + * Instead of adding a `sentry.server.config.ts` file, export this plugin in the `server/plugins` directory + * with the necessary Sentry options to enable Sentry for your Cloudflare Pages project. + * + * + * @example Basic usage + * ```ts + * // nitro/plugins/sentry.ts + * import { defineNitroPlugin } from '#imports' + * import { sentryCloudflareNitroPlugin } from '@sentry/nuxt/module/plugins' + * + * export default defineNitroPlugin(sentryCloudflareNitroPlugin({ + * dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0', + * tracesSampleRate: 1.0, + * })); + * ``` + * + * @example Dynamic configuration with nitroApp + * ```ts + * // nitro/plugins/sentry.ts + * import { defineNitroPlugin } from '#imports' + * import { sentryCloudflareNitroPlugin } from '@sentry/nuxt/module/plugins' + * + * export default defineNitroPlugin(sentryCloudflareNitroPlugin(nitroApp => ({ + * dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0', + * debug: nitroApp.h3App.options.debug + * }))); + * ``` + */ +export const sentryCloudflareNitroPlugin = + (optionsOrFn: CloudflareOptions | ((nitroApp: NitroApp) => CloudflareOptions)): NitroAppPlugin => + (nitroApp: NitroApp): void => { + const traceDataMap = new WeakMap>(); + + nitroApp.localFetch = new Proxy(nitroApp.localFetch, { + async apply(handlerTarget, handlerThisArg, handlerArgs: [string, unknown]) { + setAsyncLocalStorageAsyncContextStrategy(); + + const cloudflareOptions = typeof optionsOrFn === 'function' ? optionsOrFn(nitroApp) : optionsOrFn; + const pathname = handlerArgs[0]; + const event = handlerArgs[1]; + + if (!isEventType(event)) { + logger.log("Nitro Cloudflare plugin did not detect a Cloudflare event type. Won't patch Cloudflare handler."); + return handlerTarget.apply(handlerThisArg, handlerArgs); + } else { + // Usually, the protocol already includes ":" + const url = `${event.protocol}${event.protocol.endsWith(':') ? '' : ':'}//${event.host}${pathname}`; + const request = new Request(url, { + method: event.method, + headers: event.headers, + cf: event.context.cf, + }) as Request>; + + const requestHandlerOptions = { + options: cloudflareOptions, + request, + context: event.context.cloudflare.context, + }; + + return wrapRequestHandler(requestHandlerOptions, () => { + const isolationScope = getIsolationScope(); + const newIsolationScope = + isolationScope === getDefaultIsolationScope() ? isolationScope.clone() : isolationScope; + + const traceData = getTraceData(); + if (traceData && Object.keys(traceData).length > 0) { + // Storing trace data in the WeakMap using event.context.cf as key for later use in HTML meta-tags + traceDataMap.set(event.context.cf, traceData); + logger.log('Stored trace data for later use in HTML meta-tags: ', traceData); + } + + logger.log( + `Patched Cloudflare handler (\`nitroApp.localFetch\`). ${ + isolationScope === newIsolationScope ? 'Using existing' : 'Created new' + } isolation scope.`, + ); + + 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, { event }: { event: H3Event }) => { + const storedTraceData = event?.context?.cf ? traceDataMap.get(event.context.cf) : undefined; + + if (storedTraceData && Object.keys(storedTraceData).length > 0) { + logger.log('Using stored trace data for HTML meta-tags: ', storedTraceData); + addSentryTracingMetaTags(html.head, storedTraceData); + } else { + 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 a785e8452fac..baf9f2029051 100644 --- a/packages/nuxt/src/runtime/plugins/sentry.server.ts +++ b/packages/nuxt/src/runtime/plugins/sentry.server.ts @@ -1,61 +1,16 @@ -import { - flush, - getDefaultIsolationScope, - getIsolationScope, - GLOBAL_OBJ, - logger, - vercelWaitUntil, - withIsolationScope, -} from '@sentry/core'; -import * as SentryNode from '@sentry/node'; +import { getDefaultIsolationScope, getIsolationScope, logger, withIsolationScope } from '@sentry/core'; // eslint-disable-next-line import/no-extraneous-dependencies -import { type EventHandler, H3Error } from 'h3'; +import { type EventHandler } from 'h3'; // eslint-disable-next-line import/no-extraneous-dependencies import { defineNitroPlugin } from 'nitropack/runtime'; import type { NuxtRenderHTMLContext } from 'nuxt/app'; -import { addSentryTracingMetaTags, extractErrorContext } from '../utils'; +import { sentryCaptureErrorHook } from '../hooks/captureErrorHook'; +import { addSentryTracingMetaTags, flushIfServerless } from '../utils'; 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) => { @@ -63,34 +18,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 07b4dccdffd9..84520ce3f639 100644 --- a/packages/nuxt/src/runtime/utils.ts +++ b/packages/nuxt/src/runtime/utils.ts @@ -1,5 +1,13 @@ -import type { ClientOptions, Context } from '@sentry/core'; -import { captureException, getClient, getTraceMetaTags, logger } from '@sentry/core'; +import type { ClientOptions, Context, SerializedTraceData } from '@sentry/core'; +import { + captureException, + flush, + getClient, + getTraceMetaTags, + GLOBAL_OBJ, + logger, + vercelWaitUntil, +} from '@sentry/core'; import type { VueOptions } from '@sentry/vue/src/types'; import type { CapturedErrorContext } from 'nitropack'; import type { NuxtRenderHTMLContext } from 'nuxt/app'; @@ -33,8 +41,15 @@ export function extractErrorContext(errorContext: CapturedErrorContext | undefin * * Exported only for testing */ -export function addSentryTracingMetaTags(head: NuxtRenderHTMLContext['head']): void { - const metaTags = getTraceMetaTags(); +export function addSentryTracingMetaTags(head: NuxtRenderHTMLContext['head'], traceData?: SerializedTraceData): void { + const metaTags = getTraceMetaTags(traceData); + + if (head.some(tag => tag.includes('meta') && tag.includes('sentry-trace'))) { + logger.warn( + 'Skipping addition of meta tags. Sentry tracing meta tags are already present in HTML page. Make sure to only set up Sentry once on the server-side. ', + ); + return; + } if (metaTags) { logger.log('Adding Sentry tracing meta tags to HTML page:', metaTags); @@ -78,3 +93,32 @@ export function reportNuxtError(options: { }); }); } + +async function flushWithTimeout(): Promise { + try { + logger.log('Flushing events...'); + await flush(2000); + logger.log('Done flushing events'); + } catch (e) { + 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(); + } +} diff --git a/yarn.lock b/yarn.lock index ac3970cfb153..a44f353e348e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -29253,7 +29253,7 @@ vite@^5.0.0, vite@^5.4.11, vite@^5.4.5: optionalDependencies: fsevents "~2.3.3" -vitefu@^0.2.2, vitefu@^0.2.4, vitefu@^0.2.5: +vitefu@^0.2.2, vitefu@^0.2.4: version "0.2.5" resolved "https://registry.yarnpkg.com/vitefu/-/vitefu-0.2.5.tgz#c1b93c377fbdd3e5ddd69840ea3aa70b40d90969" integrity sha512-SgHtMLoqaeeGnd2evZ849ZbACbnwQCIwRH57t18FxcXoZop0uQu0uzlIhJBlF/eWVzuce0sHeqPcDo+evVcg8Q==