From 94710fc2225ab340b2f43efa3300f955521dd6c7 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Tue, 20 Feb 2024 11:25:52 +0100 Subject: [PATCH] feat(node-experimental): Move cron code over --- packages/node-experimental/src/cron/common.ts | 51 +++++ packages/node-experimental/src/cron/cron.ts | 147 ++++++++++++ packages/node-experimental/src/cron/index.ts | 10 + .../node-experimental/src/cron/node-cron.ts | 61 +++++ .../src/cron/node-schedule.ts | 60 +++++ packages/node-experimental/src/index.ts | 2 +- packages/node-experimental/test/cron.test.ts | 213 ++++++++++++++++++ 7 files changed, 543 insertions(+), 1 deletion(-) create mode 100644 packages/node-experimental/src/cron/common.ts create mode 100644 packages/node-experimental/src/cron/cron.ts create mode 100644 packages/node-experimental/src/cron/index.ts create mode 100644 packages/node-experimental/src/cron/node-cron.ts create mode 100644 packages/node-experimental/src/cron/node-schedule.ts create mode 100644 packages/node-experimental/test/cron.test.ts diff --git a/packages/node-experimental/src/cron/common.ts b/packages/node-experimental/src/cron/common.ts new file mode 100644 index 000000000000..0fa8c1c18d23 --- /dev/null +++ b/packages/node-experimental/src/cron/common.ts @@ -0,0 +1,51 @@ +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( + // eslint-disable-next-line @sentry-internal/sdk/no-regexp-constructor + (acc, [name, replacement]) => acc.replace(new RegExp(name, 'gi'), replacement), + cronExpression, + ); +} diff --git a/packages/node-experimental/src/cron/cron.ts b/packages/node-experimental/src/cron/cron.ts new file mode 100644 index 000000000000..2540d82e736b --- /dev/null +++ b/packages/node-experimental/src/cron/cron.ts @@ -0,0 +1,147 @@ +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; + unrefTimeout?: boolean | null; +} & ( + | { + timeZone?: string | null; + utcOffset?: never; + } + | { + timeZone?: never; + utcOffset?: number | 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?: null, + unrefTimeout?: CronJobParams['unrefTimeout'], + ): CronJob; + new ( + cronTime: CronJobParams['cronTime'], + onTick: CronJobParams['onTick'], + onComplete?: CronJobParams['onComplete'], + start?: CronJobParams['start'], + timeZone?: null, + 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 { + let jobScheduled = false; + + 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); + } + + 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 { + 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); + } + + 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) => { + 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-experimental/src/cron/index.ts b/packages/node-experimental/src/cron/index.ts new file mode 100644 index 000000000000..eb4b915cff66 --- /dev/null +++ b/packages/node-experimental/src/cron/index.ts @@ -0,0 +1,10 @@ +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 = { + instrumentCron, + instrumentNodeCron, + instrumentNodeSchedule, +}; diff --git a/packages/node-experimental/src/cron/node-cron.ts b/packages/node-experimental/src/cron/node-cron.ts new file mode 100644 index 000000000000..4495a0b54909 --- /dev/null +++ b/packages/node-experimental/src/cron/node-cron.ts @@ -0,0 +1,61 @@ +import { withMonitor } from '@sentry/core'; +import { replaceCronNames } from './common'; + +export interface NodeCronOptions { + name: string; + timezone?: string; +} + +export interface NodeCron { + schedule: (cronExpression: string, callback: () => void, options: NodeCronOptions) => unknown; +} + +/** + * Wraps the `node-cron` library with check-in monitoring. + * + * ```ts + * import * as Sentry from "@sentry/node"; + * import cron from "node-cron"; + * + * const cronWithCheckIn = Sentry.cron.instrumentNodeCron(cron); + * + * cronWithCheckIn.schedule( + * "* * * * *", + * () => { + * console.log("running a task every minute"); + * }, + * { name: "my-cron-job" }, + * ); + * ``` + */ +export function instrumentNodeCron(lib: Partial & T): T { + return new Proxy(lib, { + get(target, prop: keyof NodeCron) { + if (prop === 'schedule' && target.schedule) { + // When 'get' is called for schedule, return a proxied version of the schedule function + return new Proxy(target.schedule, { + apply(target, thisArg, argArray: Parameters) { + const [expression, , options] = argArray; + + if (!options?.name) { + throw new Error('Missing "name" for scheduled job. A name is required for Sentry check-in monitoring.'); + } + + return withMonitor( + options.name, + () => { + return target.apply(thisArg, argArray); + }, + { + schedule: { type: 'crontab', value: replaceCronNames(expression) }, + timezone: options?.timezone, + }, + ); + }, + }); + } else { + return target[prop]; + } + }, + }); +} diff --git a/packages/node-experimental/src/cron/node-schedule.ts b/packages/node-experimental/src/cron/node-schedule.ts new file mode 100644 index 000000000000..79ae44a06e52 --- /dev/null +++ b/packages/node-experimental/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-experimental/src/index.ts b/packages/node-experimental/src/index.ts index ce353f6e90ce..5d30e3280ef1 100644 --- a/packages/node-experimental/src/index.ts +++ b/packages/node-experimental/src/index.ts @@ -20,6 +20,7 @@ export { createGetModuleFromFilename } from './utils/module'; export { makeNodeTransport } from './transports'; // eslint-disable-next-line deprecation/deprecation export { getCurrentHub } from './sdk/hub'; +export { cron } from './cron'; export type { Span, NodeOptions } from './types'; @@ -36,7 +37,6 @@ export { contextLinesIntegration, nodeContextIntegration, localVariablesIntegration, - cron, } from '@sentry/node'; export { diff --git a/packages/node-experimental/test/cron.test.ts b/packages/node-experimental/test/cron.test.ts new file mode 100644 index 000000000000..eee6d4a66711 --- /dev/null +++ b/packages/node-experimental/test/cron.test.ts @@ -0,0 +1,213 @@ +import * as SentryCore from '@sentry/core'; + +import { cron } from '../src'; +import type { CronJob, CronJobParams } from '../src/cron/cron'; +import type { NodeCron, NodeCronOptions } from '../src/cron/node-cron'; + +describe('cron check-ins', () => { + 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'); + + 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'); + + 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(); + }, + }); + }); + + 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', () => { + test('calls withMonitor', done => { + expect.assertions(5); + + const nodeCron: NodeCron = { + schedule: (expression: string, callback: () => void, options?: NodeCronOptions): unknown => { + expect(expression).toBe('* * * Jan,Sep Sun'); + expect(callback).toBeInstanceOf(Function); + expect(options?.name).toBe('my-cron-job'); + return callback(); + }, + }; + + const cronWithCheckIn = cron.instrumentNodeCron(nodeCron); + + cronWithCheckIn.schedule( + '* * * Jan,Sep Sun', + () => { + expect(withMonitorSpy).toHaveBeenCalledTimes(1); + expect(withMonitorSpy).toHaveBeenLastCalledWith('my-cron-job', expect.anything(), { + schedule: { type: 'crontab', value: '* * * 1,9 0' }, + }); + done(); + }, + { name: 'my-cron-job' }, + ); + }); + + test('throws without supplied name', () => { + const nodeCron: NodeCron = { + schedule: (): unknown => { + return undefined; + }, + }; + + const cronWithCheckIn = cron.instrumentNodeCron(nodeCron); + + expect(() => { + // @ts-expect-error Initially missing name + cronWithCheckIn.schedule('* * * * *', () => { + // + }); + }).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", + ); + }); + }); +});