From 2631c808cce47fc7f9112911e7f2265585959996 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Wed, 30 Apr 2025 21:16:57 -0400 Subject: [PATCH 1/4] feat(cloudflare): Add support for durable objects --- packages/cloudflare/src/durableobject.ts | 224 +++++++++++++++++++++++ packages/cloudflare/src/handler.ts | 44 +---- packages/cloudflare/src/index.ts | 1 + packages/cloudflare/src/instrument.ts | 25 +++ 4 files changed, 257 insertions(+), 37 deletions(-) create mode 100644 packages/cloudflare/src/durableobject.ts create mode 100644 packages/cloudflare/src/instrument.ts diff --git a/packages/cloudflare/src/durableobject.ts b/packages/cloudflare/src/durableobject.ts new file mode 100644 index 000000000000..2ea7c052a6fd --- /dev/null +++ b/packages/cloudflare/src/durableobject.ts @@ -0,0 +1,224 @@ +/* eslint-disable @typescript-eslint/unbound-method */ +import { + captureException, + flush, + getClient, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + startSpan, + withIsolationScope, + withScope, +} from '@sentry/core'; +import type { DurableObject } from 'cloudflare:workers'; +import { setAsyncLocalStorageAsyncContextStrategy } from './async'; +import type { CloudflareOptions } from './client'; +import { isInstrumented, markAsInstrumented } from './instrument'; +import { wrapRequestHandler } from './request'; +import { init } from './sdk'; + +type MethodWrapperOptions = { + spanName?: string; + spanOp?: string; + options: CloudflareOptions; + context: ExecutionContext | DurableObjectState; +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function wrapMethodWithSentry any>( + wrapperOptions: MethodWrapperOptions, + handler: T, + callback?: (...args: Parameters) => void, +): T { + if (isInstrumented(handler)) { + return handler; + } + + markAsInstrumented(handler); + + return new Proxy(handler, { + apply(target, thisArg, args: Parameters) { + const currentClient = getClient(); + // if a client is already set, use withScope, otherwise use withIsolationScope + const sentryWithScope = currentClient ? withScope : withIsolationScope; + return sentryWithScope(async scope => { + // In certain situations, the passed context can become undefined. + // For example, for Astro while prerendering pages at build time. + // see: https://github.com/getsentry/sentry-javascript/issues/13217 + const context = wrapperOptions.context as ExecutionContext | undefined; + + const currentClient = scope.getClient(); + if (!currentClient) { + const client = init(wrapperOptions.options); + scope.setClient(client); + } + + if (!wrapperOptions.spanName) { + try { + if (callback) { + callback(...args); + } + return await Reflect.apply(target, thisArg, args); + } catch (e) { + captureException(e, { + mechanism: { + type: 'cloudflare_durableobject', + handled: false, + }, + }); + throw e; + } finally { + context?.waitUntil(flush(2000)); + } + } + + const attributes = wrapperOptions.spanOp + ? { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: wrapperOptions.spanOp, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.faas.cloudflare_durableobjects', + } + : {}; + + // Only create these spans if they have a parent span. + return startSpan({ name: wrapperOptions.spanName, attributes, onlyIfParent: true }, async () => { + try { + return await Reflect.apply(target, thisArg, args); + } catch (e) { + captureException(e, { + mechanism: { + type: 'cloudflare_durableobject', + handled: false, + }, + }); + throw e; + } finally { + context?.waitUntil(flush(2000)); + } + }); + }); + }, + }); +} + +/** + * Instruments a Durable Object class to capture errors and performance data. + * + * Instruments the following methods: + * - fetch + * - alarm + * - webSocketMessage + * - webSocketClose + * - webSocketError + * + * as well as any other public RPC methods on the Durable Object instance. + * + * @param optionsCallback Function that returns the options for the SDK initialization. + * @param DurableObjectClass The Durable Object class to instrument. + * @returns The instrumented Durable Object class. + * + * @example + * ```ts + * class MyDurableObjectBase extends DurableObject { + * constructor(ctx: DurableObjectState, env: Env) { + * super(ctx, env); + * } + * } + * + * export const MyDurableObject = instrumentDurableObjectWithSentry( + * env => ({ + * dsn: env.SENTRY_DSN, + * tracesSampleRate: 1.0, + * }), + * MyDurableObjectBase, + * ); + * ``` + */ +export function instrumentDurableObjectWithSentry>( + optionsCallback: (env: E) => CloudflareOptions, + DurableObjectClass: { new (state: DurableObjectState, env: E): T }, +): typeof DurableObjectClass { + return new Proxy(DurableObjectClass, { + construct(target, [context, env]) { + setAsyncLocalStorageAsyncContextStrategy(); + + const options = optionsCallback(env); + + const obj = new target(context, env); + + // These are the methods that are available on a Durable Object + // ref: https://developers.cloudflare.com/durable-objects/api/base/ + // obj.alarm + // obj.fetch + // obj.webSocketError + // obj.webSocketClose + // obj.webSocketMessage + + // Any other public methods on the Durable Object instance are RPC calls. + + if (obj.fetch && typeof obj.fetch === 'function' && !isInstrumented(obj.fetch)) { + obj.fetch = new Proxy(obj.fetch, { + apply(target, thisArg, args) { + return wrapRequestHandler({ options, request: args[0], context }, () => + Reflect.apply(target, thisArg, args), + ); + }, + }); + + markAsInstrumented(obj.fetch); + } + + if (obj.alarm && typeof obj.alarm === 'function') { + obj.alarm = wrapMethodWithSentry({ options, context, spanName: 'alarm' }, obj.alarm); + } + + if (obj.webSocketMessage && typeof obj.webSocketMessage === 'function') { + obj.webSocketMessage = wrapMethodWithSentry( + { options, context, spanName: 'webSocketMessage' }, + obj.webSocketMessage, + ); + } + + if (obj.webSocketClose && typeof obj.webSocketClose === 'function') { + obj.webSocketClose = wrapMethodWithSentry({ options, context, spanName: 'webSocketClose' }, obj.webSocketClose); + } + + if (obj.webSocketError && typeof obj.webSocketError === 'function') { + obj.webSocketError = wrapMethodWithSentry( + { options, context, spanName: 'webSocketError' }, + obj.webSocketError, + (_, error) => + captureException(error, { + mechanism: { + type: 'cloudflare_durableobject_websocket', + handled: false, + }, + }), + ); + } + + for (const method of Object.getOwnPropertyNames(obj)) { + if ( + method === 'fetch' || + method === 'alarm' || + method === 'webSocketError' || + method === 'webSocketClose' || + method === 'webSocketMessage' + ) { + continue; + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any + const value = (obj as any)[method] as unknown; + if (typeof value === 'function') { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any + (obj as any)[method] = wrapMethodWithSentry( + { options, context, spanName: method, spanOp: 'rpc' }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + value as (...args: any[]) => any, + ); + } + } + + return obj; + }, + }); +} diff --git a/packages/cloudflare/src/handler.ts b/packages/cloudflare/src/handler.ts index 2fa42afbbb79..3f78e5aa6965 100644 --- a/packages/cloudflare/src/handler.ts +++ b/packages/cloudflare/src/handler.ts @@ -1,8 +1,3 @@ -import type { - ExportedHandler, - ExportedHandlerFetchHandler, - ExportedHandlerScheduledHandler, -} from '@cloudflare/workers-types'; import { captureException, flush, @@ -13,15 +8,11 @@ import { } from '@sentry/core'; import { setAsyncLocalStorageAsyncContextStrategy } from './async'; import type { CloudflareOptions } from './client'; +import { isInstrumented, markAsInstrumented } from './instrument'; import { wrapRequestHandler } from './request'; import { addCloudResourceContext } from './scope-utils'; import { init } from './sdk'; -/** - * Extract environment generic from exported handler. - */ -type ExtractEnv

= P extends ExportedHandler ? Env : never; - /** * Wrapper for Cloudflare handlers. * @@ -33,16 +24,15 @@ type ExtractEnv

= P extends ExportedHandler ? Env : never; * @param handler {ExportedHandler} The handler to wrap. * @returns The wrapped handler. */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function withSentry>( - optionsCallback: (env: ExtractEnv) => CloudflareOptions, - handler: E, -): E { +export function withSentry( + optionsCallback: (env: Env) => CloudflareOptions, + handler: ExportedHandler, +): ExportedHandler { setAsyncLocalStorageAsyncContextStrategy(); if ('fetch' in handler && typeof handler.fetch === 'function' && !isInstrumented(handler.fetch)) { handler.fetch = new Proxy(handler.fetch, { - apply(target, thisArg, args: Parameters>>) { + apply(target, thisArg, args: Parameters>) { const [request, env, context] = args; const options = optionsCallback(env); return wrapRequestHandler({ options, request, context }, () => target.apply(thisArg, args)); @@ -54,7 +44,7 @@ export function withSentry>( if ('scheduled' in handler && typeof handler.scheduled === 'function' && !isInstrumented(handler.scheduled)) { handler.scheduled = new Proxy(handler.scheduled, { - apply(target, thisArg, args: Parameters>>) { + apply(target, thisArg, args: Parameters>) { const [event, env, context] = args; return withIsolationScope(isolationScope => { const options = optionsCallback(env); @@ -95,23 +85,3 @@ export function withSentry>( return handler; } - -type SentryInstrumented = T & { - __SENTRY_INSTRUMENTED__?: boolean; -}; - -function markAsInstrumented(handler: T): void { - try { - (handler as SentryInstrumented).__SENTRY_INSTRUMENTED__ = true; - } catch { - // ignore errors here - } -} - -function isInstrumented(handler: T): boolean | undefined { - try { - return (handler as SentryInstrumented).__SENTRY_INSTRUMENTED__; - } catch { - return false; - } -} diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index 5a62c51e7e41..af43c6e94ecd 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -92,6 +92,7 @@ export { } from '@sentry/core'; export { withSentry } from './handler'; +export { instrumentDurableObjectWithSentry } from './durableobject'; export { sentryPagesPlugin } from './pages-plugin'; export { wrapRequestHandler } from './request'; diff --git a/packages/cloudflare/src/instrument.ts b/packages/cloudflare/src/instrument.ts new file mode 100644 index 000000000000..1ebe4262644a --- /dev/null +++ b/packages/cloudflare/src/instrument.ts @@ -0,0 +1,25 @@ +type SentryInstrumented = T & { + __SENTRY_INSTRUMENTED__?: boolean; +}; + +/** + * Mark an object as instrumented. + */ +export function markAsInstrumented(obj: T): void { + try { + (obj as SentryInstrumented).__SENTRY_INSTRUMENTED__ = true; + } catch { + // ignore errors here + } +} + +/** + * Check if an object is instrumented. + */ +export function isInstrumented(obj: T): boolean | undefined { + try { + return (obj as SentryInstrumented).__SENTRY_INSTRUMENTED__; + } catch { + return false; + } +} From 632dda9e7e63d1a52778c4cca0dc6aedbdb8efb4 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Thu, 1 May 2025 10:52:58 -0400 Subject: [PATCH 2/4] fix return type --- packages/cloudflare/src/durableobject.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cloudflare/src/durableobject.ts b/packages/cloudflare/src/durableobject.ts index 2ea7c052a6fd..ba00778bded5 100644 --- a/packages/cloudflare/src/durableobject.ts +++ b/packages/cloudflare/src/durableobject.ts @@ -134,8 +134,8 @@ function wrapMethodWithSentry any>( */ export function instrumentDurableObjectWithSentry>( optionsCallback: (env: E) => CloudflareOptions, - DurableObjectClass: { new (state: DurableObjectState, env: E): T }, -): typeof DurableObjectClass { + DurableObjectClass: new (state: DurableObjectState, env: E) => T, +): new (state: DurableObjectState, env: E) => T { return new Proxy(DurableObjectClass, { construct(target, [context, env]) { setAsyncLocalStorageAsyncContextStrategy(); From b7f14571b804b0c5e6e4be4c3790983d883e9a3c Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Thu, 1 May 2025 11:43:20 -0400 Subject: [PATCH 3/4] do not capture spans for OPTIONS and HEAD --- packages/cloudflare/src/request.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/cloudflare/src/request.ts b/packages/cloudflare/src/request.ts index b36eaecdc232..8e2f3de06df0 100644 --- a/packages/cloudflare/src/request.ts +++ b/packages/cloudflare/src/request.ts @@ -76,6 +76,18 @@ export function wrapRequestHandler( const routeName = `${request.method} ${pathname ? stripUrlQueryAndFragment(pathname) : '/'}`; + // Do not capture spans for OPTIONS and HEAD requests + if (request.method === 'OPTIONS' || request.method === 'HEAD') { + try { + return await handler(); + } catch (e) { + captureException(e, { mechanism: { handled: false, type: 'cloudflare' } }); + throw e; + } finally { + context?.waitUntil(flush(2000)); + } + } + return continueTrace( { sentryTrace: request.headers.get('sentry-trace') || '', baggage: request.headers.get('baggage') }, () => { From 36ed39276af61855e8f70f01d4932b931793d81c Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Thu, 1 May 2025 11:43:34 -0400 Subject: [PATCH 4/4] try catch proxy impl --- packages/cloudflare/src/handler.ts | 96 ++++++++++++++++-------------- 1 file changed, 51 insertions(+), 45 deletions(-) diff --git a/packages/cloudflare/src/handler.ts b/packages/cloudflare/src/handler.ts index 3f78e5aa6965..f2bb059ee071 100644 --- a/packages/cloudflare/src/handler.ts +++ b/packages/cloudflare/src/handler.ts @@ -30,57 +30,63 @@ export function withSentry { setAsyncLocalStorageAsyncContextStrategy(); - if ('fetch' in handler && typeof handler.fetch === 'function' && !isInstrumented(handler.fetch)) { - handler.fetch = new Proxy(handler.fetch, { - apply(target, thisArg, args: Parameters>) { - const [request, env, context] = args; - const options = optionsCallback(env); - return wrapRequestHandler({ options, request, context }, () => target.apply(thisArg, args)); - }, - }); + try { + if ('fetch' in handler && typeof handler.fetch === 'function' && !isInstrumented(handler.fetch)) { + handler.fetch = new Proxy(handler.fetch, { + apply(target, thisArg, args: Parameters>) { + const [request, env, context] = args; + const options = optionsCallback(env); + return wrapRequestHandler({ options, request, context }, () => target.apply(thisArg, args)); + }, + }); - markAsInstrumented(handler.fetch); - } + markAsInstrumented(handler.fetch); + } - if ('scheduled' in handler && typeof handler.scheduled === 'function' && !isInstrumented(handler.scheduled)) { - handler.scheduled = new Proxy(handler.scheduled, { - apply(target, thisArg, args: Parameters>) { - const [event, env, context] = args; - return withIsolationScope(isolationScope => { - const options = optionsCallback(env); - const client = init(options); - isolationScope.setClient(client); + if ('scheduled' in handler && typeof handler.scheduled === 'function' && !isInstrumented(handler.scheduled)) { + handler.scheduled = new Proxy(handler.scheduled, { + apply(target, thisArg, args: Parameters>) { + const [event, env, context] = args; + return withIsolationScope(isolationScope => { + const options = optionsCallback(env); + const client = init(options); + isolationScope.setClient(client); - addCloudResourceContext(isolationScope); + addCloudResourceContext(isolationScope); - return startSpan( - { - op: 'faas.cron', - name: `Scheduled Cron ${event.cron}`, - attributes: { - 'faas.cron': event.cron, - 'faas.time': new Date(event.scheduledTime).toISOString(), - 'faas.trigger': 'timer', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.faas.cloudflare', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'task', + return startSpan( + { + op: 'faas.cron', + name: `Scheduled Cron ${event.cron}`, + attributes: { + 'faas.cron': event.cron, + 'faas.time': new Date(event.scheduledTime).toISOString(), + 'faas.trigger': 'timer', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.faas.cloudflare', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'task', + }, + }, + async () => { + try { + return await (target.apply(thisArg, args) as ReturnType); + } catch (e) { + captureException(e, { mechanism: { handled: false, type: 'cloudflare' } }); + throw e; + } finally { + context.waitUntil(flush(2000)); + } }, - }, - async () => { - try { - return await (target.apply(thisArg, args) as ReturnType); - } catch (e) { - captureException(e, { mechanism: { handled: false, type: 'cloudflare' } }); - throw e; - } finally { - context.waitUntil(flush(2000)); - } - }, - ); - }); - }, - }); + ); + }); + }, + }); - markAsInstrumented(handler.scheduled); + markAsInstrumented(handler.scheduled); + } + // This is here because Miniflare sometimes cannot get instrumented + // + } catch (e) { + // Do not console anything here, we don't want to spam the console with errors } return handler;