Skip to content
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
14 changes: 14 additions & 0 deletions packages/node/src/cron/cron.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ const ERROR_TEXT = 'Automatic instrumentation of CronJob only supports crontab s
* ```
*/
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;
Expand All @@ -64,6 +66,12 @@ export function instrumentCron<T>(lib: T & CronJobConstructor, monitorSlug: stri
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> {
Expand All @@ -90,6 +98,12 @@ export function instrumentCron<T>(lib: T & CronJobConstructor, monitorSlug: stri
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) => {
Expand Down
60 changes: 60 additions & 0 deletions packages/node/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: 2 additions & 0 deletions packages/node/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,9 +124,11 @@ export { hapiErrorPlugin } from './integrations/hapi';

import { instrumentCron } from './cron/cron';
import { instrumentNodeCron } from './cron/node-cron';
import { instrumentNodeSchedule } from './cron/node-schedule';

/** Methods to instrument cron libraries for Sentry check-ins */
export const cron = {
instrumentCron,
instrumentNodeCron,
instrumentNodeSchedule,
};
85 changes: 85 additions & 0 deletions packages/node/test/cron.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,26 @@ describe('cron check-ins', () => {
},
});
});

test('throws with multiple jobs same name', () => {
const CronJobWithCheckIn = cron.instrumentCron(CronJobMock, 'my-cron-job');

CronJobWithCheckIn.from({
cronTime: '* * * Jan,Sep Sun',
onTick: () => {
//
},
});

expect(() => {
CronJobWithCheckIn.from({
cronTime: '* * * Jan,Sep Sun',
onTick: () => {
//
},
});
}).toThrowError("A job named 'my-cron-job' has already been scheduled");
});
});

describe('node-cron', () => {
Expand Down Expand Up @@ -125,4 +145,69 @@ describe('cron check-ins', () => {
}).toThrowError('Missing "name" for scheduled job. A name is required for Sentry check-in monitoring.');
});
});

describe('node-schedule', () => {
test('calls withMonitor', done => {
expect.assertions(5);

class NodeScheduleMock {
scheduleJob(
nameOrExpression: string | Date | object,
expressionOrCallback: string | Date | object | (() => void),
callback: () => void,
): unknown {
expect(nameOrExpression).toBe('my-cron-job');
expect(expressionOrCallback).toBe('* * * Jan,Sep Sun');
expect(callback).toBeInstanceOf(Function);
return callback();
}
}

const scheduleWithCheckIn = cron.instrumentNodeSchedule(new NodeScheduleMock());

scheduleWithCheckIn.scheduleJob('my-cron-job', '* * * Jan,Sep Sun', () => {
expect(withMonitorSpy).toHaveBeenCalledTimes(1);
expect(withMonitorSpy).toHaveBeenLastCalledWith('my-cron-job', expect.anything(), {
schedule: { type: 'crontab', value: '* * * 1,9 0' },
});
done();
});
});

test('throws without crontab string', () => {
class NodeScheduleMock {
scheduleJob(_: string, __: string | Date, ___: () => void): unknown {
return undefined;
}
}

const scheduleWithCheckIn = cron.instrumentNodeSchedule(new NodeScheduleMock());

expect(() => {
scheduleWithCheckIn.scheduleJob('my-cron-job', new Date(), () => {
//
});
}).toThrowError(
"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",
);
});

test('throws without job name', () => {
class NodeScheduleMock {
scheduleJob(_: string, __: () => void): unknown {
return undefined;
}
}

const scheduleWithCheckIn = cron.instrumentNodeSchedule(new NodeScheduleMock());

expect(() => {
scheduleWithCheckIn.scheduleJob('* * * * *', () => {
//
});
}).toThrowError(
"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",
);
});
});
});