diff --git a/dev-packages/node-integration-tests/suites/cron/bullmq/docker-compose.yml b/dev-packages/node-integration-tests/suites/cron/bullmq/docker-compose.yml new file mode 100644 index 000000000000..c54562dfc169 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/cron/bullmq/docker-compose.yml @@ -0,0 +1,14 @@ +version: '3.9' + +services: + db: + image: redis:latest + restart: always + ports: + - '6385:6379' + healthcheck: + test: ['CMD-SHELL', 'redis-cli ping | grep -q PONG'] + interval: 2s + timeout: 3s + retries: 30 + start_period: 5s diff --git a/dev-packages/node-integration-tests/suites/cron/bullmq/scenario-telemetry.mjs b/dev-packages/node-integration-tests/suites/cron/bullmq/scenario-telemetry.mjs new file mode 100644 index 000000000000..42ac3f479d31 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/cron/bullmq/scenario-telemetry.mjs @@ -0,0 +1,64 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import { Queue, Worker } from 'bullmq'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); + +const connection = { host: 'localhost', port: 6385 }; +const telemetry = new Sentry.BullMQTelemetry(); +const instrumentBeforeRun = process.env.INSTRUMENT_BEFORE_RUN === 'true'; + +async function run() { + const queue = new Queue('crons-telemetry', { connection, telemetry }); + await queue.obliterate({ force: true }); + + await queue.upsertJobScheduler('ok-scheduler', { every: 300_000 }, { name: 'ok-job' }); + await queue.upsertJobScheduler( + 'failing-scheduler', + { pattern: '0 0 9 * * 1-5', tz: 'Europe/Vienna', immediately: true }, + { name: 'failing-job' }, + ); + + const worker = new Worker( + 'crons-telemetry', + async job => { + await Sentry.startSpan({ name: `work ${job.name}` }, async () => { + await new Promise(resolve => setTimeout(resolve, 50)); + }); + if (job.name === 'failing-job') { + throw new Error('Error in scheduled job'); + } + }, + { connection, telemetry, autorun: !instrumentBeforeRun }, + ); + Sentry.cron.instrumentBullMQWorker(worker); + + const finished = new Promise(resolve => { + let count = 0; + const done = () => { + if (++count === 2) { + resolve(); + } + }; + worker.on('completed', done); + worker.on('failed', done); + }); + + if (instrumentBeforeRun) { + void worker.run(); + } + + await finished; + await worker.close(); + await queue.obliterate({ force: true }); + await queue.close(); + await Sentry.flush(); + process.exit(); +} + +void run(); diff --git a/dev-packages/node-integration-tests/suites/cron/bullmq/scenario.ts b/dev-packages/node-integration-tests/suites/cron/bullmq/scenario.ts new file mode 100644 index 000000000000..91df1313b067 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/cron/bullmq/scenario.ts @@ -0,0 +1,44 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import { Queue, Worker } from 'bullmq'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + transport: loggingTransport, +}); + +const connection = { host: 'localhost', port: 6385 }; + +async function run(): Promise { + const queue = new Queue('crons', { connection }); + await queue.obliterate({ force: true }); + + const worker = new Worker( + 'crons', + async job => { + if (job.name === 'failing') { + throw new Error('Error in scheduled job'); + } + }, + { connection }, + ); + Sentry.cron.instrumentBullMQWorker(worker); + + await queue.add('plain', {}); + await queue.upsertJobScheduler('every-scheduler', { every: 300_000 }, { name: 'every' }); + await queue.upsertJobScheduler( + 'pattern-scheduler', + { pattern: '0 0 9 * * 1-5', tz: 'Europe/Vienna', immediately: true }, + { name: 'failing' }, + ); + + setTimeout(async () => { + await worker.close(); + await queue.obliterate({ force: true }); + await queue.close(); + process.exit(); + }, 5_000); +} + +void run(); diff --git a/dev-packages/node-integration-tests/suites/cron/bullmq/test.ts b/dev-packages/node-integration-tests/suites/cron/bullmq/test.ts new file mode 100644 index 000000000000..f3c684b09ed9 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/cron/bullmq/test.ts @@ -0,0 +1,118 @@ +import type { Event, SerializedCheckIn } from '@sentry/core'; +import { afterAll, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner, describeWithDockerCompose } from '../../../utils/runner'; + +describeWithDockerCompose('bullmq job scheduler check-ins', { workingDirectory: [__dirname] }, () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + test('sends check-ins with the job scheduler schedule', { timeout: 60_000 }, async () => { + await createRunner(__dirname, 'scenario.ts') + .unordered() + .expect({ + check_in: { + check_in_id: expect.any(String), + monitor_slug: 'every-scheduler', + status: 'in_progress', + release: '1.0', + monitor_config: { schedule: { type: 'interval', value: 5, unit: 'minute' } }, + }, + }) + .expect({ + check_in: { + check_in_id: expect.any(String), + monitor_slug: 'every-scheduler', + status: 'ok', + release: '1.0', + duration: expect.any(Number), + }, + }) + .expect({ + check_in: { + check_in_id: expect.any(String), + monitor_slug: 'pattern-scheduler', + status: 'in_progress', + release: '1.0', + monitor_config: { schedule: { type: 'crontab', value: '0 9 * * 1-5' }, timezone: 'Europe/Vienna' }, + }, + }) + .expect({ + check_in: { + check_in_id: expect.any(String), + monitor_slug: 'pattern-scheduler', + status: 'error', + release: '1.0', + duration: expect.any(Number), + }, + }) + .start() + .completed(); + }); + + test.each([ + ['after the worker starts', 'false'], + ['before the worker runs', 'true'], + ])( + 'sends one check-in pair per run in the job trace with BullMQTelemetry, instrumented %s', + { timeout: 60_000 }, + async (_, instrumentBeforeRun) => { + const checkIns: SerializedCheckIn[] = []; + const errors: Event[] = []; + const runner = createRunner(__dirname, 'scenario-telemetry.mjs') + .withEnv({ INSTRUMENT_BEFORE_RUN: instrumentBeforeRun }) + .unordered() + .expectN(4, { check_in: checkIn => void checkIns.push(checkIn) }) + .expect({ event: event => void errors.push(event) }); + const spansByJob = ['ok-job', 'failing-job'].map(jobName => + runner.collectStreamedSpans(spans => + spans.some( + span => + span.is_segment && + span.name === 'process crons-telemetry' && + span.attributes['messaging.bullmq.job.name']?.value === jobName, + ), + ), + ); + + await runner.start().completed(); + + const [okSpans, failingSpans] = await Promise.all(spansByJob); + const okSegment = okSpans!.find(span => span.is_segment)!; + const failingSegment = failingSpans!.find(span => span.is_segment)!; + + expect(okSegment.status).toBe('ok'); + expect(failingSegment.status).toBe('error'); + expect(okSpans!.some(span => span.name === 'work ok-job' && span.parent_span_id === okSegment.span_id)).toBe( + true, + ); + expect( + failingSpans!.some(span => span.name === 'work failing-job' && span.parent_span_id === failingSegment.span_id), + ).toBe(true); + + const summary = (slug: string): unknown => + checkIns + .filter(checkIn => checkIn.monitor_slug === slug) + .map(checkIn => ({ + status: checkIn.status, + check_in_id: checkIn.check_in_id, + trace_id: checkIn.contexts?.trace?.trace_id, + })); + const okCheckInId = checkIns.find(checkIn => checkIn.monitor_slug === 'ok-scheduler')?.check_in_id; + const failingCheckInId = checkIns.find(checkIn => checkIn.monitor_slug === 'failing-scheduler')?.check_in_id; + + expect(summary('ok-scheduler')).toEqual([ + { status: 'in_progress', check_in_id: okCheckInId, trace_id: okSegment.trace_id }, + { status: 'ok', check_in_id: okCheckInId, trace_id: okSegment.trace_id }, + ]); + expect(summary('failing-scheduler')).toEqual([ + { status: 'in_progress', check_in_id: failingCheckInId, trace_id: failingSegment.trace_id }, + { status: 'error', check_in_id: failingCheckInId, trace_id: failingSegment.trace_id }, + ]); + + expect(errors).toHaveLength(1); + expect(errors[0]?.exception?.values?.[0]?.value).toBe('Error in scheduled job'); + expect(errors[0]?.contexts?.trace?.trace_id).toBe(failingSegment.trace_id); + }, + ); +}); diff --git a/packages/node/src/cron/bullmq.ts b/packages/node/src/cron/bullmq.ts new file mode 100644 index 000000000000..65cb43481162 --- /dev/null +++ b/packages/node/src/cron/bullmq.ts @@ -0,0 +1,216 @@ +import { captureCheckIn, debug, type MonitorConfig, timestampInSeconds } from '@sentry/core'; +import { DEBUG_BUILD } from '../debug-build'; +import { replaceCronNames } from './common'; + +interface BullMQRepeatOptions { + pattern?: unknown; + every?: unknown; + tz?: unknown; + utc?: unknown; + limit?: unknown; + endDate?: unknown; +} + +interface BullMQJob { + repeatJobKey?: unknown; + opts?: { repeat?: BullMQRepeatOptions }; +} + +export interface InstrumentBullMQWorkerOptions { + /** Job scheduler ids to not monitor. Strings match the id exactly. */ + excludeJobSchedulers?: Array; +} + +const CRON_PRESETS: Record = { + '@yearly': '0 0 1 1 *', + '@annually': '0 0 1 1 *', + '@monthly': '0 0 1 * *', + '@weekly': '0 0 * * 0', + '@daily': '0 0 * * *', + '@hourly': '0 * * * *', +}; + +const FULL_DAY_OF_WEEK_RANGES = new Set(['0-6', '0-7', '1-7']); + +// Legacy repeatable jobs (`queue.add` with `repeat`) are keyed by a hash or `name:id:endDate:tz:pattern`. +const LEGACY_REPEAT_KEY = /^[\da-f]{32,}$|^(?:[^:]*:){4}/; + +const instrumentedWorkers = new WeakSet(); + +/** + * Sends check-ins to Sentry for every job a BullMQ worker runs from a job scheduler. + * The job scheduler id is the monitor slug, and its `pattern` or `every` is sent as the monitor schedule. + * Schedulers whose schedule Sentry can't represent, or that set `limit` or `endDate`, are not monitored. + * + * ```ts + * import * as Sentry from '@sentry/node'; + * import { Worker } from 'bullmq'; + * + * const worker = new Worker('reports', processor, { connection }); + * Sentry.cron.instrumentBullMQWorker(worker, { excludeJobSchedulers: ['cache-warmup'] }); + * ``` + */ +export function instrumentBullMQWorker unknown }>( + worker: T, + options: InstrumentBullMQWorkerOptions = {}, +): T { + if (instrumentedWorkers.has(worker)) { + return worker; + } + instrumentedWorkers.add(worker); + + // Sentry keeps the trace of the `in_progress` check-in. BullMQ emits `active` before the job's trace starts, + // often inside the previous job's, so send it from the processor instead. + const target = worker as unknown as { processFn?: (job: BullMQJob, ...args: unknown[]) => unknown }; + const processFn = target.processFn; + if (typeof processFn !== 'function') { + return worker; + } + + const running = new WeakMap(); + + target.processFn = function (this: unknown, job: BullMQJob, ...args: unknown[]): unknown { + try { + const monitor = getMonitor(job, options.excludeJobSchedulers); + if (job && monitor) { + const { monitorSlug, config } = monitor; + const checkInId = captureCheckIn({ monitorSlug, status: 'in_progress' }, config); + running.set(job, { monitorSlug, checkInId, start: timestampInSeconds() }); + } + } catch (e) { + DEBUG_BUILD && debug.error('[BullMQ] Failed to send check-in:', e); + } + return processFn.call(this, job, ...args); + }; + + const finish = + (status: 'ok' | 'error') => + (job?: BullMQJob): void => { + try { + const run = job && running.get(job); + if (job && run) { + running.delete(job); + const { monitorSlug, checkInId, start } = run; + captureCheckIn({ monitorSlug, status, checkInId, duration: timestampInSeconds() - start }); + return; + } + // BullMQ fails some jobs, such as ones that stalled too often, without running the processor. + const monitor = status === 'error' && getMonitor(job, options.excludeJobSchedulers); + if (monitor) { + captureCheckIn({ monitorSlug: monitor.monitorSlug, status }, monitor.config); + } + } catch (e) { + DEBUG_BUILD && debug.error('[BullMQ] Failed to send check-in:', e); + } + }; + const on = worker.on as unknown as (event: string, listener: (job?: BullMQJob) => void) => unknown; + on.call(worker, 'completed', finish('ok')); + on.call(worker, 'failed', finish('error')); + + return worker; +} + +function getMonitor( + job: BullMQJob | undefined, + exclude: Array | undefined, +): { monitorSlug: string; config: MonitorConfig } | undefined { + const monitorSlug = getJobSchedulerId(job, exclude); + if (!monitorSlug) { + return undefined; + } + const config = getMonitorConfig(job?.opts?.repeat); + if (!config) { + DEBUG_BUILD && debug.log(`[BullMQ] Not monitoring ${monitorSlug}: Sentry can't represent its schedule.`); + return undefined; + } + return { monitorSlug, config }; +} + +function getJobSchedulerId(job: BullMQJob | undefined, exclude: Array = []): string | undefined { + const id = job?.repeatJobKey; + if (typeof id !== 'string' || !id || LEGACY_REPEAT_KEY.test(id)) { + return undefined; + } + return exclude.some(pattern => (typeof pattern === 'string' ? pattern === id : pattern.test(id))) ? undefined : id; +} + +function getMonitorConfig(repeat: BullMQRepeatOptions | undefined): MonitorConfig | undefined { + // A scheduler that stops after `limit` runs or `endDate` would show up as missed. + if (!repeat || repeat.limit != null || repeat.endDate != null) { + return undefined; + } + + if (typeof repeat.every === 'number') { + const minutes = repeat.every / 60_000; + if (!Number.isInteger(minutes) || minutes <= 0) { + return undefined; + } + if (minutes % 1440 === 0) { + return { schedule: { type: 'interval', value: minutes / 1440, unit: 'day' } }; + } + if (minutes % 60 === 0) { + return { schedule: { type: 'interval', value: minutes / 60, unit: 'hour' } }; + } + return { schedule: { type: 'interval', value: minutes, unit: 'minute' } }; + } + + const crontab = typeof repeat.pattern === 'string' ? toCrontab(repeat.pattern) : undefined; + // cron-parser 4 (BullMQ 5) prefers `utc` over `tz`, and uses the server's time zone without either. + const timezone = toSentryTimeZone( + repeat.utc === true ? 'UTC' : typeof repeat.tz === 'string' && repeat.tz ? repeat.tz : getLocalTimeZone(), + ); + return crontab && timezone ? { schedule: { type: 'crontab', value: crontab }, timezone } : undefined; +} + +/** + * Converts a cron-parser pattern to a crontab that Sentry reads the same way, if there is one. + */ +function toCrontab(pattern: string): string | undefined { + const preset = CRON_PRESETS[pattern.trim().toLowerCase()]; + if (preset) { + return preset; + } + + const fields = replaceCronNames(pattern).trim().split(/\s+/); + if (fields.length === 6 && /^0+$/.test(fields[0] as string)) { + fields.shift(); + } + if (fields.length !== 5 || fields.some(field => !/^[\d*,/\-L#]+$/.test(field))) { + return undefined; + } + + // With both day fields set, cron-parser runs on either, but Sentry needs both when one starts with `*`. + // cron-parser 4 also treats `1-31` as `*`. + const [, , dayOfMonth = '', , dayOfWeek = ''] = fields; + if ( + dayOfMonth !== '*' && + dayOfWeek !== '*' && + (dayOfMonth.startsWith('*') || + dayOfWeek.startsWith('*') || + dayOfMonth === '1-31' || + FULL_DAY_OF_WEEK_RANGES.has(dayOfWeek)) + ) { + return undefined; + } + + return fields.join(' '); +} + +function toSentryTimeZone(timezone: string | undefined): string | undefined { + if (!timezone || timezone === 'Etc/Unknown' || /^(?:utc|gmt)?[+-]/i.test(timezone)) { + return undefined; + } + try { + return new Intl.DateTimeFormat('en-US', { timeZone: timezone }).resolvedOptions().timeZone || undefined; + } catch { + return undefined; + } +} + +function getLocalTimeZone(): string | undefined { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone || undefined; + } catch { + return undefined; + } +} diff --git a/packages/node/src/cron/index.ts b/packages/node/src/cron/index.ts index eb4b915cff66..093e2ceacfb7 100644 --- a/packages/node/src/cron/index.ts +++ b/packages/node/src/cron/index.ts @@ -1,9 +1,11 @@ +import { instrumentBullMQWorker } from './bullmq'; import { instrumentCron } from './cron'; import { instrumentNodeCron } from './node-cron'; import { instrumentNodeSchedule } from './node-schedule'; /** Methods to instrument cron libraries for Sentry check-ins */ export const cron = { + instrumentBullMQWorker, instrumentCron, instrumentNodeCron, instrumentNodeSchedule, diff --git a/packages/node/test/cron/bullmq.test.ts b/packages/node/test/cron/bullmq.test.ts new file mode 100644 index 000000000000..033b9b2b2db5 --- /dev/null +++ b/packages/node/test/cron/bullmq.test.ts @@ -0,0 +1,190 @@ +import { EventEmitter } from 'node:events'; +import * as SentryCore from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, type MockInstance, test, vi } from 'vitest'; +import { cron } from '../../src'; + +function schedulerJob(id: string, repeat: Record): object { + return { name: 'job', repeatJobKey: id, opts: { repeat: { count: 1, ...repeat } } }; +} + +describe('cron.instrumentBullMQWorker', () => { + let captureCheckInSpy: MockInstance; + + beforeEach(() => { + captureCheckInSpy = vi.spyOn(SentryCore, 'captureCheckIn').mockReturnValue('check-in-id'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + function createWorker(): EventEmitter & { processFn: (job: object) => unknown } { + return Object.assign(new EventEmitter(), { processFn: vi.fn((_job: object) => 'result') }); + } + + function runJob(job: object, outcome: 'completed' | 'failed' = 'completed', worker = createWorker()): void { + cron.instrumentBullMQWorker(worker); + worker.processFn(job); + worker.emit(outcome, job, outcome === 'failed' ? new Error('boom') : 'result', 'active'); + } + + function sentConfig(repeat: Record): unknown { + runJob(schedulerJob('my-scheduler', repeat)); + return captureCheckInSpy.mock.calls[0]?.[1]; + } + + function expectSkipped(repeat: Record): void { + runJob(schedulerJob('my-scheduler', repeat), 'failed'); + const worker = createWorker(); + cron.instrumentBullMQWorker(worker); + worker.emit('failed', schedulerJob('my-scheduler', repeat), new Error('stalled'), 'active'); + expect(captureCheckInSpy).not.toHaveBeenCalled(); + } + + test('sends in_progress and ok check-ins with the scheduler id as slug', () => { + runJob(schedulerJob('daily-report', { pattern: '0 9 * * 1-5', tz: 'Europe/Vienna' })); + + expect(captureCheckInSpy).toHaveBeenNthCalledWith( + 1, + { monitorSlug: 'daily-report', status: 'in_progress' }, + { schedule: { type: 'crontab', value: '0 9 * * 1-5' }, timezone: 'Europe/Vienna' }, + ); + expect(captureCheckInSpy).toHaveBeenNthCalledWith(2, { + monitorSlug: 'daily-report', + status: 'ok', + checkInId: 'check-in-id', + duration: expect.any(Number), + }); + }); + + test('sends an error check-in when the job fails', () => { + runJob(schedulerJob('daily-report', { every: 60_000 }), 'failed'); + + expect(captureCheckInSpy).toHaveBeenLastCalledWith(expect.objectContaining({ status: 'error' })); + }); + + test.each([ + ['0 0 9 * * *', '0 9 * * *'], + ['*/15 9-17 * * mon-fri', '*/15 9-17 * * 1-5'], + ['0 0 1 JAN *', '0 0 1 1 *'], + ['0 0 L * *', '0 0 L * *'], + ['0 0 * * 5L', '0 0 * * 5L'], + ['0 0 * * 1#2', '0 0 * * 1#2'], + ['0 0 1 * 1', '0 0 1 * 1'], + ['@daily', '0 0 * * *'], + ])('converts pattern %j to crontab %j', (pattern, crontab) => { + expect(sentConfig({ pattern, tz: 'UTC' })).toEqual({ + schedule: { type: 'crontab', value: crontab }, + timezone: 'UTC', + }); + }); + + test.each([ + '*/10 * * * * *', + '0 0 ? * 1', + '0 0 15W * *', + '0 0 */2 * 1', + '0 0 1 * */2', + '0 0 1-31 * 1', + '0 0 1 * 0-6', + '0 0 * * * * 2026', + ])('skips pattern %j', pattern => { + expectSkipped({ pattern, tz: 'UTC' }); + }); + + test('uses the local time zone without tz', () => { + const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + expect(sentConfig({ pattern: '0 * * * *' })).toEqual({ + schedule: { type: 'crontab', value: '0 * * * *' }, + timezone, + }); + }); + + test('uses UTC with utc: true', () => { + expect(sentConfig({ pattern: '0 * * * *', tz: 'Europe/Vienna', utc: true })).toEqual({ + schedule: { type: 'crontab', value: '0 * * * *' }, + timezone: 'UTC', + }); + }); + + test('skips a fixed UTC offset', () => { + expectSkipped({ pattern: '0 * * * *', tz: 'UTC+5' }); + }); + + test.each([ + [300_000, { type: 'interval', value: 5, unit: 'minute' }], + [7_200_000, { type: 'interval', value: 2, unit: 'hour' }], + [86_400_000, { type: 'interval', value: 1, unit: 'day' }], + ])('converts every %j ms to an interval', (every, schedule) => { + expect(sentConfig({ every })).toEqual({ schedule }); + }); + + test.each([30_000, 90_000])('skips every %j ms', every => { + expectSkipped({ every }); + }); + + test.each([{ limit: 3 }, { endDate: Date.now() + 60_000 }])('skips a scheduler that ends: %j', end => { + expectSkipped({ every: 60_000, ...end }); + }); + + test.each([ + ['a job that is not from a scheduler', { name: 'job', opts: {} }], + ['a legacy repeatable job', schedulerJob('ef2e05b7df2d56840b735ad4ee3a6937', { every: 60_000 })], + ['an excluded scheduler', schedulerJob('cache-warmup', { every: 60_000 })], + ['a scheduler matching an excluded pattern', schedulerJob('tmp-cleanup', { every: 60_000 })], + ])('ignores %s', (_, job) => { + const worker = createWorker(); + cron.instrumentBullMQWorker(worker, { excludeJobSchedulers: ['cache-warmup', /^tmp-/] }); + worker.processFn(job); + worker.emit('failed', job); + + expect(captureCheckInSpy).not.toHaveBeenCalled(); + }); + + test('calls the processor with the worker and its arguments', () => { + const worker = createWorker(); + const processor = worker.processFn; + cron.instrumentBullMQWorker(worker); + const job = schedulerJob('my-scheduler', { every: 60_000 }); + + expect(worker.processFn.call(worker, job, 'token')).toBe('result'); + expect(processor).toHaveBeenCalledWith(job, 'token'); + expect(vi.mocked(processor).mock.contexts[0]).toBe(worker); + }); + + test('sends an error check-in for a job that fails without running the processor', () => { + const worker = createWorker(); + cron.instrumentBullMQWorker(worker); + worker.emit('failed', schedulerJob('my-scheduler', { every: 60_000 }), new Error('stalled'), 'active'); + + expect(captureCheckInSpy).toHaveBeenCalledTimes(1); + expect(captureCheckInSpy).toHaveBeenCalledWith( + { monitorSlug: 'my-scheduler', status: 'error' }, + { schedule: { type: 'interval', value: 1, unit: 'minute' } }, + ); + }); + + test('sends no check-in for a job that completes without running the processor', () => { + const worker = createWorker(); + cron.instrumentBullMQWorker(worker); + worker.emit('completed', schedulerJob('my-scheduler', { every: 60_000 }), 'result', 'active'); + + expect(captureCheckInSpy).not.toHaveBeenCalled(); + }); + + test('instruments a worker only once', () => { + const worker = createWorker(); + cron.instrumentBullMQWorker(worker); + runJob(schedulerJob('my-scheduler', { every: 60_000 }), 'completed', worker); + + expect(captureCheckInSpy).toHaveBeenCalledTimes(2); + }); + + test('does not throw into the worker when sending a check-in fails', () => { + captureCheckInSpy.mockImplementation(() => { + throw new Error('sentry failed'); + }); + + expect(() => runJob(schedulerJob('my-scheduler', { every: 60_000 }))).not.toThrow(); + }); +});