|
| 1 | +import type { ExecutionContext, IncomingRequestCfProperties } from '@cloudflare/workers-types'; |
| 2 | +import type { CloudflareOptions } from '@sentry/cloudflare'; |
| 3 | +import { setAsyncLocalStorageAsyncContextStrategy, wrapRequestHandler } from '@sentry/cloudflare'; |
| 4 | +import { getDefaultIsolationScope, getIsolationScope, getTraceData, logger } from '@sentry/core'; |
| 5 | +import type { H3Event } from 'h3'; |
| 6 | +import type { NitroApp, NitroAppPlugin } from 'nitropack'; |
| 7 | +import type { NuxtRenderHTMLContext } from 'nuxt/app'; |
| 8 | +import { sentryCaptureErrorHook } from '../hooks/captureErrorHook'; |
| 9 | +import { addSentryTracingMetaTags } from '../utils'; |
| 10 | + |
| 11 | +interface CfEventType { |
| 12 | + protocol: string; |
| 13 | + host: string; |
| 14 | + method: string; |
| 15 | + headers: Record<string, string>; |
| 16 | + context: { |
| 17 | + cf: { |
| 18 | + httpProtocol?: string; |
| 19 | + country?: string; |
| 20 | + // ...other CF properties |
| 21 | + }; |
| 22 | + cloudflare: { |
| 23 | + context: ExecutionContext; |
| 24 | + request?: Record<string, unknown>; |
| 25 | + env?: Record<string, unknown>; |
| 26 | + }; |
| 27 | + }; |
| 28 | +} |
| 29 | + |
| 30 | +function isEventType(event: unknown): event is CfEventType { |
| 31 | + if (event === null || typeof event !== 'object') return false; |
| 32 | + |
| 33 | + return ( |
| 34 | + // basic properties |
| 35 | + 'protocol' in event && |
| 36 | + 'host' in event && |
| 37 | + typeof event.protocol === 'string' && |
| 38 | + typeof event.host === 'string' && |
| 39 | + // context property |
| 40 | + 'context' in event && |
| 41 | + typeof event.context === 'object' && |
| 42 | + event.context !== null && |
| 43 | + // context.cf properties |
| 44 | + 'cf' in event.context && |
| 45 | + typeof event.context.cf === 'object' && |
| 46 | + event.context.cf !== null && |
| 47 | + // context.cloudflare properties |
| 48 | + 'cloudflare' in event.context && |
| 49 | + typeof event.context.cloudflare === 'object' && |
| 50 | + event.context.cloudflare !== null && |
| 51 | + 'context' in event.context.cloudflare |
| 52 | + ); |
| 53 | +} |
| 54 | + |
| 55 | +/** |
| 56 | + * Sentry Cloudflare Nitro plugin for when using the "cloudflare-pages" preset in Nuxt. |
| 57 | + * This plugin automatically sets up Sentry error monitoring and performance tracking for Cloudflare Pages projects. |
| 58 | + * |
| 59 | + * Instead of adding a `sentry.server.config.ts` file, export this plugin in the `server/plugins` directory |
| 60 | + * with the necessary Sentry options to enable Sentry for your Cloudflare Pages project. |
| 61 | + * |
| 62 | + * |
| 63 | + * @example Basic usage |
| 64 | + * ```ts |
| 65 | + * // nitro/plugins/sentry.ts |
| 66 | + * import { defineNitroPlugin } from '#imports' |
| 67 | + * import { sentryCloudflareNitroPlugin } from '@sentry/nuxt/module/plugins' |
| 68 | + * |
| 69 | + * export default defineNitroPlugin(sentryCloudflareNitroPlugin({ |
| 70 | + * dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0', |
| 71 | + * tracesSampleRate: 1.0, |
| 72 | + * })); |
| 73 | + * ``` |
| 74 | + * |
| 75 | + * @example Dynamic configuration with nitroApp |
| 76 | + * ```ts |
| 77 | + * // nitro/plugins/sentry.ts |
| 78 | + * import { defineNitroPlugin } from '#imports' |
| 79 | + * import { sentryCloudflareNitroPlugin } from '@sentry/nuxt/module/plugins' |
| 80 | + * |
| 81 | + * export default defineNitroPlugin(sentryCloudflareNitroPlugin(nitroApp => ({ |
| 82 | + * dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0', |
| 83 | + * debug: nitroApp.h3App.options.debug |
| 84 | + * }))); |
| 85 | + * ``` |
| 86 | + */ |
| 87 | +export const sentryCloudflareNitroPlugin = |
| 88 | + (optionsOrFn: CloudflareOptions | ((nitroApp: NitroApp) => CloudflareOptions)): NitroAppPlugin => |
| 89 | + (nitroApp: NitroApp): void => { |
| 90 | + const traceDataMap = new WeakMap<object, ReturnType<typeof getTraceData>>(); |
| 91 | + |
| 92 | + nitroApp.localFetch = new Proxy(nitroApp.localFetch, { |
| 93 | + async apply(handlerTarget, handlerThisArg, handlerArgs: [string, unknown]) { |
| 94 | + setAsyncLocalStorageAsyncContextStrategy(); |
| 95 | + |
| 96 | + const cloudflareOptions = typeof optionsOrFn === 'function' ? optionsOrFn(nitroApp) : optionsOrFn; |
| 97 | + const pathname = handlerArgs[0]; |
| 98 | + const event = handlerArgs[1]; |
| 99 | + |
| 100 | + if (!isEventType(event)) { |
| 101 | + logger.log("Nitro Cloudflare plugin did not detect a Cloudflare event type. Won't patch Cloudflare handler."); |
| 102 | + return handlerTarget.apply(handlerThisArg, handlerArgs); |
| 103 | + } else { |
| 104 | + // Usually, the protocol already includes ":" |
| 105 | + const url = `${event.protocol}${event.protocol.endsWith(':') ? '' : ':'}//${event.host}${pathname}`; |
| 106 | + const request = new Request(url, { |
| 107 | + method: event.method, |
| 108 | + headers: event.headers, |
| 109 | + cf: event.context.cf, |
| 110 | + }) as Request<unknown, IncomingRequestCfProperties<unknown>>; |
| 111 | + |
| 112 | + const requestHandlerOptions = { |
| 113 | + options: cloudflareOptions, |
| 114 | + request, |
| 115 | + context: event.context.cloudflare.context, |
| 116 | + }; |
| 117 | + |
| 118 | + return wrapRequestHandler(requestHandlerOptions, () => { |
| 119 | + const isolationScope = getIsolationScope(); |
| 120 | + const newIsolationScope = |
| 121 | + isolationScope === getDefaultIsolationScope() ? isolationScope.clone() : isolationScope; |
| 122 | + |
| 123 | + const traceData = getTraceData(); |
| 124 | + if (traceData && Object.keys(traceData).length > 0) { |
| 125 | + // Storing trace data in the WeakMap using event.context.cf as key for later use in HTML meta-tags |
| 126 | + traceDataMap.set(event.context.cf, traceData); |
| 127 | + logger.log('Stored trace data for later use in HTML meta-tags: ', traceData); |
| 128 | + } |
| 129 | + |
| 130 | + logger.log( |
| 131 | + `Patched Cloudflare handler (\`nitroApp.localFetch\`). ${ |
| 132 | + isolationScope === newIsolationScope ? 'Using existing' : 'Created new' |
| 133 | + } isolation scope.`, |
| 134 | + ); |
| 135 | + |
| 136 | + return handlerTarget.apply(handlerThisArg, handlerArgs); |
| 137 | + }); |
| 138 | + } |
| 139 | + }, |
| 140 | + }); |
| 141 | + |
| 142 | + // @ts-expect-error - 'render:html' is a valid hook name in the Nuxt context |
| 143 | + nitroApp.hooks.hook('render:html', (html: NuxtRenderHTMLContext, { event }: { event: H3Event }) => { |
| 144 | + const storedTraceData = event?.context?.cf ? traceDataMap.get(event.context.cf) : undefined; |
| 145 | + |
| 146 | + if (storedTraceData && Object.keys(storedTraceData).length > 0) { |
| 147 | + logger.log('Using stored trace data for HTML meta-tags: ', storedTraceData); |
| 148 | + addSentryTracingMetaTags(html.head, storedTraceData); |
| 149 | + } else { |
| 150 | + addSentryTracingMetaTags(html.head); |
| 151 | + } |
| 152 | + }); |
| 153 | + |
| 154 | + nitroApp.hooks.hook('error', sentryCaptureErrorHook); |
| 155 | + }; |
0 commit comments