From 1d385222f76594c46ebd20764b85f4c558e4c5a5 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Fri, 16 Oct 2026 17:50:13 -0700 Subject: [PATCH 1/3] feat(node): Add nodeScheduleIntegration to monitor node-schedule jobs Sends check-ins for every named job created with scheduleJob through the orchestrion channel injection. Cron strings (with their tz) are sent as the monitor config when Sentry reads them the same way; recurrence rules check in without a config. Unnamed and one-off jobs are skipped. Co-Authored-By: Claude --- .../node-schedule-integration/instrument.mjs | 9 +++ .../node-schedule-integration/scenario.mjs | 26 ++++++ .../cron/node-schedule-integration/test.ts | 44 +++++++++++ packages/astro/src/index.server.ts | 1 + packages/aws-serverless/src/index.ts | 1 + packages/bun/src/index.ts | 1 + packages/elysia/src/index.ts | 1 + packages/google-cloud-serverless/src/index.ts | 1 + packages/node/src/cron/node-schedule.ts | 3 + packages/node/src/index.ts | 2 + packages/remix/src/server/index.ts | 1 + packages/server-utils/src/index.ts | 2 + .../src/integrations/node-schedule.ts | 79 +++++++++++++++++++ .../server-utils/src/orchestrion/channels.ts | 2 + .../src/orchestrion/config/index.ts | 2 + .../src/orchestrion/config/node-schedule.ts | 16 ++++ packages/solidstart/src/server/index.ts | 1 + packages/sveltekit/src/server/index.ts | 1 + 18 files changed, 193 insertions(+) create mode 100644 dev-packages/node-integration-tests/suites/cron/node-schedule-integration/instrument.mjs create mode 100644 dev-packages/node-integration-tests/suites/cron/node-schedule-integration/scenario.mjs create mode 100644 dev-packages/node-integration-tests/suites/cron/node-schedule-integration/test.ts create mode 100644 packages/server-utils/src/integrations/node-schedule.ts create mode 100644 packages/server-utils/src/orchestrion/config/node-schedule.ts diff --git a/dev-packages/node-integration-tests/suites/cron/node-schedule-integration/instrument.mjs b/dev-packages/node-integration-tests/suites/cron/node-schedule-integration/instrument.mjs new file mode 100644 index 000000000000..a3011f204b5e --- /dev/null +++ b/dev-packages/node-integration-tests/suites/cron/node-schedule-integration/instrument.mjs @@ -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'] })], +}); diff --git a/dev-packages/node-integration-tests/suites/cron/node-schedule-integration/scenario.mjs b/dev-packages/node-integration-tests/suites/cron/node-schedule-integration/scenario.mjs new file mode 100644 index 000000000000..3b05251a223d --- /dev/null +++ b/dev-packages/node-integration-tests/suites/cron/node-schedule-integration/scenario.mjs @@ -0,0 +1,26 @@ +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 }, () => {}); + +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(); +job.invoke(); +try { + job.invoke(); +} catch { + // node-schedule catches errors when it runs the job itself +} + +setTimeout(() => process.exit(), 10_000); diff --git a/dev-packages/node-integration-tests/suites/cron/node-schedule-integration/test.ts b/dev-packages/node-integration-tests/suites/cron/node-schedule-integration/test.ts new file mode 100644 index 000000000000..2266b7e11216 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/cron/node-schedule-integration/test.ts @@ -0,0 +1,44 @@ +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', +}; + +describe('nodeScheduleIntegration', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + test('sends check-ins for named jobs', async () => { + await createRunner() + .expect({ check_in: { monitor_slug: 'rule-job', status: 'in_progress' } }) + .expect({ check_in: { monitor_slug: 'rule-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) } }) + .expect({ + event: { + exception: { + values: [ + { + type: 'Error', + value: 'Error in scheduled job', + mechanism: { type: 'auto.function.node-schedule', handled: false }, + }, + ], + }, + }, + }) + .start() + .completed(); + }); + }); +}); diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index 722337ac28eb..1c1dd201df7e 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -48,6 +48,7 @@ export { functionToStringIntegration, genericPoolIntegration, nodeCronIntegration, + nodeScheduleIntegration, getActiveSpan, getAutoPerformanceIntegrations, getClient, diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index 8e6b17bba404..b096018db11a 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -117,6 +117,7 @@ export { fsIntegration, genericPoolIntegration, nodeCronIntegration, + nodeScheduleIntegration, graphqlIntegration, knexIntegration, kafkaIntegration, diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index e7c340f6386c..a7f8b5f78f5f 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -139,6 +139,7 @@ export { setupKoaErrorHandler, genericPoolIntegration, nodeCronIntegration, + nodeScheduleIntegration, graphqlIntegration, knexIntegration, kafkaIntegration, diff --git a/packages/elysia/src/index.ts b/packages/elysia/src/index.ts index 5e6d69788125..94fd4b880836 100644 --- a/packages/elysia/src/index.ts +++ b/packages/elysia/src/index.ts @@ -118,6 +118,7 @@ export { setupKoaErrorHandler, genericPoolIntegration, nodeCronIntegration, + nodeScheduleIntegration, graphqlIntegration, knexIntegration, kafkaIntegration, diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index af8e84be7a44..e78c35d7976e 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -117,6 +117,7 @@ export { firebaseIntegration, genericPoolIntegration, nodeCronIntegration, + nodeScheduleIntegration, graphqlIntegration, knexIntegration, kafkaIntegration, diff --git a/packages/node/src/cron/node-schedule.ts b/packages/node/src/cron/node-schedule.ts index f174ef7c904b..df7c775fc27b 100644 --- a/packages/node/src/cron/node-schedule.ts +++ b/packages/node/src/cron/node-schedule.ts @@ -58,6 +58,9 @@ export function instrumentNodeSchedule(lib: T & NodeSchedule): T { ); } + // Tells `nodeScheduleIntegration` that this job is already monitored. + (monitoredCallback as unknown as Record)[Symbol.for('sentry.cron.monitored')] = true; + return target.apply(thisArg, [monitorSlug, expression, monitoredCallback]); }, }); diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index f4a6ca7b51d4..30c41c98bd48 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -13,6 +13,8 @@ export { genericPoolIntegration, nodeCronIntegration, type NodeCronIntegrationOptions, + nodeScheduleIntegration, + type NodeScheduleIntegrationOptions, googleGenAIIntegration, graphqlIntegration, groqIntegration, diff --git a/packages/remix/src/server/index.ts b/packages/remix/src/server/index.ts index 375ec0597b74..695226b43bba 100644 --- a/packages/remix/src/server/index.ts +++ b/packages/remix/src/server/index.ts @@ -38,6 +38,7 @@ export { functionToStringIntegration, genericPoolIntegration, nodeCronIntegration, + nodeScheduleIntegration, getActiveSpan, getAutoPerformanceIntegrations, getClient, diff --git a/packages/server-utils/src/index.ts b/packages/server-utils/src/index.ts index c818a99b623f..589d37bd3ae7 100644 --- a/packages/server-utils/src/index.ts +++ b/packages/server-utils/src/index.ts @@ -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'; diff --git a/packages/server-utils/src/integrations/node-schedule.ts b/packages/server-utils/src/integrations/node-schedule.ts new file mode 100644 index 000000000000..7fea20bb3b7f --- /dev/null +++ b/packages/server-utils/src/integrations/node-schedule.ts @@ -0,0 +1,79 @@ +import * as diagnosticsChannel from '../utils/diagnosticsChannel'; +import type { IntegrationFn, MonitorConfig } from '@sentry/core'; +import { debug, defineIntegration, stringMatchesSomePattern } from '@sentry/core'; +import { DEBUG_BUILD } from '../debug-build'; +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, wrapScheduledJob } from '../utils/cronMonitoring'; + +const INTEGRATION_NAME = 'NodeSchedule' as const; + +// Set by `Sentry.cron.instrumentNodeSchedule`, which already sends check-ins. +const MONITORED = Symbol.for('sentry.cron.monitored'); + +const NOOP = (): void => {}; + +export interface NodeScheduleIntegrationOptions { + /** Job names not to monitor. Strings must match the name exactly. */ + excludeJobs?: Array; +} + +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 { + diagnosticsChannel.tracingChannel<{ arguments: unknown[] }>(CHANNELS.NODE_SCHEDULE_SCHEDULE_JOB).subscribe({ + start(data) { + try { + // `scheduleJob([name], spec, method, [callback])`, like node-schedule reads it. + const args = data.arguments; + 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 ( + !name || + typeof method !== 'function' || + (method as { [MONITORED]?: boolean })[MONITORED] || + spec instanceof Date || + stringMatchesSomePattern(name, options.excludeJobs, true) + ) { + return; + } + args[2] = wrapScheduledJob(method as AnyFn, name, getMonitorConfig(spec), 'auto.function.node-schedule'); + } catch (e) { + DEBUG_BUILD && debug.error('[NodeSchedule] Failed to set up the cron monitor:', e); + } + }, + end: NOOP, + asyncStart: NOOP, + asyncEnd: NOOP, + error: NOOP, + }); +} + +// Only cron strings are sent; recurrence rules check in without a config. +function getMonitorConfig(spec: unknown): MonitorConfig | undefined { + if (typeof spec === 'string') { + return cronExpressionToMonitorConfig(spec, undefined, 'or'); + } + const { rule, tz } = (spec || {}) as { rule?: unknown; tz?: unknown }; + return typeof rule === 'string' ? cronExpressionToMonitorConfig(rule, tz, 'or') : undefined; +} diff --git a/packages/server-utils/src/orchestrion/channels.ts b/packages/server-utils/src/orchestrion/channels.ts index 81f9b34d2e27..67a88bbb3825 100644 --- a/packages/server-utils/src/orchestrion/channels.ts +++ b/packages/server-utils/src/orchestrion/channels.ts @@ -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'; @@ -85,6 +86,7 @@ export const CHANNELS = { ...neonChannels, ...nestjsChannels, ...nodeCronChannels, + ...nodeScheduleChannels, ...openaiChannels, ...pgChannels, ...piDurableChannels, diff --git a/packages/server-utils/src/orchestrion/config/index.ts b/packages/server-utils/src/orchestrion/config/index.ts index 362a5806b8e5..31d3eb146db0 100644 --- a/packages/server-utils/src/orchestrion/config/index.ts +++ b/packages/server-utils/src/orchestrion/config/index.ts @@ -33,6 +33,7 @@ import { mysqlConfig } from './mysql'; import { neonConfig } from './neon'; import { nestjsConfig } from './nestjs'; import { nodeCronConfig } from './node-cron'; +import { nodeScheduleConfig } from './node-schedule'; import { openaiConfig } from './openai'; import { pgConfig } from './pg'; import { postgresJsConfig } from './postgres'; @@ -91,6 +92,7 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ ...neonConfig, ...nestjsConfig, ...nodeCronConfig, + ...nodeScheduleConfig, ...openaiConfig, ...pgConfig, ...piDurableConfig, diff --git a/packages/server-utils/src/orchestrion/config/node-schedule.ts b/packages/server-utils/src/orchestrion/config/node-schedule.ts new file mode 100644 index 000000000000..3dd231617e64 --- /dev/null +++ b/packages/server-utils/src/orchestrion/config/node-schedule.ts @@ -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; diff --git a/packages/solidstart/src/server/index.ts b/packages/solidstart/src/server/index.ts index bfc6b401411c..31873220da81 100644 --- a/packages/solidstart/src/server/index.ts +++ b/packages/solidstart/src/server/index.ts @@ -41,6 +41,7 @@ export { functionToStringIntegration, genericPoolIntegration, nodeCronIntegration, + nodeScheduleIntegration, getActiveSpan, getAutoPerformanceIntegrations, getClient, diff --git a/packages/sveltekit/src/server/index.ts b/packages/sveltekit/src/server/index.ts index f8707c82bb9a..0ab80374e7f8 100644 --- a/packages/sveltekit/src/server/index.ts +++ b/packages/sveltekit/src/server/index.ts @@ -39,6 +39,7 @@ export { functionToStringIntegration, genericPoolIntegration, nodeCronIntegration, + nodeScheduleIntegration, getActiveSpan, getAutoPerformanceIntegrations, getClient, From e30cc9c0d14297b5b2d80b4aa9482a61427633d3 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Fri, 16 Oct 2026 17:54:10 -0700 Subject: [PATCH 2/3] test(node): Assert node-schedule check-ins and errors separately Co-Authored-By: Claude --- .../suites/cron/node-schedule-integration/test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/dev-packages/node-integration-tests/suites/cron/node-schedule-integration/test.ts b/dev-packages/node-integration-tests/suites/cron/node-schedule-integration/test.ts index 2266b7e11216..c74df35d4f21 100644 --- a/dev-packages/node-integration-tests/suites/cron/node-schedule-integration/test.ts +++ b/dev-packages/node-integration-tests/suites/cron/node-schedule-integration/test.ts @@ -14,6 +14,7 @@ describe('nodeScheduleIntegration', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { test('sends check-ins for named jobs', async () => { await createRunner() + .ignore('event') .expect({ check_in: { monitor_slug: 'rule-job', status: 'in_progress' } }) .expect({ check_in: { monitor_slug: 'rule-job', status: 'ok' } }) .expect({ @@ -24,6 +25,13 @@ describe('nodeScheduleIntegration', () => { 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: { From b9c729a0d0d5cd752550170fb4599d8270c4ad58 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Fri, 16 Oct 2026 18:11:28 -0700 Subject: [PATCH 3/3] test(node): Skip node-schedule integration suite on bun run Co-Authored-By: Claude --- dev-packages/bun-integration-tests/node-suites/excludes.ts | 1 + .../test/orchestrion/moduleInjectedTransform.test.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/dev-packages/bun-integration-tests/node-suites/excludes.ts b/dev-packages/bun-integration-tests/node-suites/excludes.ts index 5d88d630c7c0..e38eeeb56384 100644 --- a/dev-packages/bun-integration-tests/node-suites/excludes.ts +++ b/dev-packages/bun-integration-tests/node-suites/excludes.ts @@ -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', diff --git a/packages/server-utils/test/orchestrion/moduleInjectedTransform.test.ts b/packages/server-utils/test/orchestrion/moduleInjectedTransform.test.ts index 1697ea30d7c0..ed55b1bec34e 100644 --- a/packages/server-utils/test/orchestrion/moduleInjectedTransform.test.ts +++ b/packages/server-utils/test/orchestrion/moduleInjectedTransform.test.ts @@ -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/'), );