Skip to content
Draft
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ const NO_FETCH_INSTRUMENTATION = [
// See https://github.com/getsentry/sentry-javascript/issues/23882
export const NO_AUTO_INSTRUMENTATION = [
'suites/cron/node-cron/integration/test.ts',
'suites/cron/node-schedule-integration/test.ts',
'suites/express/**',
'suites/fs-instrumentation/test.ts',
'suites/hono/test.ts',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import * as Sentry from '@sentry/node';
import { loggingTransport } from '@sentry-internal/node-integration-tests';

Sentry.init({
dsn: 'https://public@dsn.ingest.sentry.io/1337',
release: '1.0',
transport: loggingTransport,
integrations: [Sentry.nodeScheduleIntegration({ excludeJobs: ['excluded-job'] })],
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import schedule from 'node-schedule';

const excluded = schedule.scheduleJob('excluded-job', '0 0 * * *', () => {});
const unnamed = schedule.scheduleJob('0 0 * * *', () => {});
const recurrenceRule = schedule.scheduleJob('rule-job', { hour: 3, minute: 0 }, () => {});
const ending = schedule.scheduleJob('ending-job', { rule: '0 0 * * *', end: Date.now() + 86_400_000 }, () => {});

let runs = 0;
const job = schedule.scheduleJob('my-schedule-job', { rule: '30 9 * * 1-5', tz: 'Europe/Vienna' }, () => {
runs++;
if (runs > 1) {
throw new Error('Error in scheduled job');
}
});

// Run the jobs without waiting for their schedule.
excluded.invoke();
unnamed.invoke();
recurrenceRule.invoke();
ending.invoke();
job.invoke();
try {
job.invoke();
} catch {
// node-schedule catches errors when it runs the job itself
}

setTimeout(() => process.exit(), 10_000);
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import type { SerializedCheckIn } from '@sentry/core';
import { afterAll, describe, expect } from 'vitest';
import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner';

const MONITOR_CONFIG = {
schedule: { type: 'crontab', value: '30 9 * * 1-5' },
timezone: 'Europe/Vienna',
};

const expectNoConfig = (slug: string) => (checkIn: SerializedCheckIn) => {
expect(checkIn).toMatchObject({ monitor_slug: slug, status: 'in_progress' });
expect(checkIn.monitor_config).toBeUndefined();
};

describe('nodeScheduleIntegration', () => {
afterAll(() => {
cleanupChildProcesses();
});

createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => {
test('sends check-ins for named jobs', async () => {
await createRunner()
.ignore('event')
.expect({ check_in: expectNoConfig('rule-job') })
.expect({ check_in: { monitor_slug: 'rule-job', status: 'ok' } })
.expect({ check_in: expectNoConfig('ending-job') })
.expect({ check_in: { monitor_slug: 'ending-job', status: 'ok' } })
.expect({
check_in: { monitor_slug: 'my-schedule-job', status: 'in_progress', monitor_config: MONITOR_CONFIG },
})
.expect({ check_in: { monitor_slug: 'my-schedule-job', status: 'ok', duration: expect.any(Number) } })
.expect({
check_in: { monitor_slug: 'my-schedule-job', status: 'in_progress', monitor_config: MONITOR_CONFIG },
})
.expect({ check_in: { monitor_slug: 'my-schedule-job', status: 'error', duration: expect.any(Number) } })
.start()
.completed();
});

test('captures job errors', async () => {
await createRunner()
.unordered()
.expect({
event: {
exception: {
values: [
{
type: 'Error',
value: 'Error in scheduled job',
mechanism: { type: 'auto.function.node-schedule', handled: false },
},
],
},
},
})
.start()
.completed();
});
});
});
1 change: 1 addition & 0 deletions packages/astro/src/index.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export {
functionToStringIntegration,
genericPoolIntegration,
nodeCronIntegration,
nodeScheduleIntegration,
getActiveSpan,
getAutoPerformanceIntegrations,
getClient,
Expand Down
1 change: 1 addition & 0 deletions packages/aws-serverless/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ export {
fsIntegration,
genericPoolIntegration,
nodeCronIntegration,
nodeScheduleIntegration,
graphqlIntegration,
knexIntegration,
kafkaIntegration,
Expand Down
1 change: 1 addition & 0 deletions packages/bun/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ export {
setupKoaErrorHandler,
genericPoolIntegration,
nodeCronIntegration,
nodeScheduleIntegration,
graphqlIntegration,
knexIntegration,
kafkaIntegration,
Expand Down
1 change: 1 addition & 0 deletions packages/elysia/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ export {
setupKoaErrorHandler,
genericPoolIntegration,
nodeCronIntegration,
nodeScheduleIntegration,
graphqlIntegration,
knexIntegration,
kafkaIntegration,
Expand Down
1 change: 1 addition & 0 deletions packages/google-cloud-serverless/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ export {
firebaseIntegration,
genericPoolIntegration,
nodeCronIntegration,
nodeScheduleIntegration,
graphqlIntegration,
knexIntegration,
kafkaIntegration,
Expand Down
5 changes: 4 additions & 1 deletion packages/node/src/cron/node-schedule.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { withMonitor } from '@sentry/core';
import { addNonEnumerableProperty, withMonitor } from '@sentry/core';
import { replaceCronNames } from './common';

export interface NodeSchedule {
Expand Down Expand Up @@ -58,6 +58,9 @@ export function instrumentNodeSchedule<T>(lib: T & NodeSchedule): T {
);
}

// Tells `nodeScheduleIntegration` that this job is already monitored.
addNonEnumerableProperty(monitoredCallback, Symbol.for('sentry.cron.monitored'), true);

return target.apply(thisArg, [monitorSlug, expression, monitoredCallback]);
},
});
Expand Down
2 changes: 2 additions & 0 deletions packages/node/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ export {
genericPoolIntegration,
nodeCronIntegration,
type NodeCronIntegrationOptions,
nodeScheduleIntegration,
type NodeScheduleIntegrationOptions,
googleGenAIIntegration,
graphqlIntegration,
groqIntegration,
Expand Down
1 change: 1 addition & 0 deletions packages/remix/src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export {
functionToStringIntegration,
genericPoolIntegration,
nodeCronIntegration,
nodeScheduleIntegration,
getActiveSpan,
getAutoPerformanceIntegrations,
getClient,
Expand Down
2 changes: 2 additions & 0 deletions packages/server-utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ export { dataloaderIntegration } from './integrations/dataloader';
export { genericPoolIntegration } from './integrations/generic-pool';
export { nodeCronIntegration } from './integrations/node-cron';
export type { NodeCronIntegrationOptions } from './integrations/node-cron';
export { nodeScheduleIntegration } from './integrations/node-schedule';
export type { NodeScheduleIntegrationOptions } from './integrations/node-schedule';
export { googleGenAIIntegration } from './integrations/google-genai';
export { graphqlIntegration } from './integrations/graphql';
export { hapiIntegration } from './integrations/hapi';
Expand Down
62 changes: 62 additions & 0 deletions packages/server-utils/src/integrations/node-schedule.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import type { IntegrationFn, MonitorConfig } from '@sentry/core';
import { defineIntegration } from '@sentry/core';
import { CHANNELS } from '../orchestrion/channels';
import { nodeScheduleModuleNames } from '../orchestrion/config/node-schedule';
import { invokeOrchestrionInstrumentation } from '../orchestrion/instrumentation';
import type { AnyFn } from '../utils/cronMonitoring';
import {
cronExpressionToMonitorConfig,
shouldMonitorJob,
subscribeToJobCreation,
wrapScheduledJob,
} from '../utils/cronMonitoring';

const INTEGRATION_NAME = 'NodeSchedule' as const;

export interface NodeScheduleIntegrationOptions {
/** Job names not to monitor. Strings must match the name exactly. */
excludeJobs?: Array<string | RegExp>;
}

let options: NodeScheduleIntegrationOptions = {};

const _nodeScheduleIntegration = ((integrationOptions: NodeScheduleIntegrationOptions = {}) => {
return {
name: INTEGRATION_NAME,
setup(client) {
options = integrationOptions;
invokeOrchestrionInstrumentation(client, nodeScheduleModuleNames, subscribe, [], {
requiresTracingChannelBinding: false,
});
},
};
}) satisfies IntegrationFn;

/**
* Sends Crons check-ins for every named `node-schedule` job created with `scheduleJob`.
* The monitor slug is the job name; jobs without a name, and one-off jobs, are not monitored.
*/
export const nodeScheduleIntegration = defineIntegration(_nodeScheduleIntegration);

function subscribe(): void {
subscribeToJobCreation(CHANNELS.NODE_SCHEDULE_SCHEDULE_JOB, INTEGRATION_NAME, wrapJob);
}

// `scheduleJob([name], spec, method, [callback])`, like node-schedule reads it.
function wrapJob(args: unknown[]): void {
const name = args.length >= 3 && typeof args[0] === 'string' ? args[0] : undefined;
const [spec, method] = name ? [args[1], args[2]] : [args[0], args[1]];
if (spec instanceof Date || !shouldMonitorJob(name, method, options.excludeJobs)) {
return;
}
args[2] = wrapScheduledJob(method as AnyFn, name, getMonitorConfig(spec), 'auto.function.node-schedule');
}

// Only cron strings are sent; recurrence rules and rules limited by `start` or `end` check in without a config.
function getMonitorConfig(spec: unknown): MonitorConfig | undefined {
if (typeof spec === 'string') {
return cronExpressionToMonitorConfig(spec, undefined, 'or');
}
const { rule, tz, start, end } = (spec || {}) as { rule?: unknown; tz?: unknown; start?: unknown; end?: unknown };
return typeof rule === 'string' && !start && !end ? cronExpressionToMonitorConfig(rule, tz, 'or') : undefined;
}
2 changes: 2 additions & 0 deletions packages/server-utils/src/orchestrion/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { mysqlChannels } from './config/mysql';
import { neonChannels } from './config/neon';
import { nestjsChannels } from './config/nestjs';
import { nodeCronChannels } from './config/node-cron';
import { nodeScheduleChannels } from './config/node-schedule';
import { openaiChannels } from './config/openai';
import { pgChannels } from './config/pg';
import { piDurableChannels } from './config/pi-durable';
Expand Down Expand Up @@ -85,6 +86,7 @@ export const CHANNELS = {
...neonChannels,
...nestjsChannels,
...nodeCronChannels,
...nodeScheduleChannels,
...openaiChannels,
...pgChannels,
...piDurableChannels,
Expand Down
2 changes: 2 additions & 0 deletions packages/server-utils/src/orchestrion/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { neonConfig } from './neon';
import { nestjsConfig } from './nestjs';
import { nitroConfig } from './nitro';
import { nodeCronConfig } from './node-cron';
import { nodeScheduleConfig } from './node-schedule';
import { openaiConfig } from './openai';
import { pgConfig } from './pg';
import { postgresJsConfig } from './postgres';
Expand Down Expand Up @@ -93,6 +94,7 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [
...nestjsConfig,
...nitroConfig,
...nodeCronConfig,
...nodeScheduleConfig,
...openaiConfig,
...pgConfig,
...piDurableConfig,
Expand Down
16 changes: 16 additions & 0 deletions packages/server-utils/src/orchestrion/config/node-schedule.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { InstrumentationConfig } from '../apmTypes';
import { getModuleNames } from './module-names';

export const nodeScheduleConfig = [
{
channelName: 'scheduleJob',
module: { name: 'node-schedule', versionRange: '>=1.3.0 <3', filePath: 'lib/schedule.js' },
functionQuery: { functionName: 'scheduleJob', kind: 'Sync' },
},
] satisfies InstrumentationConfig[];

export const nodeScheduleModuleNames = getModuleNames(nodeScheduleConfig);

export const nodeScheduleChannels = {
NODE_SCHEDULE_SCHEDULE_JOB: 'orchestrion:node-schedule:scheduleJob',
} as const;
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ describe('channel integration definitions', () => {

// `@nestjs/*` and `@remix-run/*` are covered by listeners in their own SDK packages.
// Crons integrations are opt-in, so bundler-only SDKs must not install them for every app.
const optIn = new Set(['node-cron']);
const optIn = new Set(['node-cron', 'node-schedule']);
const uncovered = [...configured].filter(
name => !defined.has(name) && !optIn.has(name) && !name.startsWith('@nestjs/') && !name.startsWith('@remix-run/'),
);
Expand Down
1 change: 1 addition & 0 deletions packages/solidstart/src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export {
functionToStringIntegration,
genericPoolIntegration,
nodeCronIntegration,
nodeScheduleIntegration,
getActiveSpan,
getAutoPerformanceIntegrations,
getClient,
Expand Down
1 change: 1 addition & 0 deletions packages/sveltekit/src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export {
functionToStringIntegration,
genericPoolIntegration,
nodeCronIntegration,
nodeScheduleIntegration,
getActiveSpan,
getAutoPerformanceIntegrations,
getClient,
Expand Down
Loading