Skip to content

Commit 85b1da8

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

File tree

4 files changed

+156
-0
lines changed

4 files changed

+156
-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: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import * as SentryCore from '@sentry/core';
33
import { cron } from '../src';
44
import type { CronJob, CronJobParams } from '../src/cron/cron';
55
import type { NodeCron, NodeCronOptions } from '../src/cron/node-cron';
6+
import { NodeSchedule } from '../src/cron/node-schedule';
67

78
describe('cron check-ins', () => {
89
let withMonitorSpy: jest.SpyInstance;
@@ -78,6 +79,26 @@ describe('cron check-ins', () => {
7879
},
7980
});
8081
});
82+
83+
test('throws without multiple jobs same name', () => {
84+
const CronJobWithCheckIn = cron.instrumentCron(CronJobMock, 'my-cron-job');
85+
86+
CronJobWithCheckIn.from({
87+
cronTime: '* * * Jan,Sep Sun',
88+
onTick: () => {
89+
//
90+
},
91+
});
92+
93+
expect(() => {
94+
CronJobWithCheckIn.from({
95+
cronTime: '* * * Jan,Sep Sun',
96+
onTick: () => {
97+
//
98+
},
99+
});
100+
}).toThrowError("A job named 'my-cron-job' has already been scheduled");
101+
});
81102
});
82103

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

0 commit comments

Comments
 (0)