diff --git a/package.json b/package.json index 3d2a137ab650..b6be35869054 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "postpublish": "lerna run --stream --concurrency 1 postpublish", "test": "lerna run --ignore \"@sentry-internal/{browser-integration-tests,e2e-tests,integration-shims,node-integration-tests,overhead-metrics}\" test", "test:unit": "lerna run --ignore \"@sentry-internal/{browser-integration-tests,e2e-tests,integration-shims,node-integration-tests,overhead-metrics}\" test:unit", - "test-ci-browser": "lerna run test --ignore \"@sentry/{bun,deno,node,profiling-node,serverless,google-cloud,nextjs,remix,gatsby,sveltekit,vercel-edge}\" --ignore \"@sentry-internal/{browser-integration-tests,e2e-tests,integration-shims,node-integration-tests,overhead-metrics}\"", + "test-ci-browser": "lerna run test --ignore \"@sentry/{bun,cloudflare,deno,gatsby,google-cloud,nextjs,node,profiling-node,remix,serverless,sveltekit,vercel-edge}\" --ignore \"@sentry-internal/{browser-integration-tests,e2e-tests,integration-shims,node-integration-tests,overhead-metrics}\"", "test-ci-node": "ts-node ./scripts/node-unit-tests.ts", "test-ci-bun": "lerna run test --scope @sentry/bun", "test:update-snapshots": "lerna run test:update-snapshots", diff --git a/packages/cloudflare/README.md b/packages/cloudflare/README.md index 37f0cd94f412..7bb4984178b6 100644 --- a/packages/cloudflare/README.md +++ b/packages/cloudflare/README.md @@ -97,3 +97,47 @@ Sentry.captureEvent({ ], }); ``` + +## Cron Monitoring (Cloudflare Workers) + +[Sentry Crons](https://docs.sentry.io/product/crons/) allows you to monitor the uptime and performance of any scheduled, +recurring job in your application. + +To instrument your cron triggers, use the `Sentry.withMonitor` API in your +[`Scheduled` handler](https://developers.cloudflare.com/workers/runtime-apis/handlers/scheduled/). + +```js +export default { + async scheduled(event, env, ctx) { + Sentry.withMonitor('your-cron-name', () => { + ctx.waitUntil(doSomeTaskOnASchedule()); + }); + }, +}; +``` + +You can also use supply a monitor config to upsert cron monitors with additional metadata: + +```js +const monitorConfig = { + schedule: { + type: 'crontab', + value: '* * * * *', + }, + checkinMargin: 2, // In minutes. Optional. + maxRuntime: 10, // In minutes. Optional. + timezone: 'America/Los_Angeles', // Optional. +}; + +export default { + async scheduled(event, env, ctx) { + Sentry.withMonitor( + 'your-cron-name', + () => { + ctx.waitUntil(doSomeTaskOnASchedule()); + }, + monitorConfig, + ); + }, +}; +``` diff --git a/packages/cloudflare/src/handler.ts b/packages/cloudflare/src/handler.ts index 45eca78f9946..8418915ee884 100644 --- a/packages/cloudflare/src/handler.ts +++ b/packages/cloudflare/src/handler.ts @@ -42,6 +42,33 @@ export function withSentry>( ): E { setAsyncLocalStorageAsyncContextStrategy(); + instrumentFetchOnHandler(optionsCallback, handler); + instrumentScheduledOnHandler(optionsCallback, handler); + + return handler; +} + +function addCloudResourceContext(isolationScope: Scope): void { + isolationScope.setContext('cloud_resource', { + 'cloud.provider': 'cloudflare', + }); +} + +function addCultureContext(isolationScope: Scope, cf: IncomingRequestCfProperties): void { + isolationScope.setContext('culture', { + timezone: cf.timezone, + }); +} + +function addRequest(isolationScope: Scope, request: Request): void { + isolationScope.setSDKProcessingMetadata({ request: winterCGRequestToRequestData(request) }); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function instrumentFetchOnHandler>( + optionsCallback: (env: ExtractEnv) => Options, + handler: E, +): void { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any if ('fetch' in handler && typeof handler.fetch === 'function' && !(handler.fetch as any).__SENTRY_INSTRUMENTED__) { handler.fetch = new Proxy(handler.fetch, { @@ -117,22 +144,42 @@ export function withSentry>( // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any (handler.fetch as any).__SENTRY_INSTRUMENTED__ = true; } - - return handler; } -function addCloudResourceContext(isolationScope: Scope): void { - isolationScope.setContext('cloud_resource', { - 'cloud.provider': 'cloudflare', - }); -} +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function instrumentScheduledOnHandler>( + optionsCallback: (env: ExtractEnv) => Options, + handler: E, +): void { + if ( + 'scheduled' in handler && + typeof handler.scheduled === 'function' && + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any + !(handler.scheduled as any).__SENTRY_INSTRUMENTED__ + ) { + handler.scheduled = new Proxy(handler.scheduled, { + apply(target, thisArg, args: Parameters>>) { + const [, env, context] = args; + return withIsolationScope(async isolationScope => { + const options = optionsCallback(env); + const client = init(options); + isolationScope.setClient(client); -function addCultureContext(isolationScope: Scope, cf: IncomingRequestCfProperties): void { - isolationScope.setContext('culture', { - timezone: cf.timezone, - }); -} + addCloudResourceContext(isolationScope); -function addRequest(isolationScope: Scope, request: Request): void { - isolationScope.setSDKProcessingMetadata({ request: winterCGRequestToRequestData(request) }); + try { + return await (target.apply(thisArg, args) as ReturnType); + } catch (e) { + captureException(e, { mechanism: { handled: false, type: 'cloudflare' } }); + throw e; + } finally { + context.waitUntil(flush(2000)); + } + }); + }, + }); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any + (handler.scheduled as any).__SENTRY_INSTRUMENTED__ = true; + } } diff --git a/packages/cloudflare/src/sdk.ts b/packages/cloudflare/src/sdk.ts index edc242656195..7ad54fcb8171 100644 --- a/packages/cloudflare/src/sdk.ts +++ b/packages/cloudflare/src/sdk.ts @@ -7,9 +7,9 @@ import { linkedErrorsIntegration, requestDataIntegration, } from '@sentry/core'; -import type { Integration, Options } from '@sentry/types'; +import type { Integration } from '@sentry/types'; import { stackParserFromStackParserOptions } from '@sentry/utils'; -import type { CloudflareClientOptions } from './client'; +import type { CloudflareClientOptions, CloudflareOptions } from './client'; import { CloudflareClient } from './client'; import { fetchIntegration } from './integrations/fetch'; @@ -17,7 +17,7 @@ import { makeCloudflareTransport } from './transport'; import { defaultStackParser } from './vendor/stacktrace'; /** Get the default integrations for the Cloudflare SDK. */ -export function getDefaultIntegrations(_options: Options): Integration[] { +export function getDefaultIntegrations(_options: CloudflareOptions): Integration[] { return [ dedupeIntegration(), inboundFiltersIntegration(), @@ -31,7 +31,7 @@ export function getDefaultIntegrations(_options: Options): Integration[] { /** * Initializes the cloudflare SDK. */ -export function init(options: Options): CloudflareClient | undefined { +export function init(options: CloudflareOptions): CloudflareClient | undefined { if (options.defaultIntegrations === undefined) { options.defaultIntegrations = getDefaultIntegrations(options); } diff --git a/packages/cloudflare/test/handler.test.ts b/packages/cloudflare/test/handler.test.ts index e8358dd63f50..5397c7150c0c 100644 --- a/packages/cloudflare/test/handler.test.ts +++ b/packages/cloudflare/test/handler.test.ts @@ -12,7 +12,7 @@ const MOCK_ENV = { SENTRY_DSN: 'https://public@dsn.ingest.sentry.io/1337', }; -describe('withSentry', () => { +describe('withSentry fetch handler', () => { beforeEach(() => { vi.clearAllMocks(); }); @@ -76,9 +76,8 @@ describe('withSentry', () => { }, } satisfies ExportedHandler; - const context = createMockExecutionContext(); const wrappedHandler = withSentry(() => ({}), handler); - await wrappedHandler.fetch(new Request('https://example.com'), MOCK_ENV, context); + await wrappedHandler.fetch(new Request('https://example.com'), MOCK_ENV, createMockExecutionContext()); expect(initAndBindSpy).toHaveBeenCalledTimes(1); expect(initAndBindSpy).toHaveBeenLastCalledWith(CloudflareClient, expect.any(Object)); @@ -295,6 +294,87 @@ describe('withSentry', () => { }); }); +describe('withSentry scheduled handler', () => { + const MOCK_SCHEDULED_CONTROLLER: ScheduledController = { + scheduledTime: 123, + cron: '0 0 * * *', + noRetry: vi.fn(), + }; + + test('gets env from handler', async () => { + const handler = { + scheduled(_controller, _env, context) { + context.waitUntil(Promise.resolve()); + }, + } satisfies ExportedHandler; + + const optionsCallback = vi.fn().mockReturnValue({}); + + const wrappedHandler = withSentry(optionsCallback, handler); + await wrappedHandler.scheduled(MOCK_SCHEDULED_CONTROLLER, 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) { + // empty + }, + } satisfies ExportedHandler; + + const context = createMockExecutionContext(); + const wrappedHandler = withSentry(() => ({}), handler); + await wrappedHandler.scheduled(MOCK_SCHEDULED_CONTROLLER, 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) { + context.waitUntil(Promise.resolve()); + }, + } satisfies ExportedHandler; + + const wrappedHandler = withSentry(() => ({}), handler); + await wrappedHandler.scheduled(MOCK_SCHEDULED_CONTROLLER, 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('test'); + context.waitUntil(Promise.resolve()); + }, + } satisfies ExportedHandler; + + let sentryEvent: Event = {}; + const wrappedHandler = withSentry( + (env: any) => ({ + dsn: env.MOCK_DSN, + beforeSend(event) { + sentryEvent = event; + return null; + }, + }), + handler, + ); + await wrappedHandler.scheduled(MOCK_SCHEDULED_CONTROLLER, MOCK_ENV, createMockExecutionContext()); + expect(sentryEvent.contexts?.cloud_resource).toEqual({ 'cloud.provider': 'cloudflare' }); + }); + }); +}); + function createMockExecutionContext(): ExecutionContext { return { waitUntil: vi.fn(),