From 4c9812fb7b0dc97a7c242cd50e121f98ae43d543 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Fri, 2 Oct 2026 12:44:56 -0700 Subject: [PATCH 1/7] feat(cloudflare): Add opt-in cron monitoring for Cron Triggers Add a `monitorCronTriggers` option. When set, each Cron Trigger run of the `scheduled` handler sends in-progress and ok/error check-ins whose monitor config carries the trigger's cron expression, so Sentry creates the monitor on the first run. The slug is derived from the cron expression, or picked by a user function. Co-Authored-By: Claude Opus 5.5 --- packages/cloudflare/src/client.ts | 27 +++++ .../worker/instrumentScheduled.ts | 56 ++++++++- .../worker/instrumentScheduled.test.ts | 107 ++++++++++++++++++ 3 files changed, 188 insertions(+), 2 deletions(-) diff --git a/packages/cloudflare/src/client.ts b/packages/cloudflare/src/client.ts index 1a968cfe66b2..75038622941e 100644 --- a/packages/cloudflare/src/client.ts +++ b/packages/cloudflare/src/client.ts @@ -419,6 +419,33 @@ interface BaseCloudflareOptions { */ durableObjectStorageSpanAllowlist?: Array; + /** + * Send cron check-ins to Sentry for every Cron Trigger run of the `scheduled` handler. + * + * Each check-in carries the trigger's cron expression as the monitor schedule, so Sentry + * creates the monitor on the first run and keeps its schedule in sync. Monitors are billed, + * which is why this is off by default. + * + * With `true`, the monitor slug is `cron-` followed by the cron expression, lowercased, with + * `*` written as `x` and any other non-alphanumeric characters as `-`. For example, + * `30 9 * * 1-5` becomes `cron-30-9-x-x-1-5`. Workers that report to the same project and + * share a cron expression therefore share a monitor. Pass a function to choose the slug per + * cron expression instead; returning `undefined` sends no check-ins for that trigger. + * + * @default false + * @example + * ```ts + * export default Sentry.withSentry( + * (env) => ({ + * dsn: env.SENTRY_DSN, + * monitorCronTriggers: (cron) => (cron === '0 0 * * *' ? 'nightly-cleanup' : undefined), + * }), + * handler, + * ); + * ``` + */ + monitorCronTriggers?: boolean | ((cron: string) => string | undefined); + /** * Sets an optional server name (device name). * diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentScheduled.ts b/packages/cloudflare/src/instrumentations/worker/instrumentScheduled.ts index b0da3a279a39..9d69c3be22a8 100644 --- a/packages/cloudflare/src/instrumentations/worker/instrumentScheduled.ts +++ b/packages/cloudflare/src/instrumentations/worker/instrumentScheduled.ts @@ -12,7 +12,14 @@ import { SENTRY_ORIGIN, } from '@sentry/conventions/attributes'; import { FUNCTION } from '@sentry/conventions/op'; -import { captureException, hasSpanStreamingEnabled, startSpan, withIsolationScope } from '@sentry/core'; +import { + captureCheckIn, + captureException, + hasSpanStreamingEnabled, + startSpan, + timestampInSeconds, + withIsolationScope, +} from '@sentry/core'; import type { CloudflareOptions } from '../../client'; import { flushAndDispose } from '../../flush'; import { ensureInstrumented } from '../../instrument'; @@ -23,6 +30,46 @@ import { instrumentContext } from '../../utils/instrumentContext'; import { setInvocationState } from '../../utils/invocationContext'; import { instrumentEnv } from './instrumentEnv'; +const MAX_MONITOR_SLUG_LENGTH = 50; + +/** + * Derives a monitor slug from a cron expression, e.g. `30 9 * * 1-5` -> `cron-30-9-x-x-1-5`. + */ +function cronToMonitorSlug(cron: string): string { + const expression = cron + .toLowerCase() + .replace(/\*/g, 'x') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + return `cron-${expression}`.slice(0, MAX_MONITOR_SLUG_LENGTH).replace(/-+$/, ''); +} + +// Check-ins are captured directly rather than through `withMonitor`, which would fork the +// isolation scope and lose the invocation state attached to it. +function startCronCheckIn( + cron: string, + monitorCronTriggers: CloudflareOptions['monitorCronTriggers'], +): ((status: 'ok' | 'error') => void) | undefined { + if (!monitorCronTriggers) { + return undefined; + } + + const monitorSlug = typeof monitorCronTriggers === 'function' ? monitorCronTriggers(cron) : cronToMonitorSlug(cron); + if (!monitorSlug) { + return undefined; + } + + const checkInId = captureCheckIn( + { monitorSlug, status: 'in_progress' }, + { schedule: { type: 'crontab', value: cron } }, + ); + const startTime = timestampInSeconds(); + + return status => { + captureCheckIn({ monitorSlug, status, checkInId, duration: timestampInSeconds() - startTime }); + }; +} + function wrapScheduledHandler( controller: ScheduledController, options: CloudflareOptions, @@ -58,9 +105,14 @@ function wrapScheduledHandler( }, }, async () => { + let finishCheckIn: ReturnType; try { - return await fn(); + finishCheckIn = startCronCheckIn(controller.cron, options.monitorCronTriggers); + const result = await fn(); + finishCheckIn?.('ok'); + return result; } catch (e) { + finishCheckIn?.('error'); captureException(e, { mechanism: { handled: false, type: 'auto.faas.cloudflare.scheduled' } }); throw e; } finally { diff --git a/packages/cloudflare/test/instrumentations/worker/instrumentScheduled.test.ts b/packages/cloudflare/test/instrumentations/worker/instrumentScheduled.test.ts index 46090684daea..11823ed40cc6 100644 --- a/packages/cloudflare/test/instrumentations/worker/instrumentScheduled.test.ts +++ b/packages/cloudflare/test/instrumentations/worker/instrumentScheduled.test.ts @@ -5,6 +5,7 @@ import type { ExecutionContext, ScheduledController } from '@cloudflare/workers- import type { Event } from '@sentry/core'; import * as SentryCore from '@sentry/core'; import { beforeEach, describe, expect, onTestFinished, test, vi } from 'vitest'; +import type { CloudflareOptions } from '../../../src/client'; import { CloudflareClient } from '../../../src/client'; import { withSentry } from '../../../src/withSentry'; import { resetSdk } from '../../testUtils'; @@ -302,6 +303,112 @@ describe('instrumentScheduled', () => { }); }); + describe('cron monitoring', () => { + function controllerFor(cron: string): ScheduledController { + return { scheduledTime: 123, cron, noRetry: vi.fn() }; + } + + async function runScheduled( + monitorCronTriggers: CloudflareOptions['monitorCronTriggers'], + scheduled: ExportedHandler['scheduled'] = () => {}, + ): Promise { + const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN, monitorCronTriggers }), { scheduled }); + await wrappedHandler.scheduled?.(controllerFor('30 9 * * 1-5'), MOCK_ENV, createMockExecutionContext()); + } + + test('sends no check-ins by default', async () => { + const captureCheckInSpy = vi.spyOn(SentryCore, 'captureCheckIn'); + + await runScheduled(undefined); + + expect(captureCheckInSpy).not.toHaveBeenCalled(); + }); + + test('sends check-ins with the cron schedule when enabled', async () => { + const captureCheckInSpy = vi.spyOn(SentryCore, 'captureCheckIn'); + + await runScheduled(true); + + expect(captureCheckInSpy).toHaveBeenCalledTimes(2); + expect(captureCheckInSpy).toHaveBeenNthCalledWith( + 1, + { monitorSlug: 'cron-30-9-x-x-1-5', status: 'in_progress' }, + { schedule: { type: 'crontab', value: '30 9 * * 1-5' } }, + ); + expect(captureCheckInSpy).toHaveBeenNthCalledWith(2, { + monitorSlug: 'cron-30-9-x-x-1-5', + status: 'ok', + checkInId: expect.any(String), + duration: expect.any(Number), + }); + }); + + test('marks the check-in as failed when the handler throws', async () => { + const captureCheckInSpy = vi.spyOn(SentryCore, 'captureCheckIn'); + + await expect( + runScheduled(true, () => { + throw new Error('test'); + }), + ).rejects.toThrow('test'); + + expect(captureCheckInSpy).toHaveBeenLastCalledWith(expect.objectContaining({ status: 'error' })); + }); + + test('uses the slug returned by a function', async () => { + const captureCheckInSpy = vi.spyOn(SentryCore, 'captureCheckIn'); + const getSlug = vi.fn().mockReturnValue('weekday-report'); + + await runScheduled(getSlug); + + expect(getSlug).toHaveBeenCalledWith('30 9 * * 1-5'); + expect(captureCheckInSpy).toHaveBeenNthCalledWith( + 1, + { monitorSlug: 'weekday-report', status: 'in_progress' }, + { schedule: { type: 'crontab', value: '30 9 * * 1-5' } }, + ); + }); + + test('sends no check-ins when the function returns undefined', async () => { + const captureCheckInSpy = vi.spyOn(SentryCore, 'captureCheckIn'); + + await runScheduled(() => undefined); + + expect(captureCheckInSpy).not.toHaveBeenCalled(); + }); + + test('flushes both check-ins before the invocation ends', async () => { + const sentItemTypes: string[] = []; + const handler = { + scheduled() {}, + } satisfies ExportedHandler; + const wrappedHandler = withSentry( + env => ({ + dsn: env.SENTRY_DSN, + cacheClient: false, + monitorCronTriggers: true, + transport: () => ({ + send: async envelope => { + sentItemTypes.push(...envelope[1].map(([itemHeader]) => itemHeader.type)); + return {}; + }, + flush: async () => true, + }), + }), + handler, + ); + + const waits: Promise[] = []; + await wrappedHandler.scheduled?.(controllerFor('30 9 * * 1-5'), MOCK_ENV, { + waitUntil: vi.fn(promise => waits.push(promise)), + passThroughOnException: vi.fn(), + } as unknown as ExecutionContext); + await Promise.all(waits); + + expect(sentItemTypes.filter(type => type === 'check_in')).toHaveLength(2); + }); + }); + test('flush must be called when all waitUntil are done', async () => { const flush = vi.spyOn(SentryCore.Client.prototype, 'flush'); vi.useFakeTimers(); From 804e1a98e64875b4cfc1e4c6847affba46096adf Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Fri, 2 Oct 2026 13:27:01 -0700 Subject: [PATCH 2/7] fix(cloudflare): Convert Cron Trigger weekdays and harden check-ins Cloudflare numbers weekdays from 1 = Sunday, while Sentry uses 0 = Sunday, so the weekday field is now converted to names (or a 0-based number for `nL`) before it is sent. Expressions that can't be converted are sent without a schedule. Derived slugs now write `,`, `-` and `/` as distinct tokens and append a hash when the expression has other characters or the slug is too long. A throwing `monitorCronTriggers` function and runs without a cron expression send no check-ins instead of affecting the handler, and the function can return other monitor settings along with the slug. Co-Authored-By: Claude Opus 5.5 --- packages/cloudflare/src/client.ts | 34 +++- .../worker/instrumentScheduled.ts | 176 ++++++++++++++++-- .../worker/instrumentScheduled.test.ts | 171 ++++++++++++++++- 3 files changed, 349 insertions(+), 32 deletions(-) diff --git a/packages/cloudflare/src/client.ts b/packages/cloudflare/src/client.ts index 75038622941e..44e67e54c1bb 100644 --- a/packages/cloudflare/src/client.ts +++ b/packages/cloudflare/src/client.ts @@ -1,4 +1,4 @@ -import type { ClientOptions, Options, TracePropagationTargets } from '@sentry/core'; +import type { ClientOptions, MonitorConfig, Options, TracePropagationTargets } from '@sentry/core'; import { _INTERNAL_clearAiProviderSkips, _INTERNAL_flushLogsBuffer, @@ -313,6 +313,11 @@ export class CloudflareClient extends ServerRuntimeClient { } } +/** + * The monitor slug and settings for a Cron Trigger, see `monitorCronTriggers`. + */ +export type CronTriggerMonitorSettings = { slug: string } & Omit; + interface BaseCloudflareOptions { /** * @ignore Used internally to disable the deDupeIntegration for workflows. @@ -423,14 +428,24 @@ interface BaseCloudflareOptions { * Send cron check-ins to Sentry for every Cron Trigger run of the `scheduled` handler. * * Each check-in carries the trigger's cron expression as the monitor schedule, so Sentry - * creates the monitor on the first run and keeps its schedule in sync. Monitors are billed, - * which is why this is off by default. + * creates the monitor on the first run and keeps its schedule in sync. Cloudflare numbers + * weekdays from 1 = Sunday, so the weekday field is converted to names before it is sent + * (`1-5` becomes `SUN-THU`). If it can't be converted, check-ins are sent without a schedule. + * Monitors are billed, which is why this is off by default. * * With `true`, the monitor slug is `cron-` followed by the cron expression, lowercased, with - * `*` written as `x` and any other non-alphanumeric characters as `-`. For example, - * `30 9 * * 1-5` becomes `cron-30-9-x-x-1-5`. Workers that report to the same project and - * share a cron expression therefore share a monitor. Pass a function to choose the slug per - * cron expression instead; returning `undefined` sends no check-ins for that trigger. + * fields joined by `-` and `*`, `,`, `-` and `/` written as `x`, `_`, `to` and `by`. For example, + * `30 9 * * 1-5` becomes `cron-30-9-x-x-1to5`. If the expression has other characters or the slug + * would be longer than 50 characters, it is shortened and a hash of the expression is appended. + * Workers that report to the same project and share a cron expression therefore share a monitor. + * + * Pass a function to choose the slug per cron expression instead. It can also return an object + * with the `slug` 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. Runs + * without a cron expression, such as some manual `--test-scheduled` runs, send no check-ins. + * + * The Workers clock only advances on I/O, so the duration of a job that only uses the CPU + * may be reported as about 0. * * @default false * @example @@ -438,13 +453,14 @@ interface BaseCloudflareOptions { * export default Sentry.withSentry( * (env) => ({ * dsn: env.SENTRY_DSN, - * monitorCronTriggers: (cron) => (cron === '0 0 * * *' ? 'nightly-cleanup' : undefined), + * monitorCronTriggers: (cron) => + * cron === '0 0 * * *' ? { slug: 'nightly-cleanup', maxRuntime: 30 } : undefined, * }), * handler, * ); * ``` */ - monitorCronTriggers?: boolean | ((cron: string) => string | undefined); + monitorCronTriggers?: boolean | ((cron: string) => string | CronTriggerMonitorSettings | undefined); /** * Sets an optional server name (device name). diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentScheduled.ts b/packages/cloudflare/src/instrumentations/worker/instrumentScheduled.ts index 9d69c3be22a8..b0033d5a45b6 100644 --- a/packages/cloudflare/src/instrumentations/worker/instrumentScheduled.ts +++ b/packages/cloudflare/src/instrumentations/worker/instrumentScheduled.ts @@ -12,15 +12,18 @@ import { SENTRY_ORIGIN, } from '@sentry/conventions/attributes'; import { FUNCTION } from '@sentry/conventions/op'; +import type { MonitorConfig } from '@sentry/core'; import { captureCheckIn, captureException, + debug, hasSpanStreamingEnabled, startSpan, timestampInSeconds, withIsolationScope, } from '@sentry/core'; -import type { CloudflareOptions } from '../../client'; +import type { CloudflareOptions, CronTriggerMonitorSettings } from '../../client'; +import { DEBUG_BUILD } from '../../debug-build'; import { flushAndDispose } from '../../flush'; import { ensureInstrumented } from '../../instrument'; import { getFinalOptions } from '../../options'; @@ -31,38 +34,175 @@ import { setInvocationState } from '../../utils/invocationContext'; import { instrumentEnv } from './instrumentEnv'; const MAX_MONITOR_SLUG_LENGTH = 50; +const SLUG_HASH_LENGTH = 6; /** - * Derives a monitor slug from a cron expression, e.g. `30 9 * * 1-5` -> `cron-30-9-x-x-1-5`. + * Derives a monitor slug from a cron expression, e.g. `30 9 * * 1-5` -> `cron-30-9-x-x-1to5`. + * + * Fields are joined with `-`, and `*`, `,`, `-` and `/` are written as `x`, `_`, `to` and `by`. + * A hash of the expression is appended when it has any other characters or the slug is too long, + * so different expressions don't share a slug. */ function cronToMonitorSlug(cron: string): string { - const expression = cron - .toLowerCase() - .replace(/\*/g, 'x') - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, ''); - return `cron-${expression}`.slice(0, MAX_MONITOR_SLUG_LENGTH).replace(/-+$/, ''); + const expression = cron.trim().toLowerCase().split(/\s+/).join(' '); + const readable = expression.replace(/[ *,\-/]/g, char => SLUG_TOKENS[char] as string); + const slug = `cron-${readable}`; + + if (/^[a-z0-9_-]+$/.test(readable) && slug.length <= MAX_MONITOR_SLUG_LENGTH) { + return slug; + } + + const prefix = slug + .replace(/[^a-z0-9_-]+/g, '-') + .slice(0, MAX_MONITOR_SLUG_LENGTH - SLUG_HASH_LENGTH - 1) + .replace(/[-_]+$/, ''); + return `${prefix}-${hashString(expression)}`; } -// Check-ins are captured directly rather than through `withMonitor`, which would fork the -// isolation scope and lose the invocation state attached to it. -function startCronCheckIn( +const SLUG_TOKENS: Record = { ' ': '-', '*': 'x', ',': '_', '-': 'to', '/': 'by' }; + +// A polynomial string hash modulo 2^31 - 1, as a fixed-length base 36 string. +function hashString(value: string): string { + let hash = 0; + for (let i = 0; i < value.length; i++) { + hash = (hash * 31 + value.charCodeAt(i)) % 2147483647; + } + return hash.toString(36).padStart(SLUG_HASH_LENGTH, '0'); +} + +const WEEKDAYS = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT']; + +function weekdayIndex(value: string): number | undefined { + if (/^\d+$/.test(value)) { + const day = Number(value); + // Cloudflare numbers weekdays from 1 = Sunday to 7 = Saturday. + return day >= 1 && day <= 7 ? day - 1 : undefined; + } + const index = WEEKDAYS.indexOf(value.toUpperCase()); + return index === -1 ? undefined : index; +} + +function convertWeekdayItem(item: string): string | undefined { + const last = item.match(/^(\w+)L$/i); + if (last) { + const day = weekdayIndex(last[1] as string); + return day === undefined ? undefined : `${day}L`; + } + + const nth = item.match(/^(\w+)#([1-5])$/); + if (nth) { + const day = weekdayIndex(nth[1] as string); + return day === undefined ? undefined : `${WEEKDAYS[day]}#${nth[2]}`; + } + + const range = item.match(/^(\*|\w+)(?:-(\w+))?(?:\/(\d+))?$/); + if (!range) { + return undefined; + } + const [, from, to, step] = range; + if (step !== undefined && !(Number(step) > 0)) { + return undefined; + } + + if (from === '*') { + if (to !== undefined) { + return undefined; + } + return step === undefined ? '*' : `SUN-SAT/${step}`; + } + + const start = weekdayIndex(from as string); + // A step without an end runs to Saturday, so it is written as a range. + const end = to !== undefined ? weekdayIndex(to) : step !== undefined ? 6 : start; + if (start === undefined || end === undefined || 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 === undefined ? days : `${days}/${step}`; +} + +/** + * Converts a Cloudflare cron expression into a crontab Sentry accepts, which numbers weekdays from + * 0 = Sunday. Returns `undefined` if the weekday field can't be converted. + */ +function cloudflareCronToCrontab(cron: string): string | undefined { + const fields = cron.trim().split(/\s+/); + if (fields.length !== 5) { + return undefined; + } + + const weekdays: string[] = []; + for (const item of (fields[4] as string).split(',')) { + const converted = convertWeekdayItem(item); + if (converted === undefined) { + return undefined; + } + weekdays.push(converted); + } + + return [...fields.slice(0, 4), weekdays.join(',')].join(' '); +} + +interface CronTriggerMonitor { + monitorSlug: string; + monitorConfig: MonitorConfig | undefined; +} + +function getCronTriggerMonitor( cron: string, monitorCronTriggers: CloudflareOptions['monitorCronTriggers'], -): ((status: 'ok' | 'error') => void) | undefined { - if (!monitorCronTriggers) { +): CronTriggerMonitor | undefined { + // Manual runs, e.g. through `wrangler dev --test-scheduled`, can have no cron expression. + if (!monitorCronTriggers || !cron) { + return undefined; + } + + let monitor: string | CronTriggerMonitorSettings | undefined; + try { + monitor = typeof monitorCronTriggers === 'function' ? monitorCronTriggers(cron) : cronToMonitorSlug(cron); + } catch (e) { + DEBUG_BUILD && debug.warn(`[Cron Triggers] \`monitorCronTriggers\` threw for "${cron}", sending no check-ins:`, e); + return undefined; + } + if (!monitor) { return undefined; } - const monitorSlug = typeof monitorCronTriggers === 'function' ? monitorCronTriggers(cron) : cronToMonitorSlug(cron); + const { slug: monitorSlug, ...monitorSettings } = typeof monitor === 'string' ? { slug: monitor } : monitor; if (!monitorSlug) { return undefined; } - const checkInId = captureCheckIn( - { monitorSlug, status: 'in_progress' }, - { schedule: { type: 'crontab', value: cron } }, - ); + const crontab = cloudflareCronToCrontab(cron); + if (!crontab) { + DEBUG_BUILD && + debug.warn(`[Cron Triggers] Can't convert "${cron}" to a Sentry schedule, sending check-ins without one.`); + } + + return { + monitorSlug, + monitorConfig: crontab ? { ...monitorSettings, schedule: { type: 'crontab', value: crontab } } : undefined, + }; +} + +// Check-ins are captured directly rather than through `withMonitor`, which would fork the +// isolation scope and lose the invocation state attached to it. +function startCronCheckIn( + cron: string, + monitorCronTriggers: CloudflareOptions['monitorCronTriggers'], +): ((status: 'ok' | 'error') => void) | undefined { + const monitor = getCronTriggerMonitor(cron, monitorCronTriggers); + if (!monitor) { + return undefined; + } + + const { monitorSlug, monitorConfig } = monitor; + const checkInId = captureCheckIn({ monitorSlug, status: 'in_progress' }, monitorConfig); const startTime = timestampInSeconds(); return status => { diff --git a/packages/cloudflare/test/instrumentations/worker/instrumentScheduled.test.ts b/packages/cloudflare/test/instrumentations/worker/instrumentScheduled.test.ts index 11823ed40cc6..0d5cee5d3c69 100644 --- a/packages/cloudflare/test/instrumentations/worker/instrumentScheduled.test.ts +++ b/packages/cloudflare/test/instrumentations/worker/instrumentScheduled.test.ts @@ -7,6 +7,10 @@ import * as SentryCore from '@sentry/core'; import { beforeEach, describe, expect, onTestFinished, test, vi } from 'vitest'; import type { CloudflareOptions } from '../../../src/client'; import { CloudflareClient } from '../../../src/client'; +import { + instrumentWorkerEntrypoint, + type WorkerEntrypointConstructor, +} from '../../../src/instrumentations/instrumentWorkerEntrypoint'; import { withSentry } from '../../../src/withSentry'; import { resetSdk } from '../../testUtils'; @@ -311,9 +315,19 @@ describe('instrumentScheduled', () => { async function runScheduled( monitorCronTriggers: CloudflareOptions['monitorCronTriggers'], scheduled: ExportedHandler['scheduled'] = () => {}, + cron = '30 9 * * 1-5', ): Promise { const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN, monitorCronTriggers }), { scheduled }); - await wrappedHandler.scheduled?.(controllerFor('30 9 * * 1-5'), MOCK_ENV, createMockExecutionContext()); + await wrappedHandler.scheduled?.(controllerFor(cron), MOCK_ENV, createMockExecutionContext()); + } + + async function getInProgressCheckIn( + cron: string, + monitorCronTriggers: CloudflareOptions['monitorCronTriggers'] = true, + ): Promise { + const captureCheckInSpy = vi.spyOn(SentryCore, 'captureCheckIn'); + await runScheduled(monitorCronTriggers, undefined, cron); + return captureCheckInSpy.mock.calls[0] as unknown[]; } test('sends no check-ins by default', async () => { @@ -332,11 +346,11 @@ describe('instrumentScheduled', () => { expect(captureCheckInSpy).toHaveBeenCalledTimes(2); expect(captureCheckInSpy).toHaveBeenNthCalledWith( 1, - { monitorSlug: 'cron-30-9-x-x-1-5', status: 'in_progress' }, - { schedule: { type: 'crontab', value: '30 9 * * 1-5' } }, + { monitorSlug: 'cron-30-9-x-x-1to5', status: 'in_progress' }, + { schedule: { type: 'crontab', value: '30 9 * * SUN-THU' } }, ); expect(captureCheckInSpy).toHaveBeenNthCalledWith(2, { - monitorSlug: 'cron-30-9-x-x-1-5', + monitorSlug: 'cron-30-9-x-x-1to5', status: 'ok', checkInId: expect.any(String), duration: expect.any(Number), @@ -365,8 +379,155 @@ describe('instrumentScheduled', () => { expect(captureCheckInSpy).toHaveBeenNthCalledWith( 1, { monitorSlug: 'weekday-report', status: 'in_progress' }, - { schedule: { type: 'crontab', value: '30 9 * * 1-5' } }, + { schedule: { type: 'crontab', value: '30 9 * * SUN-THU' } }, + ); + }); + + test('sends the monitor settings returned by a function', async () => { + const [, monitorConfig] = await getInProgressCheckIn('0 0 * * *', () => ({ + slug: 'nightly', + checkinMargin: 5, + maxRuntime: 30, + })); + + expect(monitorConfig).toEqual({ + schedule: { type: 'crontab', value: '0 0 * * *' }, + checkinMargin: 5, + maxRuntime: 30, + }); + }); + + test('sends no check-ins and still runs the handler when the function throws', async () => { + const captureCheckInSpy = vi.spyOn(SentryCore, 'captureCheckIn'); + const warnSpy = vi.spyOn(SentryCore.debug, 'warn').mockImplementation(() => undefined); + const scheduled = vi.fn(); + + await runScheduled(() => { + throw new Error('slug error'); + }, scheduled); + + expect(scheduled).toHaveBeenCalledTimes(1); + expect(captureCheckInSpy).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('`monitorCronTriggers` threw'), expect.any(Error)); + }); + + test('sends no check-ins for a run without a cron expression', async () => { + const captureCheckInSpy = vi.spyOn(SentryCore, 'captureCheckIn'); + const scheduled = vi.fn(); + + await runScheduled(true, scheduled, ''); + + expect(scheduled).toHaveBeenCalledTimes(1); + expect(captureCheckInSpy).not.toHaveBeenCalled(); + }); + + test.each([ + ['1', 'SUN'], + ['7', 'SAT'], + ['2,4,6', 'MON,WED,FRI'], + ['2-6', 'MON-FRI'], + ['1-7/2', 'SUN-SAT/2'], + ['2/3', 'MON-SAT/3'], + ['7/1', 'SAT'], + ['3-3/2', 'TUE'], + ['*/2', 'SUN-SAT/2'], + ['*', '*'], + ['mon-fri', 'MON-FRI'], + ['SAT,1', 'SAT,SUN'], + ['6L', '5L'], + ['FRIL', '5L'], + ['6#2', 'FRI#2'], + ['MON#1', 'MON#1'], + ])('converts the weekday field %s to %s', async (weekdays, expected) => { + const [, monitorConfig] = await getInProgressCheckIn(`0 9 * * ${weekdays}`); + + expect(monitorConfig).toEqual({ schedule: { type: 'crontab', value: `0 9 * * ${expected}` } }); + }); + + test.each([['0'], ['8'], ['6-2'], ['L'], ['?'], ['1-*'], ['2/0'], ['6#6']])( + 'sends check-ins without a schedule for the weekday field %s', + async weekdays => { + const [checkIn, monitorConfig] = await getInProgressCheckIn(`0 9 * * ${weekdays}`); + + expect(checkIn).toEqual(expect.objectContaining({ status: 'in_progress' })); + expect(monitorConfig).toBeUndefined(); + }, + ); + + test('sends check-ins without a schedule for an expression without five fields', async () => { + const [, monitorConfig] = await getInProgressCheckIn('0 0 9 * * *'); + + expect(monitorConfig).toBeUndefined(); + }); + + test.each([ + ['0 9 * * 1,5', 'cron-0-9-x-x-1_5'], + ['0 9 * * 1-5', 'cron-0-9-x-x-1to5'], + ['*/15 * * * *', 'cron-xby15-x-x-x-x'], + ['0 9 * * MON', 'cron-0-9-x-x-mon'], + ])('derives the slug for %s as %s', async (cron, slug) => { + const [checkIn] = await getInProgressCheckIn(cron); + + expect(checkIn).toEqual(expect.objectContaining({ monitorSlug: slug })); + }); + + test('derives different slugs for expressions that differ only in separators', async () => { + const crons = ['0 9 * * 1,5', '0 9 * * 1-5', '0 9 * * 1/5', '0 9 * * 1#5']; + const slugs = []; + for (const cron of crons) { + const [checkIn] = await getInProgressCheckIn(cron); + slugs.push((checkIn as { monitorSlug: string }).monitorSlug); + vi.restoreAllMocks(); + } + + expect(new Set(slugs).size).toBe(crons.length); + expect(slugs[3]).toMatch(/^cron-0-9-x-x-1-5-[a-z0-9]{6}$/); + }); + + test('shortens long slugs and appends a hash', async () => { + const minutes = Array.from({ length: 30 }, (_, i) => i).join(','); + const [first] = await getInProgressCheckIn(`${minutes} * * * *`); + vi.restoreAllMocks(); + const [second] = await getInProgressCheckIn(`${minutes},59 * * * *`); + + const firstSlug = (first as { monitorSlug: string }).monitorSlug; + const secondSlug = (second as { monitorSlug: string }).monitorSlug; + expect(firstSlug.length).toBeLessThanOrEqual(50); + expect(firstSlug).toMatch(/^cron-0_1_2_.*-[a-z0-9]{6}$/); + expect(secondSlug).not.toBe(firstSlug); + }); + + test('marks the check-in as failed when the handler rejects', async () => { + const captureCheckInSpy = vi.spyOn(SentryCore, 'captureCheckIn'); + + await expect(runScheduled(true, () => Promise.reject(new Error('rejected')))).rejects.toThrow('rejected'); + + expect(captureCheckInSpy).toHaveBeenCalledTimes(2); + expect(captureCheckInSpy).toHaveBeenLastCalledWith( + expect.objectContaining({ monitorSlug: 'cron-30-9-x-x-1to5', status: 'error' }), + ); + }); + + test('sends check-ins for the scheduled method of a WorkerEntrypoint', async () => { + const captureCheckInSpy = vi.spyOn(SentryCore, 'captureCheckIn'); + const TestEntrypoint = class { + scheduled() {} + }; + const instrumented = instrumentWorkerEntrypoint( + () => ({ dsn: MOCK_ENV.SENTRY_DSN, monitorCronTriggers: true }), + TestEntrypoint as unknown as WorkerEntrypointConstructor, + ); + const entrypoint = Reflect.construct(instrumented, [createMockExecutionContext(), MOCK_ENV]); + + await entrypoint.scheduled(controllerFor('30 9 * * 1-5')); + + expect(captureCheckInSpy).toHaveBeenCalledTimes(2); + expect(captureCheckInSpy).toHaveBeenNthCalledWith( + 1, + { monitorSlug: 'cron-30-9-x-x-1to5', status: 'in_progress' }, + { schedule: { type: 'crontab', value: '30 9 * * SUN-THU' } }, ); + expect(captureCheckInSpy).toHaveBeenLastCalledWith(expect.objectContaining({ status: 'ok' })); }); test('sends no check-ins when the function returns undefined', async () => { From 73f550f1e3a845cae88fc5332b7dd7fc9e1082fd Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Fri, 2 Oct 2026 15:53:41 -0700 Subject: [PATCH 3/7] ref(cloudflare): Move Cron Trigger check-ins into cronTriggersIntegration Replaces the `monitorCronTriggers` option with an opt-in `cronTriggersIntegration({ slug })`, so the schedule conversion and slug code is tree-shaken for Workers that don't use it. The scheduled handler only looks the integration up by name. Also drops `nL` and `n#k` weekday conversion (sent without a schedule) and simplifies the slug hash. Co-Authored-By: Claude --- packages/cloudflare/src/client.ts | 45 +--- packages/cloudflare/src/index.ts | 2 + .../worker/instrumentScheduled.ts | 198 +----------------- .../src/integrations/cronTriggers.ts | 193 +++++++++++++++++ .../worker/instrumentScheduled.test.ts | 181 ++-------------- .../test/integrations/cronTriggers.test.ts | 154 ++++++++++++++ 6 files changed, 379 insertions(+), 394 deletions(-) create mode 100644 packages/cloudflare/src/integrations/cronTriggers.ts create mode 100644 packages/cloudflare/test/integrations/cronTriggers.test.ts diff --git a/packages/cloudflare/src/client.ts b/packages/cloudflare/src/client.ts index 44e67e54c1bb..1a968cfe66b2 100644 --- a/packages/cloudflare/src/client.ts +++ b/packages/cloudflare/src/client.ts @@ -1,4 +1,4 @@ -import type { ClientOptions, MonitorConfig, Options, TracePropagationTargets } from '@sentry/core'; +import type { ClientOptions, Options, TracePropagationTargets } from '@sentry/core'; import { _INTERNAL_clearAiProviderSkips, _INTERNAL_flushLogsBuffer, @@ -313,11 +313,6 @@ export class CloudflareClient extends ServerRuntimeClient { } } -/** - * The monitor slug and settings for a Cron Trigger, see `monitorCronTriggers`. - */ -export type CronTriggerMonitorSettings = { slug: string } & Omit; - interface BaseCloudflareOptions { /** * @ignore Used internally to disable the deDupeIntegration for workflows. @@ -424,44 +419,6 @@ interface BaseCloudflareOptions { */ durableObjectStorageSpanAllowlist?: Array; - /** - * Send cron check-ins to Sentry for every Cron Trigger run of the `scheduled` handler. - * - * Each check-in carries the trigger's cron expression as the monitor schedule, so Sentry - * creates the monitor on the first run and keeps its schedule in sync. Cloudflare numbers - * weekdays from 1 = Sunday, so the weekday field is converted to names before it is sent - * (`1-5` becomes `SUN-THU`). If it can't be converted, check-ins are sent without a schedule. - * Monitors are billed, which is why this is off by default. - * - * With `true`, the monitor slug is `cron-` followed by the cron expression, lowercased, with - * fields joined by `-` and `*`, `,`, `-` and `/` written as `x`, `_`, `to` and `by`. For example, - * `30 9 * * 1-5` becomes `cron-30-9-x-x-1to5`. If the expression has other characters or the slug - * would be longer than 50 characters, it is shortened and a hash of the expression is appended. - * Workers that report to the same project and share a cron expression therefore share a monitor. - * - * Pass a function to choose the slug per cron expression instead. It can also return an object - * with the `slug` 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. Runs - * without a cron expression, such as some manual `--test-scheduled` runs, send no check-ins. - * - * The Workers clock only advances on I/O, so the duration of a job that only uses the CPU - * may be reported as about 0. - * - * @default false - * @example - * ```ts - * export default Sentry.withSentry( - * (env) => ({ - * dsn: env.SENTRY_DSN, - * monitorCronTriggers: (cron) => - * cron === '0 0 * * *' ? { slug: 'nightly-cleanup', maxRuntime: 30 } : undefined, - * }), - * handler, - * ); - * ``` - */ - monitorCronTriggers?: boolean | ((cron: string) => string | CronTriggerMonitorSettings | undefined); - /** * Sets an optional server name (device name). * diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index 59f8713b181e..4190f12927f3 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -122,6 +122,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, diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentScheduled.ts b/packages/cloudflare/src/instrumentations/worker/instrumentScheduled.ts index b0033d5a45b6..1b7113d03ad8 100644 --- a/packages/cloudflare/src/instrumentations/worker/instrumentScheduled.ts +++ b/packages/cloudflare/src/instrumentations/worker/instrumentScheduled.ts @@ -12,20 +12,11 @@ import { SENTRY_ORIGIN, } from '@sentry/conventions/attributes'; import { FUNCTION } from '@sentry/conventions/op'; -import type { MonitorConfig } from '@sentry/core'; -import { - captureCheckIn, - captureException, - debug, - hasSpanStreamingEnabled, - startSpan, - timestampInSeconds, - withIsolationScope, -} from '@sentry/core'; -import type { CloudflareOptions, CronTriggerMonitorSettings } from '../../client'; -import { DEBUG_BUILD } from '../../debug-build'; +import { captureException, hasSpanStreamingEnabled, startSpan, withIsolationScope } from '@sentry/core'; +import type { CloudflareOptions } from '../../client'; 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'; @@ -33,183 +24,6 @@ import { instrumentContext } from '../../utils/instrumentContext'; import { setInvocationState } from '../../utils/invocationContext'; import { instrumentEnv } from './instrumentEnv'; -const MAX_MONITOR_SLUG_LENGTH = 50; -const SLUG_HASH_LENGTH = 6; - -/** - * Derives a monitor slug from a cron expression, e.g. `30 9 * * 1-5` -> `cron-30-9-x-x-1to5`. - * - * Fields are joined with `-`, and `*`, `,`, `-` and `/` are written as `x`, `_`, `to` and `by`. - * A hash of the expression is appended when it has any other characters or the slug is too long, - * so different expressions don't share a slug. - */ -function cronToMonitorSlug(cron: string): string { - const expression = cron.trim().toLowerCase().split(/\s+/).join(' '); - const readable = expression.replace(/[ *,\-/]/g, char => SLUG_TOKENS[char] as string); - const slug = `cron-${readable}`; - - if (/^[a-z0-9_-]+$/.test(readable) && slug.length <= MAX_MONITOR_SLUG_LENGTH) { - return slug; - } - - const prefix = slug - .replace(/[^a-z0-9_-]+/g, '-') - .slice(0, MAX_MONITOR_SLUG_LENGTH - SLUG_HASH_LENGTH - 1) - .replace(/[-_]+$/, ''); - return `${prefix}-${hashString(expression)}`; -} - -const SLUG_TOKENS: Record = { ' ': '-', '*': 'x', ',': '_', '-': 'to', '/': 'by' }; - -// A polynomial string hash modulo 2^31 - 1, as a fixed-length base 36 string. -function hashString(value: string): string { - let hash = 0; - for (let i = 0; i < value.length; i++) { - hash = (hash * 31 + value.charCodeAt(i)) % 2147483647; - } - return hash.toString(36).padStart(SLUG_HASH_LENGTH, '0'); -} - -const WEEKDAYS = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT']; - -function weekdayIndex(value: string): number | undefined { - if (/^\d+$/.test(value)) { - const day = Number(value); - // Cloudflare numbers weekdays from 1 = Sunday to 7 = Saturday. - return day >= 1 && day <= 7 ? day - 1 : undefined; - } - const index = WEEKDAYS.indexOf(value.toUpperCase()); - return index === -1 ? undefined : index; -} - -function convertWeekdayItem(item: string): string | undefined { - const last = item.match(/^(\w+)L$/i); - if (last) { - const day = weekdayIndex(last[1] as string); - return day === undefined ? undefined : `${day}L`; - } - - const nth = item.match(/^(\w+)#([1-5])$/); - if (nth) { - const day = weekdayIndex(nth[1] as string); - return day === undefined ? undefined : `${WEEKDAYS[day]}#${nth[2]}`; - } - - const range = item.match(/^(\*|\w+)(?:-(\w+))?(?:\/(\d+))?$/); - if (!range) { - return undefined; - } - const [, from, to, step] = range; - if (step !== undefined && !(Number(step) > 0)) { - return undefined; - } - - if (from === '*') { - if (to !== undefined) { - return undefined; - } - return step === undefined ? '*' : `SUN-SAT/${step}`; - } - - const start = weekdayIndex(from as string); - // A step without an end runs to Saturday, so it is written as a range. - const end = to !== undefined ? weekdayIndex(to) : step !== undefined ? 6 : start; - if (start === undefined || end === undefined || 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 === undefined ? days : `${days}/${step}`; -} - -/** - * Converts a Cloudflare cron expression into a crontab Sentry accepts, which numbers weekdays from - * 0 = Sunday. Returns `undefined` if the weekday field can't be converted. - */ -function cloudflareCronToCrontab(cron: string): string | undefined { - const fields = cron.trim().split(/\s+/); - if (fields.length !== 5) { - return undefined; - } - - const weekdays: string[] = []; - for (const item of (fields[4] as string).split(',')) { - const converted = convertWeekdayItem(item); - if (converted === undefined) { - return undefined; - } - weekdays.push(converted); - } - - return [...fields.slice(0, 4), weekdays.join(',')].join(' '); -} - -interface CronTriggerMonitor { - monitorSlug: string; - monitorConfig: MonitorConfig | undefined; -} - -function getCronTriggerMonitor( - cron: string, - monitorCronTriggers: CloudflareOptions['monitorCronTriggers'], -): CronTriggerMonitor | undefined { - // Manual runs, e.g. through `wrangler dev --test-scheduled`, can have no cron expression. - if (!monitorCronTriggers || !cron) { - return undefined; - } - - let monitor: string | CronTriggerMonitorSettings | undefined; - try { - monitor = typeof monitorCronTriggers === 'function' ? monitorCronTriggers(cron) : cronToMonitorSlug(cron); - } catch (e) { - DEBUG_BUILD && debug.warn(`[Cron Triggers] \`monitorCronTriggers\` threw for "${cron}", sending no check-ins:`, e); - return undefined; - } - if (!monitor) { - return undefined; - } - - const { slug: monitorSlug, ...monitorSettings } = typeof monitor === 'string' ? { slug: 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 one.`); - } - - return { - monitorSlug, - monitorConfig: crontab ? { ...monitorSettings, schedule: { type: 'crontab', value: crontab } } : undefined, - }; -} - -// Check-ins are captured directly rather than through `withMonitor`, which would fork the -// isolation scope and lose the invocation state attached to it. -function startCronCheckIn( - cron: string, - monitorCronTriggers: CloudflareOptions['monitorCronTriggers'], -): ((status: 'ok' | 'error') => void) | undefined { - const monitor = getCronTriggerMonitor(cron, monitorCronTriggers); - if (!monitor) { - return undefined; - } - - const { monitorSlug, monitorConfig } = monitor; - const checkInId = captureCheckIn({ monitorSlug, status: 'in_progress' }, monitorConfig); - const startTime = timestampInSeconds(); - - return status => { - captureCheckIn({ monitorSlug, status, checkInId, duration: timestampInSeconds() - startTime }); - }; -} - function wrapScheduledHandler( controller: ScheduledController, options: CloudflareOptions, @@ -245,9 +59,11 @@ function wrapScheduledHandler( }, }, async () => { - let finishCheckIn: ReturnType; + let finishCheckIn: ReturnType; try { - finishCheckIn = startCronCheckIn(controller.cron, options.monitorCronTriggers); + finishCheckIn = client + ?.getIntegrationByName('CronTriggers') + ?.startCheckIn(controller.cron); const result = await fn(); finishCheckIn?.('ok'); return result; diff --git a/packages/cloudflare/src/integrations/cronTriggers.ts b/packages/cloudflare/src/integrations/cronTriggers.ts new file mode 100644 index 000000000000..b01367efaacd --- /dev/null +++ b/packages/cloudflare/src/integrations/cronTriggers.ts @@ -0,0 +1,193 @@ +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 = { slug: string } & Omit; + +export interface CronTriggersOptions { + /** + * Chooses the monitor slug for a cron expression. It can also return an object with the `slug` + * 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. + */ + slug?: (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 = { ' ': '-', '*': 'x', ',': '_', '-': 'to', '/': 'by' }; + +/** + * Derives a monitor slug from a cron expression, e.g. `30 9 * * 1-5` -> `cron-30-9-x-x-1to5`. + * + * 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; + } + + if (from === '*') { + return to ? undefined : step ? `SUN-SAT/${step}` : '*'; + } + + 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 weekday field can't be converted. + */ +function cloudflareCronToCrontab(cron: string): string | undefined { + const fields = cron.trim().split(/\s+/); + if (fields.length !== 5) { + return undefined; + } + + const weekdays = (fields[4] as string).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.slug ? options.slug(cron) : cronToMonitorSlug(cron); + } catch (e) { + DEBUG_BUILD && debug.warn(`[Cron Triggers] \`slug\` threw for "${cron}", sending no check-ins:`, e); + return undefined; + } + + if (!monitor) { + return undefined; + } + + const { slug: monitorSlug, ...monitorSettings } = typeof monitor === 'string' ? { slug: 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 one.`); + } + + // 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 to Sentry for every Cron Trigger run of the `scheduled` handler. + * + * Each check-in carries the trigger's cron expression as the monitor schedule, so Sentry creates + * the monitor on the first run and keeps its schedule in sync. Cloudflare numbers weekdays from + * 1 = Sunday, so the weekday field is converted to names before it is sent (`1-5` becomes + * `SUN-THU`). If it can't be converted, check-ins are sent without a schedule. Monitors are billed, + * which is why this integration is not enabled by default. + * + * By default, the monitor slug is `cron-` followed by the cron expression, lowercased, with fields + * joined by `-` and `*`, `,`, `-` and `/` written as `x`, `_`, `to` and `by`. For example, + * `30 9 * * 1-5` becomes `cron-30-9-x-x-1to5`. If the expression has other characters or the slug + * would be longer than 50 characters, it is shortened and a hash of the expression is appended. + * Workers that report to the same project and share a cron expression therefore share a monitor. + * Pass `slug` to choose the slug, and optionally other monitor settings, per cron expression. + * + * Runs without a cron expression, such as some manual `--test-scheduled` runs, send no check-ins. + * The Workers clock only advances on I/O, so the duration of a job that only uses the CPU may be + * reported as about 0. + * + * @example + * ```ts + * export default Sentry.withSentry( + * (env) => ({ + * dsn: env.SENTRY_DSN, + * integrations: [Sentry.cronTriggersIntegration()], + * }), + * handler, + * ); + * ``` + * + * @example + * ```ts + * export default Sentry.withSentry( + * (env) => ({ + * dsn: env.SENTRY_DSN, + * integrations: [ + * Sentry.cronTriggersIntegration({ + * slug: (cron) => (cron === '0 0 * * *' ? { slug: 'nightly-cleanup', maxRuntime: 30 } : undefined), + * }), + * ], + * }), + * handler, + * ); + * ``` + */ +export const cronTriggersIntegration = defineIntegration(_cronTriggersIntegration); diff --git a/packages/cloudflare/test/instrumentations/worker/instrumentScheduled.test.ts b/packages/cloudflare/test/instrumentations/worker/instrumentScheduled.test.ts index 0d5cee5d3c69..8d4f837a3b46 100644 --- a/packages/cloudflare/test/instrumentations/worker/instrumentScheduled.test.ts +++ b/packages/cloudflare/test/instrumentations/worker/instrumentScheduled.test.ts @@ -2,15 +2,15 @@ // Although this is not ideal, this is the best we can do until we have a better way to test cloudflare workers. import type { ExecutionContext, ScheduledController } from '@cloudflare/workers-types'; -import type { Event } from '@sentry/core'; +import type { Event, Integration } from '@sentry/core'; import * as SentryCore from '@sentry/core'; import { beforeEach, describe, expect, onTestFinished, test, vi } from 'vitest'; -import type { CloudflareOptions } from '../../../src/client'; import { CloudflareClient } from '../../../src/client'; import { instrumentWorkerEntrypoint, type WorkerEntrypointConstructor, } from '../../../src/instrumentations/instrumentWorkerEntrypoint'; +import { cronTriggersIntegration } from '../../../src/integrations/cronTriggers'; import { withSentry } from '../../../src/withSentry'; import { resetSdk } from '../../testUtils'; @@ -313,35 +313,25 @@ describe('instrumentScheduled', () => { } async function runScheduled( - monitorCronTriggers: CloudflareOptions['monitorCronTriggers'], + integrations: Integration[], scheduled: ExportedHandler['scheduled'] = () => {}, - cron = '30 9 * * 1-5', ): Promise { - const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN, monitorCronTriggers }), { scheduled }); - await wrappedHandler.scheduled?.(controllerFor(cron), MOCK_ENV, createMockExecutionContext()); + const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN, integrations }), { scheduled }); + await wrappedHandler.scheduled?.(controllerFor('30 9 * * 1-5'), MOCK_ENV, createMockExecutionContext()); } - async function getInProgressCheckIn( - cron: string, - monitorCronTriggers: CloudflareOptions['monitorCronTriggers'] = true, - ): Promise { - const captureCheckInSpy = vi.spyOn(SentryCore, 'captureCheckIn'); - await runScheduled(monitorCronTriggers, undefined, cron); - return captureCheckInSpy.mock.calls[0] as unknown[]; - } - - test('sends no check-ins by default', async () => { + test('sends no check-ins without the integration', async () => { const captureCheckInSpy = vi.spyOn(SentryCore, 'captureCheckIn'); - await runScheduled(undefined); + await runScheduled([]); expect(captureCheckInSpy).not.toHaveBeenCalled(); }); - test('sends check-ins with the cron schedule when enabled', async () => { + test('sends check-ins with the cron schedule with the integration', async () => { const captureCheckInSpy = vi.spyOn(SentryCore, 'captureCheckIn'); - await runScheduled(true); + await runScheduled([cronTriggersIntegration()]); expect(captureCheckInSpy).toHaveBeenCalledTimes(2); expect(captureCheckInSpy).toHaveBeenNthCalledWith( @@ -359,148 +349,29 @@ describe('instrumentScheduled', () => { test('marks the check-in as failed when the handler throws', async () => { const captureCheckInSpy = vi.spyOn(SentryCore, 'captureCheckIn'); + const captureExceptionSpy = vi.spyOn(SentryCore, 'captureException'); + const error = new Error('test'); await expect( - runScheduled(true, () => { - throw new Error('test'); + runScheduled([cronTriggersIntegration()], () => { + throw error; }), ).rejects.toThrow('test'); - expect(captureCheckInSpy).toHaveBeenLastCalledWith(expect.objectContaining({ status: 'error' })); - }); - - test('uses the slug returned by a function', async () => { - const captureCheckInSpy = vi.spyOn(SentryCore, 'captureCheckIn'); - const getSlug = vi.fn().mockReturnValue('weekday-report'); - - await runScheduled(getSlug); - - expect(getSlug).toHaveBeenCalledWith('30 9 * * 1-5'); - expect(captureCheckInSpy).toHaveBeenNthCalledWith( - 1, - { monitorSlug: 'weekday-report', status: 'in_progress' }, - { schedule: { type: 'crontab', value: '30 9 * * SUN-THU' } }, + expect(captureCheckInSpy).toHaveBeenLastCalledWith( + expect.objectContaining({ status: 'error', duration: expect.any(Number) }), ); - }); - - test('sends the monitor settings returned by a function', async () => { - const [, monitorConfig] = await getInProgressCheckIn('0 0 * * *', () => ({ - slug: 'nightly', - checkinMargin: 5, - maxRuntime: 30, - })); - - expect(monitorConfig).toEqual({ - schedule: { type: 'crontab', value: '0 0 * * *' }, - checkinMargin: 5, - maxRuntime: 30, + expect(captureExceptionSpy).toHaveBeenCalledWith(error, { + mechanism: { handled: false, type: 'auto.faas.cloudflare.scheduled' }, }); }); - test('sends no check-ins and still runs the handler when the function throws', async () => { - const captureCheckInSpy = vi.spyOn(SentryCore, 'captureCheckIn'); - const warnSpy = vi.spyOn(SentryCore.debug, 'warn').mockImplementation(() => undefined); - const scheduled = vi.fn(); - - await runScheduled(() => { - throw new Error('slug error'); - }, scheduled); - - expect(scheduled).toHaveBeenCalledTimes(1); - expect(captureCheckInSpy).not.toHaveBeenCalled(); - expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('`monitorCronTriggers` threw'), expect.any(Error)); - }); - - test('sends no check-ins for a run without a cron expression', async () => { - const captureCheckInSpy = vi.spyOn(SentryCore, 'captureCheckIn'); - const scheduled = vi.fn(); - - await runScheduled(true, scheduled, ''); - - expect(scheduled).toHaveBeenCalledTimes(1); - expect(captureCheckInSpy).not.toHaveBeenCalled(); - }); - - test.each([ - ['1', 'SUN'], - ['7', 'SAT'], - ['2,4,6', 'MON,WED,FRI'], - ['2-6', 'MON-FRI'], - ['1-7/2', 'SUN-SAT/2'], - ['2/3', 'MON-SAT/3'], - ['7/1', 'SAT'], - ['3-3/2', 'TUE'], - ['*/2', 'SUN-SAT/2'], - ['*', '*'], - ['mon-fri', 'MON-FRI'], - ['SAT,1', 'SAT,SUN'], - ['6L', '5L'], - ['FRIL', '5L'], - ['6#2', 'FRI#2'], - ['MON#1', 'MON#1'], - ])('converts the weekday field %s to %s', async (weekdays, expected) => { - const [, monitorConfig] = await getInProgressCheckIn(`0 9 * * ${weekdays}`); - - expect(monitorConfig).toEqual({ schedule: { type: 'crontab', value: `0 9 * * ${expected}` } }); - }); - - test.each([['0'], ['8'], ['6-2'], ['L'], ['?'], ['1-*'], ['2/0'], ['6#6']])( - 'sends check-ins without a schedule for the weekday field %s', - async weekdays => { - const [checkIn, monitorConfig] = await getInProgressCheckIn(`0 9 * * ${weekdays}`); - - expect(checkIn).toEqual(expect.objectContaining({ status: 'in_progress' })); - expect(monitorConfig).toBeUndefined(); - }, - ); - - test('sends check-ins without a schedule for an expression without five fields', async () => { - const [, monitorConfig] = await getInProgressCheckIn('0 0 9 * * *'); - - expect(monitorConfig).toBeUndefined(); - }); - - test.each([ - ['0 9 * * 1,5', 'cron-0-9-x-x-1_5'], - ['0 9 * * 1-5', 'cron-0-9-x-x-1to5'], - ['*/15 * * * *', 'cron-xby15-x-x-x-x'], - ['0 9 * * MON', 'cron-0-9-x-x-mon'], - ])('derives the slug for %s as %s', async (cron, slug) => { - const [checkIn] = await getInProgressCheckIn(cron); - - expect(checkIn).toEqual(expect.objectContaining({ monitorSlug: slug })); - }); - - test('derives different slugs for expressions that differ only in separators', async () => { - const crons = ['0 9 * * 1,5', '0 9 * * 1-5', '0 9 * * 1/5', '0 9 * * 1#5']; - const slugs = []; - for (const cron of crons) { - const [checkIn] = await getInProgressCheckIn(cron); - slugs.push((checkIn as { monitorSlug: string }).monitorSlug); - vi.restoreAllMocks(); - } - - expect(new Set(slugs).size).toBe(crons.length); - expect(slugs[3]).toMatch(/^cron-0-9-x-x-1-5-[a-z0-9]{6}$/); - }); - - test('shortens long slugs and appends a hash', async () => { - const minutes = Array.from({ length: 30 }, (_, i) => i).join(','); - const [first] = await getInProgressCheckIn(`${minutes} * * * *`); - vi.restoreAllMocks(); - const [second] = await getInProgressCheckIn(`${minutes},59 * * * *`); - - const firstSlug = (first as { monitorSlug: string }).monitorSlug; - const secondSlug = (second as { monitorSlug: string }).monitorSlug; - expect(firstSlug.length).toBeLessThanOrEqual(50); - expect(firstSlug).toMatch(/^cron-0_1_2_.*-[a-z0-9]{6}$/); - expect(secondSlug).not.toBe(firstSlug); - }); - test('marks the check-in as failed when the handler rejects', async () => { const captureCheckInSpy = vi.spyOn(SentryCore, 'captureCheckIn'); - await expect(runScheduled(true, () => Promise.reject(new Error('rejected')))).rejects.toThrow('rejected'); + await expect( + runScheduled([cronTriggersIntegration()], () => Promise.reject(new Error('rejected'))), + ).rejects.toThrow('rejected'); expect(captureCheckInSpy).toHaveBeenCalledTimes(2); expect(captureCheckInSpy).toHaveBeenLastCalledWith( @@ -514,7 +385,7 @@ describe('instrumentScheduled', () => { scheduled() {} }; const instrumented = instrumentWorkerEntrypoint( - () => ({ dsn: MOCK_ENV.SENTRY_DSN, monitorCronTriggers: true }), + () => ({ dsn: MOCK_ENV.SENTRY_DSN, integrations: [cronTriggersIntegration()] }), TestEntrypoint as unknown as WorkerEntrypointConstructor, ); const entrypoint = Reflect.construct(instrumented, [createMockExecutionContext(), MOCK_ENV]); @@ -530,14 +401,6 @@ describe('instrumentScheduled', () => { expect(captureCheckInSpy).toHaveBeenLastCalledWith(expect.objectContaining({ status: 'ok' })); }); - test('sends no check-ins when the function returns undefined', async () => { - const captureCheckInSpy = vi.spyOn(SentryCore, 'captureCheckIn'); - - await runScheduled(() => undefined); - - expect(captureCheckInSpy).not.toHaveBeenCalled(); - }); - test('flushes both check-ins before the invocation ends', async () => { const sentItemTypes: string[] = []; const handler = { @@ -547,7 +410,7 @@ describe('instrumentScheduled', () => { env => ({ dsn: env.SENTRY_DSN, cacheClient: false, - monitorCronTriggers: true, + integrations: [cronTriggersIntegration()], transport: () => ({ send: async envelope => { sentItemTypes.push(...envelope[1].map(([itemHeader]) => itemHeader.type)); diff --git a/packages/cloudflare/test/integrations/cronTriggers.test.ts b/packages/cloudflare/test/integrations/cronTriggers.test.ts new file mode 100644 index 000000000000..3ad5c6d5c2c7 --- /dev/null +++ b/packages/cloudflare/test/integrations/cronTriggers.test.ts @@ -0,0 +1,154 @@ +import * as SentryCore from '@sentry/core'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import type { CronTriggersIntegration, CronTriggersOptions } from '../../src/integrations/cronTriggers'; +import { cronTriggersIntegration } from '../../src/integrations/cronTriggers'; + +function startCheckIn( + cron: string, + options?: CronTriggersOptions, +): ReturnType { + return (cronTriggersIntegration(options) as unknown as CronTriggersIntegration).startCheckIn(cron); +} + +function getInProgressCheckIn(cron: string, options?: CronTriggersOptions): unknown[] | undefined { + const captureCheckInSpy = vi.spyOn(SentryCore, 'captureCheckIn').mockReturnValue('check-in-id'); + startCheckIn(cron, options); + const call = captureCheckInSpy.mock.calls[0]; + captureCheckInSpy.mockRestore(); + return call; +} + +function getSlug(cron: string): string { + return (getInProgressCheckIn(cron)?.[0] as { monitorSlug: string }).monitorSlug; +} + +describe('cronTriggersIntegration', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + test('captures in_progress and then the final status with a duration', () => { + const captureCheckInSpy = vi.spyOn(SentryCore, 'captureCheckIn').mockReturnValue('check-in-id'); + + const finish = startCheckIn('30 9 * * 1-5'); + finish?.('error'); + + expect(captureCheckInSpy).toHaveBeenCalledTimes(2); + expect(captureCheckInSpy).toHaveBeenNthCalledWith( + 1, + { monitorSlug: 'cron-30-9-x-x-1to5', status: 'in_progress' }, + { schedule: { type: 'crontab', value: '30 9 * * SUN-THU' } }, + ); + expect(captureCheckInSpy).toHaveBeenNthCalledWith(2, { + monitorSlug: 'cron-30-9-x-x-1to5', + status: 'error', + checkInId: 'check-in-id', + duration: expect.any(Number), + }); + }); + + test('uses the slug returned by the slug function', () => { + const slug = vi.fn().mockReturnValue('weekday-report'); + + expect(getInProgressCheckIn('30 9 * * 1-5', { slug })).toEqual([ + { monitorSlug: 'weekday-report', status: 'in_progress' }, + { schedule: { type: 'crontab', value: '30 9 * * SUN-THU' } }, + ]); + expect(slug).toHaveBeenCalledWith('30 9 * * 1-5'); + }); + + test('sends the monitor settings returned by the slug function', () => { + const [, monitorConfig] = getInProgressCheckIn('0 0 * * *', { + slug: () => ({ slug: 'nightly', checkinMargin: 5, maxRuntime: 30 }), + }) as unknown[]; + + expect(monitorConfig).toEqual({ + schedule: { type: 'crontab', value: '0 0 * * *' }, + checkinMargin: 5, + maxRuntime: 30, + }); + }); + + test('sends no check-ins when the slug function returns undefined', () => { + expect(getInProgressCheckIn('0 0 * * *', { slug: () => undefined })).toBeUndefined(); + expect(startCheckIn('0 0 * * *', { slug: () => undefined })).toBeUndefined(); + }); + + test('sends no check-ins and warns when the slug function throws', () => { + const warnSpy = vi.spyOn(SentryCore.debug, 'warn').mockImplementation(() => undefined); + const slug = (): string => { + throw new Error('slug error'); + }; + + expect(getInProgressCheckIn('0 0 * * *', { slug })).toBeUndefined(); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('`slug` threw'), expect.any(Error)); + }); + + test('sends no check-ins for a run without a cron expression', () => { + expect(getInProgressCheckIn('')).toBeUndefined(); + }); + + test.each([ + ['1', 'SUN'], + ['7', 'SAT'], + ['2,4,6', 'MON,WED,FRI'], + ['2-6', 'MON-FRI'], + ['1-7/2', 'SUN-SAT/2'], + ['2/3', 'MON-SAT/3'], + ['7/1', 'SAT'], + ['3-3/2', 'TUE'], + ['*/2', 'SUN-SAT/2'], + ['*', '*'], + ['mon-fri', 'MON-FRI'], + ['SAT,1', 'SAT,SUN'], + ])('converts the weekday field %s to %s', (weekdays, expected) => { + const [, monitorConfig] = getInProgressCheckIn(`0 9 * * ${weekdays}`) as unknown[]; + + expect(monitorConfig).toEqual({ schedule: { type: 'crontab', value: `0 9 * * ${expected}` } }); + }); + + test.each([['0'], ['8'], ['6-2'], ['L'], ['6L'], ['6#2'], ['?'], ['1-*'], ['2/0']])( + 'sends check-ins without a schedule for the weekday field %s', + weekdays => { + const warnSpy = vi.spyOn(SentryCore.debug, 'warn').mockImplementation(() => undefined); + const [checkIn, monitorConfig] = getInProgressCheckIn(`0 9 * * ${weekdays}`) as unknown[]; + + expect(checkIn).toEqual(expect.objectContaining({ status: 'in_progress' })); + expect(monitorConfig).toBeUndefined(); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Can't convert")); + }, + ); + + test('sends check-ins without a schedule for an expression without five fields', () => { + const [, monitorConfig] = getInProgressCheckIn('0 0 9 * * *') as unknown[]; + + expect(monitorConfig).toBeUndefined(); + }); + + test.each([ + ['0 9 * * 1,5', 'cron-0-9-x-x-1_5'], + ['0 9 * * 1-5', 'cron-0-9-x-x-1to5'], + ['*/15 * * * *', 'cron-xby15-x-x-x-x'], + ['0 9 * * MON', 'cron-0-9-x-x-mon'], + ])('derives the slug for %s as %s', (cron, slug) => { + expect(getSlug(cron)).toBe(slug); + }); + + test('derives different slugs for expressions that differ only in separators', () => { + const crons = ['0 9 * * 1,5', '0 9 * * 1-5', '0 9 * * 1/5', '0 9 * * 1#5']; + const slugs = crons.map(getSlug); + + expect(new Set(slugs).size).toBe(crons.length); + expect(slugs[3]).toMatch(/^cron-0-9-x-x-1-5-[a-z0-9]{1,6}$/); + }); + + test('shortens long slugs and appends a hash', () => { + const minutes = Array.from({ length: 30 }, (_, i) => i).join(','); + const first = getSlug(`${minutes} * * * *`); + const second = getSlug(`${minutes},59 * * * *`); + + expect(first.length).toBeLessThanOrEqual(50); + expect(first).toMatch(/^cron-0_1_2_.*-[a-z0-9]{1,6}$/); + expect(second).not.toBe(first); + }); +}); From 58f7a7eeb0a1a1b4480ad277394a5a9b39211fb1 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Fri, 2 Oct 2026 16:06:38 -0700 Subject: [PATCH 4/7] ref(cloudflare): Shorten cronTriggersIntegration JSDoc Co-Authored-By: Claude --- .../src/integrations/cronTriggers.ts | 46 ++++++------------- 1 file changed, 13 insertions(+), 33 deletions(-) diff --git a/packages/cloudflare/src/integrations/cronTriggers.ts b/packages/cloudflare/src/integrations/cronTriggers.ts index b01367efaacd..ee7649153310 100644 --- a/packages/cloudflare/src/integrations/cronTriggers.ts +++ b/packages/cloudflare/src/integrations/cronTriggers.ts @@ -145,48 +145,28 @@ const _cronTriggersIntegration = ((options: CronTriggersOptions = {}): CronTrigg }) satisfies IntegrationFn; /** - * Sends cron check-ins to Sentry for every Cron Trigger run of the `scheduled` handler. + * 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. * - * Each check-in carries the trigger's cron expression as the monitor schedule, so Sentry creates - * the monitor on the first run and keeps its schedule in sync. Cloudflare numbers weekdays from - * 1 = Sunday, so the weekday field is converted to names before it is sent (`1-5` becomes - * `SUN-THU`). If it can't be converted, check-ins are sent without a schedule. Monitors are billed, - * which is why this integration is not enabled by default. - * - * By default, the monitor slug is `cron-` followed by the cron expression, lowercased, with fields - * joined by `-` and `*`, `,`, `-` and `/` written as `x`, `_`, `to` and `by`. For example, - * `30 9 * * 1-5` becomes `cron-30-9-x-x-1to5`. If the expression has other characters or the slug - * would be longer than 50 characters, it is shortened and a hash of the expression is appended. - * Workers that report to the same project and share a cron expression therefore share a monitor. - * Pass `slug` to choose the slug, and optionally other monitor settings, per cron expression. - * - * Runs without a cron expression, such as some manual `--test-scheduled` runs, send no check-ins. - * The Workers clock only advances on I/O, so the duration of a job that only uses the CPU may be - * reported as about 0. + * Cron Triggers have no names, so map each cron expression to a slug. Without `slug`, the slug is + * derived from the expression (`30 9 * * 1-5` becomes `cron-30-9-x-x-1to5`) and changes with it. * * @example * ```ts - * export default Sentry.withSentry( - * (env) => ({ - * dsn: env.SENTRY_DSN, - * integrations: [Sentry.cronTriggersIntegration()], - * }), - * handler, - * ); - * ``` + * const jobs = { + * '30 9 * * 1-5': { slug: 'daily-report', run: dailyReport }, + * }; * - * @example - * ```ts * export default Sentry.withSentry( * (env) => ({ * dsn: env.SENTRY_DSN, - * integrations: [ - * Sentry.cronTriggersIntegration({ - * slug: (cron) => (cron === '0 0 * * *' ? { slug: 'nightly-cleanup', maxRuntime: 30 } : undefined), - * }), - * ], + * integrations: [Sentry.cronTriggersIntegration({ slug: (cron) => jobs[cron]?.slug })], * }), - * handler, + * { + * async scheduled(controller, env) { + * await jobs[controller.cron]?.run(env); + * }, + * }, * ); * ``` */ From eb22bd1c9c5c64e6db2de84ae6a0312f5b07ca48 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Fri, 2 Oct 2026 16:09:09 -0700 Subject: [PATCH 5/7] fix(cloudflare): Keep */n weekdays and skip W/? days of month */n gives the same days in both numberings and keeps Sentry's AND rule for the day fields. Sentry rejects W and ? in the day of month, so send no schedule. Co-Authored-By: Claude --- packages/cloudflare/src/integrations/cronTriggers.ts | 6 ++++-- .../cloudflare/test/integrations/cronTriggers.test.ts | 8 +++++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/cloudflare/src/integrations/cronTriggers.ts b/packages/cloudflare/src/integrations/cronTriggers.ts index ee7649153310..d118f63dd00e 100644 --- a/packages/cloudflare/src/integrations/cronTriggers.ts +++ b/packages/cloudflare/src/integrations/cronTriggers.ts @@ -64,8 +64,9 @@ function convertWeekdayItem(item: string): string | undefined { return undefined; } + // `*` and `*/n` give the same days in both numberings, and keep their `*` meaning for Sentry. if (from === '*') { - return to ? undefined : step ? `SUN-SAT/${step}` : '*'; + return to ? undefined : item; } const start = weekdayIndex(from as string); @@ -89,7 +90,8 @@ function convertWeekdayItem(item: string): string | undefined { */ function cloudflareCronToCrontab(cron: string): string | undefined { const fields = cron.trim().split(/\s+/); - if (fields.length !== 5) { + // Sentry rejects `W` and `?` in the day of month. + if (fields.length !== 5 || /[w?]/i.test(fields[2] as string)) { return undefined; } diff --git a/packages/cloudflare/test/integrations/cronTriggers.test.ts b/packages/cloudflare/test/integrations/cronTriggers.test.ts index 3ad5c6d5c2c7..f3dcba4ea8a3 100644 --- a/packages/cloudflare/test/integrations/cronTriggers.test.ts +++ b/packages/cloudflare/test/integrations/cronTriggers.test.ts @@ -97,7 +97,7 @@ describe('cronTriggersIntegration', () => { ['2/3', 'MON-SAT/3'], ['7/1', 'SAT'], ['3-3/2', 'TUE'], - ['*/2', 'SUN-SAT/2'], + ['*/2', '*/2'], ['*', '*'], ['mon-fri', 'MON-FRI'], ['SAT,1', 'SAT,SUN'], @@ -119,6 +119,12 @@ describe('cronTriggersIntegration', () => { }, ); + test.each([['15W'], ['LW'], ['?']])('sends check-ins without a schedule for the day of month %s', dayOfMonth => { + const [, monitorConfig] = getInProgressCheckIn(`0 9 ${dayOfMonth} * *`) as unknown[]; + + expect(monitorConfig).toBeUndefined(); + }); + test('sends check-ins without a schedule for an expression without five fields', () => { const [, monitorConfig] = getInProgressCheckIn('0 0 9 * * *') as unknown[]; From ef0d7689db0cf9467a7a302a3360642f42c85d21 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Fri, 2 Oct 2026 17:41:26 -0700 Subject: [PATCH 6/7] fix(cloudflare): Keep cron check-in failures out of the scheduled handler Start the check-in before the handler's try block and guard the final check-in, so a failing check-in never skips the job, fails a successful run, or is captured as the handler's exception. Co-Authored-By: Claude --- .../worker/instrumentScheduled.ts | 20 ++++++-- .../worker/instrumentScheduled.test.ts | 46 +++++++++++++++++++ 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentScheduled.ts b/packages/cloudflare/src/instrumentations/worker/instrumentScheduled.ts index 1b7113d03ad8..8285830b73c1 100644 --- a/packages/cloudflare/src/instrumentations/worker/instrumentScheduled.ts +++ b/packages/cloudflare/src/instrumentations/worker/instrumentScheduled.ts @@ -12,8 +12,9 @@ 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'; @@ -64,11 +65,24 @@ function wrapScheduledHandler( finishCheckIn = client ?.getIntegrationByName('CronTriggers') ?.startCheckIn(controller.cron); + } 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(); - finishCheckIn?.('ok'); + finish('ok'); return result; } catch (e) { - finishCheckIn?.('error'); + finish('error'); captureException(e, { mechanism: { handled: false, type: 'auto.faas.cloudflare.scheduled' } }); throw e; } finally { diff --git a/packages/cloudflare/test/instrumentations/worker/instrumentScheduled.test.ts b/packages/cloudflare/test/instrumentations/worker/instrumentScheduled.test.ts index 8d4f837a3b46..482df9f6c868 100644 --- a/packages/cloudflare/test/instrumentations/worker/instrumentScheduled.test.ts +++ b/packages/cloudflare/test/instrumentations/worker/instrumentScheduled.test.ts @@ -379,6 +379,52 @@ describe('instrumentScheduled', () => { ); }); + test('runs the handler without check-ins when the in_progress check-in throws', async () => { + const captureCheckInSpy = vi.spyOn(SentryCore, 'captureCheckIn').mockImplementation(() => { + throw new Error('check-in error'); + }); + onTestFinished(() => captureCheckInSpy.mockRestore()); + const captureExceptionSpy = vi.spyOn(SentryCore, 'captureException'); + const scheduled = vi.fn().mockResolvedValue('result'); + const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN, integrations: [cronTriggersIntegration()] }), { + scheduled, + } as ExportedHandler); + + const result = await wrappedHandler.scheduled?.( + controllerFor('30 9 * * 1-5'), + MOCK_ENV, + createMockExecutionContext(), + ); + + expect(result).toBe('result'); + expect(scheduled).toHaveBeenCalledTimes(1); + expect(captureCheckInSpy).toHaveBeenCalledTimes(1); + expect(captureExceptionSpy).not.toHaveBeenCalled(); + }); + + test('returns the handler result when the ok check-in throws', async () => { + const captureCheckInSpy = vi + .spyOn(SentryCore, 'captureCheckIn') + .mockReturnValueOnce('check-in-id') + .mockImplementationOnce(() => { + throw new Error('check-in error'); + }); + onTestFinished(() => captureCheckInSpy.mockRestore()); + const captureExceptionSpy = vi.spyOn(SentryCore, 'captureException'); + const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN, integrations: [cronTriggersIntegration()] }), { + scheduled: () => Promise.resolve('result'), + } as unknown as ExportedHandler); + + const result = await wrappedHandler.scheduled?.( + controllerFor('30 9 * * 1-5'), + MOCK_ENV, + createMockExecutionContext(), + ); + + expect(result).toBe('result'); + expect(captureExceptionSpy).not.toHaveBeenCalled(); + }); + test('sends check-ins for the scheduled method of a WorkerEntrypoint', async () => { const captureCheckInSpy = vi.spyOn(SentryCore, 'captureCheckIn'); const TestEntrypoint = class { From 370d89133615227d85a5cddbd78ee05152eae5bd Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Fri, 2 Oct 2026 17:41:27 -0700 Subject: [PATCH 7/7] docs(cloudflare): Use MON-FRI in the weekday cron examples On Cloudflare 1 = Sunday, so `1-5` runs Sunday to Thursday. Day names are unambiguous. Co-Authored-By: Claude --- packages/cloudflare/src/integrations/cronTriggers.ts | 6 +++--- packages/cloudflare/test/integrations/cronTriggers.test.ts | 7 ++++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/cloudflare/src/integrations/cronTriggers.ts b/packages/cloudflare/src/integrations/cronTriggers.ts index d118f63dd00e..460e56166bf1 100644 --- a/packages/cloudflare/src/integrations/cronTriggers.ts +++ b/packages/cloudflare/src/integrations/cronTriggers.ts @@ -27,7 +27,7 @@ export interface CronTriggersIntegration { const SLUG_TOKENS: Record = { ' ': '-', '*': 'x', ',': '_', '-': 'to', '/': 'by' }; /** - * Derives a monitor slug from a cron expression, e.g. `30 9 * * 1-5` -> `cron-30-9-x-x-1to5`. + * 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. @@ -151,12 +151,12 @@ const _cronTriggersIntegration = ((options: CronTriggersOptions = {}): CronTrigg * schedule, so Sentry creates the monitor on the first run. * * Cron Triggers have no names, so map each cron expression to a slug. Without `slug`, the slug is - * derived from the expression (`30 9 * * 1-5` becomes `cron-30-9-x-x-1to5`) and changes with it. + * derived from the expression (`30 9 * * MON-FRI` becomes `cron-30-9-x-x-montofri`) and changes with it. * * @example * ```ts * const jobs = { - * '30 9 * * 1-5': { slug: 'daily-report', run: dailyReport }, + * '30 9 * * MON-FRI': { slug: 'daily-report', run: dailyReport }, * }; * * export default Sentry.withSentry( diff --git a/packages/cloudflare/test/integrations/cronTriggers.test.ts b/packages/cloudflare/test/integrations/cronTriggers.test.ts index f3dcba4ea8a3..a8d8b6586066 100644 --- a/packages/cloudflare/test/integrations/cronTriggers.test.ts +++ b/packages/cloudflare/test/integrations/cronTriggers.test.ts @@ -50,11 +50,11 @@ describe('cronTriggersIntegration', () => { test('uses the slug returned by the slug function', () => { const slug = vi.fn().mockReturnValue('weekday-report'); - expect(getInProgressCheckIn('30 9 * * 1-5', { slug })).toEqual([ + expect(getInProgressCheckIn('30 9 * * MON-FRI', { slug })).toEqual([ { monitorSlug: 'weekday-report', status: 'in_progress' }, - { schedule: { type: 'crontab', value: '30 9 * * SUN-THU' } }, + { schedule: { type: 'crontab', value: '30 9 * * MON-FRI' } }, ]); - expect(slug).toHaveBeenCalledWith('30 9 * * 1-5'); + expect(slug).toHaveBeenCalledWith('30 9 * * MON-FRI'); }); test('sends the monitor settings returned by the slug function', () => { @@ -136,6 +136,7 @@ describe('cronTriggersIntegration', () => { ['0 9 * * 1-5', 'cron-0-9-x-x-1to5'], ['*/15 * * * *', 'cron-xby15-x-x-x-x'], ['0 9 * * MON', 'cron-0-9-x-x-mon'], + ['30 9 * * MON-FRI', 'cron-30-9-x-x-montofri'], ])('derives the slug for %s as %s', (cron, slug) => { expect(getSlug(cron)).toBe(slug); });