Skip to content
Open
Show file tree
Hide file tree
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 Oct 2, 2026
804e1a9
fix(cloudflare): Convert Cron Trigger weekdays and harden check-ins
wedamija Oct 2, 2026
73f550f
ref(cloudflare): Move Cron Trigger check-ins into cronTriggersIntegra…
wedamija Oct 2, 2026
58f7a7e
ref(cloudflare): Shorten cronTriggersIntegration JSDoc
wedamija Oct 2, 2026
eb22bd1
fix(cloudflare): Keep */n weekdays and skip W/? days of month
wedamija Oct 2, 2026
ef0d768
fix(cloudflare): Keep cron check-in failures out of the scheduled han…
wedamija Oct 3, 2026
370d891
docs(cloudflare): Use MON-FRI in the weekday cron examples
wedamija Oct 3, 2026
e6aa167
fix(cloudflare): Send no schedule when both day fields are set
wedamija Oct 5, 2026
2abb1cf
Merge remote-tracking branch 'origin/develop' into danf/m25014-tmp
wedamija Oct 6, 2026
3e70cef
test(cloudflare): Assert cron check-ins with toHaveBeenCalledWith ins…
JPeer264 Oct 7, 2026
e018063
fix(cloudflare): Send the schedule when both Cron Trigger day fields …
JPeer264 Oct 7, 2026
74a7368
fix(cloudflare): Deliver the in_progress cron check-in while the sche…
JPeer264 Oct 7, 2026
4be67fb
ref(cloudflare): Rename the cronTriggersIntegration slug option to mo…
JPeer264 Oct 7, 2026
9f08557
docs(cloudflare): Note that Workers in one project share the default …
JPeer264 Oct 7, 2026
dd52715
docs(cloudflare): Use the Vite plugin and a slug map in the cronTrigg…
JPeer264 Oct 7, 2026
ff9e5bc
test(cloudflare): Add Cron Trigger integration tests with the Vite pl…
JPeer264 Oct 7, 2026
ea18814
fix(cloudflare): Send no schedule for an L-n day of month in Cron Tri…
JPeer264 Oct 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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>;
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] })],
}));
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',
};
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();
});
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()],
});
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"],
}
2 changes: 2 additions & 0 deletions packages/cloudflare/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,8 @@ export { httpServerIntegration } from './integrations/httpServer';
export { fetchIntegration } from './integrations/fetch';
export type { FetchIntegrationOptions } from '@sentry/core';
export { spotlightIntegration } from './integrations/spotlight';
export { cronTriggersIntegration } from './integrations/cronTriggers';
export type { CronTriggerMonitorSettings, CronTriggersOptions } from './integrations/cronTriggers';
export {
openTelemetryIntegration,
getOtlpTracesEndpoint,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@ import {
SENTRY_ORIGIN,
} from '@sentry/conventions/attributes';
import { FUNCTION } from '@sentry/conventions/op';
import { captureException, hasSpanStreamingEnabled, startSpan, withIsolationScope } from '@sentry/core';
import { captureException, debug, hasSpanStreamingEnabled, startSpan, withIsolationScope } from '@sentry/core';
import type { CloudflareOptions } from '../../client';
import { DEBUG_BUILD } from '../../debug-build';
import { flushAndDispose } from '../../flush';
import { ensureInstrumented } from '../../instrument';
import type { CronTriggersIntegration } from '../../integrations/cronTriggers';
import { getFinalOptions } from '../../options';
import { addCloudResourceContext } from '../../scope-utils';
import { init } from '../../sdk';
Expand Down Expand Up @@ -58,9 +60,34 @@ function wrapScheduledHandler(
},
},
async () => {
let finishCheckIn: ReturnType<CronTriggersIntegration['startCheckIn']>;
try {
return await fn();
finishCheckIn = client
?.getIntegrationByName<CronTriggersIntegration>('CronTriggers')
?.startCheckIn(controller.cron);
// The transport holds envelopes until the flush after the handler, so a run that is longer than
// the check-in margin would deliver its in_progress check-in too late and show as missed.
if (finishCheckIn) {
waitUntil(Promise.resolve(client?.getTransport()?.flush(2000)).catch(() => undefined));
}
} catch (e) {
DEBUG_BUILD && debug.warn('Failed to send the in_progress cron check-in', e);
}

const finish = (status: 'ok' | 'error'): void => {
try {
finishCheckIn?.(status);
} catch (e) {
DEBUG_BUILD && debug.warn(`Failed to send the ${status} cron check-in`, e);
}
};

try {
const result = await fn();
finish('ok');
return result;
} catch (e) {
finish('error');
captureException(e, { mechanism: { handled: false, type: 'auto.faas.cloudflare.scheduled' } });
throw e;
} finally {
Expand Down
195 changes: 195 additions & 0 deletions packages/cloudflare/src/integrations/cronTriggers.ts
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' },
Comment thread
wedamija marked this conversation as resolved.
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);
Loading
Loading