Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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({
Expand Down
74 changes: 67 additions & 7 deletions packages/server-utils/src/integrations/graphile-worker.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -19,7 +19,8 @@ const INTEGRATION_NAME = 'GraphileWorker' as const;
export interface GraphileWorkerOptions {
/**
* Send check-ins for the jobs the crontab schedules. The monitor slug is the identifier of the
* crontab item (its `id` option, or else the task identifier).
* crontab item (its `id` option, or else the task identifier). Items with a custom `match` function
* are not monitored.
*
* Defaults to `false`.
*/
Expand All @@ -35,7 +36,9 @@ interface CronItem {
identifier: string;
task: string;
payload?: Record<string, unknown> | null;
match: (digest: Record<'min' | 'hour' | 'date' | 'month' | 'dow', number>) => boolean;
match: ((digest: Record<'min' | 'hour' | 'date' | 'month' | 'dow', number>) => boolean) & {
parsedCronMatch?: Record<string, number[] | undefined>;
};
}

interface Job {
Expand All @@ -61,8 +64,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);

Expand Down Expand Up @@ -91,7 +94,7 @@ function instrumentGraphileWorker(options: GraphileWorkerOptions): void {

/** Sends check-ins for the jobs a runner's crontab schedules. Exported for tests. */
export function monitorGraphileCronJobs(events: Events, items: CronItem[], options: GraphileWorkerOptions): void {
const runs = new WeakMap<Job, { slug: string; checkInId: string; start: number }>();
const runs = new WeakMap<Job, { slug: string; checkInId?: string; start: number }>();

events.on('job:start', ({ job }) => {
try {
Expand All @@ -100,7 +103,13 @@ export function monitorGraphileCronJobs(events: Events, items: CronItem[], optio
return;
}
const slug = item.identifier;
const checkInId = captureCheckIn({ monitorSlug: slug, status: 'in_progress' });
const config = toMonitorConfig(item);
if (!config) {
DEBUG_BUILD && debug.log(`[GraphileWorker] Not monitoring ${slug}: Sentry can't represent its schedule.`);
runs.set(job, { slug, start: timestampInSeconds() });
return;
}
const checkInId = captureCheckIn({ monitorSlug: slug, status: 'in_progress' }, config);
runs.set(job, { slug, checkInId, start: timestampInSeconds() });
} catch (e) {
DEBUG_BUILD && debug.error('[GraphileWorker] Failed to send the check-in:', e);
Expand All @@ -117,6 +126,9 @@ export function monitorGraphileCronJobs(events: Events, items: CronItem[], optio
if (error) {
captureException(error, { mechanism: { handled: false, type: 'auto.function.graphile_worker.cron' } });
}
if (!run.checkInId) {
return;
}
captureCheckIn({
monitorSlug: run.slug,
status: error ? 'error' : 'ok',
Expand Down Expand Up @@ -154,3 +166,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(',');
}
42 changes: 40 additions & 2 deletions packages/server-utils/test/integrations/graphile-worker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) }],
]);
});
Expand All @@ -85,6 +88,41 @@ 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('sends no check-ins for items with a custom matcher but captures their errors', () => {
const events = new EventEmitter();
monitorGraphileCronJobs(events, [{ identifier: 'custom', task: 'job', payload: {}, match: () => true }], {});
const error = new Error('boom');

runJob(events, cronJob('job', '2026-10-16T09:00:00.000Z'), error);

expect(captureCheckIn).not.toHaveBeenCalled();
expect(captureException).toHaveBeenCalledWith(error, {
mechanism: { handled: false, type: 'auto.function.graphile_worker.cron' },
});
});

it('tells items of the same task apart by schedule and payload', () => {
const events = new EventEmitter();
monitorGraphileCronJobs(
Expand Down
Loading