Repository navigation
feat(cloudflare): Add opt-in cron monitoring for Cron Triggers #25014
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
wedamija
wants to merge
17
commits into
develop
Choose a base branch
from
danf/cloudflare-cron-monitors
base: develop
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
17 commits
Select commit
Hold shift + click to select a range
4c9812f
feat(cloudflare): Add opt-in cron monitoring for Cron Triggers
wedamija 804e1a9
fix(cloudflare): Convert Cron Trigger weekdays and harden check-ins
wedamija 73f550f
ref(cloudflare): Move Cron Trigger check-ins into cronTriggersIntegra…
wedamija 58f7a7e
ref(cloudflare): Shorten cronTriggersIntegration JSDoc
wedamija eb22bd1
fix(cloudflare): Keep */n weekdays and skip W/? days of month
wedamija ef0d768
fix(cloudflare): Keep cron check-in failures out of the scheduled han…
wedamija 370d891
docs(cloudflare): Use MON-FRI in the weekday cron examples
wedamija e6aa167
fix(cloudflare): Send no schedule when both day fields are set
wedamija 2abb1cf
Merge remote-tracking branch 'origin/develop' into danf/m25014-tmp
wedamija 3e70cef
test(cloudflare): Assert cron check-ins with toHaveBeenCalledWith ins…
JPeer264 e018063
fix(cloudflare): Send the schedule when both Cron Trigger day fields …
JPeer264 74a7368
fix(cloudflare): Deliver the in_progress cron check-in while the sche…
JPeer264 4be67fb
ref(cloudflare): Rename the cronTriggersIntegration slug option to mo…
JPeer264 9f08557
docs(cloudflare): Note that Workers in one project share the default …
JPeer264 dd52715
docs(cloudflare): Use the Vite plugin and a slug map in the cronTrigg…
JPeer264 ff9e5bc
test(cloudflare): Add Cron Trigger integration tests with the Vite pl…
JPeer264 ea18814
fix(cloudflare): Send no schedule for an L-n day of month in Cron Tri…
JPeer264 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
17 changes: 17 additions & 0 deletions
17
dev-packages/cloudflare-integration-tests/suites/cron-triggers/index.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| import { monitorSlugs } from './monitorSlugs'; | ||
|
|
||
| interface Env { | ||
| SERVER_URL: string; | ||
| } | ||
|
|
||
| export default { | ||
| async scheduled(controller, env) { | ||
| switch (monitorSlugs[controller.cron]) { | ||
| case 'daily-report': | ||
| await fetch(env.SERVER_URL); | ||
| break; | ||
| case 'sync-inventory': | ||
| throw new Error('Boom from sync-inventory'); | ||
| } | ||
| }, | ||
| } satisfies ExportedHandler<Env>; |
8 changes: 8 additions & 0 deletions
8
dev-packages/cloudflare-integration-tests/suites/cron-triggers/instrument.server.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| import { cronTriggersIntegration, defineCloudflareOptions } from '@sentry/cloudflare'; | ||
| import { monitorSlugs } from './monitorSlugs'; | ||
|
|
||
| export default defineCloudflareOptions((env: { SENTRY_DSN: string; CACHE_CLIENT?: string }) => ({ | ||
| dsn: env.SENTRY_DSN, | ||
| cacheClient: env.CACHE_CLIENT !== 'false', | ||
| integrations: [cronTriggersIntegration({ monitorSlug: cron => monitorSlugs[cron] })], | ||
| })); |
4 changes: 4 additions & 0 deletions
4
dev-packages/cloudflare-integration-tests/suites/cron-triggers/monitorSlugs.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| export const monitorSlugs: Record<string, string> = { | ||
| '30 9 * * MON-FRI': 'daily-report', | ||
| '0 */6 * * *': 'sync-inventory', | ||
| }; |
91 changes: 91 additions & 0 deletions
91
dev-packages/cloudflare-integration-tests/suites/cron-triggers/test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,91 @@ | ||
| import type { Envelope, SerializedCheckIn } from '@sentry/core'; | ||
| import { createServer } from 'node:http'; | ||
| import type { AddressInfo } from 'node:net'; | ||
| import { expect, it, onTestFinished } from 'vitest'; | ||
| import { createRunner } from '../../runner'; | ||
|
|
||
| it.for([true, false])( | ||
| 'cacheClient: %s - sends the in_progress check-in while the scheduled handler runs, then the ok check-in', | ||
| async (cacheClient, { signal }) => { | ||
| // The handler waits for a response from this server, which answers only after the in_progress check-in | ||
| // has arrived. A check-in that waits for the flush after the handler would keep the run from ending. | ||
| let openGate: () => void = () => undefined; | ||
| const gateOpened = new Promise<void>(resolve => { | ||
| openGate = resolve; | ||
| }); | ||
| const gate = createServer((_req, res) => { | ||
| void gateOpened.then(() => res.end()); | ||
| }); | ||
| await new Promise<void>(resolve => gate.listen(0, resolve)); | ||
| onTestFinished(() => { | ||
| gate.closeAllConnections(); | ||
| gate.close(); | ||
| }); | ||
|
|
||
| let inProgressCheckInId: string | undefined; | ||
|
|
||
| const runner = createRunner(__dirname) | ||
| .withServerUrl(`http://localhost:${(gate.address() as AddressInfo).port}`) | ||
| .withWranglerArgs('--var', `CACHE_CLIENT:${cacheClient}`) | ||
| .expect((envelope: Envelope) => { | ||
| const checkIn = envelope[1][0]?.[1] as SerializedCheckIn; | ||
|
|
||
| expect(checkIn).toEqual( | ||
| expect.objectContaining({ | ||
| monitor_slug: 'daily-report', | ||
| status: 'in_progress', | ||
| monitor_config: { schedule: { type: 'crontab', value: '30 9 * * MON-FRI' } }, | ||
| }), | ||
| ); | ||
| inProgressCheckInId = checkIn.check_in_id; | ||
| openGate(); | ||
| }) | ||
| .expect((envelope: Envelope) => { | ||
| expect(envelope[1][0]?.[1]).toEqual( | ||
| expect.objectContaining({ | ||
| check_in_id: inProgressCheckInId, | ||
| monitor_slug: 'daily-report', | ||
| status: 'ok', | ||
| duration: expect.any(Number), | ||
| }), | ||
| ); | ||
| }) | ||
| .start(signal); | ||
|
|
||
| await runner.makeRequest('get', '/cdn-cgi/handler/scheduled?cron=30+9+*+*+MON-FRI'); | ||
| await runner.completed(); | ||
| }, | ||
| ); | ||
|
|
||
| it('sends an error check-in and the error when the scheduled handler throws', async ({ signal }) => { | ||
| const runner = createRunner(__dirname) | ||
| .unordered() | ||
| .expect((envelope: Envelope) => { | ||
| expect(envelope[1][0]?.[1]).toEqual( | ||
| expect.objectContaining({ | ||
| monitor_slug: 'sync-inventory', | ||
| status: 'in_progress', | ||
| monitor_config: { schedule: { type: 'crontab', value: '0 */6 * * *' } }, | ||
| }), | ||
| ); | ||
| }) | ||
| .expect((envelope: Envelope) => { | ||
| expect(envelope[1][0]?.[1]).toEqual(expect.objectContaining({ monitor_slug: 'sync-inventory', status: 'error' })); | ||
| }) | ||
| .expect((envelope: Envelope) => { | ||
| expect(envelope[1][0]?.[1]).toMatchObject({ | ||
| exception: { | ||
| values: [ | ||
| { | ||
| value: 'Boom from sync-inventory', | ||
| mechanism: { type: 'auto.faas.cloudflare.scheduled', handled: false }, | ||
| }, | ||
| ], | ||
| }, | ||
| }); | ||
| }) | ||
| .start(signal); | ||
|
|
||
| await runner.makeRequest('get', '/cdn-cgi/handler/scheduled?cron=0+*/6+*+*+*', { expectError: true }); | ||
| await runner.completed(); | ||
| }); |
7 changes: 7 additions & 0 deletions
7
dev-packages/cloudflare-integration-tests/suites/cron-triggers/vite.config.mts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| import { cloudflare } from '@cloudflare/vite-plugin'; | ||
| import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite'; | ||
| import { defineConfig } from 'vite'; | ||
|
|
||
| export default defineConfig({ | ||
| plugins: [cloudflare(), sentryCloudflareVitePlugin()], | ||
| }); |
7 changes: 7 additions & 0 deletions
7
dev-packages/cloudflare-integration-tests/suites/cron-triggers/wrangler.jsonc
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| { | ||
| "$schema": "../../node_modules/wrangler/config-schema.json", | ||
| "name": "cloudflare-cron-triggers", | ||
| "main": "index.ts", | ||
| "compatibility_date": "2025-06-17", | ||
| "compatibility_flags": ["nodejs_compat"], | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,195 @@ | ||
| import type { IntegrationFn, MonitorConfig } from '@sentry/core'; | ||
| import { captureCheckIn, debug, defineIntegration, timestampInSeconds } from '@sentry/core'; | ||
| import { DEBUG_BUILD } from '../debug-build'; | ||
|
|
||
| const INTEGRATION_NAME = 'CronTriggers' as const; | ||
|
|
||
| /** | ||
| * The monitor slug and settings for a Cron Trigger, see `cronTriggersIntegration`. | ||
| */ | ||
| export type CronTriggerMonitorSettings = { monitorSlug: string } & Omit<MonitorConfig, 'schedule' | 'timezone'>; | ||
|
|
||
| export interface CronTriggersOptions { | ||
| /** | ||
| * Chooses the monitor slug for a cron expression. It can also return an object with the `monitorSlug` | ||
| * and other monitor settings, such as `checkinMargin` or `maxRuntime`. Returning `undefined` | ||
| * sends no check-ins for that trigger, and so does a function that throws. | ||
| */ | ||
| monitorSlug?: (cron: string) => string | CronTriggerMonitorSettings | undefined; | ||
| } | ||
|
|
||
| /** @internal Used by the scheduled handler instrumentation. */ | ||
| export interface CronTriggersIntegration { | ||
| name: string; | ||
| startCheckIn(cron: string): ((status: 'ok' | 'error') => void) | undefined; | ||
| } | ||
|
|
||
| const SLUG_TOKENS: Record<string, string> = { ' ': '-', '*': 'x', ',': '_', '-': 'to', '/': 'by' }; | ||
|
|
||
| /** | ||
| * Derives a monitor slug from a cron expression, e.g. `30 9 * * MON-FRI` -> `cron-30-9-x-x-montofri`. | ||
| * | ||
| * A hash of the expression is appended when it has any other characters or the slug would be | ||
| * longer than 50 characters, so different expressions don't share a slug. | ||
| */ | ||
| function cronToMonitorSlug(cron: string): string { | ||
| const expression = cron.trim().toLowerCase().split(/\s+/).join(' '); | ||
| const slug = `cron-${expression.replace(/[ *,\-/]/g, char => SLUG_TOKENS[char] as string)}`; | ||
| if (/^[a-z0-9_-]{1,50}$/.test(slug)) { | ||
| return slug; | ||
| } | ||
|
|
||
| // A polynomial string hash modulo 2^31 - 1, at most 6 characters in base 36. | ||
| let hash = 0; | ||
| for (let i = 0; i < expression.length; i++) { | ||
| hash = (hash * 31 + expression.charCodeAt(i)) % 2147483647; | ||
| } | ||
| return `${slug.replace(/[^a-z0-9_-]+/g, '-').slice(0, 43)}-${hash.toString(36)}`; | ||
| } | ||
|
|
||
| const WEEKDAYS = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT']; | ||
|
|
||
| // Cloudflare numbers weekdays from 1 = Sunday to 7 = Saturday. Returns -1 for anything else. | ||
| function weekdayIndex(value: string): number { | ||
| return /^[1-7]$/.test(value) ? Number(value) - 1 : WEEKDAYS.indexOf(value.toUpperCase()); | ||
| } | ||
|
|
||
| function convertWeekdayItem(item: string): string | undefined { | ||
| const match = item.match(/^(\*|\w+)(?:-(\w+))?(?:\/(\d+))?$/); | ||
| if (!match) { | ||
| return undefined; | ||
| } | ||
| const [, from, to, step] = match; | ||
| if (step && !(Number(step) > 0)) { | ||
| return undefined; | ||
| } | ||
|
|
||
| // `*` and `*/n` give the same days in both numberings, and keep their `*` meaning for Sentry. | ||
| if (from === '*') { | ||
| return to ? undefined : item; | ||
| } | ||
|
|
||
| const start = weekdayIndex(from as string); | ||
| // A step without an end runs to Saturday, so it is written as a range. | ||
| const end = to ? weekdayIndex(to) : step ? 6 : start; | ||
| if (start < 0 || end < start) { | ||
| return undefined; | ||
| } | ||
|
|
||
| // A step over a single day would run on to Sunday in Sentry's cron parser, so it is left out. | ||
| if (start === end) { | ||
| return WEEKDAYS[start]; | ||
| } | ||
| const days = `${WEEKDAYS[start]}-${WEEKDAYS[end]}`; | ||
| return step ? `${days}/${step}` : days; | ||
| } | ||
|
|
||
| /** | ||
| * Converts a Cloudflare cron expression into a crontab Sentry accepts, which numbers weekdays from | ||
| * 0 = Sunday. Returns `undefined` if the day fields can't be converted. | ||
| */ | ||
| function cloudflareCronToCrontab(cron: string): string | undefined { | ||
| const fields = cron.trim().split(/\s+/); | ||
| const [, , dayOfMonth = '', , dayOfWeek = ''] = fields; | ||
| // Sentry rejects `W`, `?` and `L-n` in the day of month. When both day fields are set, Cloudflare and Sentry | ||
| // run on the days that match either field, but Sentry requires both to match when one starts with `*`. | ||
| if ( | ||
| fields.length !== 5 || | ||
| /[w?]|l-/i.test(dayOfMonth) || | ||
| (dayOfMonth !== '*' && dayOfWeek !== '*' && (dayOfMonth.startsWith('*') || dayOfWeek.startsWith('*'))) | ||
| ) { | ||
| return undefined; | ||
| } | ||
|
|
||
| const weekdays = dayOfWeek.split(',').map(convertWeekdayItem); | ||
| return weekdays.includes(undefined) ? undefined : [...fields.slice(0, 4), weekdays.join(',')].join(' '); | ||
| } | ||
|
|
||
| const _cronTriggersIntegration = ((options: CronTriggersOptions = {}): CronTriggersIntegration => { | ||
| return { | ||
| name: INTEGRATION_NAME, | ||
| startCheckIn(cron) { | ||
| // Manual runs, e.g. through `wrangler dev --test-scheduled`, can have no cron expression. | ||
| if (!cron) { | ||
| return undefined; | ||
| } | ||
|
|
||
| let monitor: string | CronTriggerMonitorSettings | undefined; | ||
| try { | ||
| monitor = options.monitorSlug ? options.monitorSlug(cron) : cronToMonitorSlug(cron); | ||
| } catch (e) { | ||
| DEBUG_BUILD && debug.warn(`[Cron Triggers] \`monitorSlug\` threw for "${cron}", sending no check-ins:`, e); | ||
| return undefined; | ||
| } | ||
|
|
||
| if (!monitor) { | ||
| return undefined; | ||
| } | ||
|
|
||
| const { monitorSlug, ...monitorSettings } = typeof monitor === 'string' ? { monitorSlug: monitor } : monitor; | ||
| if (!monitorSlug) { | ||
| return undefined; | ||
| } | ||
|
|
||
| const crontab = cloudflareCronToCrontab(cron); | ||
| if (!crontab) { | ||
| DEBUG_BUILD && | ||
| debug.warn( | ||
| `[Cron Triggers] Can't convert "${cron}" to a Sentry schedule, sending check-ins without a schedule or the other monitor settings.`, | ||
| ); | ||
| } | ||
|
|
||
| // Check-ins are captured directly rather than through `withMonitor`, which would fork the | ||
| // isolation scope and lose the invocation state attached to it. | ||
| const checkInId = captureCheckIn( | ||
| { monitorSlug, status: 'in_progress' }, | ||
| crontab ? { ...monitorSettings, schedule: { type: 'crontab', value: crontab } } : undefined, | ||
| ); | ||
| const startTime = timestampInSeconds(); | ||
|
|
||
| return status => { | ||
| captureCheckIn({ monitorSlug, status, checkInId, duration: timestampInSeconds() - startTime }); | ||
| }; | ||
| }, | ||
| }; | ||
| }) satisfies IntegrationFn; | ||
|
|
||
| /** | ||
| * Sends cron check-ins for every Cron Trigger run of the `scheduled` handler, with the trigger's | ||
| * schedule, so Sentry creates the monitor on the first run. | ||
| * | ||
| * Cron Triggers have no names, so map each cron expression to a slug. Without `monitorSlug`, the slug is | ||
| * derived from the expression (`30 9 * * MON-FRI` becomes `cron-30-9-x-x-montofri`) and changes with it. | ||
| * Workers that send to the same project get the same slug for the same expression, so they share a monitor. | ||
| * | ||
| * @example | ||
| * ```ts | ||
| * // src/monitorSlugs.ts | ||
| * export const monitorSlugs: Record<string, string> = { | ||
| * '30 9 * * MON-FRI': 'daily-report', | ||
| * }; | ||
| * | ||
| * // src/instrument.server.ts | ||
| * import { cronTriggersIntegration, defineCloudflareOptions } from '@sentry/cloudflare'; | ||
| * import { monitorSlugs } from './monitorSlugs'; | ||
| * | ||
| * export default defineCloudflareOptions((env) => ({ | ||
| * dsn: env.SENTRY_DSN, | ||
| * integrations: [cronTriggersIntegration({ monitorSlug: (cron) => monitorSlugs[cron] })], | ||
| * })); | ||
| * | ||
| * // src/index.ts | ||
| * import { monitorSlugs } from './monitorSlugs'; | ||
| * | ||
| * export default { | ||
| * async scheduled(controller, env) { | ||
| * switch (monitorSlugs[controller.cron]) { | ||
| * case 'daily-report': | ||
| * // ... | ||
| * break; | ||
| * } | ||
| * }, | ||
| * }; | ||
| * ``` | ||
| */ | ||
| export const cronTriggersIntegration = defineIntegration(_cronTriggersIntegration); | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.