diff --git a/packages/cloudflare/src/handler.ts b/packages/cloudflare/src/handler.ts index 1533e902638a..51260f01d755 100644 --- a/packages/cloudflare/src/handler.ts +++ b/packages/cloudflare/src/handler.ts @@ -3,7 +3,14 @@ import type { ExportedHandlerFetchHandler, ExportedHandlerScheduledHandler, } from '@cloudflare/workers-types'; -import { captureException, flush, startSpan, withIsolationScope } from '@sentry/core'; +import { + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + captureException, + flush, + startSpan, + withIsolationScope, +} from '@sentry/core'; import { setAsyncLocalStorageAsyncContextStrategy } from './async'; import type { CloudflareOptions } from './client'; import { wrapRequestHandler } from './request'; @@ -71,6 +78,8 @@ export function withSentry>( 'faas.cron': event.cron, 'faas.time': new Date(event.scheduledTime).toISOString(), 'faas.trigger': 'timer', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.faas.cloudflare', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'task', }, }, async () => { diff --git a/packages/cloudflare/src/request.ts b/packages/cloudflare/src/request.ts index c6cfaf8d8bbf..560c17afb9e7 100644 --- a/packages/cloudflare/src/request.ts +++ b/packages/cloudflare/src/request.ts @@ -1,4 +1,4 @@ -import type { ExecutionContext, IncomingRequestCfProperties, Request, Response } from '@cloudflare/workers-types'; +import type { ExecutionContext, IncomingRequestCfProperties } from '@cloudflare/workers-types'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, diff --git a/packages/cloudflare/test/handler.test.ts b/packages/cloudflare/test/handler.test.ts index 238fbd987c90..861360c7906f 100644 --- a/packages/cloudflare/test/handler.test.ts +++ b/packages/cloudflare/test/handler.test.ts @@ -3,49 +3,221 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; +import type { ScheduledController } from '@cloudflare/workers-types'; +import * as SentryCore from '@sentry/core'; +import type { Event } from '@sentry/types'; +import { CloudflareClient } from '../src/client'; import { withSentry } from '../src/handler'; const MOCK_ENV = { SENTRY_DSN: 'https://public@dsn.ingest.sentry.io/1337', }; -describe('sentryPagesPlugin', () => { +describe('withSentry', () => { beforeEach(() => { vi.clearAllMocks(); }); - test('gets env from handler', async () => { - const handler = { - fetch(_request, _env, _context) { - return new Response('test'); - }, - } satisfies ExportedHandler; + 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 optionsCallback = vi.fn().mockReturnValue({}); - const wrappedHandler = withSentry(optionsCallback, handler); - await wrappedHandler.fetch(new Request('https://example.com'), MOCK_ENV, createMockExecutionContext()); + 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); + 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).toBe(response); + }); }); - test('passes through the response from the handler', async () => { - const response = new Response('test'); - const handler = { - async fetch(_request, _env, _context) { - return response; - }, - } satisfies ExportedHandler; - - const wrappedHandler = withSentry(() => ({}), handler); - const result = await wrappedHandler.fetch( - new Request('https://example.com'), - MOCK_ENV, - createMockExecutionContext(), - ); - - expect(result).toBe(response); + 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('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 wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN }), handler); + await wrappedHandler.scheduled(createMockScheduledController(), MOCK_ENV, context); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(context.waitUntil).toHaveBeenCalledTimes(1); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(context.waitUntil).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: 'cloudflare' }, + }); + }); + + 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', + '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', + span_id: expect.any(String), + trace_id: expect.any(String), + }); + }); + }); }); }); @@ -55,3 +227,11 @@ function createMockExecutionContext(): ExecutionContext { passThroughOnException: vi.fn(), }; } + +function createMockScheduledController(): ScheduledController { + return { + scheduledTime: 123, + cron: '0 0 0 * * *', + noRetry: vi.fn(), + }; +}