Skip to content

Commit 6d1001b

Browse files
committed
feat(node): Instrumentation for node-schedule library
1 parent 3fc7916 commit 6d1001b

File tree

4 files changed

+155
-0
lines changed

4 files changed

+155
-0
lines changed

packages/node/src/cron/cron.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ const ERROR_TEXT = 'Automatic instrumentation of CronJob only supports crontab s
5656
* ```
5757
*/
5858
export function instrumentCron<T>(lib: T & CronJobConstructor, monitorSlug: string): T {
59+
let jobScheduled = false;
60+
5961
return new Proxy(lib, {
6062
construct(target, args: ConstructorParameters<CronJobConstructor>) {
6163
const [cronTime, onTick, onComplete, start, timeZone, ...rest] = args;
@@ -64,6 +66,12 @@ export function instrumentCron<T>(lib: T & CronJobConstructor, monitorSlug: stri
6466
throw new Error(ERROR_TEXT);
6567
}
6668

69+
if (jobScheduled) {
70+
throw new Error(`A job named '${monitorSlug}' has already been scheduled`);
71+
}
72+
73+
jobScheduled = true;
74+
6775
const cronString = replaceCronNames(cronTime);
6876

6977
function monitoredTick(context: unknown, onComplete?: unknown): void | Promise<void> {
@@ -90,6 +98,12 @@ export function instrumentCron<T>(lib: T & CronJobConstructor, monitorSlug: stri
9098
throw new Error(ERROR_TEXT);
9199
}
92100

101+
if (jobScheduled) {
102+
throw new Error(`A job named '${monitorSlug}' has already been scheduled`);
103+
}
104+
105+
jobScheduled = true;
106+
93107
const cronString = replaceCronNames(cronTime);
94108

95109
param.onTick = (context: unknown, onComplete?: unknown) => {
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { withMonitor } from '@sentry/core';
2+
import { replaceCronNames } from './common';
3+
4+
export interface NodeSchedule {
5+
scheduleJob(expression: string | Date | object, callback: () => void): unknown;
6+
}
7+
8+
/**
9+
* Instruments the `node-schedule` library to send a check-in event to Sentry for each job execution.
10+
*
11+
* ```ts
12+
* import * as Sentry from '@sentry/node';
13+
* import * as schedule from 'node-schedule';
14+
*
15+
* const scheduleWithCheckIn = Sentry.cron.instrumentNodeSchedule(schedule, 'my-cron-job');
16+
*
17+
* const job = scheduleWithCheckIn.scheduleJob('* * * * *', () => {
18+
* console.log('You will see this message every minute');
19+
* });
20+
* ```
21+
*/
22+
export function instrumentNodeSchedule<T>(lib: T & NodeSchedule, monitorSlug: string): T {
23+
let jobScheduled = false;
24+
25+
return new Proxy(lib, {
26+
get(target, prop: keyof NodeSchedule) {
27+
if (prop === 'scheduleJob') {
28+
// eslint-disable-next-line @typescript-eslint/unbound-method
29+
return new Proxy(target.scheduleJob, {
30+
apply(target, thisArg, argArray: Parameters<NodeSchedule['scheduleJob']>) {
31+
const [expression] = argArray;
32+
33+
if (typeof expression !== 'string') {
34+
throw new Error('Automatic instrumentation of "node-schedule" only supports crontab string');
35+
}
36+
37+
if (jobScheduled) {
38+
throw new Error(`A job named '${monitorSlug}' has already been scheduled`);
39+
}
40+
41+
jobScheduled = true;
42+
43+
return withMonitor(
44+
monitorSlug,
45+
() => {
46+
return target.apply(thisArg, argArray);
47+
},
48+
{
49+
schedule: { type: 'crontab', value: replaceCronNames(expression) },
50+
},
51+
);
52+
},
53+
});
54+
}
55+
56+
return target[prop];
57+
},
58+
});
59+
}

packages/node/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,9 +124,11 @@ export { hapiErrorPlugin } from './integrations/hapi';
124124

125125
import { instrumentCron } from './cron/cron';
126126
import { instrumentNodeCron } from './cron/node-cron';
127+
import { instrumentNodeSchedule } from './cron/node-schedule';
127128

128129
/** Methods to instrument cron libraries for Sentry check-ins */
129130
export const cron = {
130131
instrumentCron,
131132
instrumentNodeCron,
133+
instrumentNodeSchedule,
132134
};

packages/node/test/cron.test.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,26 @@ describe('cron check-ins', () => {
7878
},
7979
});
8080
});
81+
82+
test('throws with multiple jobs same name', () => {
83+
const CronJobWithCheckIn = cron.instrumentCron(CronJobMock, 'my-cron-job');
84+
85+
CronJobWithCheckIn.from({
86+
cronTime: '* * * Jan,Sep Sun',
87+
onTick: () => {
88+
//
89+
},
90+
});
91+
92+
expect(() => {
93+
CronJobWithCheckIn.from({
94+
cronTime: '* * * Jan,Sep Sun',
95+
onTick: () => {
96+
//
97+
},
98+
});
99+
}).toThrowError("A job named 'my-cron-job' has already been scheduled");
100+
});
81101
});
82102

83103
describe('node-cron', () => {
@@ -125,4 +145,64 @@ describe('cron check-ins', () => {
125145
}).toThrowError('Missing "name" for scheduled job. A name is required for Sentry check-in monitoring.');
126146
});
127147
});
148+
149+
describe('node-schedule', () => {
150+
test('calls withMonitor', done => {
151+
expect.assertions(4);
152+
153+
class NodeScheduleMock {
154+
scheduleJob(expression: string, callback: () => void): unknown {
155+
expect(expression).toBe('* * * Jan,Sep Sun');
156+
expect(callback).toBeInstanceOf(Function);
157+
return callback();
158+
}
159+
}
160+
161+
const scheduleWithCheckIn = cron.instrumentNodeSchedule(new NodeScheduleMock(), 'my-cron-job');
162+
163+
scheduleWithCheckIn.scheduleJob('* * * Jan,Sep Sun', () => {
164+
expect(withMonitorSpy).toHaveBeenCalledTimes(1);
165+
expect(withMonitorSpy).toHaveBeenLastCalledWith('my-cron-job', expect.anything(), {
166+
schedule: { type: 'crontab', value: '* * * 1,9 0' },
167+
});
168+
done();
169+
});
170+
});
171+
172+
test('throws without crontab string', () => {
173+
class NodeScheduleMock {
174+
scheduleJob(_: string | Date, __: () => void): unknown {
175+
return undefined;
176+
}
177+
}
178+
179+
const scheduleWithCheckIn = cron.instrumentNodeSchedule(new NodeScheduleMock(), 'my-cron-job');
180+
181+
expect(() => {
182+
scheduleWithCheckIn.scheduleJob(new Date(), () => {
183+
//
184+
});
185+
}).toThrowError('Automatic instrumentation of "node-schedule" only supports crontab string');
186+
});
187+
188+
test('throws with multiple jobs same name', () => {
189+
class NodeScheduleMock {
190+
scheduleJob(_: string, __: () => void): unknown {
191+
return undefined;
192+
}
193+
}
194+
195+
const scheduleWithCheckIn = cron.instrumentNodeSchedule(new NodeScheduleMock(), 'my-cron-job');
196+
197+
scheduleWithCheckIn.scheduleJob('* * * * *', () => {
198+
//
199+
});
200+
201+
expect(() => {
202+
scheduleWithCheckIn.scheduleJob('* * * * *', () => {
203+
//
204+
});
205+
}).toThrowError("A job named 'my-cron-job' has already been scheduled");
206+
});
207+
});
128208
});

0 commit comments

Comments
 (0)