Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
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
60 changes: 55 additions & 5 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 Down Expand Up @@ -35,7 +35,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 +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);

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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(',');
}
38 changes: 36 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,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(
Expand Down
Loading