Skip to content

Commit 20adfb9

Browse files
committed
feat(cloudflare): instrument scheduled handler
1 parent e3af1ce commit 20adfb9

File tree

4 files changed

+190
-21
lines changed

4 files changed

+190
-21
lines changed

Diff for: packages/cloudflare/README.md

+42
Original file line numberDiff line numberDiff line change
@@ -97,3 +97,45 @@ Sentry.captureEvent({
9797
],
9898
});
9999
```
100+
101+
## Cron Monitoring (Cloudflare Workers)
102+
103+
[Sentry Crons](https://docs.sentry.io/product/crons/) allows you to monitor the uptime and performance of any scheduled, recurring job in your application.
104+
105+
To instrument your cron triggers, use the `Sentry.withMonitor` API in your [`Scheduled` handler](https://developers.cloudflare.com/workers/runtime-apis/handlers/scheduled/).
106+
107+
```js
108+
export default {
109+
async scheduled(event, env, ctx) {
110+
Sentry.withMonitor('your-cron-name', () => {
111+
ctx.waitUntil(doSomeTaskOnASchedule());
112+
});
113+
},
114+
};
115+
```
116+
117+
You can also use supply a monitor config to upsert cron monitors with additional metadata:
118+
119+
```js
120+
const monitorConfig = {
121+
schedule: {
122+
type: "crontab",
123+
value: "* * * * *",
124+
},
125+
checkinMargin: 2, // In minutes. Optional.
126+
maxRuntime: 10, // In minutes. Optional.
127+
timezone: "America/Los_Angeles", // Optional.
128+
};
129+
130+
export default {
131+
async scheduled(event, env, ctx) {
132+
Sentry.withMonitor(
133+
'your-cron-name',
134+
() => {
135+
ctx.waitUntil(doSomeTaskOnASchedule());
136+
},
137+
monitorConfig
138+
);
139+
},
140+
};
141+
```

Diff for: packages/cloudflare/src/handler.ts

+61-14
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,33 @@ export function withSentry<E extends ExportedHandler<any>>(
4242
): E {
4343
setAsyncLocalStorageAsyncContextStrategy();
4444

45+
instrumentFetchOnHandler(optionsCallback, handler);
46+
instrumentScheduledOnHandler(optionsCallback, handler);
47+
48+
return handler;
49+
}
50+
51+
function addCloudResourceContext(isolationScope: Scope): void {
52+
isolationScope.setContext('cloud_resource', {
53+
'cloud.provider': 'cloudflare',
54+
});
55+
}
56+
57+
function addCultureContext(isolationScope: Scope, cf: IncomingRequestCfProperties): void {
58+
isolationScope.setContext('culture', {
59+
timezone: cf.timezone,
60+
});
61+
}
62+
63+
function addRequest(isolationScope: Scope, request: Request): void {
64+
isolationScope.setSDKProcessingMetadata({ request: winterCGRequestToRequestData(request) });
65+
}
66+
67+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
68+
function instrumentFetchOnHandler<E extends ExportedHandler<any>>(
69+
optionsCallback: (env: ExtractEnv<E>) => Options,
70+
handler: E,
71+
): void {
4572
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
4673
if ('fetch' in handler && typeof handler.fetch === 'function' && !(handler.fetch as any).__SENTRY_INSTRUMENTED__) {
4774
handler.fetch = new Proxy(handler.fetch, {
@@ -117,22 +144,42 @@ export function withSentry<E extends ExportedHandler<any>>(
117144
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
118145
(handler.fetch as any).__SENTRY_INSTRUMENTED__ = true;
119146
}
120-
121-
return handler;
122147
}
123148

124-
function addCloudResourceContext(isolationScope: Scope): void {
125-
isolationScope.setContext('cloud_resource', {
126-
'cloud.provider': 'cloudflare',
127-
});
128-
}
149+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
150+
function instrumentScheduledOnHandler<E extends ExportedHandler<any>>(
151+
optionsCallback: (env: ExtractEnv<E>) => Options,
152+
handler: E,
153+
): void {
154+
if (
155+
'scheduled' in handler &&
156+
typeof handler.scheduled === 'function' &&
157+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
158+
!(handler.scheduled as any).__SENTRY_INSTRUMENTED__
159+
) {
160+
handler.scheduled = new Proxy(handler.scheduled, {
161+
apply(target, thisArg, args: Parameters<ExportedHandlerScheduledHandler<ExtractEnv<E>>>) {
162+
const [, env, context] = args;
163+
return withIsolationScope(async isolationScope => {
164+
const options = optionsCallback(env);
165+
const client = init(options);
166+
isolationScope.setClient(client);
129167

130-
function addCultureContext(isolationScope: Scope, cf: IncomingRequestCfProperties): void {
131-
isolationScope.setContext('culture', {
132-
timezone: cf.timezone,
133-
});
134-
}
168+
addCloudResourceContext(isolationScope);
135169

136-
function addRequest(isolationScope: Scope, request: Request): void {
137-
isolationScope.setSDKProcessingMetadata({ request: winterCGRequestToRequestData(request) });
170+
try {
171+
return await (target.apply(thisArg, args) as ReturnType<typeof target>);
172+
} catch (e) {
173+
captureException(e, { mechanism: { handled: false, type: 'cloudflare' } });
174+
throw e;
175+
} finally {
176+
context.waitUntil(flush(2000));
177+
}
178+
});
179+
},
180+
});
181+
182+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
183+
(handler.scheduled as any).__SENTRY_INSTRUMENTED__ = true;
184+
}
138185
}

Diff for: packages/cloudflare/src/sdk.ts

+4-4
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,17 @@ import {
77
linkedErrorsIntegration,
88
requestDataIntegration,
99
} from '@sentry/core';
10-
import type { Integration, Options } from '@sentry/types';
10+
import type { Integration } from '@sentry/types';
1111
import { stackParserFromStackParserOptions } from '@sentry/utils';
12-
import type { CloudflareClientOptions } from './client';
12+
import type { CloudflareClientOptions, CloudflareOptions } from './client';
1313
import { CloudflareClient } from './client';
1414

1515
import { fetchIntegration } from './integrations/fetch';
1616
import { makeCloudflareTransport } from './transport';
1717
import { defaultStackParser } from './vendor/stacktrace';
1818

1919
/** Get the default integrations for the Cloudflare SDK. */
20-
export function getDefaultIntegrations(_options: Options): Integration[] {
20+
export function getDefaultIntegrations(_options: CloudflareOptions): Integration[] {
2121
return [
2222
dedupeIntegration(),
2323
inboundFiltersIntegration(),
@@ -31,7 +31,7 @@ export function getDefaultIntegrations(_options: Options): Integration[] {
3131
/**
3232
* Initializes the cloudflare SDK.
3333
*/
34-
export function init(options: Options): CloudflareClient | undefined {
34+
export function init(options: CloudflareOptions): CloudflareClient | undefined {
3535
if (options.defaultIntegrations === undefined) {
3636
options.defaultIntegrations = getDefaultIntegrations(options);
3737
}

Diff for: packages/cloudflare/test/handler.test.ts

+83-3
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ const MOCK_ENV = {
1212
SENTRY_DSN: 'https://[email protected]/1337',
1313
};
1414

15-
describe('withSentry', () => {
15+
describe('withSentry fetch handler', () => {
1616
beforeEach(() => {
1717
vi.clearAllMocks();
1818
});
@@ -76,9 +76,8 @@ describe('withSentry', () => {
7676
},
7777
} satisfies ExportedHandler;
7878

79-
const context = createMockExecutionContext();
8079
const wrappedHandler = withSentry(() => ({}), handler);
81-
await wrappedHandler.fetch(new Request('https://example.com'), MOCK_ENV, context);
80+
await wrappedHandler.fetch(new Request('https://example.com'), MOCK_ENV, createMockExecutionContext());
8281

8382
expect(initAndBindSpy).toHaveBeenCalledTimes(1);
8483
expect(initAndBindSpy).toHaveBeenLastCalledWith(CloudflareClient, expect.any(Object));
@@ -295,6 +294,87 @@ describe('withSentry', () => {
295294
});
296295
});
297296

297+
describe('withSentry scheduled handler', () => {
298+
const MOCK_SCHEDULED_CONTROLLER: ScheduledController = {
299+
scheduledTime: 123,
300+
cron: '0 0 * * *',
301+
noRetry: vi.fn(),
302+
};
303+
304+
test('gets env from handler', async () => {
305+
const handler = {
306+
scheduled(_controller, _env, context) {
307+
context.waitUntil(Promise.resolve());
308+
},
309+
} satisfies ExportedHandler;
310+
311+
const optionsCallback = vi.fn().mockReturnValue({});
312+
313+
const wrappedHandler = withSentry(optionsCallback, handler);
314+
await wrappedHandler.scheduled(MOCK_SCHEDULED_CONTROLLER, MOCK_ENV, createMockExecutionContext());
315+
316+
expect(optionsCallback).toHaveBeenCalledTimes(1);
317+
expect(optionsCallback).toHaveBeenLastCalledWith(MOCK_ENV);
318+
});
319+
320+
test('flushes the event after the handler is done using the cloudflare context.waitUntil', async () => {
321+
const handler = {
322+
scheduled(_controller, _env, context) {
323+
context.waitUntil(Promise.resolve());
324+
},
325+
} satisfies ExportedHandler;
326+
327+
const context = createMockExecutionContext();
328+
const wrappedHandler = withSentry(() => ({}), handler);
329+
await wrappedHandler.scheduled(MOCK_SCHEDULED_CONTROLLER, MOCK_ENV, context);
330+
331+
// eslint-disable-next-line @typescript-eslint/unbound-method
332+
expect(context.waitUntil).toHaveBeenCalledTimes(1);
333+
// eslint-disable-next-line @typescript-eslint/unbound-method
334+
expect(context.waitUntil).toHaveBeenLastCalledWith(expect.any(Promise));
335+
});
336+
337+
test('creates a cloudflare client and sets it on the handler', async () => {
338+
const initAndBindSpy = vi.spyOn(SentryCore, 'initAndBind');
339+
const handler = {
340+
scheduled(_controller, _env, context) {
341+
context.waitUntil(Promise.resolve());
342+
},
343+
} satisfies ExportedHandler;
344+
345+
const wrappedHandler = withSentry(() => ({}), handler);
346+
await wrappedHandler.scheduled(MOCK_SCHEDULED_CONTROLLER, MOCK_ENV, createMockExecutionContext());
347+
348+
expect(initAndBindSpy).toHaveBeenCalledTimes(1);
349+
expect(initAndBindSpy).toHaveBeenLastCalledWith(CloudflareClient, expect.any(Object));
350+
});
351+
352+
describe('scope instrumentation', () => {
353+
test('adds cloud resource context', async () => {
354+
const handler = {
355+
scheduled(_controller, _env, context) {
356+
SentryCore.captureMessage('test');
357+
context.waitUntil(Promise.resolve());
358+
},
359+
} satisfies ExportedHandler;
360+
361+
let sentryEvent: Event = {};
362+
const wrappedHandler = withSentry(
363+
(env: any) => ({
364+
dsn: env.MOCK_DSN,
365+
beforeSend(event) {
366+
sentryEvent = event;
367+
return null;
368+
},
369+
}),
370+
handler,
371+
);
372+
await wrappedHandler.scheduled(MOCK_SCHEDULED_CONTROLLER, MOCK_ENV, createMockExecutionContext());
373+
expect(sentryEvent.contexts?.cloud_resource).toEqual({ 'cloud.provider': 'cloudflare' });
374+
});
375+
});
376+
});
377+
298378
function createMockExecutionContext(): ExecutionContext {
299379
return {
300380
waitUntil: vi.fn(),

0 commit comments

Comments
 (0)