Skip to content

feat(node-experimental): Move cron code over #10742

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Feb 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions packages/node-experimental/src/cron/common.ts
Original file line number Diff line number Diff line change
@@ -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,
);
}
147 changes: 147 additions & 0 deletions packages/node-experimental/src/cron/cron.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
onComplete?: () => void | Promise<void>;
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<T>(lib: T & CronJobConstructor, monitorSlug: string): T {
let jobScheduled = false;

return new Proxy(lib, {
construct(target, args: ConstructorParameters<CronJobConstructor>) {
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<void> {
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];
}
},
});
}
10 changes: 10 additions & 0 deletions packages/node-experimental/src/cron/index.ts
Original file line number Diff line number Diff line change
@@ -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,
};
61 changes: 61 additions & 0 deletions packages/node-experimental/src/cron/node-cron.ts
Original file line number Diff line number Diff line change
@@ -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<T>(lib: Partial<NodeCron> & 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<NodeCron['schedule']>) {
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];
}
},
});
}
60 changes: 60 additions & 0 deletions packages/node-experimental/src/cron/node-schedule.ts
Original file line number Diff line number Diff line change
@@ -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<T>(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<NodeSchedule['scheduleJob']>) {
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];
},
});
}
2 changes: 1 addition & 1 deletion packages/node-experimental/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -36,7 +37,6 @@ export {
contextLinesIntegration,
nodeContextIntegration,
localVariablesIntegration,
cron,
} from '@sentry/node';

export {
Expand Down
Loading