diff --git a/dev-packages/node-integration-tests/suites/cron/graphile-worker/test.ts b/dev-packages/node-integration-tests/suites/cron/graphile-worker/test.ts index 1dcd8ef4702c..fc1248f021e3 100644 --- a/dev-packages/node-integration-tests/suites/cron/graphile-worker/test.ts +++ b/dev-packages/node-integration-tests/suites/cron/graphile-worker/test.ts @@ -27,6 +27,7 @@ describeWithDockerCompose('graphile-worker crontab monitoring', { workingDirecto monitor_slug: 'hourly-report', status: 'in_progress', release: '1.0', + monitor_config: { schedule: { type: 'crontab', value: '0 * * * *' }, timezone: 'UTC' }, }, }) .expect({ @@ -44,6 +45,7 @@ describeWithDockerCompose('graphile-worker crontab monitoring', { workingDirecto monitor_slug: 'failing_task', status: 'in_progress', release: '1.0', + monitor_config: { schedule: { type: 'crontab', value: '30 * * * *' }, timezone: 'UTC' }, }, }) .expect({ diff --git a/packages/server-utils/src/integrations/graphile-worker.ts b/packages/server-utils/src/integrations/graphile-worker.ts index 0767a70539a2..72554da8080e 100644 --- a/packages/server-utils/src/integrations/graphile-worker.ts +++ b/packages/server-utils/src/integrations/graphile-worker.ts @@ -1,5 +1,5 @@ import * as diagnosticsChannel from '../utils/diagnosticsChannel'; -import type { IntegrationFn } from '@sentry/core'; +import type { IntegrationFn, MonitorConfig } from '@sentry/core'; import { captureCheckIn, captureException, @@ -35,7 +35,9 @@ interface CronItem { identifier: string; task: string; payload?: Record | null; - match: (digest: Record<'min' | 'hour' | 'date' | 'month' | 'dow', number>) => boolean; + match: ((digest: Record<'min' | 'hour' | 'date' | 'month' | 'dow', number>) => boolean) & { + parsedCronMatch?: Record; + }; } interface Job { @@ -61,8 +63,8 @@ const _graphileWorkerIntegration = ((options: GraphileWorkerOptions = {}) => { }) satisfies IntegrationFn; /** - * Sends Crons check-ins for the jobs the `graphile-worker` crontab schedules. - * Requires the Sentry runtime hook or bundler plugin. + * Sends Crons check-ins for the jobs the `graphile-worker` crontab schedules, with the crontab + * schedule as monitor config. Requires the Sentry runtime hook or bundler plugin. */ export const graphileWorkerIntegration = defineIntegration(_graphileWorkerIntegration); @@ -100,7 +102,7 @@ export function monitorGraphileCronJobs(events: Events, items: CronItem[], optio return; } const slug = item.identifier; - const checkInId = captureCheckIn({ monitorSlug: slug, status: 'in_progress' }); + const checkInId = captureCheckIn({ monitorSlug: slug, status: 'in_progress' }, toMonitorConfig(item)); runs.set(job, { slug, checkInId, start: timestampInSeconds() }); } catch (e) { DEBUG_BUILD && debug.error('[GraphileWorker] Failed to send the check-in:', e); @@ -154,3 +156,51 @@ function findCronItem(items: CronItem[], job: Job): CronItem | undefined { } return candidates.length === 1 ? candidates[0] : undefined; } + +// graphile-worker runs the crontab in UTC. Rebuilding it from the parsed values keeps the day fields +// unambiguous: a day field is `*` only when it matches every day, as graphile-worker reads it. +function toMonitorConfig(item: CronItem): MonitorConfig | undefined { + const match = item.match.parsedCronMatch; + if (!match) { + return undefined; + } + const fields = [ + toCrontabField(match.minutes, 60, 0, true), + toCrontabField(match.hours, 24, 0, true), + toCrontabField(match.dates, 31, 1), + toCrontabField(match.months, 12, 1, true), + toCrontabField(match.dows, 7, 0), + ]; + if (fields.some(field => !field)) { + return undefined; + } + return { schedule: { type: 'crontab', value: fields.join(' ') }, timezone: 'UTC' }; +} + +// A step (`*/n`) in a day field would change how cron combines the day fields, so only other fields use one. +function toCrontabField( + values: number[] | undefined, + size: number, + min: number, + allowStep = false, +): string | undefined { + if (!Array.isArray(values) || !values.length) { + return undefined; + } + if (values.length >= size) { + return '*'; + } + const step = (values[1] ?? 0) - (values[0] ?? 0); + if (allowStep && values.length * step >= size && values.every((value, i) => value === min + i * step)) { + return `*/${step}`; + } + const parts: string[] = []; + for (let i = 0; i < values.length; i++) { + const start = values[i]; + while (values[i + 1] === (values[i] as number) + 1) { + i++; + } + parts.push(start === values[i] ? String(start) : `${start}-${values[i]}`); + } + return parts.join(','); +} diff --git a/packages/server-utils/test/integrations/graphile-worker.test.ts b/packages/server-utils/test/integrations/graphile-worker.test.ts index a118604d17b6..db5d1bbfe873 100644 --- a/packages/server-utils/test/integrations/graphile-worker.test.ts +++ b/packages/server-utils/test/integrations/graphile-worker.test.ts @@ -60,14 +60,17 @@ describe('graphile-worker crontab monitoring', () => { events.emit('job:complete', { job, error }); } - it('sends check-ins using the item identifier as slug', () => { + it('sends check-ins with the crontab in UTC, using the item identifier as slug', () => { const events = new EventEmitter(); monitorGraphileCronJobs(events, [cronItem('hourly-report', 'report', { minutes: [0], dows: [1, 2, 3, 4, 5] })], {}); runJob(events, cronJob('report', '2026-10-16T09:00:00.000Z')); expect(captureCheckIn.mock.calls).toEqual([ - [{ monitorSlug: 'hourly-report', status: 'in_progress' }], + [ + { monitorSlug: 'hourly-report', status: 'in_progress' }, + { schedule: { type: 'crontab', value: '0 * * * 1-5' }, timezone: 'UTC' }, + ], [{ monitorSlug: 'hourly-report', status: 'ok', checkInId: 'check-in-id', duration: expect.any(Number) }], ]); }); @@ -85,6 +88,37 @@ describe('graphile-worker crontab monitoring', () => { }); }); + it.each([ + [{ minutes: [0, 15, 30, 45], hours: [0, 6, 12, 18] }, '*/15 */6 * * *'], + [ + { minutes: [0], hours: [0], dates: ALL(1, 31).filter(d => d % 2 === 1), dows: [1] }, + '0 0 1,3,5,7,9,11,13,15,17,19,21,23,25,27,29,31 * 1', + ], + [{ minutes: [5], hours: [4], dates: [1, 15], months: [1, 4, 7, 10] }, '5 4 1,15 */3 *'], + [{ minutes: [0], hours: [0], months: [3, 6, 9, 12] }, '0 0 * 3,6,9,12 *'], + [{ minutes: [0], hours: [9, 10, 11, 12] }, '0 9-12 * * *'], + ])('rebuilds the crontab from %j', (ranges, crontab) => { + const events = new EventEmitter(); + const item = cronItem('job', 'job', ranges); + monitorGraphileCronJobs(events, [{ ...item, match: Object.assign(() => true, item.match) }], {}); + + runJob(events, cronJob('job', '2026-10-16T09:00:00.000Z')); + + expect(captureCheckIn.mock.calls[0]?.[1]).toEqual({ + schedule: { type: 'crontab', value: crontab }, + timezone: 'UTC', + }); + }); + + it('checks in without a config for a custom matcher', () => { + const events = new EventEmitter(); + monitorGraphileCronJobs(events, [{ identifier: 'custom', task: 'job', payload: {}, match: () => true }], {}); + + runJob(events, cronJob('job', '2026-10-16T09:00:00.000Z')); + + expect(captureCheckIn.mock.calls[0]).toEqual([{ monitorSlug: 'custom', status: 'in_progress' }, undefined]); + }); + it('tells items of the same task apart by schedule and payload', () => { const events = new EventEmitter(); monitorGraphileCronJobs(