From 1aa125d38fd7d161d0d969ac5858ca0f8b1a36e5 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 5bf4270cc686..69d9dee08287 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 b1d9f3118ffd..8c45d2f267d9 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 3fe12fa763f0..4c4e7c48b010 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 7ce2acd76db9..a796fc84ecc7 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 d6479c4bdc7c..317424507421 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 a03c2953ceb5..5e4c60e9706e 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 ae0ea866bfc3..88ef6a841518 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 acb0ffb47c75..005e8a4a8b79 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 56b7bf008c56..bf2c4b50b178 100644 --- a/packages/server-utils/src/orchestrion/config/index.ts +++ b/packages/server-utils/src/orchestrion/config/index.ts @@ -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'; @@ -93,6 +94,7 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ ...nestjsConfig, ...nitroConfig, ...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 90710a604eb5..2237235f5c1b 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 75f610ba0789..e5b73bcd2acc 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 7f4f833ab0a68a3c7422d98e6a506f628cf6757e 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 231f7bca351773a58ee4cff458b68de3ed2e7d3c 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/'), );