Skip to content

Commit

Permalink
feat(cloudflare): instrument scheduled handler
Browse files Browse the repository at this point in the history
  • Loading branch information
AbhiPrasad committed Jul 30, 2024
1 parent e3af1ce commit 1482a37
Show file tree
Hide file tree
Showing 5 changed files with 193 additions and 22 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
44 changes: 44 additions & 0 deletions packages/cloudflare/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
},
};
```
75 changes: 61 additions & 14 deletions packages/cloudflare/src/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,33 @@ export function withSentry<E extends ExportedHandler<any>>(
): 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<E extends ExportedHandler<any>>(
optionsCallback: (env: ExtractEnv<E>) => 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, {
Expand Down Expand Up @@ -117,22 +144,42 @@ export function withSentry<E extends ExportedHandler<any>>(
// 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<E extends ExportedHandler<any>>(
optionsCallback: (env: ExtractEnv<E>) => 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<ExportedHandlerScheduledHandler<ExtractEnv<E>>>) {
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<typeof target>);
} 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;
}
}
8 changes: 4 additions & 4 deletions packages/cloudflare/src/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,17 @@ 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';
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(),
Expand All @@ -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);
}
Expand Down
86 changes: 83 additions & 3 deletions packages/cloudflare/test/handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const MOCK_ENV = {
SENTRY_DSN: 'https://[email protected]/1337',
};

describe('withSentry', () => {
describe('withSentry fetch handler', () => {
beforeEach(() => {
vi.clearAllMocks();
});
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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(),
Expand Down

0 comments on commit 1482a37

Please sign in to comment.