diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index 5dd9f1047431..04d00134880b 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -66,6 +66,7 @@ export { startInactiveSpan, startSpanManual, continueTrace, + cron, } from '@sentry/node'; // We can still leave this for the carrier init and type exports diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index bc29dcd908b5..215f91e2cdd8 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -73,7 +73,7 @@ export { metrics, } from '@sentry/core'; export type { SpanStatusType } from '@sentry/core'; -export { autoDiscoverNodePerformanceMonitoringIntegrations } from '@sentry/node'; +export { autoDiscoverNodePerformanceMonitoringIntegrations, cron } from '@sentry/node'; export { BunClient } from './client'; export { defaultIntegrations, init } from './sdk'; diff --git a/packages/node/src/cron/common.ts b/packages/node/src/cron/common.ts index c710d154fdd5..0fa8c1c18d23 100644 --- a/packages/node/src/cron/common.ts +++ b/packages/node/src/cron/common.ts @@ -44,6 +44,7 @@ const replacements: [string, string][] = [ */ export function replaceCronNames(cronExpression: string): string { return replacements.reduce( + // eslint-disable-next-line @sentry-internal/sdk/no-regexp-constructor (acc, [name, replacement]) => acc.replace(new RegExp(name, 'gi'), replacement), cronExpression, ); diff --git a/packages/node/src/cron/node-cron.ts b/packages/node/src/cron/node-cron.ts new file mode 100644 index 000000000000..ba3a3b555965 --- /dev/null +++ b/packages/node/src/cron/node-cron.ts @@ -0,0 +1,61 @@ +import { withMonitor } from '@sentry/core'; +import { replaceCronNames } from './common'; + +export interface NodeCronOptions { + name?: string; + timezone?: string; +} + +export interface NodeCron { + schedule: (cronExpression: string, callback: () => void, options?: NodeCronOptions) => unknown; +} + +/** + * Wraps the `node-cron` library with check-in monitoring. + * + * ```ts + * import * as Sentry from "@sentry/node"; + * import cron from "node-cron"; + * + * const cronWithCheckIn = Sentry.cron.instrumentNodeCron(cron); + * + * cronWithCheckIn.schedule( + * "* * * * *", + * () => { + * console.log("running a task every minute"); + * }, + * { name: "my-cron-job" }, + * ); + * ``` + */ +export function instrumentNodeCron(lib: Partial & T): T { + return new Proxy(lib, { + get(target, prop: keyof NodeCron) { + if (prop === 'schedule' && target.schedule) { + // When 'get' is called for schedule, return a proxied version of the schedule function + return new Proxy(target.schedule, { + apply(target, thisArg, argArray: Parameters) { + const [expression, _, options] = argArray; + + if (!options?.name) { + throw new Error('Missing "name" for scheduled job. A name is required for Sentry check-in monitoring.'); + } + + return withMonitor( + options.name, + () => { + return target.apply(thisArg, argArray); + }, + { + schedule: { type: 'crontab', value: replaceCronNames(expression) }, + timezone: options?.timezone, + }, + ); + }, + }); + } else { + return target[prop]; + } + }, + }); +} diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 140bbabf3bdc..258e65590f9b 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -120,8 +120,10 @@ export { INTEGRATIONS as Integrations, Handlers }; export { hapiErrorPlugin } from './integrations/hapi'; import { instrumentCron } from './cron/cron'; +import { instrumentNodeCron } from './cron/node-cron'; /** Methods to instrument cron libraries for Sentry check-ins */ export const cron = { instrumentCron, + instrumentNodeCron, }; diff --git a/packages/node/test/cron.test.ts b/packages/node/test/cron.test.ts index 9d4b082e9c22..3c8bb1a66a4c 100644 --- a/packages/node/test/cron.test.ts +++ b/packages/node/test/cron.test.ts @@ -2,8 +2,9 @@ import * as SentryCore from '@sentry/core'; import { cron } from '../src'; import type { CronJob, CronJobParams } from '../src/cron/cron'; +import type { NodeCron, NodeCronOptions } from '../src/cron/node-cron'; -describe('cron', () => { +describe('cron check-ins', () => { let withMonitorSpy: jest.SpyInstance; beforeEach(() => { @@ -78,4 +79,49 @@ describe('cron', () => { }); }); }); + + describe('node-cron', () => { + test('calls withMonitor', done => { + expect.assertions(5); + + const nodeCron: NodeCron = { + schedule: (expression: string, callback: () => void, options?: NodeCronOptions): unknown => { + expect(expression).toBe('* * * Jan,Sep Sun'); + expect(callback).toBeInstanceOf(Function); + expect(options?.name).toBe('my-cron-job'); + return callback(); + }, + }; + + const cronWithCheckIn = cron.instrumentNodeCron(nodeCron); + + cronWithCheckIn.schedule( + '* * * Jan,Sep Sun', + () => { + expect(withMonitorSpy).toHaveBeenCalledTimes(1); + expect(withMonitorSpy).toHaveBeenLastCalledWith('my-cron-job', expect.anything(), { + schedule: { type: 'crontab', value: '* * * 1,9 0' }, + }); + done(); + }, + { name: 'my-cron-job' }, + ); + }); + + test('throws without supplied name', () => { + const nodeCron: NodeCron = { + schedule: (): unknown => { + return undefined; + }, + }; + + const cronWithCheckIn = cron.instrumentNodeCron(nodeCron); + + expect(() => { + cronWithCheckIn.schedule('* * * * *', () => { + // + }); + }).toThrowError('Missing "name" for scheduled job. A name is required for Sentry check-in monitoring.'); + }); + }); }); diff --git a/packages/remix/src/index.server.ts b/packages/remix/src/index.server.ts index 785285ecdaf1..6d4d9fee5626 100644 --- a/packages/remix/src/index.server.ts +++ b/packages/remix/src/index.server.ts @@ -57,6 +57,7 @@ export { deepReadDirSync, Integrations, Handlers, + cron, } from '@sentry/node'; // Keeping the `*` exports for backwards compatibility and types diff --git a/packages/sveltekit/src/server/index.ts b/packages/sveltekit/src/server/index.ts index 16556479511e..a7395e3cfb18 100644 --- a/packages/sveltekit/src/server/index.ts +++ b/packages/sveltekit/src/server/index.ts @@ -63,6 +63,7 @@ export { startInactiveSpan, startSpanManual, continueTrace, + cron, } from '@sentry/node'; // We can still leave this for the carrier init and type exports