diff --git a/packages/node/src/cron/cron.ts b/packages/node/src/cron/cron.ts index a8b42ec0fed7..88a3e9e58eb5 100644 --- a/packages/node/src/cron/cron.ts +++ b/packages/node/src/cron/cron.ts @@ -56,6 +56,8 @@ const ERROR_TEXT = 'Automatic instrumentation of CronJob only supports crontab s * ``` */ export function instrumentCron(lib: T & CronJobConstructor, monitorSlug: string): T { + let jobScheduled = false; + return new Proxy(lib, { construct(target, args: ConstructorParameters) { const [cronTime, onTick, onComplete, start, timeZone, ...rest] = args; @@ -64,6 +66,12 @@ export function instrumentCron(lib: T & CronJobConstructor, monitorSlug: stri throw new Error(ERROR_TEXT); } + if (jobScheduled) { + throw new Error(`A job named '${monitorSlug}' has already been scheduled`); + } + + jobScheduled = true; + const cronString = replaceCronNames(cronTime); function monitoredTick(context: unknown, onComplete?: unknown): void | Promise { @@ -90,6 +98,12 @@ export function instrumentCron(lib: T & CronJobConstructor, monitorSlug: stri throw new Error(ERROR_TEXT); } + if (jobScheduled) { + throw new Error(`A job named '${monitorSlug}' has already been scheduled`); + } + + jobScheduled = true; + const cronString = replaceCronNames(cronTime); param.onTick = (context: unknown, onComplete?: unknown) => { diff --git a/packages/node/src/cron/node-schedule.ts b/packages/node/src/cron/node-schedule.ts new file mode 100644 index 000000000000..79ae44a06e52 --- /dev/null +++ b/packages/node/src/cron/node-schedule.ts @@ -0,0 +1,60 @@ +import { withMonitor } from '@sentry/core'; +import { replaceCronNames } from './common'; + +export interface NodeSchedule { + scheduleJob( + nameOrExpression: string | Date | object, + expressionOrCallback: string | Date | object | (() => void), + callback?: () => void, + ): unknown; +} + +/** + * Instruments the `node-schedule` library to send a check-in event to Sentry for each job execution. + * + * ```ts + * import * as Sentry from '@sentry/node'; + * import * as schedule from 'node-schedule'; + * + * const scheduleWithCheckIn = Sentry.cron.instrumentNodeSchedule(schedule); + * + * const job = scheduleWithCheckIn.scheduleJob('my-cron-job', '* * * * *', () => { + * console.log('You will see this message every minute'); + * }); + * ``` + */ +export function instrumentNodeSchedule(lib: T & NodeSchedule): T { + return new Proxy(lib, { + get(target, prop: keyof NodeSchedule) { + if (prop === 'scheduleJob') { + // eslint-disable-next-line @typescript-eslint/unbound-method + return new Proxy(target.scheduleJob, { + apply(target, thisArg, argArray: Parameters) { + const [nameOrExpression, expressionOrCallback] = argArray; + + if (typeof nameOrExpression !== 'string' || typeof expressionOrCallback !== 'string') { + throw new Error( + "Automatic instrumentation of 'node-schedule' requires the first parameter of 'scheduleJob' to be a job name string and the second parameter to be a crontab string", + ); + } + + const monitorSlug = nameOrExpression; + const expression = expressionOrCallback; + + return withMonitor( + monitorSlug, + () => { + return target.apply(thisArg, argArray); + }, + { + schedule: { type: 'crontab', value: replaceCronNames(expression) }, + }, + ); + }, + }); + } + + return target[prop]; + }, + }); +} diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 47206462b937..a157e2bc2028 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -124,9 +124,11 @@ export { hapiErrorPlugin } from './integrations/hapi'; import { instrumentCron } from './cron/cron'; import { instrumentNodeCron } from './cron/node-cron'; +import { instrumentNodeSchedule } from './cron/node-schedule'; /** Methods to instrument cron libraries for Sentry check-ins */ export const cron = { instrumentCron, instrumentNodeCron, + instrumentNodeSchedule, }; diff --git a/packages/node/test/cron.test.ts b/packages/node/test/cron.test.ts index 8f479e7a16d4..eee6d4a66711 100644 --- a/packages/node/test/cron.test.ts +++ b/packages/node/test/cron.test.ts @@ -78,6 +78,26 @@ describe('cron check-ins', () => { }, }); }); + + test('throws with multiple jobs same name', () => { + const CronJobWithCheckIn = cron.instrumentCron(CronJobMock, 'my-cron-job'); + + CronJobWithCheckIn.from({ + cronTime: '* * * Jan,Sep Sun', + onTick: () => { + // + }, + }); + + expect(() => { + CronJobWithCheckIn.from({ + cronTime: '* * * Jan,Sep Sun', + onTick: () => { + // + }, + }); + }).toThrowError("A job named 'my-cron-job' has already been scheduled"); + }); }); describe('node-cron', () => { @@ -125,4 +145,69 @@ describe('cron check-ins', () => { }).toThrowError('Missing "name" for scheduled job. A name is required for Sentry check-in monitoring.'); }); }); + + describe('node-schedule', () => { + test('calls withMonitor', done => { + expect.assertions(5); + + class NodeScheduleMock { + scheduleJob( + nameOrExpression: string | Date | object, + expressionOrCallback: string | Date | object | (() => void), + callback: () => void, + ): unknown { + expect(nameOrExpression).toBe('my-cron-job'); + expect(expressionOrCallback).toBe('* * * Jan,Sep Sun'); + expect(callback).toBeInstanceOf(Function); + return callback(); + } + } + + const scheduleWithCheckIn = cron.instrumentNodeSchedule(new NodeScheduleMock()); + + scheduleWithCheckIn.scheduleJob('my-cron-job', '* * * Jan,Sep Sun', () => { + expect(withMonitorSpy).toHaveBeenCalledTimes(1); + expect(withMonitorSpy).toHaveBeenLastCalledWith('my-cron-job', expect.anything(), { + schedule: { type: 'crontab', value: '* * * 1,9 0' }, + }); + done(); + }); + }); + + test('throws without crontab string', () => { + class NodeScheduleMock { + scheduleJob(_: string, __: string | Date, ___: () => void): unknown { + return undefined; + } + } + + const scheduleWithCheckIn = cron.instrumentNodeSchedule(new NodeScheduleMock()); + + expect(() => { + scheduleWithCheckIn.scheduleJob('my-cron-job', new Date(), () => { + // + }); + }).toThrowError( + "Automatic instrumentation of 'node-schedule' requires the first parameter of 'scheduleJob' to be a job name string and the second parameter to be a crontab string", + ); + }); + + test('throws without job name', () => { + class NodeScheduleMock { + scheduleJob(_: string, __: () => void): unknown { + return undefined; + } + } + + const scheduleWithCheckIn = cron.instrumentNodeSchedule(new NodeScheduleMock()); + + expect(() => { + scheduleWithCheckIn.scheduleJob('* * * * *', () => { + // + }); + }).toThrowError( + "Automatic instrumentation of 'node-schedule' requires the first parameter of 'scheduleJob' to be a job name string and the second parameter to be a crontab string", + ); + }); + }); });