Skip to content

feat(node): Instrumentation for cron library #9999

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 3 commits into from
Jan 3, 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
50 changes: 50 additions & 0 deletions packages/node/src/cron/common.ts
Original file line number Diff line number Diff line change
@@ -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,
);
}
115 changes: 115 additions & 0 deletions packages/node/src/cron/cron.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
onComplete?: () => void | Promise<void>;
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<T>(lib: T & CronJobConstructor, monitorSlug: string): T {
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);
}

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);
}

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];
}
},
});
}
7 changes: 7 additions & 0 deletions packages/node/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
81 changes: 81 additions & 0 deletions packages/node/test/cron.test.ts
Original file line number Diff line number Diff line change
@@ -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();
},
});
});
});
});