Skip to content

Commit 5cbe28b

Browse files
authored
feat(node): Instrumentation for cron library (#9999)
```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'); }); ```
1 parent 011ba71 commit 5cbe28b

File tree

4 files changed

+253
-0
lines changed

4 files changed

+253
-0
lines changed

packages/node/src/cron/common.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
const replacements: [string, string][] = [
2+
['january', '1'],
3+
['february', '2'],
4+
['march', '3'],
5+
['april', '4'],
6+
['may', '5'],
7+
['june', '6'],
8+
['july', '7'],
9+
['august', '8'],
10+
['september', '9'],
11+
['october', '10'],
12+
['november', '11'],
13+
['december', '12'],
14+
['jan', '1'],
15+
['feb', '2'],
16+
['mar', '3'],
17+
['apr', '4'],
18+
['may', '5'],
19+
['jun', '6'],
20+
['jul', '7'],
21+
['aug', '8'],
22+
['sep', '9'],
23+
['oct', '10'],
24+
['nov', '11'],
25+
['dec', '12'],
26+
['sunday', '0'],
27+
['monday', '1'],
28+
['tuesday', '2'],
29+
['wednesday', '3'],
30+
['thursday', '4'],
31+
['friday', '5'],
32+
['saturday', '6'],
33+
['sun', '0'],
34+
['mon', '1'],
35+
['tue', '2'],
36+
['wed', '3'],
37+
['thu', '4'],
38+
['fri', '5'],
39+
['sat', '6'],
40+
];
41+
42+
/**
43+
* Replaces names in cron expressions
44+
*/
45+
export function replaceCronNames(cronExpression: string): string {
46+
return replacements.reduce(
47+
(acc, [name, replacement]) => acc.replace(new RegExp(name, 'gi'), replacement),
48+
cronExpression,
49+
);
50+
}

packages/node/src/cron/cron.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { withMonitor } from '@sentry/core';
2+
import { replaceCronNames } from './common';
3+
4+
export type CronJobParams = {
5+
cronTime: string | Date;
6+
onTick: (context: unknown, onComplete?: unknown) => void | Promise<void>;
7+
onComplete?: () => void | Promise<void>;
8+
start?: boolean | null;
9+
context?: unknown;
10+
runOnInit?: boolean | null;
11+
utcOffset?: number;
12+
timeZone?: string;
13+
unrefTimeout?: boolean | null;
14+
};
15+
16+
export type CronJob = {
17+
//
18+
};
19+
20+
export type CronJobConstructor = {
21+
from: (param: CronJobParams) => CronJob;
22+
23+
new (
24+
cronTime: CronJobParams['cronTime'],
25+
onTick: CronJobParams['onTick'],
26+
onComplete?: CronJobParams['onComplete'],
27+
start?: CronJobParams['start'],
28+
timeZone?: CronJobParams['timeZone'],
29+
context?: CronJobParams['context'],
30+
runOnInit?: CronJobParams['runOnInit'],
31+
utcOffset?: CronJobParams['utcOffset'],
32+
unrefTimeout?: CronJobParams['unrefTimeout'],
33+
): CronJob;
34+
};
35+
36+
const ERROR_TEXT = 'Automatic instrumentation of CronJob only supports crontab string';
37+
38+
/**
39+
* Instruments the `cron` library to send a check-in event to Sentry for each job execution.
40+
*
41+
* ```ts
42+
* import * as Sentry from '@sentry/node';
43+
* import { CronJob } from 'cron';
44+
*
45+
* const CronJobWithCheckIn = Sentry.cron.instrumentCron(CronJob, 'my-cron-job');
46+
*
47+
* // use the constructor
48+
* const job = new CronJobWithCheckIn('* * * * *', () => {
49+
* console.log('You will see this message every minute');
50+
* });
51+
*
52+
* // or from
53+
* const job = CronJobWithCheckIn.from({ cronTime: '* * * * *', onTick: () => {
54+
* console.log('You will see this message every minute');
55+
* });
56+
* ```
57+
*/
58+
export function instrumentCron<T>(lib: T & CronJobConstructor, monitorSlug: string): T {
59+
return new Proxy(lib, {
60+
construct(target, args: ConstructorParameters<CronJobConstructor>) {
61+
const [cronTime, onTick, onComplete, start, timeZone, ...rest] = args;
62+
63+
if (typeof cronTime !== 'string') {
64+
throw new Error(ERROR_TEXT);
65+
}
66+
67+
const cronString = replaceCronNames(cronTime);
68+
69+
function monitoredTick(context: unknown, onComplete?: unknown): void | Promise<void> {
70+
return withMonitor(
71+
monitorSlug,
72+
() => {
73+
return onTick(context, onComplete);
74+
},
75+
{
76+
schedule: { type: 'crontab', value: cronString },
77+
...(timeZone ? { timeZone } : {}),
78+
},
79+
);
80+
}
81+
82+
return new target(cronTime, monitoredTick, onComplete, start, timeZone, ...rest);
83+
},
84+
get(target, prop: keyof CronJobConstructor) {
85+
if (prop === 'from') {
86+
return (param: CronJobParams) => {
87+
const { cronTime, onTick, timeZone } = param;
88+
89+
if (typeof cronTime !== 'string') {
90+
throw new Error(ERROR_TEXT);
91+
}
92+
93+
const cronString = replaceCronNames(cronTime);
94+
95+
param.onTick = (context: unknown, onComplete?: unknown) => {
96+
return withMonitor(
97+
monitorSlug,
98+
() => {
99+
return onTick(context, onComplete);
100+
},
101+
{
102+
schedule: { type: 'crontab', value: cronString },
103+
...(timeZone ? { timeZone } : {}),
104+
},
105+
);
106+
};
107+
108+
return target.from(param);
109+
};
110+
} else {
111+
return target[prop];
112+
}
113+
},
114+
});
115+
}

packages/node/src/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,3 +118,10 @@ const INTEGRATIONS = {
118118
export { INTEGRATIONS as Integrations, Handlers };
119119

120120
export { hapiErrorPlugin } from './integrations/hapi';
121+
122+
import { instrumentCron } from './cron/cron';
123+
124+
/** Methods to instrument cron libraries for Sentry check-ins */
125+
export const cron = {
126+
instrumentCron,
127+
};

packages/node/test/cron.test.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import * as SentryCore from '@sentry/core';
2+
3+
import { cron } from '../src';
4+
import type { CronJob, CronJobParams } from '../src/cron/cron';
5+
6+
describe('cron', () => {
7+
let withMonitorSpy: jest.SpyInstance;
8+
9+
beforeEach(() => {
10+
withMonitorSpy = jest.spyOn(SentryCore, 'withMonitor');
11+
});
12+
13+
afterEach(() => {
14+
jest.restoreAllMocks();
15+
});
16+
17+
describe('cron', () => {
18+
class CronJobMock {
19+
constructor(
20+
cronTime: CronJobParams['cronTime'],
21+
onTick: CronJobParams['onTick'],
22+
_onComplete?: CronJobParams['onComplete'],
23+
_start?: CronJobParams['start'],
24+
_timeZone?: CronJobParams['timeZone'],
25+
_context?: CronJobParams['context'],
26+
_runOnInit?: CronJobParams['runOnInit'],
27+
_utcOffset?: CronJobParams['utcOffset'],
28+
_unrefTimeout?: CronJobParams['unrefTimeout'],
29+
) {
30+
expect(cronTime).toBe('* * * Jan,Sep Sun');
31+
expect(onTick).toBeInstanceOf(Function);
32+
setImmediate(() => onTick(undefined, undefined));
33+
}
34+
35+
static from(params: CronJobParams): CronJob {
36+
return new CronJobMock(
37+
params.cronTime,
38+
params.onTick,
39+
params.onComplete,
40+
params.start,
41+
params.timeZone,
42+
params.context,
43+
params.runOnInit,
44+
params.utcOffset,
45+
params.unrefTimeout,
46+
);
47+
}
48+
}
49+
50+
test('new CronJob()', done => {
51+
expect.assertions(4);
52+
53+
const CronJobWithCheckIn = cron.instrumentCron(CronJobMock, 'my-cron-job');
54+
55+
const _ = new CronJobWithCheckIn('* * * Jan,Sep Sun', () => {
56+
expect(withMonitorSpy).toHaveBeenCalledTimes(1);
57+
expect(withMonitorSpy).toHaveBeenLastCalledWith('my-cron-job', expect.anything(), {
58+
schedule: { type: 'crontab', value: '* * * 1,9 0' },
59+
});
60+
done();
61+
});
62+
});
63+
64+
test('CronJob.from()', done => {
65+
expect.assertions(4);
66+
67+
const CronJobWithCheckIn = cron.instrumentCron(CronJobMock, 'my-cron-job');
68+
69+
const _ = CronJobWithCheckIn.from({
70+
cronTime: '* * * Jan,Sep Sun',
71+
onTick: () => {
72+
expect(withMonitorSpy).toHaveBeenCalledTimes(1);
73+
expect(withMonitorSpy).toHaveBeenLastCalledWith('my-cron-job', expect.anything(), {
74+
schedule: { type: 'crontab', value: '* * * 1,9 0' },
75+
});
76+
done();
77+
},
78+
});
79+
});
80+
});
81+
});

0 commit comments

Comments
 (0)