diff --git a/packages/cloudflare/src/request.ts b/packages/cloudflare/src/request.ts index f1905609fb94..519121243cc8 100644 --- a/packages/cloudflare/src/request.ts +++ b/packages/cloudflare/src/request.ts @@ -3,7 +3,9 @@ import { captureException, continueTrace, flush, + getCurrentScope, getHttpSpanDetailsFromUrlObject, + getTraceData, parseStringToURLObject, SEMANTIC_ATTRIBUTE_SENTRY_OP, setHttpStatus, @@ -69,6 +71,40 @@ export function wrapRequestHandler( } } + // fixme: at this point, there is no active span + + // Check if we already have active trace data - if so, don't wrap with continueTrace + // This allows us to continue an existing trace from the parent context (e.g., Nuxt) + // todo: create an option for opting out of continueTrace + const existingPropagationContext = getCurrentScope().getPropagationContext(); + if (existingPropagationContext?.traceId) { + return startSpan( + { + name, + attributes, + }, + async span => { + // fixme: same as 2 + console.log('::traceData 2', getTraceData()); + console.log('::propagationContext 2', JSON.stringify(getCurrentScope().getPropagationContext())); + + try { + const res = await handler(); + setHttpStatus(span, res.status); + return res; + } catch (e) { + captureException(e, { mechanism: { handled: false, type: 'cloudflare' } }); + throw e; + } finally { + context?.waitUntil(flush(2000)); + } + }, + ); + } + + console.log('request.headers', request.headers); + + // No active trace, create one from headers return continueTrace( { sentryTrace: request.headers.get('sentry-trace') || '', baggage: request.headers.get('baggage') }, () => { @@ -81,6 +117,9 @@ export function wrapRequestHandler( attributes, }, async span => { + console.log('::traceData 3', getTraceData()); + console.log('::propagationContext 3', JSON.stringify(getCurrentScope().getPropagationContext())); + try { const res = await handler(); setHttpStatus(span, res.status); diff --git a/packages/nuxt/package.json b/packages/nuxt/package.json index 932f82a86037..6d94d1a6e0ee 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.26.0", "@sentry/core": "9.26.0", + "@sentry/cloudflare": "9.26.0", "@sentry/node": "9.26.0", "@sentry/opentelemetry": "9.26.0", "@sentry/rollup-plugin": "3.4.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..d53c51b3f79b --- /dev/null +++ b/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts @@ -0,0 +1,147 @@ +import type { ExecutionContext } from '@cloudflare/workers-types'; +import type { CloudflareOptions } from '@sentry/cloudflare'; +import { + getActiveSpan, + getTraceData, + setAsyncLocalStorageAsyncContextStrategy, + spanToJSON, + wrapRequestHandler, +} from '@sentry/cloudflare'; +import { continueTrace, getCurrentScope, getDefaultIsolationScope, getIsolationScope, 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; + 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 + ); +} + +/** + * 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 => { + nitroApp.localFetch = new Proxy(nitroApp.localFetch, { + async apply(handlerTarget, handlerThisArg, handlerArgs: [string, unknown]) { + setAsyncLocalStorageAsyncContextStrategy(); + + const sentryOptions = typeof optionsOrFn === 'function' ? optionsOrFn(nitroApp) : optionsOrFn; + + 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, + }; + + // fixme same as 5 + console.log('::traceData 1', getTraceData()); + console.log('::propagationContext 1', JSON.stringify(getCurrentScope().getPropagationContext())); + + const traceData = getTraceData(); + + // return continueTrace({ sentryTrace: traceData['sentry-trace'] || '', baggage: traceData.baggage }, () => { + return wrapRequestHandler(requestHandlerOptions, () => { + const isolationScope = getIsolationScope(); + const newIsolationScope = + isolationScope === getDefaultIsolationScope() ? isolationScope.clone() : isolationScope; + + logger.log( + `Patched Cloudflare handler (\`nitroApp.localFetch\`). ${ + isolationScope === newIsolationScope ? 'Using existing' : 'Created new' + } isolation scope.`, + ); + + console.log('::traceData 4', getTraceData()); + console.log('::propagationContext 4', JSON.stringify(getCurrentScope().getPropagationContext())); + + return handlerTarget.apply(handlerThisArg, handlerArgs); + }); + // }); + } + + return handlerTarget.apply(handlerThisArg, handlerArgs); + }, + }); + + // todo: start span in a hook before the request handler + + // @ts-expect-error - 'render:html' is a valid hook name in the Nuxt context + nitroApp.hooks.hook('render:html', (html: NuxtRenderHTMLContext, { event }: { event: H3Event }) => { + // fixme: it's attaching the html meta tag but it's not connecting the trace + // fixme: its' actually connecting the trace but the meta tags are cached + console.log('event.headers', event.headers); + console.log('event.node.req.headers.cache-control', event.node.req.headers['cache-control']); + console.log('event.context', event.context); + + const span = getActiveSpan(); + + console.log('::active span', span ? spanToJSON(span) : 'no active span'); + + console.log('::traceData 5', getTraceData()); + console.log('::propagationContext 5', JSON.stringify(getCurrentScope().getPropagationContext())); + + 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..d2974def2165 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 { + 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'; @@ -78,3 +86,35 @@ export function reportNuxtError(options: { }); }); } + +async function flushWithTimeout(): Promise { + const sentryClient = 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(); + } +}