diff --git a/packages/cloudflare/src/handler.ts b/packages/cloudflare/src/handler.ts deleted file mode 100644 index 29b32225e433..000000000000 --- a/packages/cloudflare/src/handler.ts +++ /dev/null @@ -1,261 +0,0 @@ -import { - captureException, - SEMANTIC_ATTRIBUTE_SENTRY_OP, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, - startSpan, - withIsolationScope, -} from '@sentry/core'; -import { setAsyncLocalStorageAsyncContextStrategy } from './async'; -import type { CloudflareOptions } from './client'; -import { flushAndDispose } from './flush'; -import { isInstrumented, markAsInstrumented } from './instrument'; -import { getHonoIntegration } from './integrations/hono'; -import { getFinalOptions } from './options'; -import { wrapRequestHandler } from './request'; -import { addCloudResourceContext } from './scope-utils'; -import { init } from './sdk'; -import { instrumentContext } from './utils/instrumentContext'; - -/** - * Wrapper for Cloudflare handlers. - * - * Initializes the SDK and wraps the handler with Sentry instrumentation. - * - * Automatically instruments the `fetch` method of the handler. - * - * @param optionsCallback Function that returns the options for the SDK initialization. - * @param handler {ExportedHandler} The handler to wrap. - * @returns The wrapped handler. - */ -// eslint-disable-next-line complexity -export function withSentry< - Env = unknown, - QueueHandlerMessage = unknown, - CfHostMetadata = unknown, - T extends ExportedHandler = ExportedHandler< - Env, - QueueHandlerMessage, - CfHostMetadata - >, ->(optionsCallback: (env: Env) => CloudflareOptions | undefined, handler: T): T { - setAsyncLocalStorageAsyncContextStrategy(); - - 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, ctx] = args; - const context = instrumentContext(ctx); - args[2] = context; - - const options = getFinalOptions(optionsCallback(env), env); - - return wrapRequestHandler({ options, request, context }, () => target.apply(thisArg, args)); - }, - }); - - markAsInstrumented(handler.fetch); - } - - /* Hono does not reach the catch block of the fetch handler and captureException needs to be called in the hono errorHandler */ - if ( - 'onError' in handler && - 'errorHandler' in handler && - typeof handler.errorHandler === 'function' && - !isInstrumented(handler.errorHandler) - ) { - handler.errorHandler = new Proxy(handler.errorHandler, { - apply(target, thisArg, args) { - const [err, context] = args; - - getHonoIntegration()?.handleHonoException(err, context); - - return Reflect.apply(target, thisArg, args); - }, - }); - - markAsInstrumented(handler.errorHandler); - } - - 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, ctx] = args; - const context = instrumentContext(ctx); - args[2] = context; - - return withIsolationScope(isolationScope => { - const options = getFinalOptions(optionsCallback(env), env); - const waitUntil = context.waitUntil.bind(context); - - const client = init({ ...options, ctx: context }); - isolationScope.setClient(client); - - 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.scheduled', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'task', - }, - }, - async () => { - try { - return await (target.apply(thisArg, args) as ReturnType); - } catch (e) { - captureException(e, { mechanism: { handled: false, type: 'auto.faas.cloudflare.scheduled' } }); - throw e; - } finally { - waitUntil(flushAndDispose(client)); - } - }, - ); - }); - }, - }); - - markAsInstrumented(handler.scheduled); - } - - if ('email' in handler && typeof handler.email === 'function' && !isInstrumented(handler.email)) { - handler.email = new Proxy(handler.email, { - apply(target, thisArg, args: Parameters>) { - const [emailMessage, env, ctx] = args; - const context = instrumentContext(ctx); - args[2] = context; - - return withIsolationScope(isolationScope => { - const options = getFinalOptions(optionsCallback(env), env); - const waitUntil = context.waitUntil.bind(context); - - const client = init({ ...options, ctx: context }); - isolationScope.setClient(client); - - addCloudResourceContext(isolationScope); - - return startSpan( - { - op: 'faas.email', - name: `Handle Email ${emailMessage.to}`, - attributes: { - 'faas.trigger': 'email', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.faas.cloudflare.email', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'task', - }, - }, - async () => { - try { - return await (target.apply(thisArg, args) as ReturnType); - } catch (e) { - captureException(e, { mechanism: { handled: false, type: 'auto.faas.cloudflare.email' } }); - throw e; - } finally { - waitUntil(flushAndDispose(client)); - } - }, - ); - }); - }, - }); - - markAsInstrumented(handler.email); - } - - if ('queue' in handler && typeof handler.queue === 'function' && !isInstrumented(handler.queue)) { - handler.queue = new Proxy(handler.queue, { - apply(target, thisArg, args: Parameters>) { - const [batch, env, ctx] = args; - const context = instrumentContext(ctx); - args[2] = context; - - return withIsolationScope(isolationScope => { - const options = getFinalOptions(optionsCallback(env), env); - const waitUntil = context.waitUntil.bind(context); - - const client = init({ ...options, ctx: context }); - isolationScope.setClient(client); - - addCloudResourceContext(isolationScope); - - return startSpan( - { - op: 'faas.queue', - name: `process ${batch.queue}`, - attributes: { - 'faas.trigger': 'pubsub', - 'messaging.destination.name': batch.queue, - 'messaging.system': 'cloudflare', - 'messaging.batch.message_count': batch.messages.length, - 'messaging.message.retry.count': batch.messages.reduce( - (acc, message) => acc + message.attempts - 1, - 0, - ), - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'queue.process', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.faas.cloudflare.queue', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'task', - }, - }, - async () => { - try { - return await (target.apply(thisArg, args) as ReturnType); - } catch (e) { - captureException(e, { mechanism: { handled: false, type: 'auto.faas.cloudflare.queue' } }); - throw e; - } finally { - waitUntil(flushAndDispose(client)); - } - }, - ); - }); - }, - }); - - markAsInstrumented(handler.queue); - } - - if ('tail' in handler && typeof handler.tail === 'function' && !isInstrumented(handler.tail)) { - handler.tail = new Proxy(handler.tail, { - apply(target, thisArg, args: Parameters>) { - const [, env, ctx] = args; - const context = instrumentContext(ctx); - args[2] = context; - - return withIsolationScope(async isolationScope => { - const options = getFinalOptions(optionsCallback(env), env); - - const waitUntil = context.waitUntil.bind(context); - - const client = init({ ...options, ctx: context }); - isolationScope.setClient(client); - - addCloudResourceContext(isolationScope); - - try { - return await (target.apply(thisArg, args) as ReturnType); - } catch (e) { - captureException(e, { mechanism: { handled: false, type: 'auto.faas.cloudflare.tail' } }); - throw e; - } finally { - waitUntil(flushAndDispose(client)); - } - }); - }, - }); - - markAsInstrumented(handler.tail); - } - - // This is here because Miniflare sometimes cannot get instrumented - } catch { - // Do not console anything here, we don't want to spam the console with errors - } - - return handler; -} diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index 62263627aa24..94933f2b8915 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -108,7 +108,7 @@ export { instrumentLangGraph, } from '@sentry/core'; -export { withSentry } from './handler'; +export { withSentry } from './withSentry'; export { instrumentDurableObjectWithSentry } from './durableobject'; export { sentryPagesPlugin } from './pages-plugin'; diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentEmail.ts b/packages/cloudflare/src/instrumentations/worker/instrumentEmail.ts new file mode 100644 index 000000000000..8c91bf2cb3d2 --- /dev/null +++ b/packages/cloudflare/src/instrumentations/worker/instrumentEmail.ts @@ -0,0 +1,83 @@ +import type { EmailMessage, ExportedHandler } from '@cloudflare/workers-types'; +import { + captureException, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + startSpan, + withIsolationScope, +} from '@sentry/core'; +import type { CloudflareOptions } from '../../client'; +import { flushAndDispose } from '../../flush'; +import { isInstrumented, markAsInstrumented } from '../../instrument'; +import { getFinalOptions } from '../../options'; +import { addCloudResourceContext } from '../../scope-utils'; +import { init } from '../../sdk'; +import { instrumentContext } from '../../utils/instrumentContext'; + +/** + * Core email handler logic - wraps execution with Sentry instrumentation. + */ +function wrapEmailHandler( + emailMessage: EmailMessage, + options: CloudflareOptions, + context: ExecutionContext, + fn: () => unknown, +): unknown { + return withIsolationScope(isolationScope => { + const waitUntil = context.waitUntil.bind(context); + + const client = init({ ...options, ctx: context }); + isolationScope.setClient(client); + + addCloudResourceContext(isolationScope); + + return startSpan( + { + op: 'faas.email', + name: `Handle Email ${emailMessage.to}`, + attributes: { + 'faas.trigger': 'email', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.faas.cloudflare.email', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'task', + }, + }, + async () => { + try { + return await fn(); + } catch (e) { + captureException(e, { mechanism: { handled: false, type: 'auto.faas.cloudflare.email' } }); + throw e; + } finally { + waitUntil(flushAndDispose(client)); + } + }, + ); + }); +} + +/** + * Instruments an email handler for ExportedHandler (env/ctx come from args). + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function instrumentExportedHandlerEmail>( + handler: T, + optionsCallback: (env: Parameters>[1]) => CloudflareOptions | undefined, +): void { + if (!('email' in handler) || typeof handler.email !== 'function' || isInstrumented(handler.email)) { + return; + } + + handler.email = new Proxy(handler.email, { + apply(target, thisArg, args: Parameters>) { + const [emailMessage, env, ctx] = args; + const context = instrumentContext(ctx); + args[2] = context; + + const options = getFinalOptions(optionsCallback(env), env); + + return wrapEmailHandler(emailMessage, options, context, () => target.apply(thisArg, args)); + }, + }); + + markAsInstrumented(handler.email); +} diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentFetch.ts b/packages/cloudflare/src/instrumentations/worker/instrumentFetch.ts new file mode 100644 index 000000000000..be58fa07e18f --- /dev/null +++ b/packages/cloudflare/src/instrumentations/worker/instrumentFetch.ts @@ -0,0 +1,33 @@ +import type { ExportedHandler } from '@cloudflare/workers-types'; +import type { CloudflareOptions } from '../../client'; +import { isInstrumented, markAsInstrumented } from '../../instrument'; +import { getFinalOptions } from '../../options'; +import { wrapRequestHandler } from '../../request'; +import { instrumentContext } from '../../utils/instrumentContext'; + +/** + * Instruments a fetch handler for ExportedHandler (env/ctx come from args). + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function instrumentExportedHandlerFetch>( + handler: T, + optionsCallback: (env: Parameters>[1]) => CloudflareOptions | undefined, +): void { + if (!('fetch' in handler) || typeof handler.fetch !== 'function' || isInstrumented(handler.fetch)) { + return; + } + + handler.fetch = new Proxy(handler.fetch, { + apply(target, thisArg, args: Parameters>) { + const [request, env, ctx] = args; + const context = instrumentContext(ctx); + args[2] = context; + + const options = getFinalOptions(optionsCallback(env), env); + + return wrapRequestHandler({ options, request, context }, () => target.apply(thisArg, args)); + }, + }); + + markAsInstrumented(handler.fetch); +} diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentQueue.ts b/packages/cloudflare/src/instrumentations/worker/instrumentQueue.ts new file mode 100644 index 000000000000..366fb7e98f51 --- /dev/null +++ b/packages/cloudflare/src/instrumentations/worker/instrumentQueue.ts @@ -0,0 +1,89 @@ +import type { ExportedHandler, MessageBatch } from '@cloudflare/workers-types'; +import { + captureException, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + startSpan, + withIsolationScope, +} from '@sentry/core'; +import type { CloudflareOptions } from '../../client'; +import { flushAndDispose } from '../../flush'; +import { isInstrumented, markAsInstrumented } from '../../instrument'; +import { getFinalOptions } from '../../options'; +import { addCloudResourceContext } from '../../scope-utils'; +import { init } from '../../sdk'; +import { instrumentContext } from '../../utils/instrumentContext'; + +/** + * Core queue handler logic - wraps execution with Sentry instrumentation. + */ +function wrapQueueHandler( + batch: MessageBatch, + options: CloudflareOptions, + context: ExecutionContext, + fn: () => unknown, +): unknown { + return withIsolationScope(isolationScope => { + const waitUntil = context.waitUntil.bind(context); + + const client = init({ ...options, ctx: context }); + isolationScope.setClient(client); + + addCloudResourceContext(isolationScope); + + return startSpan( + { + op: 'faas.queue', + name: `process ${batch.queue}`, + attributes: { + 'faas.trigger': 'pubsub', + 'messaging.destination.name': batch.queue, + 'messaging.system': 'cloudflare', + 'messaging.batch.message_count': batch.messages.length, + 'messaging.message.retry.count': batch.messages.reduce((acc, message) => acc + message.attempts - 1, 0), + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'queue.process', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.faas.cloudflare.queue', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'task', + }, + }, + async () => { + try { + return await fn(); + } catch (e) { + captureException(e, { mechanism: { handled: false, type: 'auto.faas.cloudflare.queue' } }); + throw e; + } finally { + waitUntil(flushAndDispose(client)); + } + }, + ); + }); +} + +/** + * Instruments a queue handler for ExportedHandler (env/ctx come from args). + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function instrumentExportedHandlerQueue>( + handler: T, + optionsCallback: (env: Parameters>[1]) => CloudflareOptions | undefined, +): void { + if (!('queue' in handler) || typeof handler.queue !== 'function' || isInstrumented(handler.queue)) { + return; + } + + handler.queue = new Proxy(handler.queue, { + apply(target, thisArg, args: Parameters>) { + const [batch, env, ctx] = args; + const context = instrumentContext(ctx); + args[2] = context; + + const options = getFinalOptions(optionsCallback(env), env); + + return wrapQueueHandler(batch, options, context, () => target.apply(thisArg, args)); + }, + }); + + markAsInstrumented(handler.queue); +} diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentScheduled.ts b/packages/cloudflare/src/instrumentations/worker/instrumentScheduled.ts new file mode 100644 index 000000000000..2ef682829bcb --- /dev/null +++ b/packages/cloudflare/src/instrumentations/worker/instrumentScheduled.ts @@ -0,0 +1,85 @@ +import type { ExportedHandler, ScheduledController } from '@cloudflare/workers-types'; +import { + captureException, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + startSpan, + withIsolationScope, +} from '@sentry/core'; +import type { CloudflareOptions } from '../../client'; +import { flushAndDispose } from '../../flush'; +import { isInstrumented, markAsInstrumented } from '../../instrument'; +import { getFinalOptions } from '../../options'; +import { addCloudResourceContext } from '../../scope-utils'; +import { init } from '../../sdk'; +import { instrumentContext } from '../../utils/instrumentContext'; + +/** + * Core scheduled handler logic - wraps execution with Sentry instrumentation. + */ +function wrapScheduledHandler( + controller: ScheduledController, + options: CloudflareOptions, + context: ExecutionContext, + fn: () => unknown, +): unknown { + return withIsolationScope(isolationScope => { + const waitUntil = context.waitUntil.bind(context); + + const client = init({ ...options, ctx: context }); + isolationScope.setClient(client); + + addCloudResourceContext(isolationScope); + + return startSpan( + { + op: 'faas.cron', + name: `Scheduled Cron ${controller.cron}`, + attributes: { + 'faas.cron': controller.cron, + 'faas.time': new Date(controller.scheduledTime).toISOString(), + 'faas.trigger': 'timer', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.faas.cloudflare.scheduled', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'task', + }, + }, + async () => { + try { + return await fn(); + } catch (e) { + captureException(e, { mechanism: { handled: false, type: 'auto.faas.cloudflare.scheduled' } }); + throw e; + } finally { + waitUntil(flushAndDispose(client)); + } + }, + ); + }); +} + +/** + * Instruments a scheduled handler for ExportedHandler (env/ctx come from args). + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function instrumentExportedHandlerScheduled>( + handler: T, + optionsCallback: (env: Parameters>[1]) => CloudflareOptions | undefined, +): void { + if (!('scheduled' in handler) || typeof handler.scheduled !== 'function' || isInstrumented(handler.scheduled)) { + return; + } + + handler.scheduled = new Proxy(handler.scheduled, { + apply(target, thisArg, args: Parameters>) { + const [controller, env, ctx] = args; + const context = instrumentContext(ctx); + args[2] = context; + + const options = getFinalOptions(optionsCallback(env), env); + + return wrapScheduledHandler(controller, options, context, () => target.apply(thisArg, args)); + }, + }); + + markAsInstrumented(handler.scheduled); +} diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentTail.ts b/packages/cloudflare/src/instrumentations/worker/instrumentTail.ts new file mode 100644 index 000000000000..f6b2e4492106 --- /dev/null +++ b/packages/cloudflare/src/instrumentations/worker/instrumentTail.ts @@ -0,0 +1,60 @@ +import type { ExportedHandler } from '@cloudflare/workers-types'; +import { captureException, withIsolationScope } from '@sentry/core'; +import type { CloudflareOptions } from '../../client'; +import { flushAndDispose } from '../../flush'; +import { isInstrumented, markAsInstrumented } from '../../instrument'; +import { getFinalOptions } from '../../options'; +import { addCloudResourceContext } from '../../scope-utils'; +import { init } from '../../sdk'; +import { instrumentContext } from '../../utils/instrumentContext'; + +/** + * Core tail handler logic - wraps execution with Sentry instrumentation. + * Note: tail handlers don't create spans, just error capture. + */ +function wrapTailHandler(options: CloudflareOptions, context: ExecutionContext, fn: () => unknown): unknown { + return withIsolationScope(async isolationScope => { + const waitUntil = context.waitUntil.bind(context); + + const client = init({ ...options, ctx: context }); + isolationScope.setClient(client); + + addCloudResourceContext(isolationScope); + + try { + return await fn(); + } catch (e) { + captureException(e, { mechanism: { handled: false, type: 'auto.faas.cloudflare.tail' } }); + throw e; + } finally { + waitUntil(flushAndDispose(client)); + } + }); +} + +/** + * Instruments a tail handler for ExportedHandler (env/ctx come from args). + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function instrumentExportedHandlerTail>( + handler: T, + optionsCallback: (env: Parameters>[1]) => CloudflareOptions | undefined, +): void { + if (!('tail' in handler) || typeof handler.tail !== 'function' || isInstrumented(handler.tail)) { + return; + } + + handler.tail = new Proxy(handler.tail, { + apply(target, thisArg, args: Parameters>) { + const [, env, ctx] = args; + const context = instrumentContext(ctx); + args[2] = context; + + const options = getFinalOptions(optionsCallback(env), env); + + return wrapTailHandler(options, context, () => target.apply(thisArg, args)); + }, + }); + + markAsInstrumented(handler.tail); +} diff --git a/packages/cloudflare/src/withSentry.ts b/packages/cloudflare/src/withSentry.ts new file mode 100644 index 000000000000..addc82429b85 --- /dev/null +++ b/packages/cloudflare/src/withSentry.ts @@ -0,0 +1,69 @@ +import { setAsyncLocalStorageAsyncContextStrategy } from './async'; +import type { CloudflareOptions } from './client'; +import { isInstrumented, markAsInstrumented } from './instrument'; +import { getHonoIntegration } from './integrations/hono'; +import { instrumentExportedHandlerEmail } from './instrumentations/worker/instrumentEmail'; +import { instrumentExportedHandlerFetch } from './instrumentations/worker/instrumentFetch'; +import { instrumentExportedHandlerQueue } from './instrumentations/worker/instrumentQueue'; +import { instrumentExportedHandlerScheduled } from './instrumentations/worker/instrumentScheduled'; +import { instrumentExportedHandlerTail } from './instrumentations/worker/instrumentTail'; + +/** + * Wrapper for Cloudflare handlers. + * + * Initializes the SDK and wraps the handler with Sentry instrumentation. + * + * Automatically instruments the `fetch` method of the handler. + * + * @param optionsCallback Function that returns the options for the SDK initialization. + * @param handler {ExportedHandler} The handler to wrap. + * @returns The wrapped handler. + */ +export function withSentry< + Env = unknown, + QueueHandlerMessage = unknown, + CfHostMetadata = unknown, + T extends ExportedHandler = ExportedHandler< + Env, + QueueHandlerMessage, + CfHostMetadata + >, +>(optionsCallback: (env: Env) => CloudflareOptions | undefined, handler: T): T { + setAsyncLocalStorageAsyncContextStrategy(); + + try { + instrumentExportedHandlerFetch(handler, optionsCallback); + instrumentHonoErrorHandler(handler); + instrumentExportedHandlerScheduled(handler, optionsCallback); + instrumentExportedHandlerEmail(handler, optionsCallback); + instrumentExportedHandlerQueue(handler, optionsCallback); + instrumentExportedHandlerTail(handler, optionsCallback); + // This is here because Miniflare sometimes cannot get instrumented + } catch { + // Do not console anything here, we don't want to spam the console with errors + } + + return handler; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function instrumentHonoErrorHandler>(handler: T): void { + if ( + 'onError' in handler && + 'errorHandler' in handler && + typeof handler.errorHandler === 'function' && + !isInstrumented(handler.errorHandler) + ) { + handler.errorHandler = new Proxy(handler.errorHandler, { + apply(target, thisArg, args) { + const [err, context] = args; + + getHonoIntegration()?.handleHonoException(err, context); + + return Reflect.apply(target, thisArg, args); + }, + }); + + markAsInstrumented(handler.errorHandler); + } +} diff --git a/packages/cloudflare/test/handler.test.ts b/packages/cloudflare/test/handler.test.ts deleted file mode 100644 index 52ed02d07ee1..000000000000 --- a/packages/cloudflare/test/handler.test.ts +++ /dev/null @@ -1,1254 +0,0 @@ -// Note: These tests run the handler in Node.js, which has some differences to the cloudflare workers runtime. -// Although this is not ideal, this is the best we can do until we have a better way to test cloudflare workers. - -import type { - ExecutionContext, - ForwardableEmailMessage, - MessageBatch, - ScheduledController, - TraceItem, -} from '@cloudflare/workers-types'; -import type { Event } from '@sentry/core'; -import * as SentryCore from '@sentry/core'; -import { beforeEach, describe, expect, onTestFinished, test, vi } from 'vitest'; -import { CloudflareClient } from '../src/client'; -import { withSentry } from '../src/handler'; -import { markAsInstrumented } from '../src/instrument'; -import * as HonoIntegration from '../src/integrations/hono'; - -// Custom type for hono-like apps (cloudflare handlers) that include errorHandler and onError -type HonoLikeApp = ExportedHandler< - Env, - QueueHandlerMessage, - CfHostMetadata -> & { - onError?: () => void; - errorHandler?: (err: Error) => Response; -}; - -const MOCK_ENV = { - SENTRY_DSN: 'https://public@dsn.ingest.sentry.io/1337', - SENTRY_RELEASE: '1.1.1', -}; - -// Mock env without DSN for tests that should not initialize the SDK -const MOCK_ENV_WITHOUT_DSN = { - SENTRY_RELEASE: '1.1.1', -}; - -function addDelayedWaitUntil(context: ExecutionContext) { - context.waitUntil(new Promise(resolve => setTimeout(() => resolve()))); -} - -describe('withSentry', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - describe('fetch handler', () => { - test('executes options callback with env', async () => { - const handler = { - fetch(_request, _env, _context) { - return new Response('test'); - }, - } satisfies ExportedHandler; - - const optionsCallback = vi.fn().mockReturnValue({}); - - const wrappedHandler = withSentry(optionsCallback, handler); - await wrappedHandler.fetch?.(new Request('https://example.com'), MOCK_ENV, createMockExecutionContext()); - - expect(optionsCallback).toHaveBeenCalledTimes(1); - expect(optionsCallback).toHaveBeenLastCalledWith(MOCK_ENV); - }); - - test('passes through the handler response', async () => { - const response = new Response('test'); - const handler = { - async fetch(_request, _env, _context) { - return response; - }, - } satisfies ExportedHandler; - - const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN }), handler); - const result = await wrappedHandler.fetch?.( - new Request('https://example.com'), - MOCK_ENV, - createMockExecutionContext(), - ); - - // Response may be wrapped for streaming detection, verify content - expect(result?.status).toBe(response.status); - if (result) { - expect(await result.text()).toBe('test'); - } - }); - - test('merges options from env and callback', async () => { - const handler = { - fetch(_request, _env, _context) { - throw new Error('test'); - }, - } satisfies ExportedHandler; - - let sentryEvent: Event = {}; - - const wrappedHandler = withSentry( - env => ({ - dsn: env.SENTRY_DSN, - beforeSend(event) { - sentryEvent = event; - return null; - }, - }), - handler, - ); - - try { - await wrappedHandler.fetch?.(new Request('https://example.com'), MOCK_ENV, createMockExecutionContext()); - } catch { - // ignore - } - - expect(sentryEvent.release).toEqual('1.1.1'); - }); - - test('callback options take precedence over env options', async () => { - const handler = { - fetch(_request, _env, _context) { - throw new Error('test'); - }, - } satisfies ExportedHandler; - - let sentryEvent: Event = {}; - - const wrappedHandler = withSentry( - env => ({ - dsn: env.SENTRY_DSN, - release: '2.0.0', - beforeSend(event) { - sentryEvent = event; - return null; - }, - }), - handler, - ); - - try { - await wrappedHandler.fetch?.(new Request('https://example.com'), MOCK_ENV, createMockExecutionContext()); - } catch { - // ignore - } - - expect(sentryEvent.release).toEqual('2.0.0'); - }); - - test('flush must be called when all waitUntil are done', async () => { - const flush = vi.spyOn(SentryCore.Client.prototype, 'flush'); - vi.useFakeTimers(); - onTestFinished(() => { - vi.useRealTimers(); - }); - const handler = { - fetch(_request, _env, _context) { - addDelayedWaitUntil(_context); - return new Response('test'); - }, - } satisfies ExportedHandler; - - const wrappedHandler = withSentry(vi.fn(), handler); - const waits: Promise[] = []; - const waitUntil = vi.fn(promise => waits.push(promise)); - await wrappedHandler.fetch?.(new Request('https://example.com'), MOCK_ENV_WITHOUT_DSN, { - waitUntil, - } as unknown as ExecutionContext); - expect(flush).not.toBeCalled(); - expect(waitUntil).toBeCalled(); - vi.advanceTimersToNextTimer().runAllTimers(); - await Promise.all(waits); - expect(flush).toHaveBeenCalledOnce(); - }); - }); - - describe('scheduled handler', () => { - test('executes options callback with env', async () => { - const handler = { - scheduled(_controller, _env, _context) { - return; - }, - } satisfies ExportedHandler; - - const optionsCallback = vi.fn().mockReturnValue({}); - - const wrappedHandler = withSentry(optionsCallback, handler); - await wrappedHandler.scheduled?.(createMockScheduledController(), MOCK_ENV, createMockExecutionContext()); - - expect(optionsCallback).toHaveBeenCalledTimes(1); - expect(optionsCallback).toHaveBeenLastCalledWith(MOCK_ENV); - }); - - test('merges options from env and callback', async () => { - const handler = { - scheduled(_controller, _env, _context) { - SentryCore.captureMessage('cloud_resource'); - return; - }, - } satisfies ExportedHandler; - - let sentryEvent: Event = {}; - const wrappedHandler = withSentry( - env => ({ - dsn: env.SENTRY_DSN, - beforeSend(event) { - sentryEvent = event; - return null; - }, - }), - handler, - ); - await wrappedHandler.scheduled?.(createMockScheduledController(), MOCK_ENV, createMockExecutionContext()); - - expect(sentryEvent.release).toBe('1.1.1'); - }); - - test('callback options take precedence over env options', async () => { - const handler = { - scheduled(_controller, _env, _context) { - SentryCore.captureMessage('cloud_resource'); - return; - }, - } satisfies ExportedHandler; - - let sentryEvent: Event = {}; - const wrappedHandler = withSentry( - env => ({ - dsn: env.SENTRY_DSN, - release: '2.0.0', - beforeSend(event) { - sentryEvent = event; - return null; - }, - }), - handler, - ); - await wrappedHandler.scheduled?.(createMockScheduledController(), MOCK_ENV, createMockExecutionContext()); - - expect(sentryEvent.release).toEqual('2.0.0'); - }); - - test('flushes the event after the handler is done using the cloudflare context.waitUntil', async () => { - const handler = { - scheduled(_controller, _env, _context) { - return; - }, - } satisfies ExportedHandler; - - const context = createMockExecutionContext(); - const waitUntilSpy = vi.spyOn(context, 'waitUntil'); - const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN }), handler); - await wrappedHandler.scheduled?.(createMockScheduledController(), MOCK_ENV, context); - - expect(waitUntilSpy).toHaveBeenCalledTimes(1); - expect(waitUntilSpy).toHaveBeenLastCalledWith(expect.any(Promise)); - }); - - test('creates a cloudflare client and sets it on the handler', async () => { - const initAndBindSpy = vi.spyOn(SentryCore, 'initAndBind'); - const handler = { - scheduled(_controller, _env, _context) { - return; - }, - } satisfies ExportedHandler; - - const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN }), handler); - await wrappedHandler.scheduled?.(createMockScheduledController(), MOCK_ENV, createMockExecutionContext()); - - expect(initAndBindSpy).toHaveBeenCalledTimes(1); - expect(initAndBindSpy).toHaveBeenLastCalledWith(CloudflareClient, expect.any(Object)); - }); - - describe('scope instrumentation', () => { - test('adds cloud resource context', async () => { - const handler = { - scheduled(_controller, _env, _context) { - SentryCore.captureMessage('cloud_resource'); - return; - }, - } satisfies ExportedHandler; - - let sentryEvent: Event = {}; - const wrappedHandler = withSentry( - env => ({ - dsn: env.SENTRY_DSN, - beforeSend(event) { - sentryEvent = event; - return null; - }, - }), - handler, - ); - await wrappedHandler.scheduled?.(createMockScheduledController(), MOCK_ENV, createMockExecutionContext()); - - expect(sentryEvent.contexts?.cloud_resource).toEqual({ 'cloud.provider': 'cloudflare' }); - }); - }); - - describe('error instrumentation', () => { - test('captures errors thrown by the handler', async () => { - const captureExceptionSpy = vi.spyOn(SentryCore, 'captureException'); - const error = new Error('test'); - - expect(captureExceptionSpy).not.toHaveBeenCalled(); - - const handler = { - scheduled(_controller, _env, _context) { - throw error; - }, - } satisfies ExportedHandler; - - const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN }), handler); - try { - await wrappedHandler.scheduled?.(createMockScheduledController(), MOCK_ENV, createMockExecutionContext()); - } catch { - // ignore - } - - expect(captureExceptionSpy).toHaveBeenCalledTimes(1); - expect(captureExceptionSpy).toHaveBeenLastCalledWith(error, { - mechanism: { handled: false, type: 'auto.faas.cloudflare.scheduled' }, - }); - }); - - test('re-throws the error after capturing', async () => { - const error = new Error('test'); - const handler = { - scheduled(_controller, _env, _context) { - throw error; - }, - } satisfies ExportedHandler; - - const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN }), handler); - - let thrownError: Error | undefined; - try { - await wrappedHandler.scheduled?.(createMockScheduledController(), MOCK_ENV, createMockExecutionContext()); - } catch (e: any) { - thrownError = e; - } - - expect(thrownError).toBe(error); - }); - }); - - describe('tracing instrumentation', () => { - test('creates a span that wraps scheduled invocation', async () => { - const handler = { - scheduled(_controller, _env, _context) { - return; - }, - } satisfies ExportedHandler; - - let sentryEvent: Event = {}; - const wrappedHandler = withSentry( - env => ({ - dsn: env.SENTRY_DSN, - tracesSampleRate: 1, - beforeSendTransaction(event) { - sentryEvent = event; - return null; - }, - }), - handler, - ); - - await wrappedHandler.scheduled?.(createMockScheduledController(), MOCK_ENV, createMockExecutionContext()); - - expect(sentryEvent.transaction).toEqual('Scheduled Cron 0 0 0 * * *'); - expect(sentryEvent.spans).toHaveLength(0); - expect(sentryEvent.contexts?.trace).toEqual({ - data: { - 'sentry.origin': 'auto.faas.cloudflare.scheduled', - 'sentry.op': 'faas.cron', - 'faas.cron': '0 0 0 * * *', - 'faas.time': expect.any(String), - 'faas.trigger': 'timer', - 'sentry.sample_rate': 1, - 'sentry.source': 'task', - }, - op: 'faas.cron', - origin: 'auto.faas.cloudflare.scheduled', - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - }); - }); - }); - - test('flush must be called when all waitUntil are done', async () => { - const flush = vi.spyOn(SentryCore.Client.prototype, 'flush'); - vi.useFakeTimers(); - onTestFinished(() => { - vi.useRealTimers(); - }); - const handler = { - scheduled(_controller, _env, _context) { - addDelayedWaitUntil(_context); - return; - }, - } satisfies ExportedHandler; - - const wrappedHandler = withSentry(vi.fn(), handler); - const waits: Promise[] = []; - const waitUntil = vi.fn(promise => waits.push(promise)); - await wrappedHandler.scheduled?.(createMockScheduledController(), MOCK_ENV_WITHOUT_DSN, { - waitUntil, - } as unknown as ExecutionContext); - expect(flush).not.toBeCalled(); - expect(waitUntil).toBeCalled(); - vi.advanceTimersToNextTimer().runAllTimers(); - await Promise.all(waits); - expect(flush).toHaveBeenCalledOnce(); - }); - }); - - describe('email handler', () => { - test('executes options callback with env', async () => { - const handler = { - email(_message, _env, _context) { - return; - }, - } satisfies ExportedHandler; - - const optionsCallback = vi.fn().mockReturnValue({}); - - const wrappedHandler = withSentry(optionsCallback, handler); - await wrappedHandler.email?.(createMockEmailMessage(), MOCK_ENV, createMockExecutionContext()); - - expect(optionsCallback).toHaveBeenCalledTimes(1); - expect(optionsCallback).toHaveBeenLastCalledWith(MOCK_ENV); - }); - - test('merges options from env and callback', async () => { - const handler = { - email(_message, _env, _context) { - SentryCore.captureMessage('cloud_resource'); - return; - }, - } satisfies ExportedHandler; - - let sentryEvent: Event = {}; - const wrappedHandler = withSentry( - env => ({ - dsn: env.SENTRY_DSN, - beforeSend(event) { - sentryEvent = event; - return null; - }, - }), - handler, - ); - await wrappedHandler.email?.(createMockEmailMessage(), MOCK_ENV, createMockExecutionContext()); - - expect(sentryEvent.release).toBe('1.1.1'); - }); - - test('callback options take precedence over env options', async () => { - const handler = { - email(_message, _env, _context) { - SentryCore.captureMessage('cloud_resource'); - return; - }, - } satisfies ExportedHandler; - - let sentryEvent: Event = {}; - const wrappedHandler = withSentry( - env => ({ - dsn: env.SENTRY_DSN, - release: '2.0.0', - beforeSend(event) { - sentryEvent = event; - return null; - }, - }), - handler, - ); - await wrappedHandler.email?.(createMockEmailMessage(), MOCK_ENV, createMockExecutionContext()); - - expect(sentryEvent.release).toEqual('2.0.0'); - }); - - test('flushes the event after the handler is done using the cloudflare context.waitUntil', async () => { - const handler = { - email(_message, _env, _context) { - return; - }, - } satisfies ExportedHandler; - - const context = createMockExecutionContext(); - const waitUntilSpy = vi.spyOn(context, 'waitUntil'); - const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN }), handler); - await wrappedHandler.email?.(createMockEmailMessage(), MOCK_ENV, context); - - expect(waitUntilSpy).toHaveBeenCalledTimes(1); - expect(waitUntilSpy).toHaveBeenLastCalledWith(expect.any(Promise)); - }); - - test('creates a cloudflare client and sets it on the handler', async () => { - const initAndBindSpy = vi.spyOn(SentryCore, 'initAndBind'); - const handler = { - email(_message, _env, _context) { - return; - }, - } satisfies ExportedHandler; - - const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN }), handler); - await wrappedHandler.email?.(createMockEmailMessage(), MOCK_ENV, createMockExecutionContext()); - - expect(initAndBindSpy).toHaveBeenCalledTimes(1); - expect(initAndBindSpy).toHaveBeenLastCalledWith(CloudflareClient, expect.any(Object)); - }); - - describe('scope instrumentation', () => { - test('adds cloud resource context', async () => { - const handler = { - email(_message, _env, _context) { - SentryCore.captureMessage('cloud_resource'); - return; - }, - } satisfies ExportedHandler; - - let sentryEvent: Event = {}; - const wrappedHandler = withSentry( - env => ({ - dsn: env.SENTRY_DSN, - beforeSend(event) { - sentryEvent = event; - return null; - }, - }), - handler, - ); - await wrappedHandler.email?.(createMockEmailMessage(), MOCK_ENV, createMockExecutionContext()); - - expect(sentryEvent.contexts?.cloud_resource).toEqual({ 'cloud.provider': 'cloudflare' }); - }); - }); - - describe('error instrumentation', () => { - test('captures errors thrown by the handler', async () => { - const captureExceptionSpy = vi.spyOn(SentryCore, 'captureException'); - const error = new Error('test'); - - expect(captureExceptionSpy).not.toHaveBeenCalled(); - - const handler = { - email(_message, _env, _context) { - throw error; - }, - } satisfies ExportedHandler; - - const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN }), handler); - try { - await wrappedHandler.email?.(createMockEmailMessage(), MOCK_ENV, createMockExecutionContext()); - } catch { - // ignore - } - - expect(captureExceptionSpy).toHaveBeenCalledTimes(1); - expect(captureExceptionSpy).toHaveBeenLastCalledWith(error, { - mechanism: { handled: false, type: 'auto.faas.cloudflare.email' }, - }); - }); - - test('re-throws the error after capturing', async () => { - const error = new Error('test'); - const handler = { - email(_message, _env, _context) { - throw error; - }, - } satisfies ExportedHandler; - - const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN }), handler); - - let thrownError: Error | undefined; - try { - await wrappedHandler.email?.(createMockEmailMessage(), MOCK_ENV, createMockExecutionContext()); - } catch (e: any) { - thrownError = e; - } - - expect(thrownError).toBe(error); - }); - }); - - describe('tracing instrumentation', () => { - test('creates a span that wraps email invocation', async () => { - const handler = { - email(_message, _env, _context) { - return; - }, - } satisfies ExportedHandler; - - let sentryEvent: Event = {}; - const wrappedHandler = withSentry( - env => ({ - dsn: env.SENTRY_DSN, - tracesSampleRate: 1, - beforeSendTransaction(event) { - sentryEvent = event; - return null; - }, - }), - handler, - ); - - const emailMessage = createMockEmailMessage(); - await wrappedHandler.email?.(emailMessage, MOCK_ENV, createMockExecutionContext()); - - expect(sentryEvent.transaction).toEqual(`Handle Email ${emailMessage.to}`); - expect(sentryEvent.spans).toHaveLength(0); - expect(sentryEvent.contexts?.trace).toEqual({ - data: { - 'sentry.origin': 'auto.faas.cloudflare.email', - 'sentry.op': 'faas.email', - 'faas.trigger': 'email', - 'sentry.sample_rate': 1, - 'sentry.source': 'task', - }, - op: 'faas.email', - origin: 'auto.faas.cloudflare.email', - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - }); - }); - }); - - test('flush must be called when all waitUntil are done', async () => { - const flush = vi.spyOn(SentryCore.Client.prototype, 'flush'); - vi.useFakeTimers(); - onTestFinished(() => { - vi.useRealTimers(); - }); - const handler = { - email(_controller, _env, _context) { - addDelayedWaitUntil(_context); - return; - }, - } satisfies ExportedHandler; - - const wrappedHandler = withSentry(vi.fn(), handler); - const waits: Promise[] = []; - const waitUntil = vi.fn(promise => waits.push(promise)); - await wrappedHandler.email?.(createMockEmailMessage(), MOCK_ENV_WITHOUT_DSN, { - waitUntil, - } as unknown as ExecutionContext); - expect(flush).not.toBeCalled(); - expect(waitUntil).toBeCalled(); - vi.advanceTimersToNextTimer().runAllTimers(); - await Promise.all(waits); - expect(flush).toHaveBeenCalledOnce(); - }); - }); - - describe('queue handler', () => { - test('executes options callback with env', async () => { - const handler = { - queue(_batch, _env, _context) { - return; - }, - } satisfies ExportedHandler; - - const optionsCallback = vi.fn().mockReturnValue({}); - - const wrappedHandler = withSentry(optionsCallback, handler); - await wrappedHandler.queue?.(createMockQueueBatch(), MOCK_ENV, createMockExecutionContext()); - - expect(optionsCallback).toHaveBeenCalledTimes(1); - expect(optionsCallback).toHaveBeenLastCalledWith(MOCK_ENV); - }); - - test('merges options from env and callback', async () => { - const handler = { - queue(_batch, _env, _context) { - SentryCore.captureMessage('cloud_resource'); - return; - }, - } satisfies ExportedHandler; - - let sentryEvent: Event = {}; - const wrappedHandler = withSentry( - env => ({ - dsn: env.SENTRY_DSN, - beforeSend(event) { - sentryEvent = event; - return null; - }, - }), - handler, - ); - await wrappedHandler.queue?.(createMockQueueBatch(), MOCK_ENV, createMockExecutionContext()); - - expect(sentryEvent.release).toBe('1.1.1'); - }); - - test('callback options take precedence over env options', async () => { - const handler = { - queue(_batch, _env, _context) { - SentryCore.captureMessage('cloud_resource'); - return; - }, - } satisfies ExportedHandler; - - let sentryEvent: Event = {}; - const wrappedHandler = withSentry( - env => ({ - dsn: env.SENTRY_DSN, - release: '2.0.0', - beforeSend(event) { - sentryEvent = event; - return null; - }, - }), - handler, - ); - await wrappedHandler.queue?.(createMockQueueBatch(), MOCK_ENV, createMockExecutionContext()); - - expect(sentryEvent.release).toEqual('2.0.0'); - }); - - test('flushes the event after the handler is done using the cloudflare context.waitUntil', async () => { - const handler = { - queue(_batch, _env, _context) { - return; - }, - } satisfies ExportedHandler; - - const context = createMockExecutionContext(); - const waitUntilSpy = vi.spyOn(context, 'waitUntil'); - const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN }), handler); - await wrappedHandler.queue?.(createMockQueueBatch(), MOCK_ENV, context); - - expect(waitUntilSpy).toHaveBeenCalledTimes(1); - expect(waitUntilSpy).toHaveBeenLastCalledWith(expect.any(Promise)); - }); - - test('creates a cloudflare client and sets it on the handler', async () => { - const initAndBindSpy = vi.spyOn(SentryCore, 'initAndBind'); - const handler = { - queue(_batch, _env, _context) { - return; - }, - } satisfies ExportedHandler; - - const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN }), handler); - await wrappedHandler.queue?.(createMockQueueBatch(), MOCK_ENV, createMockExecutionContext()); - - expect(initAndBindSpy).toHaveBeenCalledTimes(1); - expect(initAndBindSpy).toHaveBeenLastCalledWith(CloudflareClient, expect.any(Object)); - }); - - describe('scope instrumentation', () => { - test('adds cloud resource context', async () => { - const handler = { - queue(_batch, _env, _context) { - SentryCore.captureMessage('cloud_resource'); - return; - }, - } satisfies ExportedHandler; - - let sentryEvent: Event = {}; - const wrappedHandler = withSentry( - env => ({ - dsn: env.SENTRY_DSN, - beforeSend(event) { - sentryEvent = event; - return null; - }, - }), - handler, - ); - await wrappedHandler.queue?.(createMockQueueBatch(), MOCK_ENV, createMockExecutionContext()); - - expect(sentryEvent.contexts?.cloud_resource).toEqual({ 'cloud.provider': 'cloudflare' }); - }); - }); - - describe('error instrumentation', () => { - test('captures errors thrown by the handler', async () => { - const captureExceptionSpy = vi.spyOn(SentryCore, 'captureException'); - const error = new Error('test'); - - expect(captureExceptionSpy).not.toHaveBeenCalled(); - - const handler = { - queue(_batch, _env, _context) { - throw error; - }, - } satisfies ExportedHandler; - - const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN }), handler); - try { - await wrappedHandler.queue?.(createMockQueueBatch(), MOCK_ENV, createMockExecutionContext()); - } catch { - // ignore - } - - expect(captureExceptionSpy).toHaveBeenCalledTimes(1); - expect(captureExceptionSpy).toHaveBeenLastCalledWith(error, { - mechanism: { handled: false, type: 'auto.faas.cloudflare.queue' }, - }); - }); - - test('re-throws the error after capturing', async () => { - const error = new Error('test'); - const handler = { - queue(_batch, _env, _context) { - throw error; - }, - } satisfies ExportedHandler; - - const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN }), handler); - - let thrownError: Error | undefined; - try { - await wrappedHandler.queue?.(createMockQueueBatch(), MOCK_ENV, createMockExecutionContext()); - } catch (e: any) { - thrownError = e; - } - - expect(thrownError).toBe(error); - }); - }); - - describe('tracing instrumentation', () => { - test('creates a span that wraps queue invocation with correct attributes', async () => { - const handler = { - queue(_batch, _env, _context) { - return; - }, - } satisfies ExportedHandler; - - let sentryEvent: Event = {}; - const wrappedHandler = withSentry( - env => ({ - dsn: env.SENTRY_DSN, - tracesSampleRate: 1, - beforeSendTransaction(event) { - sentryEvent = event; - return null; - }, - }), - handler, - ); - - const batch = createMockQueueBatch(); - await wrappedHandler.queue?.(batch, MOCK_ENV, createMockExecutionContext()); - - expect(sentryEvent.transaction).toEqual(`process ${batch.queue}`); - expect(sentryEvent.spans).toHaveLength(0); - expect(sentryEvent.contexts?.trace).toEqual({ - data: { - 'sentry.origin': 'auto.faas.cloudflare.queue', - 'sentry.op': 'queue.process', - 'faas.trigger': 'pubsub', - 'messaging.destination.name': batch.queue, - 'messaging.system': 'cloudflare', - 'messaging.batch.message_count': batch.messages.length, - 'messaging.message.retry.count': batch.messages.reduce((acc, message) => acc + message.attempts - 1, 0), - 'sentry.sample_rate': 1, - 'sentry.source': 'task', - }, - op: 'queue.process', - origin: 'auto.faas.cloudflare.queue', - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - }); - }); - }); - - test('flush must be called when all waitUntil are done', async () => { - const flush = vi.spyOn(SentryCore.Client.prototype, 'flush'); - vi.useFakeTimers(); - onTestFinished(() => { - vi.useRealTimers(); - }); - const handler = { - queue(_controller, _env, _context) { - addDelayedWaitUntil(_context); - return; - }, - } satisfies ExportedHandler; - - const wrappedHandler = withSentry(vi.fn(), handler); - const waits: Promise[] = []; - const waitUntil = vi.fn(promise => waits.push(promise)); - await wrappedHandler.queue?.(createMockQueueBatch(), MOCK_ENV_WITHOUT_DSN, { - waitUntil, - } as unknown as ExecutionContext); - expect(flush).not.toBeCalled(); - expect(waitUntil).toBeCalled(); - vi.advanceTimersToNextTimer().runAllTimers(); - await Promise.all(waits); - expect(flush).toHaveBeenCalledOnce(); - }); - }); - - describe('tail handler', () => { - test('executes options callback with env', async () => { - const handler = { - tail(_event, _env, _context) { - return; - }, - } satisfies ExportedHandler; - - const optionsCallback = vi.fn().mockReturnValue({}); - - const wrappedHandler = withSentry(optionsCallback, handler); - await wrappedHandler.tail?.(createMockTailEvent(), MOCK_ENV, createMockExecutionContext()); - - expect(optionsCallback).toHaveBeenCalledTimes(1); - expect(optionsCallback).toHaveBeenLastCalledWith(MOCK_ENV); - }); - - test('merges options from env and callback', async () => { - const handler = { - tail(_event, _env, _context) { - SentryCore.captureMessage('cloud_resource'); - return; - }, - } satisfies ExportedHandler; - - let sentryEvent: Event = {}; - const wrappedHandler = withSentry( - env => ({ - dsn: env.SENTRY_DSN, - beforeSend(event) { - sentryEvent = event; - return null; - }, - }), - handler, - ); - await wrappedHandler.tail?.(createMockTailEvent(), MOCK_ENV, createMockExecutionContext()); - - expect(sentryEvent.release).toBe('1.1.1'); - }); - - test('callback options take precedence over env options', async () => { - const handler = { - tail(_event, _env, _context) { - SentryCore.captureMessage('cloud_resource'); - return; - }, - } satisfies ExportedHandler; - - let sentryEvent: Event = {}; - const wrappedHandler = withSentry( - env => ({ - dsn: env.SENTRY_DSN, - release: '2.0.0', - beforeSend(event) { - sentryEvent = event; - return null; - }, - }), - handler, - ); - await wrappedHandler.tail?.(createMockTailEvent(), MOCK_ENV, createMockExecutionContext()); - - expect(sentryEvent.release).toEqual('2.0.0'); - }); - - test('flushes the event after the handler is done using the cloudflare context.waitUntil', async () => { - const handler = { - tail(_event, _env, _context) { - return; - }, - } satisfies ExportedHandler; - - const context = createMockExecutionContext(); - const waitUntilSpy = vi.spyOn(context, 'waitUntil'); - const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN }), handler); - await wrappedHandler.tail?.(createMockTailEvent(), MOCK_ENV, context); - - expect(waitUntilSpy).toHaveBeenCalledTimes(1); - expect(waitUntilSpy).toHaveBeenLastCalledWith(expect.any(Promise)); - }); - - test('creates a cloudflare client and sets it on the handler', async () => { - const initAndBindSpy = vi.spyOn(SentryCore, 'initAndBind'); - const handler = { - tail(_event, _env, _context) { - return; - }, - } satisfies ExportedHandler; - - const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN }), handler); - await wrappedHandler.tail?.(createMockTailEvent(), MOCK_ENV, createMockExecutionContext()); - - expect(initAndBindSpy).toHaveBeenCalledTimes(1); - expect(initAndBindSpy).toHaveBeenLastCalledWith(CloudflareClient, expect.any(Object)); - }); - - describe('scope instrumentation', () => { - test('adds cloud resource context', async () => { - const handler = { - tail(_event, _env, _context) { - SentryCore.captureMessage('cloud_resource'); - return; - }, - } satisfies ExportedHandler; - - let sentryEvent: Event = {}; - const wrappedHandler = withSentry( - env => ({ - dsn: env.SENTRY_DSN, - beforeSend(event) { - sentryEvent = event; - return null; - }, - }), - handler, - ); - await wrappedHandler.tail?.(createMockTailEvent(), MOCK_ENV, createMockExecutionContext()); - - expect(sentryEvent.contexts?.cloud_resource).toEqual({ 'cloud.provider': 'cloudflare' }); - }); - }); - - describe('error instrumentation', () => { - test('captures errors thrown by the handler', async () => { - const captureExceptionSpy = vi.spyOn(SentryCore, 'captureException'); - const error = new Error('test'); - - expect(captureExceptionSpy).not.toHaveBeenCalled(); - - const handler = { - tail(_event, _env, _context) { - throw error; - }, - } satisfies ExportedHandler; - - const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN }), handler); - try { - await wrappedHandler.tail?.(createMockTailEvent(), MOCK_ENV, createMockExecutionContext()); - } catch { - // ignore - } - - expect(captureExceptionSpy).toHaveBeenCalledTimes(1); - expect(captureExceptionSpy).toHaveBeenLastCalledWith(error, { - mechanism: { handled: false, type: 'auto.faas.cloudflare.tail' }, - }); - }); - - test('re-throws the error after capturing', async () => { - const error = new Error('test'); - const handler = { - tail(_event, _env, _context) { - throw error; - }, - } satisfies ExportedHandler; - - const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN }), handler); - - let thrownError: Error | undefined; - try { - await wrappedHandler.tail?.(createMockTailEvent(), MOCK_ENV, createMockExecutionContext()); - } catch (e: any) { - thrownError = e; - } - - expect(thrownError).toBe(error); - }); - }); - - test('flush must be called when all waitUntil are done', async () => { - const flush = vi.spyOn(SentryCore.Client.prototype, 'flush'); - vi.useFakeTimers(); - onTestFinished(() => { - vi.useRealTimers(); - flush.mockRestore(); - }); - const handler = { - tail(_controller, _env, _context) { - addDelayedWaitUntil(_context); - return; - }, - } satisfies ExportedHandler; - - const wrappedHandler = withSentry(vi.fn(), handler); - const waits: Promise[] = []; - const waitUntil = vi.fn(promise => waits.push(promise)); - await wrappedHandler.tail?.(createMockTailEvent(), MOCK_ENV_WITHOUT_DSN, { - waitUntil, - } as unknown as ExecutionContext); - expect(flush).not.toBeCalled(); - expect(waitUntil).toBeCalled(); - vi.advanceTimersToNextTimer().runAllTimers(); - await Promise.all(waits); - expect(flush).toHaveBeenCalledOnce(); - }); - }); - - describe('hono errorHandler', () => { - test('calls Hono Integration to handle error captured by the errorHandler', async () => { - const error = new Error('test hono error'); - - const handleHonoException = vi.fn(); - vi.spyOn(HonoIntegration, 'getHonoIntegration').mockReturnValue({ handleHonoException } as any); - - const honoApp = { - fetch(_request, _env, _context) { - return new Response('test'); - }, - onError() {}, // hono-like onError - errorHandler(err: Error) { - return new Response(`Error: ${err.message}`, { status: 500 }); - }, - } satisfies HonoLikeApp; - - withSentry(env => ({ dsn: env.SENTRY_DSN }), honoApp); - - // simulates hono's error handling - const errorHandlerResponse = honoApp.errorHandler?.(error); - - expect(handleHonoException).toHaveBeenCalledTimes(1); - // 2nd param is context, which is undefined here - expect(handleHonoException).toHaveBeenLastCalledWith(error, undefined); - expect(errorHandlerResponse?.status).toBe(500); - }); - - test('preserves the original errorHandler functionality', async () => { - const originalErrorHandlerSpy = vi.fn().mockImplementation((err: Error) => { - return new Response(`Error: ${err.message}`, { status: 500 }); - }); - - const error = new Error('test hono error'); - - const honoApp = { - fetch(_request, _env, _context) { - return new Response('test'); - }, - onError() {}, // hono-like onError - errorHandler: originalErrorHandlerSpy, - } satisfies HonoLikeApp; - - withSentry(env => ({ dsn: env.SENTRY_DSN }), honoApp); - - // Call the errorHandler directly to simulate Hono's error handling - const errorHandlerResponse = honoApp.errorHandler?.(error); - - expect(originalErrorHandlerSpy).toHaveBeenCalledTimes(1); - expect(originalErrorHandlerSpy).toHaveBeenLastCalledWith(error); - expect(errorHandlerResponse?.status).toBe(500); - }); - - test('does not instrument an already instrumented errorHandler', async () => { - const captureExceptionSpy = vi.spyOn(SentryCore, 'captureException'); - const error = new Error('test hono error'); - - // Create a handler with an errorHandler that's already been instrumented - const originalErrorHandler = (err: Error) => { - return new Response(`Error: ${err.message}`, { status: 500 }); - }; - - // Mark as instrumented before wrapping - markAsInstrumented(originalErrorHandler); - - const honoApp = { - fetch(_request, _env, _context) { - return new Response('test'); - }, - onError() {}, // hono-like onError - errorHandler: originalErrorHandler, - } satisfies HonoLikeApp; - - withSentry(env => ({ dsn: env.SENTRY_DSN }), honoApp); - - // The errorHandler should not have been wrapped again - honoApp.errorHandler?.(error); - expect(captureExceptionSpy).not.toHaveBeenCalled(); - }); - }); -}); - -function createMockExecutionContext(): ExecutionContext { - return { - waitUntil: vi.fn(), - passThroughOnException: vi.fn(), - }; -} - -function createMockScheduledController(): ScheduledController { - return { - scheduledTime: 123, - cron: '0 0 0 * * *', - noRetry: vi.fn(), - }; -} - -function createMockEmailMessage(): ForwardableEmailMessage { - return { - from: 'sender@example.com', - to: 'recipient@example.com', - raw: new ReadableStream(), - rawSize: 1024, - headers: new Headers(), - setReject: vi.fn(), - forward: vi.fn(), - reply: vi.fn(), - }; -} - -function createMockQueueBatch(): MessageBatch { - return { - queue: 'test-queue', - messages: [ - { - id: '1', - timestamp: new Date(), - body: 'test message 1', - attempts: 1, - retry: vi.fn(), - ack: vi.fn(), - }, - { - id: '2', - timestamp: new Date(), - body: 'test message 2', - attempts: 2, - retry: vi.fn(), - ack: vi.fn(), - }, - ], - retryAll: vi.fn(), - ackAll: vi.fn(), - }; -} - -function createMockTailEvent(): TraceItem[] { - return [ - { - event: { - consumedEvents: [ - { - scriptName: 'test-script', - }, - ], - }, - eventTimestamp: Date.now(), - logs: [ - { - timestamp: Date.now(), - level: 'info', - message: 'Test log message', - }, - ], - exceptions: [], - diagnosticsChannelEvents: [], - scriptName: 'test-script', - outcome: 'ok', - truncated: false, - }, - ]; -} diff --git a/packages/cloudflare/test/instrumentations/worker/instrumentEmail.test.ts b/packages/cloudflare/test/instrumentations/worker/instrumentEmail.test.ts new file mode 100644 index 000000000000..5d2f01b428df --- /dev/null +++ b/packages/cloudflare/test/instrumentations/worker/instrumentEmail.test.ts @@ -0,0 +1,285 @@ +// Note: These tests run the handler in Node.js, which has some differences to the cloudflare workers runtime. +// Although this is not ideal, this is the best we can do until we have a better way to test cloudflare workers. + +import type { ExecutionContext, ForwardableEmailMessage } from '@cloudflare/workers-types'; +import type { Event } from '@sentry/core'; +import * as SentryCore from '@sentry/core'; +import { beforeEach, describe, expect, onTestFinished, test, vi } from 'vitest'; +import { CloudflareClient } from '../../../src/client'; +import { withSentry } from '../../../src/withSentry'; + +const MOCK_ENV = { + SENTRY_DSN: 'https://public@dsn.ingest.sentry.io/1337', + SENTRY_RELEASE: '1.1.1', +}; + +const MOCK_ENV_WITHOUT_DSN = { + SENTRY_RELEASE: '1.1.1', +}; + +function createMockExecutionContext(): ExecutionContext { + return { + waitUntil: vi.fn(), + passThroughOnException: vi.fn(), + }; +} + +function createMockEmailMessage(): ForwardableEmailMessage { + return { + from: 'sender@example.com', + to: 'recipient@example.com', + raw: new ReadableStream(), + rawSize: 1024, + headers: new Headers(), + setReject: vi.fn(), + forward: vi.fn(), + reply: vi.fn(), + }; +} + +function addDelayedWaitUntil(context: ExecutionContext) { + context.waitUntil(new Promise(resolve => setTimeout(() => resolve()))); +} + +describe('instrumentEmail', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('executes options callback with env', async () => { + const handler = { + email(_message, _env, _context) { + return; + }, + } satisfies ExportedHandler; + + const optionsCallback = vi.fn().mockReturnValue({}); + + const wrappedHandler = withSentry(optionsCallback, handler); + await wrappedHandler.email?.(createMockEmailMessage(), MOCK_ENV, createMockExecutionContext()); + + expect(optionsCallback).toHaveBeenCalledTimes(1); + expect(optionsCallback).toHaveBeenLastCalledWith(MOCK_ENV); + }); + + test('merges options from env and callback', async () => { + const handler = { + email(_message, _env, _context) { + SentryCore.captureMessage('cloud_resource'); + return; + }, + } satisfies ExportedHandler; + + let sentryEvent: Event = {}; + const wrappedHandler = withSentry( + env => ({ + dsn: env.SENTRY_DSN, + beforeSend(event) { + sentryEvent = event; + return null; + }, + }), + handler, + ); + await wrappedHandler.email?.(createMockEmailMessage(), MOCK_ENV, createMockExecutionContext()); + + expect(sentryEvent.release).toBe('1.1.1'); + }); + + test('callback options take precedence over env options', async () => { + const handler = { + email(_message, _env, _context) { + SentryCore.captureMessage('cloud_resource'); + return; + }, + } satisfies ExportedHandler; + + let sentryEvent: Event = {}; + const wrappedHandler = withSentry( + env => ({ + dsn: env.SENTRY_DSN, + release: '2.0.0', + beforeSend(event) { + sentryEvent = event; + return null; + }, + }), + handler, + ); + await wrappedHandler.email?.(createMockEmailMessage(), MOCK_ENV, createMockExecutionContext()); + + expect(sentryEvent.release).toEqual('2.0.0'); + }); + + test('flushes the event after the handler is done using the cloudflare context.waitUntil', async () => { + const handler = { + email(_message, _env, _context) { + return; + }, + } satisfies ExportedHandler; + + const context = createMockExecutionContext(); + const waitUntilSpy = vi.spyOn(context, 'waitUntil'); + const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN }), handler); + await wrappedHandler.email?.(createMockEmailMessage(), MOCK_ENV, context); + + expect(waitUntilSpy).toHaveBeenCalledTimes(1); + expect(waitUntilSpy).toHaveBeenLastCalledWith(expect.any(Promise)); + }); + + test('creates a cloudflare client and sets it on the handler', async () => { + const initAndBindSpy = vi.spyOn(SentryCore, 'initAndBind'); + const handler = { + email(_message, _env, _context) { + return; + }, + } satisfies ExportedHandler; + + const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN }), handler); + await wrappedHandler.email?.(createMockEmailMessage(), MOCK_ENV, createMockExecutionContext()); + + expect(initAndBindSpy).toHaveBeenCalledTimes(1); + expect(initAndBindSpy).toHaveBeenLastCalledWith(CloudflareClient, expect.any(Object)); + }); + + describe('scope instrumentation', () => { + test('adds cloud resource context', async () => { + const handler = { + email(_message, _env, _context) { + SentryCore.captureMessage('cloud_resource'); + return; + }, + } satisfies ExportedHandler; + + let sentryEvent: Event = {}; + const wrappedHandler = withSentry( + env => ({ + dsn: env.SENTRY_DSN, + beforeSend(event) { + sentryEvent = event; + return null; + }, + }), + handler, + ); + await wrappedHandler.email?.(createMockEmailMessage(), MOCK_ENV, createMockExecutionContext()); + + expect(sentryEvent.contexts?.cloud_resource).toEqual({ 'cloud.provider': 'cloudflare' }); + }); + }); + + describe('error instrumentation', () => { + test('captures errors thrown by the handler', async () => { + const captureExceptionSpy = vi.spyOn(SentryCore, 'captureException'); + const error = new Error('test'); + + expect(captureExceptionSpy).not.toHaveBeenCalled(); + + const handler = { + email(_message, _env, _context) { + throw error; + }, + } satisfies ExportedHandler; + + const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN }), handler); + try { + await wrappedHandler.email?.(createMockEmailMessage(), MOCK_ENV, createMockExecutionContext()); + } catch { + // ignore + } + + expect(captureExceptionSpy).toHaveBeenCalledTimes(1); + expect(captureExceptionSpy).toHaveBeenLastCalledWith(error, { + mechanism: { handled: false, type: 'auto.faas.cloudflare.email' }, + }); + }); + + test('re-throws the error after capturing', async () => { + const error = new Error('test'); + const handler = { + email(_message, _env, _context) { + throw error; + }, + } satisfies ExportedHandler; + + const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN }), handler); + + let thrownError: Error | undefined; + try { + await wrappedHandler.email?.(createMockEmailMessage(), MOCK_ENV, createMockExecutionContext()); + } catch (e: any) { + thrownError = e; + } + + expect(thrownError).toBe(error); + }); + }); + + describe('tracing instrumentation', () => { + test('creates a span that wraps email invocation', async () => { + const handler = { + email(_message, _env, _context) { + return; + }, + } satisfies ExportedHandler; + + let sentryEvent: Event = {}; + const wrappedHandler = withSentry( + env => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1, + beforeSendTransaction(event) { + sentryEvent = event; + return null; + }, + }), + handler, + ); + + const emailMessage = createMockEmailMessage(); + await wrappedHandler.email?.(emailMessage, MOCK_ENV, createMockExecutionContext()); + + expect(sentryEvent.transaction).toEqual(`Handle Email ${emailMessage.to}`); + expect(sentryEvent.spans).toHaveLength(0); + expect(sentryEvent.contexts?.trace).toEqual({ + data: { + 'sentry.origin': 'auto.faas.cloudflare.email', + 'sentry.op': 'faas.email', + 'faas.trigger': 'email', + 'sentry.sample_rate': 1, + 'sentry.source': 'task', + }, + op: 'faas.email', + origin: 'auto.faas.cloudflare.email', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }); + }); + }); + + test('flush must be called when all waitUntil are done', async () => { + const flush = vi.spyOn(SentryCore.Client.prototype, 'flush'); + vi.useFakeTimers(); + onTestFinished(() => { + vi.useRealTimers(); + }); + const handler = { + email(_controller, _env, _context) { + addDelayedWaitUntil(_context); + return; + }, + } satisfies ExportedHandler; + + const wrappedHandler = withSentry(vi.fn(), handler); + const waits: Promise[] = []; + const waitUntil = vi.fn(promise => waits.push(promise)); + await wrappedHandler.email?.(createMockEmailMessage(), MOCK_ENV_WITHOUT_DSN, { + waitUntil, + } as unknown as ExecutionContext); + expect(flush).not.toBeCalled(); + expect(waitUntil).toBeCalled(); + vi.advanceTimersToNextTimer().runAllTimers(); + await Promise.all(waits); + expect(flush).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/cloudflare/test/instrumentations/worker/instrumentFetch.test.ts b/packages/cloudflare/test/instrumentations/worker/instrumentFetch.test.ts new file mode 100644 index 000000000000..1ae4b965d238 --- /dev/null +++ b/packages/cloudflare/test/instrumentations/worker/instrumentFetch.test.ts @@ -0,0 +1,156 @@ +// Note: These tests run the handler in Node.js, which has some differences to the cloudflare workers runtime. +// Although this is not ideal, this is the best we can do until we have a better way to test cloudflare workers. + +import type { ExecutionContext } from '@cloudflare/workers-types'; +import type { Event } from '@sentry/core'; +import * as SentryCore from '@sentry/core'; +import { beforeEach, describe, expect, onTestFinished, test, vi } from 'vitest'; +import { withSentry } from '../../../src/withSentry'; + +const MOCK_ENV = { + SENTRY_DSN: 'https://public@dsn.ingest.sentry.io/1337', + SENTRY_RELEASE: '1.1.1', +}; + +const MOCK_ENV_WITHOUT_DSN = { + SENTRY_RELEASE: '1.1.1', +}; + +function createMockExecutionContext(): ExecutionContext { + return { + waitUntil: vi.fn(), + passThroughOnException: vi.fn(), + }; +} + +function addDelayedWaitUntil(context: ExecutionContext) { + context.waitUntil(new Promise(resolve => setTimeout(() => resolve()))); +} + +describe('instrumentFetch', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('executes options callback with env', async () => { + const handler = { + fetch(_request, _env, _context) { + return new Response('test'); + }, + } satisfies ExportedHandler; + + const optionsCallback = vi.fn().mockReturnValue({}); + + const wrappedHandler = withSentry(optionsCallback, handler); + await wrappedHandler.fetch?.(new Request('https://example.com'), MOCK_ENV, createMockExecutionContext()); + + expect(optionsCallback).toHaveBeenCalledTimes(1); + expect(optionsCallback).toHaveBeenLastCalledWith(MOCK_ENV); + }); + + test('passes through the handler response', async () => { + const response = new Response('test'); + const handler = { + async fetch(_request, _env, _context) { + return response; + }, + } satisfies ExportedHandler; + + const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN }), handler); + const result = await wrappedHandler.fetch?.( + new Request('https://example.com'), + MOCK_ENV, + createMockExecutionContext(), + ); + + expect(result?.status).toBe(response.status); + if (result) { + expect(await result.text()).toBe('test'); + } + }); + + test('merges options from env and callback', async () => { + const handler = { + fetch(_request, _env, _context) { + throw new Error('test'); + }, + } satisfies ExportedHandler; + + let sentryEvent: Event = {}; + + const wrappedHandler = withSentry( + env => ({ + dsn: env.SENTRY_DSN, + beforeSend(event) { + sentryEvent = event; + return null; + }, + }), + handler, + ); + + try { + await wrappedHandler.fetch?.(new Request('https://example.com'), MOCK_ENV, createMockExecutionContext()); + } catch { + // ignore + } + + expect(sentryEvent.release).toEqual('1.1.1'); + }); + + test('callback options take precedence over env options', async () => { + const handler = { + fetch(_request, _env, _context) { + throw new Error('test'); + }, + } satisfies ExportedHandler; + + let sentryEvent: Event = {}; + + const wrappedHandler = withSentry( + env => ({ + dsn: env.SENTRY_DSN, + release: '2.0.0', + beforeSend(event) { + sentryEvent = event; + return null; + }, + }), + handler, + ); + + try { + await wrappedHandler.fetch?.(new Request('https://example.com'), MOCK_ENV, createMockExecutionContext()); + } catch { + // ignore + } + + expect(sentryEvent.release).toEqual('2.0.0'); + }); + + test('flush must be called when all waitUntil are done', async () => { + const flush = vi.spyOn(SentryCore.Client.prototype, 'flush'); + vi.useFakeTimers(); + onTestFinished(() => { + vi.useRealTimers(); + }); + const handler = { + fetch(_request, _env, _context) { + addDelayedWaitUntil(_context); + return new Response('test'); + }, + } satisfies ExportedHandler; + + const wrappedHandler = withSentry(vi.fn(), handler); + const waits: Promise[] = []; + const waitUntil = vi.fn(promise => waits.push(promise)); + await wrappedHandler.fetch?.(new Request('https://example.com'), MOCK_ENV_WITHOUT_DSN, { + waitUntil, + } as unknown as ExecutionContext); + expect(flush).not.toBeCalled(); + expect(waitUntil).toBeCalled(); + vi.advanceTimersToNextTimer().runAllTimers(); + await Promise.all(waits); + expect(flush).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/cloudflare/test/instrumentations/worker/instrumentQueue.test.ts b/packages/cloudflare/test/instrumentations/worker/instrumentQueue.test.ts new file mode 100644 index 000000000000..a38a8f24d79e --- /dev/null +++ b/packages/cloudflare/test/instrumentations/worker/instrumentQueue.test.ts @@ -0,0 +1,302 @@ +// Note: These tests run the handler in Node.js, which has some differences to the cloudflare workers runtime. +// Although this is not ideal, this is the best we can do until we have a better way to test cloudflare workers. + +import type { ExecutionContext, MessageBatch } from '@cloudflare/workers-types'; +import type { Event } from '@sentry/core'; +import * as SentryCore from '@sentry/core'; +import { beforeEach, describe, expect, onTestFinished, test, vi } from 'vitest'; +import { CloudflareClient } from '../../../src/client'; +import { withSentry } from '../../../src/withSentry'; + +const MOCK_ENV = { + SENTRY_DSN: 'https://public@dsn.ingest.sentry.io/1337', + SENTRY_RELEASE: '1.1.1', +}; + +const MOCK_ENV_WITHOUT_DSN = { + SENTRY_RELEASE: '1.1.1', +}; + +function createMockExecutionContext(): ExecutionContext { + return { + waitUntil: vi.fn(), + passThroughOnException: vi.fn(), + }; +} + +function createMockQueueBatch(): MessageBatch { + return { + queue: 'test-queue', + messages: [ + { + id: '1', + timestamp: new Date(), + body: 'test message 1', + attempts: 1, + retry: vi.fn(), + ack: vi.fn(), + }, + { + id: '2', + timestamp: new Date(), + body: 'test message 2', + attempts: 2, + retry: vi.fn(), + ack: vi.fn(), + }, + ], + retryAll: vi.fn(), + ackAll: vi.fn(), + }; +} + +function addDelayedWaitUntil(context: ExecutionContext) { + context.waitUntil(new Promise(resolve => setTimeout(() => resolve()))); +} + +describe('instrumentQueue', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('executes options callback with env', async () => { + const handler = { + queue(_batch, _env, _context) { + return; + }, + } satisfies ExportedHandler; + + const optionsCallback = vi.fn().mockReturnValue({}); + + const wrappedHandler = withSentry(optionsCallback, handler); + await wrappedHandler.queue?.(createMockQueueBatch(), MOCK_ENV, createMockExecutionContext()); + + expect(optionsCallback).toHaveBeenCalledTimes(1); + expect(optionsCallback).toHaveBeenLastCalledWith(MOCK_ENV); + }); + + test('merges options from env and callback', async () => { + const handler = { + queue(_batch, _env, _context) { + SentryCore.captureMessage('cloud_resource'); + return; + }, + } satisfies ExportedHandler; + + let sentryEvent: Event = {}; + const wrappedHandler = withSentry( + env => ({ + dsn: env.SENTRY_DSN, + beforeSend(event) { + sentryEvent = event; + return null; + }, + }), + handler, + ); + await wrappedHandler.queue?.(createMockQueueBatch(), MOCK_ENV, createMockExecutionContext()); + + expect(sentryEvent.release).toBe('1.1.1'); + }); + + test('callback options take precedence over env options', async () => { + const handler = { + queue(_batch, _env, _context) { + SentryCore.captureMessage('cloud_resource'); + return; + }, + } satisfies ExportedHandler; + + let sentryEvent: Event = {}; + const wrappedHandler = withSentry( + env => ({ + dsn: env.SENTRY_DSN, + release: '2.0.0', + beforeSend(event) { + sentryEvent = event; + return null; + }, + }), + handler, + ); + await wrappedHandler.queue?.(createMockQueueBatch(), MOCK_ENV, createMockExecutionContext()); + + expect(sentryEvent.release).toEqual('2.0.0'); + }); + + test('flushes the event after the handler is done using the cloudflare context.waitUntil', async () => { + const handler = { + queue(_batch, _env, _context) { + return; + }, + } satisfies ExportedHandler; + + const context = createMockExecutionContext(); + const waitUntilSpy = vi.spyOn(context, 'waitUntil'); + const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN }), handler); + await wrappedHandler.queue?.(createMockQueueBatch(), MOCK_ENV, context); + + expect(waitUntilSpy).toHaveBeenCalledTimes(1); + expect(waitUntilSpy).toHaveBeenLastCalledWith(expect.any(Promise)); + }); + + test('creates a cloudflare client and sets it on the handler', async () => { + const initAndBindSpy = vi.spyOn(SentryCore, 'initAndBind'); + const handler = { + queue(_batch, _env, _context) { + return; + }, + } satisfies ExportedHandler; + + const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN }), handler); + await wrappedHandler.queue?.(createMockQueueBatch(), MOCK_ENV, createMockExecutionContext()); + + expect(initAndBindSpy).toHaveBeenCalledTimes(1); + expect(initAndBindSpy).toHaveBeenLastCalledWith(CloudflareClient, expect.any(Object)); + }); + + describe('scope instrumentation', () => { + test('adds cloud resource context', async () => { + const handler = { + queue(_batch, _env, _context) { + SentryCore.captureMessage('cloud_resource'); + return; + }, + } satisfies ExportedHandler; + + let sentryEvent: Event = {}; + const wrappedHandler = withSentry( + env => ({ + dsn: env.SENTRY_DSN, + beforeSend(event) { + sentryEvent = event; + return null; + }, + }), + handler, + ); + await wrappedHandler.queue?.(createMockQueueBatch(), MOCK_ENV, createMockExecutionContext()); + + expect(sentryEvent.contexts?.cloud_resource).toEqual({ 'cloud.provider': 'cloudflare' }); + }); + }); + + describe('error instrumentation', () => { + test('captures errors thrown by the handler', async () => { + const captureExceptionSpy = vi.spyOn(SentryCore, 'captureException'); + const error = new Error('test'); + + expect(captureExceptionSpy).not.toHaveBeenCalled(); + + const handler = { + queue(_batch, _env, _context) { + throw error; + }, + } satisfies ExportedHandler; + + const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN }), handler); + try { + await wrappedHandler.queue?.(createMockQueueBatch(), MOCK_ENV, createMockExecutionContext()); + } catch { + // ignore + } + + expect(captureExceptionSpy).toHaveBeenCalledTimes(1); + expect(captureExceptionSpy).toHaveBeenLastCalledWith(error, { + mechanism: { handled: false, type: 'auto.faas.cloudflare.queue' }, + }); + }); + + test('re-throws the error after capturing', async () => { + const error = new Error('test'); + const handler = { + queue(_batch, _env, _context) { + throw error; + }, + } satisfies ExportedHandler; + + const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN }), handler); + + let thrownError: Error | undefined; + try { + await wrappedHandler.queue?.(createMockQueueBatch(), MOCK_ENV, createMockExecutionContext()); + } catch (e: any) { + thrownError = e; + } + + expect(thrownError).toBe(error); + }); + }); + + describe('tracing instrumentation', () => { + test('creates a span that wraps queue invocation with correct attributes', async () => { + const handler = { + queue(_batch, _env, _context) { + return; + }, + } satisfies ExportedHandler; + + let sentryEvent: Event = {}; + const wrappedHandler = withSentry( + env => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1, + beforeSendTransaction(event) { + sentryEvent = event; + return null; + }, + }), + handler, + ); + + const batch = createMockQueueBatch(); + await wrappedHandler.queue?.(batch, MOCK_ENV, createMockExecutionContext()); + + expect(sentryEvent.transaction).toEqual(`process ${batch.queue}`); + expect(sentryEvent.spans).toHaveLength(0); + expect(sentryEvent.contexts?.trace).toEqual({ + data: { + 'sentry.origin': 'auto.faas.cloudflare.queue', + 'sentry.op': 'queue.process', + 'faas.trigger': 'pubsub', + 'messaging.destination.name': batch.queue, + 'messaging.system': 'cloudflare', + 'messaging.batch.message_count': batch.messages.length, + 'messaging.message.retry.count': batch.messages.reduce((acc, message) => acc + message.attempts - 1, 0), + 'sentry.sample_rate': 1, + 'sentry.source': 'task', + }, + op: 'queue.process', + origin: 'auto.faas.cloudflare.queue', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }); + }); + }); + + test('flush must be called when all waitUntil are done', async () => { + const flush = vi.spyOn(SentryCore.Client.prototype, 'flush'); + vi.useFakeTimers(); + onTestFinished(() => { + vi.useRealTimers(); + }); + const handler = { + queue(_controller, _env, _context) { + addDelayedWaitUntil(_context); + return; + }, + } satisfies ExportedHandler; + + const wrappedHandler = withSentry(vi.fn(), handler); + const waits: Promise[] = []; + const waitUntil = vi.fn(promise => waits.push(promise)); + await wrappedHandler.queue?.(createMockQueueBatch(), MOCK_ENV_WITHOUT_DSN, { + waitUntil, + } as unknown as ExecutionContext); + expect(flush).not.toBeCalled(); + expect(waitUntil).toBeCalled(); + vi.advanceTimersToNextTimer().runAllTimers(); + await Promise.all(waits); + expect(flush).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/cloudflare/test/instrumentations/worker/instrumentScheduled.test.ts b/packages/cloudflare/test/instrumentations/worker/instrumentScheduled.test.ts new file mode 100644 index 000000000000..64833d70ddfb --- /dev/null +++ b/packages/cloudflare/test/instrumentations/worker/instrumentScheduled.test.ts @@ -0,0 +1,281 @@ +// Note: These tests run the handler in Node.js, which has some differences to the cloudflare workers runtime. +// Although this is not ideal, this is the best we can do until we have a better way to test cloudflare workers. + +import type { ExecutionContext, ScheduledController } from '@cloudflare/workers-types'; +import type { Event } from '@sentry/core'; +import * as SentryCore from '@sentry/core'; +import { beforeEach, describe, expect, onTestFinished, test, vi } from 'vitest'; +import { CloudflareClient } from '../../../src/client'; +import { withSentry } from '../../../src/withSentry'; + +const MOCK_ENV = { + SENTRY_DSN: 'https://public@dsn.ingest.sentry.io/1337', + SENTRY_RELEASE: '1.1.1', +}; + +const MOCK_ENV_WITHOUT_DSN = { + SENTRY_RELEASE: '1.1.1', +}; + +function createMockExecutionContext(): ExecutionContext { + return { + waitUntil: vi.fn(), + passThroughOnException: vi.fn(), + }; +} + +function createMockScheduledController(): ScheduledController { + return { + scheduledTime: 123, + cron: '0 0 0 * * *', + noRetry: vi.fn(), + }; +} + +function addDelayedWaitUntil(context: ExecutionContext) { + context.waitUntil(new Promise(resolve => setTimeout(() => resolve()))); +} + +describe('instrumentScheduled', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('executes options callback with env', async () => { + const handler = { + scheduled(_controller, _env, _context) { + return; + }, + } satisfies ExportedHandler; + + const optionsCallback = vi.fn().mockReturnValue({}); + + const wrappedHandler = withSentry(optionsCallback, handler); + await wrappedHandler.scheduled?.(createMockScheduledController(), MOCK_ENV, createMockExecutionContext()); + + expect(optionsCallback).toHaveBeenCalledTimes(1); + expect(optionsCallback).toHaveBeenLastCalledWith(MOCK_ENV); + }); + + test('merges options from env and callback', async () => { + const handler = { + scheduled(_controller, _env, _context) { + SentryCore.captureMessage('cloud_resource'); + return; + }, + } satisfies ExportedHandler; + + let sentryEvent: Event = {}; + const wrappedHandler = withSentry( + env => ({ + dsn: env.SENTRY_DSN, + beforeSend(event) { + sentryEvent = event; + return null; + }, + }), + handler, + ); + await wrappedHandler.scheduled?.(createMockScheduledController(), MOCK_ENV, createMockExecutionContext()); + + expect(sentryEvent.release).toBe('1.1.1'); + }); + + test('callback options take precedence over env options', async () => { + const handler = { + scheduled(_controller, _env, _context) { + SentryCore.captureMessage('cloud_resource'); + return; + }, + } satisfies ExportedHandler; + + let sentryEvent: Event = {}; + const wrappedHandler = withSentry( + env => ({ + dsn: env.SENTRY_DSN, + release: '2.0.0', + beforeSend(event) { + sentryEvent = event; + return null; + }, + }), + handler, + ); + await wrappedHandler.scheduled?.(createMockScheduledController(), MOCK_ENV, createMockExecutionContext()); + + expect(sentryEvent.release).toEqual('2.0.0'); + }); + + test('flushes the event after the handler is done using the cloudflare context.waitUntil', async () => { + const handler = { + scheduled(_controller, _env, _context) { + return; + }, + } satisfies ExportedHandler; + + const context = createMockExecutionContext(); + const waitUntilSpy = vi.spyOn(context, 'waitUntil'); + const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN }), handler); + await wrappedHandler.scheduled?.(createMockScheduledController(), MOCK_ENV, context); + + expect(waitUntilSpy).toHaveBeenCalledTimes(1); + expect(waitUntilSpy).toHaveBeenLastCalledWith(expect.any(Promise)); + }); + + test('creates a cloudflare client and sets it on the handler', async () => { + const initAndBindSpy = vi.spyOn(SentryCore, 'initAndBind'); + const handler = { + scheduled(_controller, _env, _context) { + return; + }, + } satisfies ExportedHandler; + + const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN }), handler); + await wrappedHandler.scheduled?.(createMockScheduledController(), MOCK_ENV, createMockExecutionContext()); + + expect(initAndBindSpy).toHaveBeenCalledTimes(1); + expect(initAndBindSpy).toHaveBeenLastCalledWith(CloudflareClient, expect.any(Object)); + }); + + describe('scope instrumentation', () => { + test('adds cloud resource context', async () => { + const handler = { + scheduled(_controller, _env, _context) { + SentryCore.captureMessage('cloud_resource'); + return; + }, + } satisfies ExportedHandler; + + let sentryEvent: Event = {}; + const wrappedHandler = withSentry( + env => ({ + dsn: env.SENTRY_DSN, + beforeSend(event) { + sentryEvent = event; + return null; + }, + }), + handler, + ); + await wrappedHandler.scheduled?.(createMockScheduledController(), MOCK_ENV, createMockExecutionContext()); + + expect(sentryEvent.contexts?.cloud_resource).toEqual({ 'cloud.provider': 'cloudflare' }); + }); + }); + + describe('error instrumentation', () => { + test('captures errors thrown by the handler', async () => { + const captureExceptionSpy = vi.spyOn(SentryCore, 'captureException'); + const error = new Error('test'); + + expect(captureExceptionSpy).not.toHaveBeenCalled(); + + const handler = { + scheduled(_controller, _env, _context) { + throw error; + }, + } satisfies ExportedHandler; + + const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN }), handler); + try { + await wrappedHandler.scheduled?.(createMockScheduledController(), MOCK_ENV, createMockExecutionContext()); + } catch { + // ignore + } + + expect(captureExceptionSpy).toHaveBeenCalledTimes(1); + expect(captureExceptionSpy).toHaveBeenLastCalledWith(error, { + mechanism: { handled: false, type: 'auto.faas.cloudflare.scheduled' }, + }); + }); + + test('re-throws the error after capturing', async () => { + const error = new Error('test'); + const handler = { + scheduled(_controller, _env, _context) { + throw error; + }, + } satisfies ExportedHandler; + + const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN }), handler); + + let thrownError: Error | undefined; + try { + await wrappedHandler.scheduled?.(createMockScheduledController(), MOCK_ENV, createMockExecutionContext()); + } catch (e: any) { + thrownError = e; + } + + expect(thrownError).toBe(error); + }); + }); + + describe('tracing instrumentation', () => { + test('creates a span that wraps scheduled invocation', async () => { + const handler = { + scheduled(_controller, _env, _context) { + return; + }, + } satisfies ExportedHandler; + + let sentryEvent: Event = {}; + const wrappedHandler = withSentry( + env => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1, + beforeSendTransaction(event) { + sentryEvent = event; + return null; + }, + }), + handler, + ); + + await wrappedHandler.scheduled?.(createMockScheduledController(), MOCK_ENV, createMockExecutionContext()); + + expect(sentryEvent.transaction).toEqual('Scheduled Cron 0 0 0 * * *'); + expect(sentryEvent.spans).toHaveLength(0); + expect(sentryEvent.contexts?.trace).toEqual({ + data: { + 'sentry.origin': 'auto.faas.cloudflare.scheduled', + 'sentry.op': 'faas.cron', + 'faas.cron': '0 0 0 * * *', + 'faas.time': expect.any(String), + 'faas.trigger': 'timer', + 'sentry.sample_rate': 1, + 'sentry.source': 'task', + }, + op: 'faas.cron', + origin: 'auto.faas.cloudflare.scheduled', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }); + }); + }); + + test('flush must be called when all waitUntil are done', async () => { + const flush = vi.spyOn(SentryCore.Client.prototype, 'flush'); + vi.useFakeTimers(); + onTestFinished(() => { + vi.useRealTimers(); + }); + const handler = { + scheduled(_controller, _env, _context) { + addDelayedWaitUntil(_context); + return; + }, + } satisfies ExportedHandler; + + const wrappedHandler = withSentry(vi.fn(), handler); + const waits: Promise[] = []; + const waitUntil = vi.fn(promise => waits.push(promise)); + await wrappedHandler.scheduled?.(createMockScheduledController(), MOCK_ENV_WITHOUT_DSN, { + waitUntil, + } as unknown as ExecutionContext); + expect(flush).not.toBeCalled(); + expect(waitUntil).toBeCalled(); + vi.advanceTimersToNextTimer().runAllTimers(); + await Promise.all(waits); + expect(flush).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/cloudflare/test/instrumentations/worker/instrumentTail.test.ts b/packages/cloudflare/test/instrumentations/worker/instrumentTail.test.ts new file mode 100644 index 000000000000..f85507e2c734 --- /dev/null +++ b/packages/cloudflare/test/instrumentations/worker/instrumentTail.test.ts @@ -0,0 +1,258 @@ +// Note: These tests run the handler in Node.js, which has some differences to the cloudflare workers runtime. +// Although this is not ideal, this is the best we can do until we have a better way to test cloudflare workers. + +import type { ExecutionContext, TraceItem } from '@cloudflare/workers-types'; +import type { Event } from '@sentry/core'; +import * as SentryCore from '@sentry/core'; +import { beforeEach, describe, expect, onTestFinished, test, vi } from 'vitest'; +import { CloudflareClient } from '../../../src/client'; +import { withSentry } from '../../../src/withSentry'; + +const MOCK_ENV = { + SENTRY_DSN: 'https://public@dsn.ingest.sentry.io/1337', + SENTRY_RELEASE: '1.1.1', +}; + +const MOCK_ENV_WITHOUT_DSN = { + SENTRY_RELEASE: '1.1.1', +}; + +function createMockExecutionContext(): ExecutionContext { + return { + waitUntil: vi.fn(), + passThroughOnException: vi.fn(), + }; +} + +function createMockTailEvent(): TraceItem[] { + return [ + { + event: { + consumedEvents: [ + { + scriptName: 'test-script', + }, + ], + }, + eventTimestamp: Date.now(), + logs: [ + { + timestamp: Date.now(), + level: 'info', + message: 'Test log message', + }, + ], + exceptions: [], + diagnosticsChannelEvents: [], + scriptName: 'test-script', + outcome: 'ok', + truncated: false, + }, + ]; +} + +function addDelayedWaitUntil(context: ExecutionContext) { + context.waitUntil(new Promise(resolve => setTimeout(() => resolve()))); +} + +describe('instrumentTail', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('executes options callback with env', async () => { + const handler = { + tail(_event, _env, _context) { + return; + }, + } satisfies ExportedHandler; + + const optionsCallback = vi.fn().mockReturnValue({}); + + const wrappedHandler = withSentry(optionsCallback, handler); + await wrappedHandler.tail?.(createMockTailEvent(), MOCK_ENV, createMockExecutionContext()); + + expect(optionsCallback).toHaveBeenCalledTimes(1); + expect(optionsCallback).toHaveBeenLastCalledWith(MOCK_ENV); + }); + + test('merges options from env and callback', async () => { + const handler = { + tail(_event, _env, _context) { + SentryCore.captureMessage('cloud_resource'); + return; + }, + } satisfies ExportedHandler; + + let sentryEvent: Event = {}; + const wrappedHandler = withSentry( + env => ({ + dsn: env.SENTRY_DSN, + beforeSend(event) { + sentryEvent = event; + return null; + }, + }), + handler, + ); + await wrappedHandler.tail?.(createMockTailEvent(), MOCK_ENV, createMockExecutionContext()); + + expect(sentryEvent.release).toBe('1.1.1'); + }); + + test('callback options take precedence over env options', async () => { + const handler = { + tail(_event, _env, _context) { + SentryCore.captureMessage('cloud_resource'); + return; + }, + } satisfies ExportedHandler; + + let sentryEvent: Event = {}; + const wrappedHandler = withSentry( + env => ({ + dsn: env.SENTRY_DSN, + release: '2.0.0', + beforeSend(event) { + sentryEvent = event; + return null; + }, + }), + handler, + ); + await wrappedHandler.tail?.(createMockTailEvent(), MOCK_ENV, createMockExecutionContext()); + + expect(sentryEvent.release).toEqual('2.0.0'); + }); + + test('flushes the event after the handler is done using the cloudflare context.waitUntil', async () => { + const handler = { + tail(_event, _env, _context) { + return; + }, + } satisfies ExportedHandler; + + const context = createMockExecutionContext(); + const waitUntilSpy = vi.spyOn(context, 'waitUntil'); + const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN }), handler); + await wrappedHandler.tail?.(createMockTailEvent(), MOCK_ENV, context); + + expect(waitUntilSpy).toHaveBeenCalledTimes(1); + expect(waitUntilSpy).toHaveBeenLastCalledWith(expect.any(Promise)); + }); + + test('creates a cloudflare client and sets it on the handler', async () => { + const initAndBindSpy = vi.spyOn(SentryCore, 'initAndBind'); + const handler = { + tail(_event, _env, _context) { + return; + }, + } satisfies ExportedHandler; + + const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN }), handler); + await wrappedHandler.tail?.(createMockTailEvent(), MOCK_ENV, createMockExecutionContext()); + + expect(initAndBindSpy).toHaveBeenCalledTimes(1); + expect(initAndBindSpy).toHaveBeenLastCalledWith(CloudflareClient, expect.any(Object)); + }); + + describe('scope instrumentation', () => { + test('adds cloud resource context', async () => { + const handler = { + tail(_event, _env, _context) { + SentryCore.captureMessage('cloud_resource'); + return; + }, + } satisfies ExportedHandler; + + let sentryEvent: Event = {}; + const wrappedHandler = withSentry( + env => ({ + dsn: env.SENTRY_DSN, + beforeSend(event) { + sentryEvent = event; + return null; + }, + }), + handler, + ); + await wrappedHandler.tail?.(createMockTailEvent(), MOCK_ENV, createMockExecutionContext()); + + expect(sentryEvent.contexts?.cloud_resource).toEqual({ 'cloud.provider': 'cloudflare' }); + }); + }); + + describe('error instrumentation', () => { + test('captures errors thrown by the handler', async () => { + const captureExceptionSpy = vi.spyOn(SentryCore, 'captureException'); + const error = new Error('test'); + + expect(captureExceptionSpy).not.toHaveBeenCalled(); + + const handler = { + tail(_event, _env, _context) { + throw error; + }, + } satisfies ExportedHandler; + + const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN }), handler); + try { + await wrappedHandler.tail?.(createMockTailEvent(), MOCK_ENV, createMockExecutionContext()); + } catch { + // ignore + } + + expect(captureExceptionSpy).toHaveBeenCalledTimes(1); + expect(captureExceptionSpy).toHaveBeenLastCalledWith(error, { + mechanism: { handled: false, type: 'auto.faas.cloudflare.tail' }, + }); + }); + + test('re-throws the error after capturing', async () => { + const error = new Error('test'); + const handler = { + tail(_event, _env, _context) { + throw error; + }, + } satisfies ExportedHandler; + + const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN }), handler); + + let thrownError: Error | undefined; + try { + await wrappedHandler.tail?.(createMockTailEvent(), MOCK_ENV, createMockExecutionContext()); + } catch (e: any) { + thrownError = e; + } + + expect(thrownError).toBe(error); + }); + }); + + test('flush must be called when all waitUntil are done', async () => { + const flush = vi.spyOn(SentryCore.Client.prototype, 'flush'); + vi.useFakeTimers(); + onTestFinished(() => { + vi.useRealTimers(); + flush.mockRestore(); + }); + const handler = { + tail(_controller, _env, _context) { + addDelayedWaitUntil(_context); + return; + }, + } satisfies ExportedHandler; + + const wrappedHandler = withSentry(vi.fn(), handler); + const waits: Promise[] = []; + const waitUntil = vi.fn(promise => waits.push(promise)); + await wrappedHandler.tail?.(createMockTailEvent(), MOCK_ENV_WITHOUT_DSN, { + waitUntil, + } as unknown as ExecutionContext); + expect(flush).not.toBeCalled(); + expect(waitUntil).toBeCalled(); + vi.advanceTimersToNextTimer().runAllTimers(); + await Promise.all(waits); + expect(flush).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/cloudflare/test/withSentry.test.ts b/packages/cloudflare/test/withSentry.test.ts new file mode 100644 index 000000000000..56a35efe1a06 --- /dev/null +++ b/packages/cloudflare/test/withSentry.test.ts @@ -0,0 +1,112 @@ +// Note: These tests run the handler in Node.js, which has some differences to the cloudflare workers runtime. +// Although this is not ideal, this is the best we can do until we have a better way to test cloudflare workers. + +import type { ExecutionContext } from '@cloudflare/workers-types'; +import type { Event } from '@sentry/core'; +import * as SentryCore from '@sentry/core'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { withSentry } from '../src/withSentry'; +import { markAsInstrumented } from '../src/instrument'; +import * as HonoIntegration from '../src/integrations/hono'; + +type HonoLikeApp = ExportedHandler< + Env, + QueueHandlerMessage, + CfHostMetadata +> & { + onError?: () => void; + errorHandler?: (err: Error) => Response; +}; + +const MOCK_ENV = { + SENTRY_DSN: 'https://public@dsn.ingest.sentry.io/1337', + SENTRY_RELEASE: '1.1.1', +}; + +function createMockExecutionContext(): ExecutionContext { + return { + waitUntil: vi.fn(), + passThroughOnException: vi.fn(), + }; +} + +describe('withSentry', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('hono errorHandler', () => { + test('calls Hono Integration to handle error captured by the errorHandler', async () => { + const error = new Error('test hono error'); + + const handleHonoException = vi.fn(); + vi.spyOn(HonoIntegration, 'getHonoIntegration').mockReturnValue({ handleHonoException } as any); + + const honoApp = { + fetch(_request, _env, _context) { + return new Response('test'); + }, + onError() {}, + errorHandler(err: Error) { + return new Response(`Error: ${err.message}`, { status: 500 }); + }, + } satisfies HonoLikeApp; + + withSentry(env => ({ dsn: env.SENTRY_DSN }), honoApp); + + const errorHandlerResponse = honoApp.errorHandler?.(error); + + expect(handleHonoException).toHaveBeenCalledTimes(1); + expect(handleHonoException).toHaveBeenLastCalledWith(error, undefined); + expect(errorHandlerResponse?.status).toBe(500); + }); + + test('preserves the original errorHandler functionality', async () => { + const originalErrorHandlerSpy = vi.fn().mockImplementation((err: Error) => { + return new Response(`Error: ${err.message}`, { status: 500 }); + }); + + const error = new Error('test hono error'); + + const honoApp = { + fetch(_request, _env, _context) { + return new Response('test'); + }, + onError() {}, + errorHandler: originalErrorHandlerSpy, + } satisfies HonoLikeApp; + + withSentry(env => ({ dsn: env.SENTRY_DSN }), honoApp); + + const errorHandlerResponse = honoApp.errorHandler?.(error); + + expect(originalErrorHandlerSpy).toHaveBeenCalledTimes(1); + expect(originalErrorHandlerSpy).toHaveBeenLastCalledWith(error); + expect(errorHandlerResponse?.status).toBe(500); + }); + + test('does not instrument an already instrumented errorHandler', async () => { + const captureExceptionSpy = vi.spyOn(SentryCore, 'captureException'); + const error = new Error('test hono error'); + + const originalErrorHandler = (err: Error) => { + return new Response(`Error: ${err.message}`, { status: 500 }); + }; + + markAsInstrumented(originalErrorHandler); + + const honoApp = { + fetch(_request, _env, _context) { + return new Response('test'); + }, + onError() {}, + errorHandler: originalErrorHandler, + } satisfies HonoLikeApp; + + withSentry(env => ({ dsn: env.SENTRY_DSN }), honoApp); + + honoApp.errorHandler?.(error); + expect(captureExceptionSpy).not.toHaveBeenCalled(); + }); + }); +});