From e7f59b89faba9bc252e35daa47b1689a27738011 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Thu, 21 Dec 2023 12:59:09 +0000 Subject: [PATCH 1/2] feat(node): Instrument `cron` library for check-ins --- packages/node/src/cron/cron.ts | 106 +++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 packages/node/src/cron/cron.ts diff --git a/packages/node/src/cron/cron.ts b/packages/node/src/cron/cron.ts new file mode 100644 index 000000000000..bd91be05dd62 --- /dev/null +++ b/packages/node/src/cron/cron.ts @@ -0,0 +1,106 @@ +import { withMonitor } from '@sentry/core'; + +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; +}; + +type CronJob = { + 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; +}; + +/** + * 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 & CronJob, 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('Cron time must be a string'); + } + + const cronString = 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 CronJob) { + if (prop === 'from') { + return (param: CronJobParams) => { + const { cronTime, onTick, timeZone } = param; + + if (typeof cronTime !== 'string') { + throw new Error('Cron time must be a string'); + } + + param.onTick = (context: unknown, onComplete?: unknown) => { + return withMonitor( + monitorSlug, + () => { + return onTick(context, onComplete); + }, + { + schedule: { type: 'crontab', value: cronTime }, + ...(timeZone ? { timeZone } : {}), + }, + ); + }; + + return target.from(param); + }; + } else { + return target[prop]; + } + }, + }); +} From ce1e8af489963c8fd77f39a5562073d1e938a4ce Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Sun, 31 Dec 2023 13:24:13 +0100 Subject: [PATCH 2/2] add test --- packages/node/src/cron/common.ts | 50 ++++++++++++++++++++ packages/node/src/cron/cron.ts | 29 ++++++++---- packages/node/src/index.ts | 7 +++ packages/node/test/cron.test.ts | 81 ++++++++++++++++++++++++++++++++ 4 files changed, 157 insertions(+), 10 deletions(-) create mode 100644 packages/node/src/cron/common.ts create mode 100644 packages/node/test/cron.test.ts 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 index bd91be05dd62..a8b42ec0fed7 100644 --- a/packages/node/src/cron/cron.ts +++ b/packages/node/src/cron/cron.ts @@ -1,6 +1,7 @@ import { withMonitor } from '@sentry/core'; +import { replaceCronNames } from './common'; -type CronJobParams = { +export type CronJobParams = { cronTime: string | Date; onTick: (context: unknown, onComplete?: unknown) => void | Promise; onComplete?: () => void | Promise; @@ -12,7 +13,11 @@ type CronJobParams = { unrefTimeout?: boolean | null; }; -type CronJob = { +export type CronJob = { + // +}; + +export type CronJobConstructor = { from: (param: CronJobParams) => CronJob; new ( @@ -28,6 +33,8 @@ type CronJob = { ): 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. * @@ -43,21 +50,21 @@ type CronJob = { * }); * * // or from - * const job = CronJobWithCheckIn.from({cronTime: '* * * * *', onTick: () => { + * const job = CronJobWithCheckIn.from({ cronTime: '* * * * *', onTick: () => { * console.log('You will see this message every minute'); * }); * ``` */ -export function instrumentCron(lib: T & CronJob, monitorSlug: string): T { +export function instrumentCron(lib: T & CronJobConstructor, monitorSlug: string): T { return new Proxy(lib, { - construct(target, args: ConstructorParameters) { + construct(target, args: ConstructorParameters) { const [cronTime, onTick, onComplete, start, timeZone, ...rest] = args; if (typeof cronTime !== 'string') { - throw new Error('Cron time must be a string'); + throw new Error(ERROR_TEXT); } - const cronString = cronTime; + const cronString = replaceCronNames(cronTime); function monitoredTick(context: unknown, onComplete?: unknown): void | Promise { return withMonitor( @@ -74,15 +81,17 @@ export function instrumentCron(lib: T & CronJob, monitorSlug: string): T { return new target(cronTime, monitoredTick, onComplete, start, timeZone, ...rest); }, - get(target, prop: keyof CronJob) { + get(target, prop: keyof CronJobConstructor) { if (prop === 'from') { return (param: CronJobParams) => { const { cronTime, onTick, timeZone } = param; if (typeof cronTime !== 'string') { - throw new Error('Cron time must be a string'); + throw new Error(ERROR_TEXT); } + const cronString = replaceCronNames(cronTime); + param.onTick = (context: unknown, onComplete?: unknown) => { return withMonitor( monitorSlug, @@ -90,7 +99,7 @@ export function instrumentCron(lib: T & CronJob, monitorSlug: string): T { return onTick(context, onComplete); }, { - schedule: { type: 'crontab', value: cronTime }, + schedule: { type: 'crontab', value: cronString }, ...(timeZone ? { timeZone } : {}), }, ); 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(); + }, + }); + }); + }); +});