diff --git a/packages/node/src/cron/common.ts b/packages/node/src/cron/common.ts new file mode 100644 index 000000000000..c710d154fdd5 --- /dev/null +++ b/packages/node/src/cron/common.ts @@ -0,0 +1,50 @@ +const replacements: [string, string][] = [ + ['january', '1'], + ['february', '2'], + ['march', '3'], + ['april', '4'], + ['may', '5'], + ['june', '6'], + ['july', '7'], + ['august', '8'], + ['september', '9'], + ['october', '10'], + ['november', '11'], + ['december', '12'], + ['jan', '1'], + ['feb', '2'], + ['mar', '3'], + ['apr', '4'], + ['may', '5'], + ['jun', '6'], + ['jul', '7'], + ['aug', '8'], + ['sep', '9'], + ['oct', '10'], + ['nov', '11'], + ['dec', '12'], + ['sunday', '0'], + ['monday', '1'], + ['tuesday', '2'], + ['wednesday', '3'], + ['thursday', '4'], + ['friday', '5'], + ['saturday', '6'], + ['sun', '0'], + ['mon', '1'], + ['tue', '2'], + ['wed', '3'], + ['thu', '4'], + ['fri', '5'], + ['sat', '6'], +]; + +/** + * Replaces names in cron expressions + */ +export function replaceCronNames(cronExpression: string): string { + return replacements.reduce( + (acc, [name, replacement]) => acc.replace(new RegExp(name, 'gi'), replacement), + cronExpression, + ); +} diff --git a/packages/node/src/cron/cron.ts b/packages/node/src/cron/cron.ts new file mode 100644 index 000000000000..a8b42ec0fed7 --- /dev/null +++ b/packages/node/src/cron/cron.ts @@ -0,0 +1,115 @@ +import { withMonitor } from '@sentry/core'; +import { replaceCronNames } from './common'; + +export type CronJobParams = { + cronTime: string | Date; + onTick: (context: unknown, onComplete?: unknown) => void | Promise; + onComplete?: () => void | Promise; + start?: boolean | null; + context?: unknown; + runOnInit?: boolean | null; + utcOffset?: number; + timeZone?: string; + unrefTimeout?: boolean | null; +}; + +export type CronJob = { + // +}; + +export type CronJobConstructor = { + from: (param: CronJobParams) => CronJob; + + new ( + cronTime: CronJobParams['cronTime'], + onTick: CronJobParams['onTick'], + onComplete?: CronJobParams['onComplete'], + start?: CronJobParams['start'], + timeZone?: CronJobParams['timeZone'], + context?: CronJobParams['context'], + runOnInit?: CronJobParams['runOnInit'], + utcOffset?: CronJobParams['utcOffset'], + unrefTimeout?: CronJobParams['unrefTimeout'], + ): CronJob; +}; + +const ERROR_TEXT = 'Automatic instrumentation of CronJob only supports crontab string'; + +/** + * Instruments the `cron` library to send a check-in event to Sentry for each job execution. + * + * ```ts + * import * as Sentry from '@sentry/node'; + * import { CronJob } from 'cron'; + * + * const CronJobWithCheckIn = Sentry.cron.instrumentCron(CronJob, 'my-cron-job'); + * + * // use the constructor + * const job = new CronJobWithCheckIn('* * * * *', () => { + * console.log('You will see this message every minute'); + * }); + * + * // or from + * const job = CronJobWithCheckIn.from({ cronTime: '* * * * *', onTick: () => { + * console.log('You will see this message every minute'); + * }); + * ``` + */ +export function instrumentCron(lib: T & CronJobConstructor, monitorSlug: string): T { + return new Proxy(lib, { + construct(target, args: ConstructorParameters) { + const [cronTime, onTick, onComplete, start, timeZone, ...rest] = args; + + if (typeof cronTime !== 'string') { + throw new Error(ERROR_TEXT); + } + + const cronString = replaceCronNames(cronTime); + + function monitoredTick(context: unknown, onComplete?: unknown): void | Promise { + return withMonitor( + monitorSlug, + () => { + return onTick(context, onComplete); + }, + { + schedule: { type: 'crontab', value: cronString }, + ...(timeZone ? { timeZone } : {}), + }, + ); + } + + return new target(cronTime, monitoredTick, onComplete, start, timeZone, ...rest); + }, + get(target, prop: keyof CronJobConstructor) { + if (prop === 'from') { + return (param: CronJobParams) => { + const { cronTime, onTick, timeZone } = param; + + if (typeof cronTime !== 'string') { + throw new Error(ERROR_TEXT); + } + + const cronString = replaceCronNames(cronTime); + + param.onTick = (context: unknown, onComplete?: unknown) => { + return withMonitor( + monitorSlug, + () => { + return onTick(context, onComplete); + }, + { + schedule: { type: 'crontab', value: cronString }, + ...(timeZone ? { timeZone } : {}), + }, + ); + }; + + return target.from(param); + }; + } else { + return target[prop]; + } + }, + }); +} diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 36d2d8beac53..222adc68a4f4 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -100,3 +100,10 @@ const INTEGRATIONS = { export { INTEGRATIONS as Integrations, Handlers }; export { hapiErrorPlugin } from './integrations/hapi'; + +import { instrumentCron } from './cron/cron'; + +/** Methods to instrument cron libraries for Sentry check-ins */ +export const cron = { + instrumentCron, +}; diff --git a/packages/node/test/cron.test.ts b/packages/node/test/cron.test.ts new file mode 100644 index 000000000000..9d4b082e9c22 --- /dev/null +++ b/packages/node/test/cron.test.ts @@ -0,0 +1,81 @@ +import * as SentryCore from '@sentry/core'; + +import { cron } from '../src'; +import type { CronJob, CronJobParams } from '../src/cron/cron'; + +describe('cron', () => { + let withMonitorSpy: jest.SpyInstance; + + beforeEach(() => { + withMonitorSpy = jest.spyOn(SentryCore, 'withMonitor'); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('cron', () => { + class CronJobMock { + constructor( + cronTime: CronJobParams['cronTime'], + onTick: CronJobParams['onTick'], + _onComplete?: CronJobParams['onComplete'], + _start?: CronJobParams['start'], + _timeZone?: CronJobParams['timeZone'], + _context?: CronJobParams['context'], + _runOnInit?: CronJobParams['runOnInit'], + _utcOffset?: CronJobParams['utcOffset'], + _unrefTimeout?: CronJobParams['unrefTimeout'], + ) { + expect(cronTime).toBe('* * * Jan,Sep Sun'); + expect(onTick).toBeInstanceOf(Function); + setImmediate(() => onTick(undefined, undefined)); + } + + static from(params: CronJobParams): CronJob { + return new CronJobMock( + params.cronTime, + params.onTick, + params.onComplete, + params.start, + params.timeZone, + params.context, + params.runOnInit, + params.utcOffset, + params.unrefTimeout, + ); + } + } + + test('new CronJob()', done => { + expect.assertions(4); + + const CronJobWithCheckIn = cron.instrumentCron(CronJobMock, 'my-cron-job'); + + const _ = new CronJobWithCheckIn('* * * Jan,Sep Sun', () => { + expect(withMonitorSpy).toHaveBeenCalledTimes(1); + expect(withMonitorSpy).toHaveBeenLastCalledWith('my-cron-job', expect.anything(), { + schedule: { type: 'crontab', value: '* * * 1,9 0' }, + }); + done(); + }); + }); + + test('CronJob.from()', done => { + expect.assertions(4); + + const CronJobWithCheckIn = cron.instrumentCron(CronJobMock, 'my-cron-job'); + + const _ = CronJobWithCheckIn.from({ + cronTime: '* * * Jan,Sep Sun', + onTick: () => { + expect(withMonitorSpy).toHaveBeenCalledTimes(1); + expect(withMonitorSpy).toHaveBeenLastCalledWith('my-cron-job', expect.anything(), { + schedule: { type: 'crontab', value: '* * * 1,9 0' }, + }); + done(); + }, + }); + }); + }); +});