From db27775ac65042d7ce058be682a720ab37e9d9bd Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Fri, 16 Oct 2026 17:46:56 -0700 Subject: [PATCH 01/33] feat(node): Add nodeCronIntegration to monitor node-cron tasks Sends check-ins for every named node-cron task through the orchestrion createTask channel, with the task's schedule and time zone as monitor config when Sentry reads it the same way. Tasks without a name, and tasks wrapped by Sentry.cron.instrumentNodeCron, are skipped. Co-Authored-By: Claude --- .../cron/node-cron/integration/instrument.mjs | 9 + .../cron/node-cron/integration/scenario.mjs | 25 +++ .../suites/cron/node-cron/integration/test.ts | 42 +++++ 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-cron.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-cron.ts | 165 ++++++++++++++++++ .../server-utils/src/orchestrion/channels.ts | 2 + .../src/orchestrion/config/index.ts | 2 + .../src/orchestrion/config/node-cron.ts | 27 +++ .../test/integrations/node-cron.test.ts | 47 +++++ packages/solidstart/src/server/index.ts | 1 + packages/sveltekit/src/server/index.ts | 1 + 19 files changed, 334 insertions(+) create mode 100644 dev-packages/node-integration-tests/suites/cron/node-cron/integration/instrument.mjs create mode 100644 dev-packages/node-integration-tests/suites/cron/node-cron/integration/scenario.mjs create mode 100644 dev-packages/node-integration-tests/suites/cron/node-cron/integration/test.ts create mode 100644 packages/server-utils/src/integrations/node-cron.ts create mode 100644 packages/server-utils/src/orchestrion/config/node-cron.ts create mode 100644 packages/server-utils/test/integrations/node-cron.test.ts diff --git a/dev-packages/node-integration-tests/suites/cron/node-cron/integration/instrument.mjs b/dev-packages/node-integration-tests/suites/cron/node-cron/integration/instrument.mjs new file mode 100644 index 000000000000..521d7bbe12ff --- /dev/null +++ b/dev-packages/node-integration-tests/suites/cron/node-cron/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.nodeCronIntegration({ excludeJobs: ['excluded-job'] })], +}); diff --git a/dev-packages/node-integration-tests/suites/cron/node-cron/integration/scenario.mjs b/dev-packages/node-integration-tests/suites/cron/node-cron/integration/scenario.mjs new file mode 100644 index 000000000000..776fea6a61a5 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/cron/node-cron/integration/scenario.mjs @@ -0,0 +1,25 @@ +import cron from 'node-cron'; + +const excluded = cron.schedule('0 * * * * *', () => {}, { name: 'excluded-job' }); +const unnamed = cron.schedule('0 * * * * *', () => {}); + +let runs = 0; +const task = cron.schedule( + '0 * * * * *', + () => { + runs++; + if (runs > 1) { + throw new Error('Error in cron job'); + } + }, + { name: 'my-cron-job', timezone: 'America/New_York' }, +); + +// Run the task without waiting for the next minute. +excluded + .execute() + .then(() => unnamed.execute()) + .then(() => task.execute()) + .then(() => task.execute()); + +setTimeout(() => process.exit(), 10_000); diff --git a/dev-packages/node-integration-tests/suites/cron/node-cron/integration/test.ts b/dev-packages/node-integration-tests/suites/cron/node-cron/integration/test.ts new file mode 100644 index 000000000000..a859737c4101 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/cron/node-cron/integration/test.ts @@ -0,0 +1,42 @@ +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/runner'; + +const MONITOR_CONFIG = { + schedule: { type: 'crontab', value: '* * * * *' }, + timezone: 'America/New_York', +}; + +describe('nodeCronIntegration', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + test('sends check-ins for named tasks', async () => { + await createRunner() + .expect({ + check_in: { monitor_slug: 'my-cron-job', status: 'in_progress', monitor_config: MONITOR_CONFIG }, + }) + .expect({ check_in: { monitor_slug: 'my-cron-job', status: 'ok', duration: expect.any(Number) } }) + .expect({ + check_in: { monitor_slug: 'my-cron-job', status: 'in_progress', monitor_config: MONITOR_CONFIG }, + }) + .expect({ check_in: { monitor_slug: 'my-cron-job', status: 'error', duration: expect.any(Number) } }) + .expect({ + event: { + exception: { + values: [ + { + type: 'Error', + value: 'Error in cron job', + mechanism: { type: 'auto.function.node-cron', handled: false }, + }, + ], + }, + }, + }) + .start() + .completed(); + }); + }); +}); diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index 80158e311e49..5bf4270cc686 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -47,6 +47,7 @@ export { fsIntegration, functionToStringIntegration, genericPoolIntegration, + nodeCronIntegration, getActiveSpan, getAutoPerformanceIntegrations, getClient, diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index fd07e76a3fba..b1d9f3118ffd 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -116,6 +116,7 @@ export { firebaseIntegration, fsIntegration, genericPoolIntegration, + nodeCronIntegration, graphqlIntegration, knexIntegration, kafkaIntegration, diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index 4e91068d21f8..3fe12fa763f0 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -138,6 +138,7 @@ export { // oxlint-disable-next-line typescript/no-deprecated setupKoaErrorHandler, genericPoolIntegration, + nodeCronIntegration, graphqlIntegration, knexIntegration, kafkaIntegration, diff --git a/packages/elysia/src/index.ts b/packages/elysia/src/index.ts index 76c135d6f3f4..7ce2acd76db9 100644 --- a/packages/elysia/src/index.ts +++ b/packages/elysia/src/index.ts @@ -117,6 +117,7 @@ export { // oxlint-disable-next-line typescript/no-deprecated setupKoaErrorHandler, genericPoolIntegration, + nodeCronIntegration, graphqlIntegration, knexIntegration, kafkaIntegration, diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index b90e609bbfc1..d6479c4bdc7c 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -116,6 +116,7 @@ export { fastifyIntegration, firebaseIntegration, genericPoolIntegration, + nodeCronIntegration, graphqlIntegration, knexIntegration, kafkaIntegration, diff --git a/packages/node/src/cron/node-cron.ts b/packages/node/src/cron/node-cron.ts index db3445fe8471..c0bf0f27ad8c 100644 --- a/packages/node/src/cron/node-cron.ts +++ b/packages/node/src/cron/node-cron.ts @@ -78,6 +78,9 @@ export function instrumentNodeCron( ); }; + // Tells `nodeCronIntegration` that this task is already monitored. + (monitoredCallback as unknown as Record)[Symbol.for('sentry.cron.monitored')] = true; + return target.apply(thisArg, [expression, monitoredCallback, options]); }, }); diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 130194f72cf5..a03c2953ceb5 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -11,6 +11,8 @@ export { type ExpressIntegrationOptions, firebaseIntegration, genericPoolIntegration, + nodeCronIntegration, + type NodeCronIntegrationOptions, googleGenAIIntegration, graphqlIntegration, groqIntegration, diff --git a/packages/remix/src/server/index.ts b/packages/remix/src/server/index.ts index 7b8b29d46a02..ae0ea866bfc3 100644 --- a/packages/remix/src/server/index.ts +++ b/packages/remix/src/server/index.ts @@ -37,6 +37,7 @@ export { flush, functionToStringIntegration, genericPoolIntegration, + nodeCronIntegration, getActiveSpan, getAutoPerformanceIntegrations, getClient, diff --git a/packages/server-utils/src/index.ts b/packages/server-utils/src/index.ts index dacf7ff16b7f..acb0ffb47c75 100644 --- a/packages/server-utils/src/index.ts +++ b/packages/server-utils/src/index.ts @@ -38,6 +38,8 @@ export { anthropicAIIntegration } from './integrations/anthropic'; export { awsIntegration } from './integrations/aws-sdk'; 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 { googleGenAIIntegration } from './integrations/google-genai'; export { graphqlIntegration } from './integrations/graphql'; export { hapiIntegration } from './integrations/hapi'; diff --git a/packages/server-utils/src/integrations/node-cron.ts b/packages/server-utils/src/integrations/node-cron.ts new file mode 100644 index 000000000000..ac9775442812 --- /dev/null +++ b/packages/server-utils/src/integrations/node-cron.ts @@ -0,0 +1,165 @@ +import * as diagnosticsChannel from '../utils/diagnosticsChannel'; +import type { IntegrationFn, MonitorConfig } from '@sentry/core'; +import { + captureException, + debug, + defineIntegration, + isThenable, + stringMatchesSomePattern, + withMonitor, +} from '@sentry/core'; +import { DEBUG_BUILD } from '../debug-build'; +import { CHANNELS } from '../orchestrion/channels'; +import { nodeCronModuleNames } from '../orchestrion/config/node-cron'; +import { invokeOrchestrionInstrumentation } from '../orchestrion/instrumentation'; + +const INTEGRATION_NAME = 'NodeCron' as const; + +// Set by `Sentry.cron.instrumentNodeCron`, which already sends check-ins. +const MONITORED = Symbol.for('sentry.cron.monitored'); + +export interface NodeCronIntegrationOptions { + /** Task names not to monitor. Strings must match the name exactly. */ + excludeJobs?: Array; +} + +const NICKNAMES: Record = { + '@yearly': '0 0 1 1 *', + '@annually': '0 0 1 1 *', + '@monthly': '0 0 1 * *', + '@weekly': '0 0 * * 0', + '@daily': '0 0 * * *', + '@midnight': '0 0 * * *', + '@hourly': '0 * * * *', +}; + +const MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']; +const WEEKDAYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']; + +type AnyFn = (this: unknown, ...args: unknown[]) => unknown; + +const NOOP = (): void => {}; + +let options: NodeCronIntegrationOptions = {}; + +const _nodeCronIntegration = ((integrationOptions: NodeCronIntegrationOptions = {}) => { + return { + name: INTEGRATION_NAME, + setup(client) { + options = integrationOptions; + invokeOrchestrionInstrumentation(client, nodeCronModuleNames, subscribe, [], { + requiresTracingChannelBinding: false, + }); + }, + }; +}) satisfies IntegrationFn; + +/** + * Sends Crons check-ins for every named `node-cron` task, with its schedule as the monitor config. + * The monitor slug is the task's `name` option; tasks without a name are not monitored. + */ +export const nodeCronIntegration = defineIntegration(_nodeCronIntegration); + +function subscribe(): void { + diagnosticsChannel.tracingChannel<{ arguments: unknown[] }>(CHANNELS.NODE_CRON_CREATE_TASK).subscribe({ + start(data) { + try { + const [expression, func, taskOptions] = data.arguments; + const { name, timezone } = (taskOptions || {}) as { name?: unknown; timezone?: unknown }; + if ( + typeof func !== 'function' || + (func as { [MONITORED]?: boolean })[MONITORED] || + typeof name !== 'string' || + !name || + stringMatchesSomePattern(name, options.excludeJobs, true) + ) { + return; + } + data.arguments[1] = wrapTask(func as AnyFn, name, toMonitorConfig(String(expression), timezone)); + } catch (e) { + DEBUG_BUILD && debug.error('[NodeCron] Failed to set up the cron monitor:', e); + } + }, + end: NOOP, + asyncStart: NOOP, + asyncEnd: NOOP, + error: NOOP, + }); +} + +function wrapTask(func: AnyFn, name: string, monitorConfig: MonitorConfig | undefined): AnyFn { + return function (this: unknown, ...args: unknown[]): unknown { + return withMonitor( + name, + () => { + // node-cron catches task errors, so capture them here. + try { + const result: unknown = func.apply(this, args); + return isThenable(result) ? result.then(undefined, captureAndRethrow) : result; + } catch (e) { + return captureAndRethrow(e); + } + }, + monitorConfig, + ); + }; +} + +function captureAndRethrow(error: unknown): never { + captureException(error, { mechanism: { handled: false, type: 'auto.function.node-cron' } }); + throw error; +} + +/** + * The monitor config for a `node-cron` expression, or `undefined` if Sentry would read it differently. + */ +export function toMonitorConfig(expression: string, timezone: unknown): MonitorConfig | undefined { + const trimmed = expression.trim(); + const fields = (NICKNAMES[trimmed.toLowerCase()] || trimmed).split(/\s+/); + + // Sentry schedules have minute granularity, so only a fixed second can be dropped. + if (fields.length === 6 && /^\d+$/.test(fields[0] as string)) { + fields.shift(); + } + if (fields.length !== 5 || !fields.every(field => /^[\d*,/a-z-]+$/i.test(field))) { + return undefined; + } + + const [minute, hour, dayOfMonth, month, dayOfWeek] = fields as [string, string, string, string, string]; + // node-cron requires both day fields to match, Sentry does that only when one starts with `*`. + if (!dayOfMonth.startsWith('*') && !dayOfWeek.startsWith('*')) { + return undefined; + } + + const crontab = [minute, hour, dayOfMonth, replaceNames(month, MONTHS, 1), replaceNames(dayOfWeek, WEEKDAYS, 0)]; + if (crontab.some(field => /[a-z]/i.test(field))) { + return undefined; + } + + const sentryTimezone = toSentryTimezone(typeof timezone === 'string' && timezone ? timezone : undefined); + if (!sentryTimezone) { + return undefined; + } + + return { schedule: { type: 'crontab', value: crontab.join(' ') }, timezone: sentryTimezone }; +} + +function replaceNames(field: string, names: string[], offset: number): string { + return field.replace(/[a-z]+/gi, word => { + const index = names.indexOf(word.slice(0, 3).toLowerCase()); + return index === -1 ? word : String(index + offset); + }); +} + +// node-cron runs in the server's time zone without a `timezone` option. +function toSentryTimezone(timezone: string | undefined): string | undefined { + if (timezone && /^(?:utc|gmt)?[+-]/i.test(timezone)) { + return undefined; + } + try { + const resolved = new Intl.DateTimeFormat('en-US', { timeZone: timezone }).resolvedOptions().timeZone; + return resolved && resolved !== 'Etc/Unknown' ? resolved : undefined; + } catch { + return undefined; + } +} diff --git a/packages/server-utils/src/orchestrion/channels.ts b/packages/server-utils/src/orchestrion/channels.ts index b5919b9eb071..81f9b34d2e27 100644 --- a/packages/server-utils/src/orchestrion/channels.ts +++ b/packages/server-utils/src/orchestrion/channels.ts @@ -26,6 +26,7 @@ import { mysql2Channels } from './config/mysql2'; import { mysqlChannels } from './config/mysql'; import { neonChannels } from './config/neon'; import { nestjsChannels } from './config/nestjs'; +import { nodeCronChannels } from './config/node-cron'; import { openaiChannels } from './config/openai'; import { pgChannels } from './config/pg'; import { piDurableChannels } from './config/pi-durable'; @@ -83,6 +84,7 @@ export const CHANNELS = { ...mysqlChannels, ...neonChannels, ...nestjsChannels, + ...nodeCronChannels, ...openaiChannels, ...pgChannels, ...piDurableChannels, diff --git a/packages/server-utils/src/orchestrion/config/index.ts b/packages/server-utils/src/orchestrion/config/index.ts index e60810315420..56b7bf008c56 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 { nitroConfig } from './nitro'; +import { nodeCronConfig } from './node-cron'; import { openaiConfig } from './openai'; import { pgConfig } from './pg'; import { postgresJsConfig } from './postgres'; @@ -91,6 +92,7 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ ...neonConfig, ...nestjsConfig, ...nitroConfig, + ...nodeCronConfig, ...openaiConfig, ...pgConfig, ...piDurableConfig, diff --git a/packages/server-utils/src/orchestrion/config/node-cron.ts b/packages/server-utils/src/orchestrion/config/node-cron.ts new file mode 100644 index 000000000000..09363be9e1f6 --- /dev/null +++ b/packages/server-utils/src/orchestrion/config/node-cron.ts @@ -0,0 +1,27 @@ +import type { InstrumentationConfig } from '../apmTypes'; +import { getModuleNames } from './module-names'; + +// `schedule()` calls `createTask(expression, func, options)`, so wrapping `createTask` covers both. +export const nodeCronConfig = [ + { + channelName: 'createTask', + module: { name: 'node-cron', versionRange: '>=4.0.0 <5', filePath: 'dist/node-cron.cjs' }, + functionQuery: { functionName: 'createTask', kind: 'Sync' }, + }, + { + channelName: 'createTask', + module: { name: 'node-cron', versionRange: '>=4.0.0 <5', filePath: 'dist/node-cron.js' }, + functionQuery: { functionName: 'createTask', kind: 'Sync' }, + }, + { + channelName: 'createTask', + module: { name: 'node-cron', versionRange: '>=3.0.0 <4', filePath: 'src/node-cron.js' }, + functionQuery: { functionName: 'createTask', kind: 'Sync' }, + }, +] satisfies InstrumentationConfig[]; + +export const nodeCronModuleNames = getModuleNames(nodeCronConfig); + +export const nodeCronChannels = { + NODE_CRON_CREATE_TASK: 'orchestrion:node-cron:createTask', +} as const; diff --git a/packages/server-utils/test/integrations/node-cron.test.ts b/packages/server-utils/test/integrations/node-cron.test.ts new file mode 100644 index 000000000000..34a1a188b4ae --- /dev/null +++ b/packages/server-utils/test/integrations/node-cron.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import { toMonitorConfig } from '../../src/integrations/node-cron'; + +describe('node-cron toMonitorConfig', () => { + it.each([ + ['* * * * *', '* * * * *'], + ['0 30 9 * * 1-5', '30 9 * * 1-5'], + ['0 9 * jan,Jul mon-fri', '0 9 * 1,7 1-5'], + ['0 9 * * sunday', '0 9 * * 0'], + ['0 9 */2 * 1', '0 9 */2 * 1'], + ['@daily', '0 0 * * *'], + ['@Weekly', '0 0 * * 0'], + ['*/15 * * * *', '*/15 * * * *'], + ])('sends %s as %s', (expression, crontab) => { + expect(toMonitorConfig(expression, 'Europe/Vienna')).toEqual({ + schedule: { type: 'crontab', value: crontab }, + timezone: 'Europe/Vienna', + }); + }); + + it.each([ + '* * * * * *', + '*/10 * * * * *', + '0 9 1 * 1', + '0 9 1-31 * 1', + '0 9 L * *', + '0 9 ? * 1', + '0 9 * * 1#2', + '* * * *', + '0 9 * foo *', + ])('sends no config for %s', expression => { + expect(toMonitorConfig(expression, 'UTC')).toBeUndefined(); + }); + + it('sends no config for fixed UTC offsets', () => { + expect(toMonitorConfig('* * * * *', 'GMT+5')).toBeUndefined(); + expect(toMonitorConfig('* * * * *', 'Not/AZone')).toBeUndefined(); + }); + + it('uses the canonical time zone name', () => { + expect(toMonitorConfig('* * * * *', 'europe/vienna')?.timezone).toBe('Europe/Vienna'); + }); + + it('falls back to the local time zone', () => { + expect(toMonitorConfig('* * * * *', undefined)?.timezone).toBe(Intl.DateTimeFormat().resolvedOptions().timeZone); + }); +}); diff --git a/packages/solidstart/src/server/index.ts b/packages/solidstart/src/server/index.ts index 0e7cec54fa4f..90710a604eb5 100644 --- a/packages/solidstart/src/server/index.ts +++ b/packages/solidstart/src/server/index.ts @@ -40,6 +40,7 @@ export { flush, functionToStringIntegration, genericPoolIntegration, + nodeCronIntegration, getActiveSpan, getAutoPerformanceIntegrations, getClient, diff --git a/packages/sveltekit/src/server/index.ts b/packages/sveltekit/src/server/index.ts index 55c0f48ba010..75f610ba0789 100644 --- a/packages/sveltekit/src/server/index.ts +++ b/packages/sveltekit/src/server/index.ts @@ -38,6 +38,7 @@ export { flush, functionToStringIntegration, genericPoolIntegration, + nodeCronIntegration, getActiveSpan, getAutoPerformanceIntegrations, getClient, From 0cb322db255ab8f0dc1cdb7fcc1e09eb4c2f916a Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Fri, 16 Oct 2026 17:50:03 -0700 Subject: [PATCH 02/33] ref(node): Move cron schedule conversion to a shared helper Prepares for other scheduler integrations; the helper also handles schedulers that run on either day field (cron-parser, cron). Co-Authored-By: Claude --- .../src/integrations/node-cron.ts | 112 ++------------- .../server-utils/src/utils/cronMonitoring.ts | 127 ++++++++++++++++++ .../cronMonitoring.test.ts} | 27 +++- 3 files changed, 162 insertions(+), 104 deletions(-) create mode 100644 packages/server-utils/src/utils/cronMonitoring.ts rename packages/server-utils/test/{integrations/node-cron.test.ts => utils/cronMonitoring.test.ts} (59%) diff --git a/packages/server-utils/src/integrations/node-cron.ts b/packages/server-utils/src/integrations/node-cron.ts index ac9775442812..6bb7cea805c7 100644 --- a/packages/server-utils/src/integrations/node-cron.ts +++ b/packages/server-utils/src/integrations/node-cron.ts @@ -1,17 +1,12 @@ import * as diagnosticsChannel from '../utils/diagnosticsChannel'; -import type { IntegrationFn, MonitorConfig } from '@sentry/core'; -import { - captureException, - debug, - defineIntegration, - isThenable, - stringMatchesSomePattern, - withMonitor, -} from '@sentry/core'; +import type { IntegrationFn } from '@sentry/core'; +import { debug, defineIntegration, stringMatchesSomePattern } from '@sentry/core'; import { DEBUG_BUILD } from '../debug-build'; import { CHANNELS } from '../orchestrion/channels'; import { nodeCronModuleNames } from '../orchestrion/config/node-cron'; import { invokeOrchestrionInstrumentation } from '../orchestrion/instrumentation'; +import type { AnyFn } from '../utils/cronMonitoring'; +import { cronExpressionToMonitorConfig, wrapScheduledJob } from '../utils/cronMonitoring'; const INTEGRATION_NAME = 'NodeCron' as const; @@ -23,21 +18,6 @@ export interface NodeCronIntegrationOptions { excludeJobs?: Array; } -const NICKNAMES: Record = { - '@yearly': '0 0 1 1 *', - '@annually': '0 0 1 1 *', - '@monthly': '0 0 1 * *', - '@weekly': '0 0 * * 0', - '@daily': '0 0 * * *', - '@midnight': '0 0 * * *', - '@hourly': '0 * * * *', -}; - -const MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']; -const WEEKDAYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']; - -type AnyFn = (this: unknown, ...args: unknown[]) => unknown; - const NOOP = (): void => {}; let options: NodeCronIntegrationOptions = {}; @@ -75,7 +55,12 @@ function subscribe(): void { ) { return; } - data.arguments[1] = wrapTask(func as AnyFn, name, toMonitorConfig(String(expression), timezone)); + data.arguments[1] = wrapScheduledJob( + func as AnyFn, + name, + cronExpressionToMonitorConfig(String(expression), timezone, 'and'), + 'auto.function.node-cron', + ); } catch (e) { DEBUG_BUILD && debug.error('[NodeCron] Failed to set up the cron monitor:', e); } @@ -86,80 +71,3 @@ function subscribe(): void { error: NOOP, }); } - -function wrapTask(func: AnyFn, name: string, monitorConfig: MonitorConfig | undefined): AnyFn { - return function (this: unknown, ...args: unknown[]): unknown { - return withMonitor( - name, - () => { - // node-cron catches task errors, so capture them here. - try { - const result: unknown = func.apply(this, args); - return isThenable(result) ? result.then(undefined, captureAndRethrow) : result; - } catch (e) { - return captureAndRethrow(e); - } - }, - monitorConfig, - ); - }; -} - -function captureAndRethrow(error: unknown): never { - captureException(error, { mechanism: { handled: false, type: 'auto.function.node-cron' } }); - throw error; -} - -/** - * The monitor config for a `node-cron` expression, or `undefined` if Sentry would read it differently. - */ -export function toMonitorConfig(expression: string, timezone: unknown): MonitorConfig | undefined { - const trimmed = expression.trim(); - const fields = (NICKNAMES[trimmed.toLowerCase()] || trimmed).split(/\s+/); - - // Sentry schedules have minute granularity, so only a fixed second can be dropped. - if (fields.length === 6 && /^\d+$/.test(fields[0] as string)) { - fields.shift(); - } - if (fields.length !== 5 || !fields.every(field => /^[\d*,/a-z-]+$/i.test(field))) { - return undefined; - } - - const [minute, hour, dayOfMonth, month, dayOfWeek] = fields as [string, string, string, string, string]; - // node-cron requires both day fields to match, Sentry does that only when one starts with `*`. - if (!dayOfMonth.startsWith('*') && !dayOfWeek.startsWith('*')) { - return undefined; - } - - const crontab = [minute, hour, dayOfMonth, replaceNames(month, MONTHS, 1), replaceNames(dayOfWeek, WEEKDAYS, 0)]; - if (crontab.some(field => /[a-z]/i.test(field))) { - return undefined; - } - - const sentryTimezone = toSentryTimezone(typeof timezone === 'string' && timezone ? timezone : undefined); - if (!sentryTimezone) { - return undefined; - } - - return { schedule: { type: 'crontab', value: crontab.join(' ') }, timezone: sentryTimezone }; -} - -function replaceNames(field: string, names: string[], offset: number): string { - return field.replace(/[a-z]+/gi, word => { - const index = names.indexOf(word.slice(0, 3).toLowerCase()); - return index === -1 ? word : String(index + offset); - }); -} - -// node-cron runs in the server's time zone without a `timezone` option. -function toSentryTimezone(timezone: string | undefined): string | undefined { - if (timezone && /^(?:utc|gmt)?[+-]/i.test(timezone)) { - return undefined; - } - try { - const resolved = new Intl.DateTimeFormat('en-US', { timeZone: timezone }).resolvedOptions().timeZone; - return resolved && resolved !== 'Etc/Unknown' ? resolved : undefined; - } catch { - return undefined; - } -} diff --git a/packages/server-utils/src/utils/cronMonitoring.ts b/packages/server-utils/src/utils/cronMonitoring.ts new file mode 100644 index 000000000000..56d7fe97e321 --- /dev/null +++ b/packages/server-utils/src/utils/cronMonitoring.ts @@ -0,0 +1,127 @@ +import type { MonitorConfig } from '@sentry/core'; +import { captureException, isThenable, withMonitor } from '@sentry/core'; + +export type AnyFn = (this: unknown, ...args: unknown[]) => unknown; + +const NICKNAMES: Record = { + '@yearly': '0 0 1 1 *', + '@annually': '0 0 1 1 *', + '@monthly': '0 0 1 * *', + '@weekly': '0 0 * * 0', + '@daily': '0 0 * * *', + '@midnight': '0 0 * * *', + '@hourly': '0 * * * *', +}; + +const MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']; +const WEEKDAYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']; + +/** + * How a scheduler combines a restricted day-of-month and day-of-week: + * - `and`: both must match (`node-cron`). + * - `or`: either matches, unless one is `*` or a full range (`cron-parser`, `cron`). + */ +export type DayFieldSemantics = 'and' | 'or'; + +/** + * The Sentry monitor config for a cron expression, or `undefined` if Sentry would read it differently. + * Without a `timezone`, the server's time zone is used, like the schedulers do. + */ +export function cronExpressionToMonitorConfig( + expression: string, + timezone: unknown, + daySemantics: DayFieldSemantics, +): MonitorConfig | undefined { + const trimmed = expression.trim(); + const fields = (NICKNAMES[trimmed.toLowerCase()] || trimmed).split(/\s+/); + + // Sentry schedules have minute granularity, so only a fixed second can be dropped. + if (fields.length === 6 && /^\d+$/.test(fields[0] as string)) { + fields.shift(); + } + if (fields.length !== 5 || !fields.every(field => /^[\d*,/a-z-]+$/i.test(field))) { + return undefined; + } + + const [minute, hour, dayOfMonth, month, dayOfWeek] = fields as [string, string, string, string, string]; + if (!isSameDayMatching(dayOfMonth, dayOfWeek, daySemantics)) { + return undefined; + } + + const crontab = [minute, hour, dayOfMonth, replaceNames(month, MONTHS, 1), replaceNames(dayOfWeek, WEEKDAYS, 0)]; + if (crontab.some(field => /[a-z]/i.test(field))) { + return undefined; + } + + const sentryTimezone = toSentryTimezone(typeof timezone === 'string' && timezone ? timezone : undefined); + if (!sentryTimezone) { + return undefined; + } + + return { schedule: { type: 'crontab', value: crontab.join(' ') }, timezone: sentryTimezone }; +} + +// Sentry requires both day fields to match when one starts with `*`, and either otherwise. +function isSameDayMatching(dayOfMonth: string, dayOfWeek: string, daySemantics: DayFieldSemantics): boolean { + if (dayOfMonth === '*' || dayOfWeek === '*') { + return true; + } + if (daySemantics === 'and') { + return dayOfMonth.startsWith('*') || dayOfWeek.startsWith('*'); + } + return !( + dayOfMonth.startsWith('*') || + dayOfWeek.startsWith('*') || + dayOfMonth === '1-31' || + ['0-6', '0-7', '1-7'].includes(dayOfWeek) + ); +} + +function replaceNames(field: string, names: string[], offset: number): string { + return field.replace(/[a-z]+/gi, word => { + const index = names.indexOf(word.slice(0, 3).toLowerCase()); + return index === -1 ? word : String(index + offset); + }); +} + +function toSentryTimezone(timezone: string | undefined): string | undefined { + if (timezone && /^(?:utc|gmt)?[+-]/i.test(timezone)) { + return undefined; + } + try { + const resolved = new Intl.DateTimeFormat('en-US', { timeZone: timezone }).resolvedOptions().timeZone; + return resolved && resolved !== 'Etc/Unknown' ? resolved : undefined; + } catch { + return undefined; + } +} + +/** + * Wraps a scheduled job with check-ins. Errors are captured, because schedulers catch them, and rethrown. + */ +export function wrapScheduledJob( + func: AnyFn, + monitorSlug: string, + monitorConfig: MonitorConfig | undefined, + mechanismType: string, +): AnyFn { + const captureAndRethrow = (error: unknown): never => { + captureException(error, { mechanism: { handled: false, type: mechanismType } }); + throw error; + }; + + return function (this: unknown, ...args: unknown[]): unknown { + return withMonitor( + monitorSlug, + () => { + try { + const result: unknown = func.apply(this, args); + return isThenable(result) ? result.then(undefined, captureAndRethrow) : result; + } catch (e) { + return captureAndRethrow(e); + } + }, + monitorConfig, + ); + }; +} diff --git a/packages/server-utils/test/integrations/node-cron.test.ts b/packages/server-utils/test/utils/cronMonitoring.test.ts similarity index 59% rename from packages/server-utils/test/integrations/node-cron.test.ts rename to packages/server-utils/test/utils/cronMonitoring.test.ts index 34a1a188b4ae..63495f8512f0 100644 --- a/packages/server-utils/test/integrations/node-cron.test.ts +++ b/packages/server-utils/test/utils/cronMonitoring.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { toMonitorConfig } from '../../src/integrations/node-cron'; +import { cronExpressionToMonitorConfig } from '../../src/utils/cronMonitoring'; -describe('node-cron toMonitorConfig', () => { +const toMonitorConfig = (expression: string, timezone: unknown) => + cronExpressionToMonitorConfig(expression, timezone, 'and'); + +describe('cronExpressionToMonitorConfig with node-cron day matching', () => { it.each([ ['* * * * *', '* * * * *'], ['0 30 9 * * 1-5', '30 9 * * 1-5'], @@ -45,3 +48,23 @@ describe('node-cron toMonitorConfig', () => { expect(toMonitorConfig('* * * * *', undefined)?.timezone).toBe(Intl.DateTimeFormat().resolvedOptions().timeZone); }); }); + +describe('cronExpressionToMonitorConfig with cron-parser day matching', () => { + it.each([ + ['0 9 1,15 * mon', '0 9 1,15 * 1'], + ['0 9 */2 * *', '0 9 */2 * *'], + ['0 9 * * 1-5', '0 9 * * 1-5'], + ])('sends %s as %s', (expression, crontab) => { + expect(cronExpressionToMonitorConfig(expression, 'UTC', 'or')?.schedule).toEqual({ + type: 'crontab', + value: crontab, + }); + }); + + it.each(['0 9 */2 * 1', '0 9 1 * */2', '0 9 1-31 * 1', '0 9 15 * 0-6', '0 9 15 * 1-7'])( + 'sends no config for %s', + expression => { + expect(cronExpressionToMonitorConfig(expression, 'UTC', 'or')).toBeUndefined(); + }, + ); +}); From d9e3b62689cdf10e50114decbfbe00d0b2c8f87b Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Fri, 16 Oct 2026 17:54:07 -0700 Subject: [PATCH 03/33] test(node): Assert node-cron check-ins and errors separately The error event and the error check-in can arrive in either order. Co-Authored-By: Claude --- .../suites/cron/node-cron/integration/test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/dev-packages/node-integration-tests/suites/cron/node-cron/integration/test.ts b/dev-packages/node-integration-tests/suites/cron/node-cron/integration/test.ts index a859737c4101..58328f9ce7d7 100644 --- a/dev-packages/node-integration-tests/suites/cron/node-cron/integration/test.ts +++ b/dev-packages/node-integration-tests/suites/cron/node-cron/integration/test.ts @@ -14,6 +14,7 @@ describe('nodeCronIntegration', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { test('sends check-ins for named tasks', async () => { await createRunner() + .ignore('event') .expect({ check_in: { monitor_slug: 'my-cron-job', status: 'in_progress', monitor_config: MONITOR_CONFIG }, }) @@ -22,6 +23,13 @@ describe('nodeCronIntegration', () => { check_in: { monitor_slug: 'my-cron-job', status: 'in_progress', monitor_config: MONITOR_CONFIG }, }) .expect({ check_in: { monitor_slug: 'my-cron-job', status: 'error', duration: expect.any(Number) } }) + .start() + .completed(); + }); + + test('captures job errors', async () => { + await createRunner() + .unordered() .expect({ event: { exception: { From 905ed6b6e4e58b1152bde9322bbdbac26e69f782 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Fri, 16 Oct 2026 18:11:25 -0700 Subject: [PATCH 04/33] test(node): Skip node-cron integration suite on bun run and allow opt-in modules `bun run` can't inject channels into libraries. nodeCronIntegration is opt-in, so it has no lazy channel-integration definition. Co-Authored-By: Claude --- dev-packages/bun-integration-tests/node-suites/excludes.ts | 1 + .../test/orchestrion/moduleInjectedTransform.test.ts | 4 +++- 2 files changed, 4 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 65edcd311712..5d88d630c7c0 100644 --- a/dev-packages/bun-integration-tests/node-suites/excludes.ts +++ b/dev-packages/bun-integration-tests/node-suites/excludes.ts @@ -62,6 +62,7 @@ const NO_FETCH_INSTRUMENTATION = [ // instrumentation creates no spans. Apps must be built with `@sentry/bun/plugin`. // See https://github.com/getsentry/sentry-javascript/issues/23882 export const NO_AUTO_INSTRUMENTATION = [ + 'suites/cron/node-cron/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 750ffeaa8d19..1697ea30d7c0 100644 --- a/packages/server-utils/test/orchestrion/moduleInjectedTransform.test.ts +++ b/packages/server-utils/test/orchestrion/moduleInjectedTransform.test.ts @@ -43,8 +43,10 @@ describe('channel integration definitions', () => { const defined = new Set(CHANNEL_INTEGRATION_DEFINITIONS.flatMap(d => d.modules as readonly string[])); // `@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 uncovered = [...configured].filter( - name => !defined.has(name) && !name.startsWith('@nestjs/') && !name.startsWith('@remix-run/'), + name => !defined.has(name) && !optIn.has(name) && !name.startsWith('@nestjs/') && !name.startsWith('@remix-run/'), ); expect(uncovered).toEqual([]); }); From 1aa125d38fd7d161d0d969ac5858ca0f8b1a36e5 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Fri, 16 Oct 2026 17:50:13 -0700 Subject: [PATCH 05/33] 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 06/33] 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 07/33] 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/'), ); From cf8b5be3d56d3877186f79b841995fbc373c2ec1 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Fri, 16 Oct 2026 17:54:55 -0700 Subject: [PATCH 08/33] feat(node): Add cronJobIntegration to monitor cron jobs Sends check-ins for every named CronJob of the cron package (4.3+) through the orchestrion channel injection, with the cron expression and time zone as monitor config when Sentry reads it the same way. Bumps the cron dev dependency of the node integration tests to 4.x. Co-Authored-By: Claude --- .../node-integration-tests/package.json | 2 +- .../cron/cron-integration/instrument.mjs | 9 +++ .../suites/cron/cron-integration/scenario.mjs | 29 ++++++++ .../suites/cron/cron-integration/test.ts | 50 +++++++++++++ .../suites/cron/cron/scenario.ts | 4 +- 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/cron.ts | 7 ++ packages/node/src/index.ts | 2 + packages/remix/src/server/index.ts | 1 + packages/server-utils/src/index.ts | 2 + .../server-utils/src/integrations/cron.ts | 73 +++++++++++++++++++ .../server-utils/src/orchestrion/channels.ts | 2 + .../src/orchestrion/config/cron.ts | 17 +++++ .../src/orchestrion/config/index.ts | 2 + .../server-utils/src/utils/cronMonitoring.ts | 3 + .../test/utils/cronMonitoring.test.ts | 1 + packages/solidstart/src/server/index.ts | 1 + packages/sveltekit/src/server/index.ts | 1 + 22 files changed, 208 insertions(+), 3 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/cron/cron-integration/instrument.mjs create mode 100644 dev-packages/node-integration-tests/suites/cron/cron-integration/scenario.mjs create mode 100644 dev-packages/node-integration-tests/suites/cron/cron-integration/test.ts create mode 100644 packages/server-utils/src/integrations/cron.ts create mode 100644 packages/server-utils/src/orchestrion/config/cron.ts diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index fdef92a3ba24..e1b6ef2606af 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -65,7 +65,7 @@ "body-parser": "^2.3.0", "consola": "^3.2.3", "cors": "^2.8.5", - "cron": "^3.1.6", + "cron": "^4.4.0", "dataloader": "2.2.2", "express": "^4.21.2", "fastify": "^5.12.5", diff --git a/dev-packages/node-integration-tests/suites/cron/cron-integration/instrument.mjs b/dev-packages/node-integration-tests/suites/cron/cron-integration/instrument.mjs new file mode 100644 index 000000000000..3ecfc0097afc --- /dev/null +++ b/dev-packages/node-integration-tests/suites/cron/cron-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.cronJobIntegration({ excludeJobs: ['excluded-job'] })], +}); diff --git a/dev-packages/node-integration-tests/suites/cron/cron-integration/scenario.mjs b/dev-packages/node-integration-tests/suites/cron/cron-integration/scenario.mjs new file mode 100644 index 000000000000..a608fd44d135 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/cron/cron-integration/scenario.mjs @@ -0,0 +1,29 @@ +import { CronJob } from 'cron'; + +const excluded = CronJob.from({ cronTime: '0 0 * * *', onTick: () => {}, name: 'excluded-job' }); +const unnamed = new CronJob('0 0 * * *', () => {}); + +let runs = 0; +const job = CronJob.from({ + cronTime: '0 30 9 * * 1-5', + timeZone: 'Europe/Vienna', + name: 'my-cron-job', + waitForCompletion: true, + errorHandler: () => {}, + onTick: async () => { + runs++; + if (runs > 1) { + throw new Error('Error in cron job'); + } + }, +}); + +// Run the jobs without waiting for their schedule. +(async () => { + await excluded.fireOnTick(); + await unnamed.fireOnTick(); + await job.fireOnTick(); + await job.fireOnTick(); +})(); + +setTimeout(() => process.exit(), 10_000); diff --git a/dev-packages/node-integration-tests/suites/cron/cron-integration/test.ts b/dev-packages/node-integration-tests/suites/cron/cron-integration/test.ts new file mode 100644 index 000000000000..7c658b22408d --- /dev/null +++ b/dev-packages/node-integration-tests/suites/cron/cron-integration/test.ts @@ -0,0 +1,50 @@ +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('cronJobIntegration', () => { + 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: { monitor_slug: 'my-cron-job', status: 'in_progress', monitor_config: MONITOR_CONFIG }, + }) + .expect({ check_in: { monitor_slug: 'my-cron-job', status: 'ok', duration: expect.any(Number) } }) + .expect({ + check_in: { monitor_slug: 'my-cron-job', status: 'in_progress', monitor_config: MONITOR_CONFIG }, + }) + .expect({ check_in: { monitor_slug: 'my-cron-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 cron job', + mechanism: { type: 'auto.function.cron', handled: false }, + }, + ], + }, + }, + }) + .start() + .completed(); + }); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/cron/cron/scenario.ts b/dev-packages/node-integration-tests/suites/cron/cron/scenario.ts index b3966b050d89..68cf28bcde94 100644 --- a/dev-packages/node-integration-tests/suites/cron/cron/scenario.ts +++ b/dev-packages/node-integration-tests/suites/cron/cron/scenario.ts @@ -28,11 +28,11 @@ cron.start(); // is enough to hit it, and the job is left scheduled for nothing: no ticks, no check-ins, and the // scenario exits on the timeout below having sent nothing at all. Starting again re-reads the clock // away from the boundary. -for (let attempt = 0; attempt < 5 && !cron.running; attempt++) { +for (let attempt = 0; attempt < 5 && !cron.isActive; attempt++) { cron.start(); } -if (!cron.running) { +if (!cron.isActive) { throw new Error('`cron` refused to schedule the job'); } diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index 69d9dee08287..e7675e02ab85 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -47,6 +47,7 @@ export { fsIntegration, functionToStringIntegration, genericPoolIntegration, + cronJobIntegration, nodeCronIntegration, nodeScheduleIntegration, getActiveSpan, diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index 8c45d2f267d9..3ba287a78bcf 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -116,6 +116,7 @@ export { firebaseIntegration, fsIntegration, genericPoolIntegration, + cronJobIntegration, nodeCronIntegration, nodeScheduleIntegration, graphqlIntegration, diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index 4c4e7c48b010..3122d4857bbc 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -138,6 +138,7 @@ export { // oxlint-disable-next-line typescript/no-deprecated setupKoaErrorHandler, genericPoolIntegration, + cronJobIntegration, nodeCronIntegration, nodeScheduleIntegration, graphqlIntegration, diff --git a/packages/elysia/src/index.ts b/packages/elysia/src/index.ts index a796fc84ecc7..60863f006a52 100644 --- a/packages/elysia/src/index.ts +++ b/packages/elysia/src/index.ts @@ -117,6 +117,7 @@ export { // oxlint-disable-next-line typescript/no-deprecated setupKoaErrorHandler, genericPoolIntegration, + cronJobIntegration, nodeCronIntegration, nodeScheduleIntegration, graphqlIntegration, diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index 317424507421..55aa4f7692dd 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -116,6 +116,7 @@ export { fastifyIntegration, firebaseIntegration, genericPoolIntegration, + cronJobIntegration, nodeCronIntegration, nodeScheduleIntegration, graphqlIntegration, diff --git a/packages/node/src/cron/cron.ts b/packages/node/src/cron/cron.ts index 2c764c877c46..e92625ebee18 100644 --- a/packages/node/src/cron/cron.ts +++ b/packages/node/src/cron/cron.ts @@ -51,6 +51,8 @@ export type CronJobConstructor = { ): CronJob; }; +const MONITORED = Symbol.for('sentry.cron.monitored'); + const ERROR_TEXT = 'Automatic instrumentation of CronJob only supports crontab string'; /** @@ -115,6 +117,9 @@ export function instrumentCron(lib: T & CronJobConstructor, monitorSlug: stri ); } + // Tells `cronJobIntegration` that this job is already monitored. + (monitoredTick as unknown as Record)[MONITORED] = true; + return new target(cronTime, monitoredTick, onComplete, start, timeZone, ...rest); }, get(target, prop: keyof CronJobConstructor) { @@ -157,6 +162,8 @@ export function instrumentCron(lib: T & CronJobConstructor, monitorSlug: stri ); }; + (param.onTick as unknown as Record)[MONITORED] = true; + return target.from(param); }; } else { diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 5e4c60e9706e..3afae9e27db9 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -11,6 +11,8 @@ export { type ExpressIntegrationOptions, firebaseIntegration, genericPoolIntegration, + cronJobIntegration, + type CronJobIntegrationOptions, nodeCronIntegration, type NodeCronIntegrationOptions, nodeScheduleIntegration, diff --git a/packages/remix/src/server/index.ts b/packages/remix/src/server/index.ts index 88ef6a841518..b842efd5f534 100644 --- a/packages/remix/src/server/index.ts +++ b/packages/remix/src/server/index.ts @@ -37,6 +37,7 @@ export { flush, functionToStringIntegration, genericPoolIntegration, + cronJobIntegration, nodeCronIntegration, nodeScheduleIntegration, getActiveSpan, diff --git a/packages/server-utils/src/index.ts b/packages/server-utils/src/index.ts index 005e8a4a8b79..2550a7fd6884 100644 --- a/packages/server-utils/src/index.ts +++ b/packages/server-utils/src/index.ts @@ -38,6 +38,8 @@ export { anthropicAIIntegration } from './integrations/anthropic'; export { awsIntegration } from './integrations/aws-sdk'; export { dataloaderIntegration } from './integrations/dataloader'; export { genericPoolIntegration } from './integrations/generic-pool'; +export { cronJobIntegration } from './integrations/cron'; +export type { CronJobIntegrationOptions } from './integrations/cron'; export { nodeCronIntegration } from './integrations/node-cron'; export type { NodeCronIntegrationOptions } from './integrations/node-cron'; export { nodeScheduleIntegration } from './integrations/node-schedule'; diff --git a/packages/server-utils/src/integrations/cron.ts b/packages/server-utils/src/integrations/cron.ts new file mode 100644 index 000000000000..c15968bc17c7 --- /dev/null +++ b/packages/server-utils/src/integrations/cron.ts @@ -0,0 +1,73 @@ +import * as diagnosticsChannel from '../utils/diagnosticsChannel'; +import type { IntegrationFn } from '@sentry/core'; +import { debug, defineIntegration, stringMatchesSomePattern } from '@sentry/core'; +import { DEBUG_BUILD } from '../debug-build'; +import { CHANNELS } from '../orchestrion/channels'; +import { cronModuleNames } from '../orchestrion/config/cron'; +import { invokeOrchestrionInstrumentation } from '../orchestrion/instrumentation'; +import type { AnyFn } from '../utils/cronMonitoring'; +import { cronExpressionToMonitorConfig, wrapScheduledJob } from '../utils/cronMonitoring'; + +const INTEGRATION_NAME = 'CronJob' as const; + +// Set by `Sentry.cron.instrumentCron`, which already sends check-ins. +const MONITORED = Symbol.for('sentry.cron.monitored'); + +const NOOP = (): void => {}; + +export interface CronJobIntegrationOptions { + /** Job names not to monitor. Strings must match the name exactly. */ + excludeJobs?: Array; +} + +let options: CronJobIntegrationOptions = {}; + +const _cronJobIntegration = ((integrationOptions: CronJobIntegrationOptions = {}) => { + return { + name: INTEGRATION_NAME, + setup(client) { + options = integrationOptions; + invokeOrchestrionInstrumentation(client, cronModuleNames, subscribe, [], { + requiresTracingChannelBinding: false, + }); + }, + }; +}) satisfies IntegrationFn; + +/** + * Sends Crons check-ins for every `CronJob` of the `cron` package (4.3+) that has a `name`. + * The monitor slug is the job name; jobs without a name, and one-off jobs, are not monitored. + */ +export const cronJobIntegration = defineIntegration(_cronJobIntegration); + +function subscribe(): void { + diagnosticsChannel.tracingChannel<{ arguments: unknown[] }>(CHANNELS.CRON_JOB_CONSTRUCTOR).subscribe({ + start(data) { + try { + // `new CronJob(cronTime, onTick, onComplete, start, timeZone, context, runOnInit, utcOffset, ..., name)` + const args = data.arguments; + const [cronTime, onTick, , , timeZone] = args; + const utcOffset = args[7]; + const name = args[11]; + if ( + typeof name !== 'string' || + !name || + typeof onTick !== 'function' || + (onTick as { [MONITORED]?: boolean })[MONITORED] || + typeof cronTime !== 'string' || + stringMatchesSomePattern(name, options.excludeJobs, true) + ) { + return; + } + const monitorConfig = utcOffset == null ? cronExpressionToMonitorConfig(cronTime, timeZone, 'or') : undefined; + args[1] = wrapScheduledJob(onTick as AnyFn, name, monitorConfig, 'auto.function.cron'); + } catch (e) { + DEBUG_BUILD && debug.error('[CronJob] Failed to set up the cron monitor:', e); + } + }, + end: NOOP, + asyncStart: NOOP, + asyncEnd: NOOP, + error: NOOP, + }); +} diff --git a/packages/server-utils/src/orchestrion/channels.ts b/packages/server-utils/src/orchestrion/channels.ts index 67a88bbb3825..f593fefe0ebd 100644 --- a/packages/server-utils/src/orchestrion/channels.ts +++ b/packages/server-utils/src/orchestrion/channels.ts @@ -1,6 +1,7 @@ import { amqplibChannels } from './config/amqplib'; import { anthropicAiChannels } from './config/anthropic-ai'; import { awsSdkChannels } from './config/aws-sdk'; +import { cronChannels } from './config/cron'; import { dataloaderChannels } from './config/dataloader'; import { expressChannels } from './config/express'; import { firebaseChannels } from './config/firebase'; @@ -60,6 +61,7 @@ export const CHANNELS = { ...amqplibChannels, ...anthropicAiChannels, ...awsSdkChannels, + ...cronChannels, ...dataloaderChannels, ...expressChannels, ...firebaseChannels, diff --git a/packages/server-utils/src/orchestrion/config/cron.ts b/packages/server-utils/src/orchestrion/config/cron.ts new file mode 100644 index 000000000000..d15c1d90821a --- /dev/null +++ b/packages/server-utils/src/orchestrion/config/cron.ts @@ -0,0 +1,17 @@ +import type { InstrumentationConfig } from '../apmTypes'; +import { getModuleNames } from './module-names'; + +// `CronJob.from()` calls the constructor. Job names were added in 4.3.0. +export const cronConfig = [ + { + channelName: 'cronJobConstructor', + module: { name: 'cron', versionRange: '>=4.3.0 <5', filePath: 'dist/job.js' }, + functionQuery: { className: 'CronJob' }, + }, +] satisfies InstrumentationConfig[]; + +export const cronModuleNames = getModuleNames(cronConfig); + +export const cronChannels = { + CRON_JOB_CONSTRUCTOR: 'orchestrion:cron:cronJobConstructor', +} as const; diff --git a/packages/server-utils/src/orchestrion/config/index.ts b/packages/server-utils/src/orchestrion/config/index.ts index bf2c4b50b178..7636fbef80fc 100644 --- a/packages/server-utils/src/orchestrion/config/index.ts +++ b/packages/server-utils/src/orchestrion/config/index.ts @@ -5,6 +5,7 @@ import { uniq } from '@sentry/core'; import { awsSdkConfig } from './aws-sdk'; import { amqplibConfig } from './amqplib'; import { anthropicAiConfig } from './anthropic-ai'; +import { cronConfig } from './cron'; import { dataloaderConfig } from './dataloader'; import { expressConfig } from './express'; import { firebaseConfig } from './firebase'; @@ -66,6 +67,7 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ ...amqplibConfig, ...anthropicAiConfig, ...awsSdkConfig, + ...cronConfig, ...dataloaderConfig, ...expressConfig, ...firebaseConfig, diff --git a/packages/server-utils/src/utils/cronMonitoring.ts b/packages/server-utils/src/utils/cronMonitoring.ts index 56d7fe97e321..82c0e8dc3814 100644 --- a/packages/server-utils/src/utils/cronMonitoring.ts +++ b/packages/server-utils/src/utils/cronMonitoring.ts @@ -11,6 +11,9 @@ const NICKNAMES: Record = { '@daily': '0 0 * * *', '@midnight': '0 0 * * *', '@hourly': '0 * * * *', + '@minutely': '* * * * *', + '@weekdays': '0 0 * * 1-5', + '@weekends': '0 0 * * 0,6', }; const MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']; diff --git a/packages/server-utils/test/utils/cronMonitoring.test.ts b/packages/server-utils/test/utils/cronMonitoring.test.ts index 63495f8512f0..084641817259 100644 --- a/packages/server-utils/test/utils/cronMonitoring.test.ts +++ b/packages/server-utils/test/utils/cronMonitoring.test.ts @@ -13,6 +13,7 @@ describe('cronExpressionToMonitorConfig with node-cron day matching', () => { ['0 9 */2 * 1', '0 9 */2 * 1'], ['@daily', '0 0 * * *'], ['@Weekly', '0 0 * * 0'], + ['@weekdays', '0 0 * * 1-5'], ['*/15 * * * *', '*/15 * * * *'], ])('sends %s as %s', (expression, crontab) => { expect(toMonitorConfig(expression, 'Europe/Vienna')).toEqual({ diff --git a/packages/solidstart/src/server/index.ts b/packages/solidstart/src/server/index.ts index 2237235f5c1b..e0339440f4c9 100644 --- a/packages/solidstart/src/server/index.ts +++ b/packages/solidstart/src/server/index.ts @@ -40,6 +40,7 @@ export { flush, functionToStringIntegration, genericPoolIntegration, + cronJobIntegration, nodeCronIntegration, nodeScheduleIntegration, getActiveSpan, diff --git a/packages/sveltekit/src/server/index.ts b/packages/sveltekit/src/server/index.ts index e5b73bcd2acc..c6fed60ea263 100644 --- a/packages/sveltekit/src/server/index.ts +++ b/packages/sveltekit/src/server/index.ts @@ -38,6 +38,7 @@ export { flush, functionToStringIntegration, genericPoolIntegration, + cronJobIntegration, nodeCronIntegration, nodeScheduleIntegration, getActiveSpan, From 1908bf3f7505d75c363abeaf87b2c7fbbef5f289 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Fri, 16 Oct 2026 18:11:31 -0700 Subject: [PATCH 09/33] test(node): Skip cron 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 e38eeeb56384..039e0b07f684 100644 --- a/dev-packages/bun-integration-tests/node-suites/excludes.ts +++ b/dev-packages/bun-integration-tests/node-suites/excludes.ts @@ -62,6 +62,7 @@ const NO_FETCH_INSTRUMENTATION = [ // instrumentation creates no spans. Apps must be built with `@sentry/bun/plugin`. // See https://github.com/getsentry/sentry-javascript/issues/23882 export const NO_AUTO_INSTRUMENTATION = [ + 'suites/cron/cron-integration/test.ts', 'suites/cron/node-cron/integration/test.ts', 'suites/cron/node-schedule-integration/test.ts', 'suites/express/**', diff --git a/packages/server-utils/test/orchestrion/moduleInjectedTransform.test.ts b/packages/server-utils/test/orchestrion/moduleInjectedTransform.test.ts index ed55b1bec34e..5780411ff4c3 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', 'node-schedule']); + const optIn = new Set(['cron', 'node-cron', 'node-schedule']); const uncovered = [...configured].filter( name => !defined.has(name) && !optIn.has(name) && !name.startsWith('@nestjs/') && !name.startsWith('@remix-run/'), ); From 487dc0c86292ff95bdfcdbfc7ee5d143230c8ad6 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Mon, 19 Oct 2026 17:49:42 -0700 Subject: [PATCH 10/33] chore(deps): Update pnpm-lock.yaml for cron 4 and dedupe luxon Co-Authored-By: Claude --- pnpm-lock.yaml | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cfdd2832b6d3..34148cbb88e8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -831,8 +831,8 @@ importers: specifier: ^2.8.5 version: 2.8.6 cron: - specifier: ^3.1.6 - version: 3.1.6 + specifier: ^4.4.0 + version: 4.4.0 dataloader: specifier: 2.2.2 version: 2.2.2 @@ -8702,8 +8702,8 @@ packages: '@types/long@4.0.2': resolution: {integrity: sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==} - '@types/luxon@3.3.8': - resolution: {integrity: sha512-jYvz8UMLDgy3a5SkGJne8H7VA7zPV2Lwohjx0V8V31+SqAjNmurWMkk9cQhfvlcnXWudBpK9xPM1n4rljOcHYQ==} + '@types/luxon@3.7.6': + resolution: {integrity: sha512-6KSjliQAXK8ZLgFQO4B7iE4Rpf/B3rItzCFuXezBY/XIAGxOdLd7vRNK9/StRPwiS/yJsVbB7HKpW46B8tcEuQ==} '@types/mdast@4.0.1': resolution: {integrity: sha512-IlKct1rUTJ1T81d8OHzyop15kGv9A/ff7Gz7IJgrk6jDb4Udw77pCJ+vq8oxZf4Ghpm+616+i1s/LNg/Vh7d+g==} @@ -10753,8 +10753,9 @@ packages: engines: {node: '>=12.0.0'} deprecated: v4 is no longer maintained, upgrade to v5 - cron@3.1.6: - resolution: {integrity: sha512-cvFiQCeVzsA+QPM6fhjBtlKGij7tLLISnTSvFxVdnFGLdz+ZdXN37kNe0i2gefmdD17XuZA6n2uPVwzl4FxW/w==} + cron@4.4.0: + resolution: {integrity: sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==} + engines: {node: '>=18.x'} croner@10.0.1: resolution: {integrity: sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g==} @@ -14009,8 +14010,8 @@ packages: resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==} engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} - luxon@3.4.4: - resolution: {integrity: sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA==} + luxon@3.7.2: + resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} engines: {node: '>=12'} lz-string@1.5.0: @@ -27027,7 +27028,7 @@ snapshots: '@types/long@4.0.2': {} - '@types/luxon@3.3.8': {} + '@types/luxon@3.7.6': {} '@types/mdast@4.0.1': dependencies: @@ -29587,12 +29588,12 @@ snapshots: cron-parser@4.9.0: dependencies: - luxon: 3.4.4 + luxon: 3.7.2 - cron@3.1.6: + cron@4.4.0: dependencies: - '@types/luxon': 3.3.8 - luxon: 3.4.4 + '@types/luxon': 3.7.6 + luxon: 3.7.2 croner@10.0.1: {} @@ -33527,7 +33528,7 @@ snapshots: lru.min@1.1.4: {} - luxon@3.4.4: {} + luxon@3.7.2: {} lz-string@1.5.0: {} From ffb2c5d165771a1a30c2731eb0631b1eeadc1fd7 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Tue, 20 Oct 2026 10:45:53 -0700 Subject: [PATCH 11/33] fix(node): Instrument node-cron 4.0-4.2 and skip configs node-cron 3 reads differently node-cron 4.0 to 4.2 ship `dist/cjs` and `dist/esm` builds, which weren't instrumented. node-cron 3 keeps the values of a stepped field that are multiples of the step (`*/2` days run on 2, 4, ...), so those tasks now check in without a monitor config. Also moves the channel subscription and job filtering into shared helpers for the other scheduler integrations. Co-Authored-By: Claude --- .../src/integrations/node-cron.ts | 81 ++++++++++--------- .../src/orchestrion/config/node-cron.ts | 19 ++++- .../server-utils/src/utils/cronMonitoring.ts | 40 ++++++++- .../test/integrations/node-cron.test.ts | 24 ++++++ .../test/utils/cronMonitoring.test.ts | 27 ++++++- 5 files changed, 149 insertions(+), 42 deletions(-) create mode 100644 packages/server-utils/test/integrations/node-cron.test.ts diff --git a/packages/server-utils/src/integrations/node-cron.ts b/packages/server-utils/src/integrations/node-cron.ts index 6bb7cea805c7..be4fb742c129 100644 --- a/packages/server-utils/src/integrations/node-cron.ts +++ b/packages/server-utils/src/integrations/node-cron.ts @@ -1,25 +1,23 @@ -import * as diagnosticsChannel from '../utils/diagnosticsChannel'; import type { IntegrationFn } from '@sentry/core'; -import { debug, defineIntegration, stringMatchesSomePattern } from '@sentry/core'; -import { DEBUG_BUILD } from '../debug-build'; +import { defineIntegration } from '@sentry/core'; import { CHANNELS } from '../orchestrion/channels'; import { nodeCronModuleNames } from '../orchestrion/config/node-cron'; import { invokeOrchestrionInstrumentation } from '../orchestrion/instrumentation'; import type { AnyFn } from '../utils/cronMonitoring'; -import { cronExpressionToMonitorConfig, wrapScheduledJob } from '../utils/cronMonitoring'; +import { + cronExpressionToMonitorConfig, + shouldMonitorJob, + subscribeToJobCreation, + wrapScheduledJob, +} from '../utils/cronMonitoring'; const INTEGRATION_NAME = 'NodeCron' as const; -// Set by `Sentry.cron.instrumentNodeCron`, which already sends check-ins. -const MONITORED = Symbol.for('sentry.cron.monitored'); - export interface NodeCronIntegrationOptions { /** Task names not to monitor. Strings must match the name exactly. */ excludeJobs?: Array; } -const NOOP = (): void => {}; - let options: NodeCronIntegrationOptions = {}; const _nodeCronIntegration = ((integrationOptions: NodeCronIntegrationOptions = {}) => { @@ -41,33 +39,42 @@ const _nodeCronIntegration = ((integrationOptions: NodeCronIntegrationOptions = export const nodeCronIntegration = defineIntegration(_nodeCronIntegration); function subscribe(): void { - diagnosticsChannel.tracingChannel<{ arguments: unknown[] }>(CHANNELS.NODE_CRON_CREATE_TASK).subscribe({ - start(data) { - try { - const [expression, func, taskOptions] = data.arguments; - const { name, timezone } = (taskOptions || {}) as { name?: unknown; timezone?: unknown }; - if ( - typeof func !== 'function' || - (func as { [MONITORED]?: boolean })[MONITORED] || - typeof name !== 'string' || - !name || - stringMatchesSomePattern(name, options.excludeJobs, true) - ) { - return; - } - data.arguments[1] = wrapScheduledJob( - func as AnyFn, - name, - cronExpressionToMonitorConfig(String(expression), timezone, 'and'), - 'auto.function.node-cron', - ); - } catch (e) { - DEBUG_BUILD && debug.error('[NodeCron] Failed to set up the cron monitor:', e); - } - }, - end: NOOP, - asyncStart: NOOP, - asyncEnd: NOOP, - error: NOOP, + subscribeToJobCreation(CHANNELS.NODE_CRON_CREATE_TASK, INTEGRATION_NAME, args => wrapTask(args, false)); + subscribeToJobCreation(CHANNELS.NODE_CRON_V3_CREATE_TASK, INTEGRATION_NAME, args => wrapTask(args, true)); +} + +// `createTask(expression, func, options)` +function wrapTask(args: unknown[], isNodeCron3: boolean): void { + const [expression, func, taskOptions] = args; + const { name, timezone } = (taskOptions || {}) as { name?: unknown; timezone?: unknown }; + if (!shouldMonitorJob(name, func, options.excludeJobs)) { + return; + } + const monitorConfig = + isNodeCron3 && !isReadAlikeByNodeCron3(String(expression)) + ? undefined + : cronExpressionToMonitorConfig(String(expression), timezone, 'and'); + args[1] = wrapScheduledJob(func as AnyFn, name, monitorConfig, 'auto.function.node-cron'); +} + +const NODE_CRON_3_FIELD_MINIMUMS = [0, 0, 1, 1, 0]; + +/** + * node-cron 3 keeps the values of a stepped field that are multiples of the step (`*\/2` days are 2, 4, ...) + * and reads `7` in a weekday range as `0`. Returns `false` where that differs from standard cron. + */ +export function isReadAlikeByNodeCron3(expression: string): boolean { + const fields = expression.trim().split(/\s+/); + const minimums = fields.length === 6 ? [0, ...NODE_CRON_3_FIELD_MINIMUMS] : NODE_CRON_3_FIELD_MINIMUMS; + const weekday = fields[fields.length - 1] || ''; + if (weekday.includes('-') && weekday.includes('7')) { + return false; + } + return fields.every((field, index) => { + if (!field.includes('/')) { + return true; + } + const match = /^(?:\*|(\d+)-\d+)\/(\d+)$/.exec(field); + return !!match && Number(match[1] ?? minimums[index]) % Number(match[2]) === 0; }); } diff --git a/packages/server-utils/src/orchestrion/config/node-cron.ts b/packages/server-utils/src/orchestrion/config/node-cron.ts index 09363be9e1f6..0f841ce297cc 100644 --- a/packages/server-utils/src/orchestrion/config/node-cron.ts +++ b/packages/server-utils/src/orchestrion/config/node-cron.ts @@ -1,22 +1,34 @@ import type { InstrumentationConfig } from '../apmTypes'; import { getModuleNames } from './module-names'; +const createTask = { functionName: 'createTask', kind: 'Sync' } as const; + // `schedule()` calls `createTask(expression, func, options)`, so wrapping `createTask` covers both. export const nodeCronConfig = [ { channelName: 'createTask', module: { name: 'node-cron', versionRange: '>=4.0.0 <5', filePath: 'dist/node-cron.cjs' }, - functionQuery: { functionName: 'createTask', kind: 'Sync' }, + functionQuery: createTask, }, { channelName: 'createTask', module: { name: 'node-cron', versionRange: '>=4.0.0 <5', filePath: 'dist/node-cron.js' }, - functionQuery: { functionName: 'createTask', kind: 'Sync' }, + functionQuery: createTask, + }, + { + channelName: 'createTask', + module: { name: 'node-cron', versionRange: '>=4.0.0 <4.3.0', filePath: 'dist/cjs/node-cron.js' }, + functionQuery: createTask, }, { channelName: 'createTask', + module: { name: 'node-cron', versionRange: '>=4.0.0 <4.3.0', filePath: 'dist/esm/node-cron.js' }, + functionQuery: createTask, + }, + { + channelName: 'v3_createTask', module: { name: 'node-cron', versionRange: '>=3.0.0 <4', filePath: 'src/node-cron.js' }, - functionQuery: { functionName: 'createTask', kind: 'Sync' }, + functionQuery: createTask, }, ] satisfies InstrumentationConfig[]; @@ -24,4 +36,5 @@ export const nodeCronModuleNames = getModuleNames(nodeCronConfig); export const nodeCronChannels = { NODE_CRON_CREATE_TASK: 'orchestrion:node-cron:createTask', + NODE_CRON_V3_CREATE_TASK: 'orchestrion:node-cron:v3_createTask', } as const; diff --git a/packages/server-utils/src/utils/cronMonitoring.ts b/packages/server-utils/src/utils/cronMonitoring.ts index 56d7fe97e321..0518f5bcdd95 100644 --- a/packages/server-utils/src/utils/cronMonitoring.ts +++ b/packages/server-utils/src/utils/cronMonitoring.ts @@ -1,8 +1,46 @@ import type { MonitorConfig } from '@sentry/core'; -import { captureException, isThenable, withMonitor } from '@sentry/core'; +import { captureException, debug, isThenable, stringMatchesSomePattern, withMonitor } from '@sentry/core'; +import { DEBUG_BUILD } from '../debug-build'; +import * as diagnosticsChannel from './diagnosticsChannel'; export type AnyFn = (this: unknown, ...args: unknown[]) => unknown; +// Set by `Sentry.cron.instrument*`, which already send check-ins. +const MONITORED = Symbol.for('sentry.cron.monitored'); + +/** + * Calls `onJob` with the arguments of each call on a scheduler's job-creation channel, so it can wrap the job. + * Errors are logged and the job is created unmonitored. + */ +export function subscribeToJobCreation( + channelName: string, + integrationName: string, + onJob: (args: unknown[]) => void, +): void { + diagnosticsChannel.tracingChannel<{ arguments: unknown[] }>(channelName).start.subscribe(message => { + try { + onJob((message as { arguments: unknown[] }).arguments); + } catch (e) { + DEBUG_BUILD && debug.error(`[${integrationName}] Failed to set up the cron monitor:`, e); + } + }); +} + +/** Whether a job is named, not excluded, and not already monitored by `Sentry.cron.instrument*`. */ +export function shouldMonitorJob( + name: unknown, + job: unknown, + excludeJobs: Array | undefined, +): name is string { + return ( + typeof name === 'string' && + !!name && + typeof job === 'function' && + !(job as { [MONITORED]?: boolean })[MONITORED] && + !stringMatchesSomePattern(name, excludeJobs, true) + ); +} + const NICKNAMES: Record = { '@yearly': '0 0 1 1 *', '@annually': '0 0 1 1 *', diff --git a/packages/server-utils/test/integrations/node-cron.test.ts b/packages/server-utils/test/integrations/node-cron.test.ts new file mode 100644 index 000000000000..3daae36b71f1 --- /dev/null +++ b/packages/server-utils/test/integrations/node-cron.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { isReadAlikeByNodeCron3 } from '../../src/integrations/node-cron'; + +describe('isReadAlikeByNodeCron3', () => { + it.each(['* * * * *', '*/15 * * * *', '0 */2 * * *', '0 8-18/2 * * *', '0 0 9 * * 1-5', '0 9 * * 7', '0 9 * * 0,7'])( + 'is true for %s', + expression => { + expect(isReadAlikeByNodeCron3(expression)).toBe(true); + }, + ); + + // node-cron 3 runs these on 2,4,...; 10,12,...; 3,6,...; and Sunday-Monday. + it.each([ + '0 9 */2 * *', + '0 9-17/2 * * *', + '0 9 * */3 *', + '0 9 * * 1-7', + '0 9 * * 5-7', + '5/15 * * * *', + '0,30/15 * * * *', + ])('is false for %s', expression => { + expect(isReadAlikeByNodeCron3(expression)).toBe(false); + }); +}); diff --git a/packages/server-utils/test/utils/cronMonitoring.test.ts b/packages/server-utils/test/utils/cronMonitoring.test.ts index 63495f8512f0..8d0c5d863416 100644 --- a/packages/server-utils/test/utils/cronMonitoring.test.ts +++ b/packages/server-utils/test/utils/cronMonitoring.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { cronExpressionToMonitorConfig } from '../../src/utils/cronMonitoring'; +import { cronExpressionToMonitorConfig, shouldMonitorJob } from '../../src/utils/cronMonitoring'; const toMonitorConfig = (expression: string, timezone: unknown) => cronExpressionToMonitorConfig(expression, timezone, 'and'); @@ -68,3 +68,28 @@ describe('cronExpressionToMonitorConfig with cron-parser day matching', () => { }, ); }); + +describe('shouldMonitorJob', () => { + const job = (): void => {}; + + it('monitors named jobs', () => { + expect(shouldMonitorJob('job', job, undefined)).toBe(true); + }); + + it('skips unnamed jobs and jobs that are not functions', () => { + expect(shouldMonitorJob(undefined, job, undefined)).toBe(false); + expect(shouldMonitorJob('', job, undefined)).toBe(false); + expect(shouldMonitorJob('job', './job.js', undefined)).toBe(false); + }); + + it('skips excluded jobs', () => { + expect(shouldMonitorJob('job', job, ['job'])).toBe(false); + expect(shouldMonitorJob('job-2', job, ['job'])).toBe(true); + expect(shouldMonitorJob('debug-job', job, [/^debug-/])).toBe(false); + }); + + it('skips jobs that `Sentry.cron.instrument*` already monitors', () => { + const monitored = Object.assign((): void => {}, { [Symbol.for('sentry.cron.monitored')]: true }); + expect(shouldMonitorJob('job', monitored, undefined)).toBe(false); + }); +}); From 4f360eb79b1ff6bf0a6220cfe09941a6be744e50 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Tue, 20 Oct 2026 10:47:12 -0700 Subject: [PATCH 12/33] fix(node): Send no monitor config for node-schedule rules with a start or end A rule limited by `start` or `end` doesn't run outside that window, so sending its schedule would report missed check-ins. Also uses the shared job-creation helpers. Co-Authored-By: Claude --- .../node-schedule-integration/scenario.mjs | 2 + .../cron/node-schedule-integration/test.ts | 10 +++- .../src/integrations/node-schedule.ts | 59 +++++++------------ 3 files changed, 32 insertions(+), 39 deletions(-) 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 index 3b05251a223d..de750a422252 100644 --- 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 @@ -3,6 +3,7 @@ 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' }, () => { @@ -16,6 +17,7 @@ const job = schedule.scheduleJob('my-schedule-job', { rule: '30 9 * * 1-5', tz: excluded.invoke(); unnamed.invoke(); recurrenceRule.invoke(); +ending.invoke(); job.invoke(); try { job.invoke(); 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 c74df35d4f21..61e9d261718c 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 @@ -1,3 +1,4 @@ +import type { SerializedCheckIn } from '@sentry/core'; import { afterAll, describe, expect } from 'vitest'; import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; @@ -6,6 +7,11 @@ const MONITOR_CONFIG = { 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(); @@ -15,8 +21,10 @@ describe('nodeScheduleIntegration', () => { 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: 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 }, }) diff --git a/packages/server-utils/src/integrations/node-schedule.ts b/packages/server-utils/src/integrations/node-schedule.ts index 7fea20bb3b7f..6e139a84849d 100644 --- a/packages/server-utils/src/integrations/node-schedule.ts +++ b/packages/server-utils/src/integrations/node-schedule.ts @@ -1,20 +1,18 @@ -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 { 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, wrapScheduledJob } from '../utils/cronMonitoring'; +import { + cronExpressionToMonitorConfig, + shouldMonitorJob, + subscribeToJobCreation, + 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; @@ -41,39 +39,24 @@ const _nodeScheduleIntegration = ((integrationOptions: NodeScheduleIntegrationOp 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, - }); + 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 check in without a config. +// 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 } = (spec || {}) as { rule?: unknown; tz?: unknown }; - return typeof rule === 'string' ? cronExpressionToMonitorConfig(rule, tz, 'or') : undefined; + 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; } From c11e2fdf45140977bb8a328510c024ece75e4948 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Tue, 20 Oct 2026 10:48:11 -0700 Subject: [PATCH 13/33] ref(node): Use the shared job-creation helpers in cronJobIntegration Co-Authored-By: Claude --- .../server-utils/src/integrations/cron.ts | 58 +++++++------------ 1 file changed, 20 insertions(+), 38 deletions(-) diff --git a/packages/server-utils/src/integrations/cron.ts b/packages/server-utils/src/integrations/cron.ts index c15968bc17c7..d815ef084cc6 100644 --- a/packages/server-utils/src/integrations/cron.ts +++ b/packages/server-utils/src/integrations/cron.ts @@ -1,20 +1,18 @@ -import * as diagnosticsChannel from '../utils/diagnosticsChannel'; import type { IntegrationFn } from '@sentry/core'; -import { debug, defineIntegration, stringMatchesSomePattern } from '@sentry/core'; -import { DEBUG_BUILD } from '../debug-build'; +import { defineIntegration } from '@sentry/core'; import { CHANNELS } from '../orchestrion/channels'; import { cronModuleNames } from '../orchestrion/config/cron'; import { invokeOrchestrionInstrumentation } from '../orchestrion/instrumentation'; import type { AnyFn } from '../utils/cronMonitoring'; -import { cronExpressionToMonitorConfig, wrapScheduledJob } from '../utils/cronMonitoring'; +import { + cronExpressionToMonitorConfig, + shouldMonitorJob, + subscribeToJobCreation, + wrapScheduledJob, +} from '../utils/cronMonitoring'; const INTEGRATION_NAME = 'CronJob' as const; -// Set by `Sentry.cron.instrumentCron`, which already sends check-ins. -const MONITORED = Symbol.for('sentry.cron.monitored'); - -const NOOP = (): void => {}; - export interface CronJobIntegrationOptions { /** Job names not to monitor. Strings must match the name exactly. */ excludeJobs?: Array; @@ -41,33 +39,17 @@ const _cronJobIntegration = ((integrationOptions: CronJobIntegrationOptions = {} export const cronJobIntegration = defineIntegration(_cronJobIntegration); function subscribe(): void { - diagnosticsChannel.tracingChannel<{ arguments: unknown[] }>(CHANNELS.CRON_JOB_CONSTRUCTOR).subscribe({ - start(data) { - try { - // `new CronJob(cronTime, onTick, onComplete, start, timeZone, context, runOnInit, utcOffset, ..., name)` - const args = data.arguments; - const [cronTime, onTick, , , timeZone] = args; - const utcOffset = args[7]; - const name = args[11]; - if ( - typeof name !== 'string' || - !name || - typeof onTick !== 'function' || - (onTick as { [MONITORED]?: boolean })[MONITORED] || - typeof cronTime !== 'string' || - stringMatchesSomePattern(name, options.excludeJobs, true) - ) { - return; - } - const monitorConfig = utcOffset == null ? cronExpressionToMonitorConfig(cronTime, timeZone, 'or') : undefined; - args[1] = wrapScheduledJob(onTick as AnyFn, name, monitorConfig, 'auto.function.cron'); - } catch (e) { - DEBUG_BUILD && debug.error('[CronJob] Failed to set up the cron monitor:', e); - } - }, - end: NOOP, - asyncStart: NOOP, - asyncEnd: NOOP, - error: NOOP, - }); + subscribeToJobCreation(CHANNELS.CRON_JOB_CONSTRUCTOR, INTEGRATION_NAME, wrapJob); +} + +// `new CronJob(cronTime, onTick, onComplete, start, timeZone, context, runOnInit, utcOffset, ..., name)` +function wrapJob(args: unknown[]): void { + const [cronTime, onTick, , , timeZone] = args; + const utcOffset = args[7]; + const name = args[11]; + if (typeof cronTime !== 'string' || !shouldMonitorJob(name, onTick, options.excludeJobs)) { + return; + } + const monitorConfig = utcOffset == null ? cronExpressionToMonitorConfig(cronTime, timeZone, 'or') : undefined; + args[1] = wrapScheduledJob(onTick as AnyFn, name, monitorConfig, 'auto.function.cron'); } From 3d72a0b7d8304abb2dd15ee8eec3d535eba978b1 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Wed, 21 Oct 2026 08:59:23 -0700 Subject: [PATCH 14/33] ref(node): Mark monitored node-cron callbacks without a double cast Co-Authored-By: Claude --- packages/node/src/cron/node-cron.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/node/src/cron/node-cron.ts b/packages/node/src/cron/node-cron.ts index c0bf0f27ad8c..ab3d935c1a89 100644 --- a/packages/node/src/cron/node-cron.ts +++ b/packages/node/src/cron/node-cron.ts @@ -1,4 +1,4 @@ -import { captureException, type MonitorConfig, withMonitor } from '@sentry/core'; +import { addNonEnumerableProperty, captureException, type MonitorConfig, withMonitor } from '@sentry/core'; import { replaceCronNames } from './common'; export interface NodeCronOptions { @@ -79,7 +79,7 @@ export function instrumentNodeCron( }; // Tells `nodeCronIntegration` that this task is already monitored. - (monitoredCallback as unknown as Record)[Symbol.for('sentry.cron.monitored')] = true; + addNonEnumerableProperty(monitoredCallback, Symbol.for('sentry.cron.monitored'), true); return target.apply(thisArg, [expression, monitoredCallback, options]); }, From 8184b788f29f825d7a5bb76461c2859e158a6c27 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Wed, 21 Oct 2026 08:59:54 -0700 Subject: [PATCH 15/33] ref(node): Mark monitored node-schedule callbacks without a double cast Co-Authored-By: Claude --- packages/node/src/cron/node-schedule.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/node/src/cron/node-schedule.ts b/packages/node/src/cron/node-schedule.ts index df7c775fc27b..548f60ac48f8 100644 --- a/packages/node/src/cron/node-schedule.ts +++ b/packages/node/src/cron/node-schedule.ts @@ -1,4 +1,4 @@ -import { withMonitor } from '@sentry/core'; +import { addNonEnumerableProperty, withMonitor } from '@sentry/core'; import { replaceCronNames } from './common'; export interface NodeSchedule { @@ -59,7 +59,7 @@ 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; + addNonEnumerableProperty(monitoredCallback, Symbol.for('sentry.cron.monitored'), true); return target.apply(thisArg, [monitorSlug, expression, monitoredCallback]); }, From dbd9ecb96aeb1ce4ac88f56129efce1cda202c5a Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Wed, 21 Oct 2026 09:00:40 -0700 Subject: [PATCH 16/33] ref(node): Mark monitored cron jobs without a double cast Co-Authored-By: Claude --- packages/node/src/cron/cron.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/node/src/cron/cron.ts b/packages/node/src/cron/cron.ts index e92625ebee18..f95c1a1b6f9d 100644 --- a/packages/node/src/cron/cron.ts +++ b/packages/node/src/cron/cron.ts @@ -1,4 +1,4 @@ -import { captureException, withMonitor } from '@sentry/core'; +import { addNonEnumerableProperty, captureException, withMonitor } from '@sentry/core'; import { replaceCronNames } from './common'; export type CronJobParams = { @@ -118,7 +118,7 @@ export function instrumentCron(lib: T & CronJobConstructor, monitorSlug: stri } // Tells `cronJobIntegration` that this job is already monitored. - (monitoredTick as unknown as Record)[MONITORED] = true; + addNonEnumerableProperty(monitoredTick, MONITORED, true); return new target(cronTime, monitoredTick, onComplete, start, timeZone, ...rest); }, @@ -162,7 +162,7 @@ export function instrumentCron(lib: T & CronJobConstructor, monitorSlug: stri ); }; - (param.onTick as unknown as Record)[MONITORED] = true; + addNonEnumerableProperty(param.onTick, MONITORED, true); return target.from(param); }; From 6f712c71c59c3688381b96286056fc7480181b0b Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Wed, 21 Oct 2026 17:29:23 -0700 Subject: [PATCH 17/33] fix(node): Don't monitor one-off node-schedule jobs from timestamps or date strings Co-Authored-By: Claude --- .../node-schedule-integration/scenario.mjs | 2 + .../src/integrations/node-schedule.ts | 23 ++++++++- .../test/integrations/node-schedule.test.ts | 51 +++++++++++++++++++ 3 files changed, 74 insertions(+), 2 deletions(-) create mode 100644 packages/server-utils/test/integrations/node-schedule.test.ts 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 index de750a422252..70393dfa602f 100644 --- 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 @@ -2,6 +2,7 @@ import schedule from 'node-schedule'; const excluded = schedule.scheduleJob('excluded-job', '0 0 * * *', () => {}); const unnamed = schedule.scheduleJob('0 0 * * *', () => {}); +const oneOff = schedule.scheduleJob('one-off-job', Date.now() + 86_400_000, () => {}); 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 }, () => {}); @@ -16,6 +17,7 @@ const job = schedule.scheduleJob('my-schedule-job', { rule: '30 9 * * 1-5', tz: // Run the jobs without waiting for their schedule. excluded.invoke(); unnamed.invoke(); +oneOff.invoke(); recurrenceRule.invoke(); ending.invoke(); job.invoke(); diff --git a/packages/server-utils/src/integrations/node-schedule.ts b/packages/server-utils/src/integrations/node-schedule.ts index 6e139a84849d..592bcf4b851f 100644 --- a/packages/server-utils/src/integrations/node-schedule.ts +++ b/packages/server-utils/src/integrations/node-schedule.ts @@ -46,14 +46,33 @@ function subscribe(): void { 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)) { + if (isOneOffSpec(spec) || !shouldMonitorJob(name, method, options.excludeJobs)) { return; } args[2] = wrapScheduledJob(method as AnyFn, name, getMonitorConfig(spec), 'auto.function.node-schedule'); } +/** Whether node-schedule runs a job with this spec once: a `Date`, a timestamp, or a date string. */ +export function isOneOffSpec(spec: unknown): boolean { + const { rule } = (spec || {}) as { rule?: unknown }; + const when = typeof spec === 'object' && rule ? rule : spec; + if (typeof when === 'string') { + // `Date.parse` also accepts some crons, like `0 9 * * *`. + return !isCronShaped(when) && !Number.isNaN(Date.parse(when)); + } + return when instanceof Date || typeof when === 'number'; +} + +function isCronShaped(value: string): boolean { + const fields = value.trim().split(/\s+/); + return ( + /^@[a-z]+$/i.test(value.trim()) || + ((fields.length === 5 || fields.length === 6) && fields.every(field => /^[\d*,/?#a-z-]+$/i.test(field))) + ); +} + // 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 { +export function getMonitorConfig(spec: unknown): MonitorConfig | undefined { if (typeof spec === 'string') { return cronExpressionToMonitorConfig(spec, undefined, 'or'); } diff --git a/packages/server-utils/test/integrations/node-schedule.test.ts b/packages/server-utils/test/integrations/node-schedule.test.ts new file mode 100644 index 000000000000..cc2fdb957a50 --- /dev/null +++ b/packages/server-utils/test/integrations/node-schedule.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; +import { getMonitorConfig, isOneOffSpec } from '../../src/integrations/node-schedule'; + +describe('isOneOffSpec', () => { + it.each([ + ['a Date', new Date()], + ['a timestamp', 1_793_527_200_000], + ['a date string', '2026-11-01T10:00:00Z'], + ['a long date string', 'Sun Nov 01 2026 10:00:00 GMT+0000'], + ['a Date rule', { rule: new Date(), tz: 'UTC' }], + ])('is true for %s', (_, spec) => { + expect(isOneOffSpec(spec)).toBe(true); + }); + + it.each([ + ['a cron string', '0 9 * * *'], + ['a cron with names', '0 9 * jan mon-fri'], + ['a cron with seconds', '*/10 * * * * *'], + ['a nickname', '@daily'], + ['a cron rule', { rule: '0 9 * * *', tz: 'UTC' }], + ['a recurrence rule', { hour: 3, minute: 0 }], + ])('is false for %s', (_, spec) => { + expect(isOneOffSpec(spec)).toBe(false); + }); +}); + +describe('getMonitorConfig', () => { + it('sends a cron rule with its time zone', () => { + expect(getMonitorConfig({ rule: '30 9 * * 1-5', tz: 'Europe/Vienna' })).toEqual({ + schedule: { type: 'crontab', value: '30 9 * * 1-5' }, + timezone: 'Europe/Vienna', + }); + }); + + it('drops a fixed second', () => { + expect(getMonitorConfig({ rule: '0 30 9 * * *', tz: 'UTC' })?.schedule).toEqual({ + type: 'crontab', + value: '30 9 * * *', + }); + }); + + it.each([ + ['a sub-minute cron', '*/10 * * * * *'], + ['a rule with a start', { rule: '0 9 * * *', start: Date.now() + 1000 }], + ['a rule with an end', { rule: '0 9 * * *', end: Date.now() + 1000 }], + ['a recurrence rule', { hour: 3, minute: 0 }], + ['a UTC offset', { rule: '0 9 * * *', tz: '+02:00' }], + ])('is undefined for %s', (_, spec) => { + expect(getMonitorConfig(spec)).toBeUndefined(); + }); +}); From aa654d6abef242ee632302f1b0add9cc0f0eb87e Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Wed, 21 Oct 2026 17:31:15 -0700 Subject: [PATCH 18/33] test(node): Cover cron jobs sent without a config or skipped as one-off Co-Authored-By: Claude --- .../suites/cron/cron-integration/scenario.mjs | 6 ++++++ .../suites/cron/cron-integration/test.ts | 10 ++++++++++ 2 files changed, 16 insertions(+) diff --git a/dev-packages/node-integration-tests/suites/cron/cron-integration/scenario.mjs b/dev-packages/node-integration-tests/suites/cron/cron-integration/scenario.mjs index a608fd44d135..ad4b46558324 100644 --- a/dev-packages/node-integration-tests/suites/cron/cron-integration/scenario.mjs +++ b/dev-packages/node-integration-tests/suites/cron/cron-integration/scenario.mjs @@ -2,6 +2,9 @@ import { CronJob } from 'cron'; const excluded = CronJob.from({ cronTime: '0 0 * * *', onTick: () => {}, name: 'excluded-job' }); const unnamed = new CronJob('0 0 * * *', () => {}); +const oneOff = CronJob.from({ cronTime: new Date(Date.now() + 86_400_000), onTick: () => {}, name: 'one-off-job' }); +const offset = CronJob.from({ cronTime: '0 0 * * *', utcOffset: 60, onTick: () => {}, name: 'offset-job' }); +const subMinute = CronJob.from({ cronTime: '*/10 * * * * *', onTick: () => {}, name: 'sub-minute-job' }); let runs = 0; const job = CronJob.from({ @@ -22,6 +25,9 @@ const job = CronJob.from({ (async () => { await excluded.fireOnTick(); await unnamed.fireOnTick(); + await oneOff.fireOnTick(); + await offset.fireOnTick(); + await subMinute.fireOnTick(); await job.fireOnTick(); await job.fireOnTick(); })(); diff --git a/dev-packages/node-integration-tests/suites/cron/cron-integration/test.ts b/dev-packages/node-integration-tests/suites/cron/cron-integration/test.ts index 7c658b22408d..3b1efd64f2e3 100644 --- a/dev-packages/node-integration-tests/suites/cron/cron-integration/test.ts +++ b/dev-packages/node-integration-tests/suites/cron/cron-integration/test.ts @@ -1,3 +1,4 @@ +import type { SerializedCheckIn } from '@sentry/core'; import { afterAll, describe, expect } from 'vitest'; import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; @@ -6,6 +7,11 @@ const MONITOR_CONFIG = { timezone: 'Europe/Vienna', }; +const expectNoConfig = (slug: string) => (checkIn: SerializedCheckIn) => { + expect(checkIn).toMatchObject({ monitor_slug: slug, status: 'in_progress' }); + expect(checkIn.monitor_config).toBeUndefined(); +}; + describe('cronJobIntegration', () => { afterAll(() => { cleanupChildProcesses(); @@ -15,6 +21,10 @@ describe('cronJobIntegration', () => { test('sends check-ins for named jobs', async () => { await createRunner() .ignore('event') + .expect({ check_in: expectNoConfig('offset-job') }) + .expect({ check_in: { monitor_slug: 'offset-job', status: 'ok' } }) + .expect({ check_in: expectNoConfig('sub-minute-job') }) + .expect({ check_in: { monitor_slug: 'sub-minute-job', status: 'ok' } }) .expect({ check_in: { monitor_slug: 'my-cron-job', status: 'in_progress', monitor_config: MONITOR_CONFIG }, }) From ea0509e4724fbe38a79a8704cd7d93462f251616 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Fri, 23 Oct 2026 09:54:28 -0700 Subject: [PATCH 19/33] ref(node): Pass nodeCronIntegration options to the subscriber --- .../server-utils/src/integrations/node-cron.ts | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/packages/server-utils/src/integrations/node-cron.ts b/packages/server-utils/src/integrations/node-cron.ts index be4fb742c129..ae557d28d909 100644 --- a/packages/server-utils/src/integrations/node-cron.ts +++ b/packages/server-utils/src/integrations/node-cron.ts @@ -18,14 +18,11 @@ export interface NodeCronIntegrationOptions { excludeJobs?: Array; } -let options: NodeCronIntegrationOptions = {}; - -const _nodeCronIntegration = ((integrationOptions: NodeCronIntegrationOptions = {}) => { +const _nodeCronIntegration = ((options: NodeCronIntegrationOptions = {}) => { return { name: INTEGRATION_NAME, setup(client) { - options = integrationOptions; - invokeOrchestrionInstrumentation(client, nodeCronModuleNames, subscribe, [], { + invokeOrchestrionInstrumentation(client, nodeCronModuleNames, subscribe, [options], { requiresTracingChannelBinding: false, }); }, @@ -38,13 +35,13 @@ const _nodeCronIntegration = ((integrationOptions: NodeCronIntegrationOptions = */ export const nodeCronIntegration = defineIntegration(_nodeCronIntegration); -function subscribe(): void { - subscribeToJobCreation(CHANNELS.NODE_CRON_CREATE_TASK, INTEGRATION_NAME, args => wrapTask(args, false)); - subscribeToJobCreation(CHANNELS.NODE_CRON_V3_CREATE_TASK, INTEGRATION_NAME, args => wrapTask(args, true)); +function subscribe(options: NodeCronIntegrationOptions): void { + subscribeToJobCreation(CHANNELS.NODE_CRON_CREATE_TASK, INTEGRATION_NAME, args => wrapTask(args, false, options)); + subscribeToJobCreation(CHANNELS.NODE_CRON_V3_CREATE_TASK, INTEGRATION_NAME, args => wrapTask(args, true, options)); } // `createTask(expression, func, options)` -function wrapTask(args: unknown[], isNodeCron3: boolean): void { +function wrapTask(args: unknown[], isNodeCron3: boolean, options: NodeCronIntegrationOptions): void { const [expression, func, taskOptions] = args; const { name, timezone } = (taskOptions || {}) as { name?: unknown; timezone?: unknown }; if (!shouldMonitorJob(name, func, options.excludeJobs)) { From afaaa48f182a4199141abee9a850e405f43d6551 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Fri, 23 Oct 2026 09:54:54 -0700 Subject: [PATCH 20/33] ref(node): Pass nodeScheduleIntegration options to the subscriber --- .../server-utils/src/integrations/node-schedule.ts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/packages/server-utils/src/integrations/node-schedule.ts b/packages/server-utils/src/integrations/node-schedule.ts index 592bcf4b851f..4c3011808d6c 100644 --- a/packages/server-utils/src/integrations/node-schedule.ts +++ b/packages/server-utils/src/integrations/node-schedule.ts @@ -18,14 +18,11 @@ export interface NodeScheduleIntegrationOptions { excludeJobs?: Array; } -let options: NodeScheduleIntegrationOptions = {}; - -const _nodeScheduleIntegration = ((integrationOptions: NodeScheduleIntegrationOptions = {}) => { +const _nodeScheduleIntegration = ((options: NodeScheduleIntegrationOptions = {}) => { return { name: INTEGRATION_NAME, setup(client) { - options = integrationOptions; - invokeOrchestrionInstrumentation(client, nodeScheduleModuleNames, subscribe, [], { + invokeOrchestrionInstrumentation(client, nodeScheduleModuleNames, subscribe, [options], { requiresTracingChannelBinding: false, }); }, @@ -38,12 +35,12 @@ const _nodeScheduleIntegration = ((integrationOptions: NodeScheduleIntegrationOp */ export const nodeScheduleIntegration = defineIntegration(_nodeScheduleIntegration); -function subscribe(): void { - subscribeToJobCreation(CHANNELS.NODE_SCHEDULE_SCHEDULE_JOB, INTEGRATION_NAME, wrapJob); +function subscribe(options: NodeScheduleIntegrationOptions): void { + subscribeToJobCreation(CHANNELS.NODE_SCHEDULE_SCHEDULE_JOB, INTEGRATION_NAME, args => wrapJob(args, options)); } // `scheduleJob([name], spec, method, [callback])`, like node-schedule reads it. -function wrapJob(args: unknown[]): void { +function wrapJob(args: unknown[], options: NodeScheduleIntegrationOptions): 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 (isOneOffSpec(spec) || !shouldMonitorJob(name, method, options.excludeJobs)) { From a1f362d7cb8c08b5cb55f6b3ec0d8b021a7994e2 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Fri, 23 Oct 2026 09:56:15 -0700 Subject: [PATCH 21/33] ref(node): Pass cronJobIntegration options to the subscriber --- packages/server-utils/src/integrations/cron.ts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/packages/server-utils/src/integrations/cron.ts b/packages/server-utils/src/integrations/cron.ts index d815ef084cc6..93a5a20807b8 100644 --- a/packages/server-utils/src/integrations/cron.ts +++ b/packages/server-utils/src/integrations/cron.ts @@ -18,14 +18,11 @@ export interface CronJobIntegrationOptions { excludeJobs?: Array; } -let options: CronJobIntegrationOptions = {}; - -const _cronJobIntegration = ((integrationOptions: CronJobIntegrationOptions = {}) => { +const _cronJobIntegration = ((options: CronJobIntegrationOptions = {}) => { return { name: INTEGRATION_NAME, setup(client) { - options = integrationOptions; - invokeOrchestrionInstrumentation(client, cronModuleNames, subscribe, [], { + invokeOrchestrionInstrumentation(client, cronModuleNames, subscribe, [options], { requiresTracingChannelBinding: false, }); }, @@ -38,12 +35,12 @@ const _cronJobIntegration = ((integrationOptions: CronJobIntegrationOptions = {} */ export const cronJobIntegration = defineIntegration(_cronJobIntegration); -function subscribe(): void { - subscribeToJobCreation(CHANNELS.CRON_JOB_CONSTRUCTOR, INTEGRATION_NAME, wrapJob); +function subscribe(options: CronJobIntegrationOptions): void { + subscribeToJobCreation(CHANNELS.CRON_JOB_CONSTRUCTOR, INTEGRATION_NAME, args => wrapJob(args, options)); } // `new CronJob(cronTime, onTick, onComplete, start, timeZone, context, runOnInit, utcOffset, ..., name)` -function wrapJob(args: unknown[]): void { +function wrapJob(args: unknown[], options: CronJobIntegrationOptions): void { const [cronTime, onTick, , , timeZone] = args; const utcOffset = args[7]; const name = args[11]; From 46495506d2801846aa2d1dcbd44df868c523a442 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Wed, 28 Oct 2026 09:51:18 -0700 Subject: [PATCH 22/33] ref(node): Address node-cron review comments --- .../suites/cron/node-cron/integration/scenario.mjs | 8 ++++---- .../suites/cron/node-cron/integration/test.ts | 2 +- packages/deno/src/index.ts | 1 + packages/server-utils/src/integrations/node-cron.ts | 1 + packages/server-utils/src/utils/cronMonitoring.ts | 4 ++-- .../test/orchestrion/moduleInjectedTransform.test.ts | 2 +- 6 files changed, 10 insertions(+), 8 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/cron/node-cron/integration/scenario.mjs b/dev-packages/node-integration-tests/suites/cron/node-cron/integration/scenario.mjs index 776fea6a61a5..bee2fec76356 100644 --- a/dev-packages/node-integration-tests/suites/cron/node-cron/integration/scenario.mjs +++ b/dev-packages/node-integration-tests/suites/cron/node-cron/integration/scenario.mjs @@ -1,11 +1,11 @@ import cron from 'node-cron'; -const excluded = cron.schedule('0 * * * * *', () => {}, { name: 'excluded-job' }); -const unnamed = cron.schedule('0 * * * * *', () => {}); +const excluded = cron.schedule('0 0 0 1 1 *', () => {}, { name: 'excluded-job' }); +const unnamed = cron.schedule('0 0 0 1 1 *', () => {}); let runs = 0; const task = cron.schedule( - '0 * * * * *', + '0 0 0 1 1 *', () => { runs++; if (runs > 1) { @@ -15,7 +15,7 @@ const task = cron.schedule( { name: 'my-cron-job', timezone: 'America/New_York' }, ); -// Run the task without waiting for the next minute. +// Run the tasks by hand. Their schedules can't fire during the test. excluded .execute() .then(() => unnamed.execute()) diff --git a/dev-packages/node-integration-tests/suites/cron/node-cron/integration/test.ts b/dev-packages/node-integration-tests/suites/cron/node-cron/integration/test.ts index 58328f9ce7d7..e90549ce84c8 100644 --- a/dev-packages/node-integration-tests/suites/cron/node-cron/integration/test.ts +++ b/dev-packages/node-integration-tests/suites/cron/node-cron/integration/test.ts @@ -2,7 +2,7 @@ import { afterAll, describe, expect } from 'vitest'; import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/runner'; const MONITOR_CONFIG = { - schedule: { type: 'crontab', value: '* * * * *' }, + schedule: { type: 'crontab', value: '0 0 1 1 *' }, timezone: 'America/New_York', }; diff --git a/packages/deno/src/index.ts b/packages/deno/src/index.ts index f55fe14de14f..b22aac2bdf6c 100644 --- a/packages/deno/src/index.ts +++ b/packages/deno/src/index.ts @@ -183,6 +183,7 @@ export { postgresIntegration, postgresJsIntegration, neonIntegration, + nodeCronIntegration, tediousIntegration, eveConversationHook, eveIntegration, diff --git a/packages/server-utils/src/integrations/node-cron.ts b/packages/server-utils/src/integrations/node-cron.ts index ae557d28d909..a735d143b3d9 100644 --- a/packages/server-utils/src/integrations/node-cron.ts +++ b/packages/server-utils/src/integrations/node-cron.ts @@ -23,6 +23,7 @@ const _nodeCronIntegration = ((options: NodeCronIntegrationOptions = {}) => { name: INTEGRATION_NAME, setup(client) { invokeOrchestrionInstrumentation(client, nodeCronModuleNames, subscribe, [options], { + // The binding only parents spans, and this integration creates none. requiresTracingChannelBinding: false, }); }, diff --git a/packages/server-utils/src/utils/cronMonitoring.ts b/packages/server-utils/src/utils/cronMonitoring.ts index 0518f5bcdd95..09c532449814 100644 --- a/packages/server-utils/src/utils/cronMonitoring.ts +++ b/packages/server-utils/src/utils/cronMonitoring.ts @@ -5,7 +5,7 @@ import * as diagnosticsChannel from './diagnosticsChannel'; export type AnyFn = (this: unknown, ...args: unknown[]) => unknown; -// Set by `Sentry.cron.instrument*`, which already send check-ins. +// Set by `Sentry.cron.instrumentNodeCron`, which already sends check-ins. const MONITORED = Symbol.for('sentry.cron.monitored'); /** @@ -26,7 +26,7 @@ export function subscribeToJobCreation( }); } -/** Whether a job is named, not excluded, and not already monitored by `Sentry.cron.instrument*`. */ +/** Whether a job is named, not excluded, and not already monitored by `Sentry.cron.instrumentNodeCron`. */ export function shouldMonitorJob( name: unknown, job: unknown, diff --git a/packages/server-utils/test/orchestrion/moduleInjectedTransform.test.ts b/packages/server-utils/test/orchestrion/moduleInjectedTransform.test.ts index 1697ea30d7c0..5dac489035d1 100644 --- a/packages/server-utils/test/orchestrion/moduleInjectedTransform.test.ts +++ b/packages/server-utils/test/orchestrion/moduleInjectedTransform.test.ts @@ -43,7 +43,7 @@ describe('channel integration definitions', () => { const defined = new Set(CHANNEL_INTEGRATION_DEFINITIONS.flatMap(d => d.modules as readonly string[])); // `@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. + // Each cron monitor is billed, so bundler-only SDKs must not install crons integrations for every app. const optIn = new Set(['node-cron']); const uncovered = [...configured].filter( name => !defined.has(name) && !optIn.has(name) && !name.startsWith('@nestjs/') && !name.startsWith('@remix-run/'), From 82b2bffea5bd2813c02446aa57b26b17522cc183 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Wed, 28 Oct 2026 09:51:59 -0700 Subject: [PATCH 23/33] ref(node): Export nodeScheduleIntegration from Deno --- packages/deno/src/index.ts | 1 + packages/server-utils/src/utils/cronMonitoring.ts | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/deno/src/index.ts b/packages/deno/src/index.ts index b22aac2bdf6c..8d922f286434 100644 --- a/packages/deno/src/index.ts +++ b/packages/deno/src/index.ts @@ -184,6 +184,7 @@ export { postgresJsIntegration, neonIntegration, nodeCronIntegration, + nodeScheduleIntegration, tediousIntegration, eveConversationHook, eveIntegration, diff --git a/packages/server-utils/src/utils/cronMonitoring.ts b/packages/server-utils/src/utils/cronMonitoring.ts index 09c532449814..c6471668f4dd 100644 --- a/packages/server-utils/src/utils/cronMonitoring.ts +++ b/packages/server-utils/src/utils/cronMonitoring.ts @@ -5,7 +5,7 @@ import * as diagnosticsChannel from './diagnosticsChannel'; export type AnyFn = (this: unknown, ...args: unknown[]) => unknown; -// Set by `Sentry.cron.instrumentNodeCron`, which already sends check-ins. +// Set by `Sentry.cron.instrumentNodeCron` and `Sentry.cron.instrumentNodeSchedule`, which already send check-ins. const MONITORED = Symbol.for('sentry.cron.monitored'); /** @@ -26,7 +26,7 @@ export function subscribeToJobCreation( }); } -/** Whether a job is named, not excluded, and not already monitored by `Sentry.cron.instrumentNodeCron`. */ +/** Whether a job is named, not excluded, and not already monitored by `Sentry.cron.instrumentNodeCron` or `instrumentNodeSchedule`. */ export function shouldMonitorJob( name: unknown, job: unknown, From d13ffdd4c66947a61b0c6739b6147c9b729d6703 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Wed, 28 Oct 2026 09:52:35 -0700 Subject: [PATCH 24/33] ref(node): Export cronJobIntegration from Deno --- packages/deno/src/index.ts | 1 + packages/server-utils/src/utils/cronMonitoring.ts | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/deno/src/index.ts b/packages/deno/src/index.ts index 8d922f286434..ad4a32a4454a 100644 --- a/packages/deno/src/index.ts +++ b/packages/deno/src/index.ts @@ -183,6 +183,7 @@ export { postgresIntegration, postgresJsIntegration, neonIntegration, + cronJobIntegration, nodeCronIntegration, nodeScheduleIntegration, tediousIntegration, diff --git a/packages/server-utils/src/utils/cronMonitoring.ts b/packages/server-utils/src/utils/cronMonitoring.ts index f4e8d14540e3..21c42fcb8b26 100644 --- a/packages/server-utils/src/utils/cronMonitoring.ts +++ b/packages/server-utils/src/utils/cronMonitoring.ts @@ -5,7 +5,7 @@ import * as diagnosticsChannel from './diagnosticsChannel'; export type AnyFn = (this: unknown, ...args: unknown[]) => unknown; -// Set by `Sentry.cron.instrumentNodeCron` and `Sentry.cron.instrumentNodeSchedule`, which already send check-ins. +// Set by `Sentry.cron.instrumentCron`, `instrumentNodeCron` and `instrumentNodeSchedule`, which already send check-ins. const MONITORED = Symbol.for('sentry.cron.monitored'); /** @@ -26,7 +26,7 @@ export function subscribeToJobCreation( }); } -/** Whether a job is named, not excluded, and not already monitored by `Sentry.cron.instrumentNodeCron` or `instrumentNodeSchedule`. */ +/** Whether a job is named, not excluded, and not already monitored by `Sentry.cron.instrumentCron`, `instrumentNodeCron` or `instrumentNodeSchedule`. */ export function shouldMonitorJob( name: unknown, job: unknown, From 0af113c30c6506d7715550b47387205a838ff7d2 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Wed, 28 Oct 2026 10:34:24 -0700 Subject: [PATCH 25/33] fix(node): Send the new schedule after a node-schedule job is rescheduled nodeScheduleIntegration kept sending the config from scheduleJob after rescheduleJob or job.reschedule, so Sentry reverted the monitor and reported false misses. Update the job's config when a reschedule succeeds. --- .../node-schedule-integration/scenario.mjs | 4 +- .../cron/node-schedule-integration/test.ts | 8 +++ .../src/integrations/node-schedule.ts | 13 ++++- .../src/orchestrion/config/node-schedule.ts | 16 ++++++ .../server-utils/src/utils/cronMonitoring.ts | 40 ++++++++++++++- .../test/integrations/node-schedule.test.ts | 51 ++++++++++++++++++- 6 files changed, 126 insertions(+), 6 deletions(-) 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 index 70393dfa602f..a8cdd91ed74a 100644 --- 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 @@ -9,7 +9,7 @@ const ending = schedule.scheduleJob('ending-job', { rule: '0 0 * * *', end: Date let runs = 0; const job = schedule.scheduleJob('my-schedule-job', { rule: '30 9 * * 1-5', tz: 'Europe/Vienna' }, () => { runs++; - if (runs > 1) { + if (runs === 2) { throw new Error('Error in scheduled job'); } }); @@ -26,5 +26,7 @@ try { } catch { // node-schedule catches errors when it runs the job itself } +schedule.rescheduleJob(job, { rule: '0 8 * * *', tz: 'UTC' }); +job.invoke(); 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 index 61e9d261718c..dbe3c9ac5fd7 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 @@ -33,6 +33,14 @@ 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) } }) + .expect({ + check_in: { + monitor_slug: 'my-schedule-job', + status: 'in_progress', + monitor_config: { schedule: { type: 'crontab', value: '0 8 * * *' }, timezone: 'UTC' }, + }, + }) + .expect({ check_in: { monitor_slug: 'my-schedule-job', status: 'ok', duration: expect.any(Number) } }) .start() .completed(); }); diff --git a/packages/server-utils/src/integrations/node-schedule.ts b/packages/server-utils/src/integrations/node-schedule.ts index 4c3011808d6c..1c532c2fb0ba 100644 --- a/packages/server-utils/src/integrations/node-schedule.ts +++ b/packages/server-utils/src/integrations/node-schedule.ts @@ -3,11 +3,13 @@ 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 type { AnyFn, ScheduleChange } from '../utils/cronMonitoring'; import { cronExpressionToMonitorConfig, shouldMonitorJob, subscribeToJobCreation, + subscribeToScheduleChange, + updateMonitorConfig, wrapScheduledJob, } from '../utils/cronMonitoring'; @@ -37,6 +39,15 @@ export const nodeScheduleIntegration = defineIntegration(_nodeScheduleIntegratio function subscribe(options: NodeScheduleIntegrationOptions): void { subscribeToJobCreation(CHANNELS.NODE_SCHEDULE_SCHEDULE_JOB, INTEGRATION_NAME, args => wrapJob(args, options)); + subscribeToScheduleChange(CHANNELS.NODE_SCHEDULE_RESCHEDULE, INTEGRATION_NAME, onReschedule); +} + +/** Sends the new spec's config after `job.reschedule(spec)`, which `rescheduleJob` calls. */ +export function onReschedule({ arguments: [spec], self, result }: ScheduleChange): void { + // node-schedule keeps the old schedule when this returns false. + if (result === true) { + updateMonitorConfig((self as { job?: unknown } | undefined)?.job, getMonitorConfig(spec)); + } } // `scheduleJob([name], spec, method, [callback])`, like node-schedule reads it. diff --git a/packages/server-utils/src/orchestrion/config/node-schedule.ts b/packages/server-utils/src/orchestrion/config/node-schedule.ts index 3dd231617e64..8ed2398df597 100644 --- a/packages/server-utils/src/orchestrion/config/node-schedule.ts +++ b/packages/server-utils/src/orchestrion/config/node-schedule.ts @@ -1,16 +1,32 @@ import type { InstrumentationConfig } from '../apmTypes'; import { getModuleNames } from './module-names'; +// `Job` sets `reschedule` on each instance in its constructor. It moved from `lib/schedule.js` to `lib/Job.js` in 2.0.0. +const reschedule = { + channelName: 'reschedule', + astQuery: "AssignmentExpression[left.property.name='reschedule'] > FunctionExpression", + functionQuery: { kind: 'Sync' }, +} as const; + export const nodeScheduleConfig = [ { channelName: 'scheduleJob', module: { name: 'node-schedule', versionRange: '>=1.3.0 <3', filePath: 'lib/schedule.js' }, functionQuery: { functionName: 'scheduleJob', kind: 'Sync' }, }, + { + ...reschedule, + module: { name: 'node-schedule', versionRange: '>=1.3.0 <2', filePath: 'lib/schedule.js' }, + }, + { + ...reschedule, + module: { name: 'node-schedule', versionRange: '>=2.0.0 <3', filePath: 'lib/Job.js' }, + }, ] satisfies InstrumentationConfig[]; export const nodeScheduleModuleNames = getModuleNames(nodeScheduleConfig); export const nodeScheduleChannels = { NODE_SCHEDULE_SCHEDULE_JOB: 'orchestrion:node-schedule:scheduleJob', + NODE_SCHEDULE_RESCHEDULE: 'orchestrion:node-schedule:reschedule', } as const; diff --git a/packages/server-utils/src/utils/cronMonitoring.ts b/packages/server-utils/src/utils/cronMonitoring.ts index c6471668f4dd..8debd6d6ff95 100644 --- a/packages/server-utils/src/utils/cronMonitoring.ts +++ b/packages/server-utils/src/utils/cronMonitoring.ts @@ -26,6 +26,31 @@ export function subscribeToJobCreation( }); } +/** A call on a schedule-change channel: its arguments, the job it was called on, and what it returned. */ +export interface ScheduleChange { + arguments: unknown[]; + self?: unknown; + result?: unknown; +} + +/** + * Calls `onChange` after each call on a scheduler's schedule-change channel, so it can update the job's monitor config. + * Errors are logged and the job keeps its config. + */ +export function subscribeToScheduleChange( + channelName: string, + integrationName: string, + onChange: (change: ScheduleChange) => void, +): void { + diagnosticsChannel.tracingChannel(channelName).end.subscribe(message => { + try { + onChange(message as ScheduleChange); + } catch (e) { + DEBUG_BUILD && debug.error(`[${integrationName}] Failed to update the cron monitor:`, e); + } + }); +} + /** Whether a job is named, not excluded, and not already monitored by `Sentry.cron.instrumentNodeCron` or `instrumentNodeSchedule`. */ export function shouldMonitorJob( name: unknown, @@ -134,6 +159,8 @@ function toSentryTimezone(timezone: string | undefined): string | undefined { } } +const monitorConfigs = new WeakMap(); + /** * Wraps a scheduled job with check-ins. Errors are captured, because schedulers catch them, and rethrown. */ @@ -148,7 +175,7 @@ export function wrapScheduledJob( throw error; }; - return function (this: unknown, ...args: unknown[]): unknown { + const wrapped = function (this: unknown, ...args: unknown[]): unknown { return withMonitor( monitorSlug, () => { @@ -159,7 +186,16 @@ export function wrapScheduledJob( return captureAndRethrow(e); } }, - monitorConfig, + monitorConfigs.get(wrapped), ); }; + monitorConfigs.set(wrapped, monitorConfig); + return wrapped; +} + +/** Sets the monitor config a job from `wrapScheduledJob` sends on its next runs. Other functions are ignored. */ +export function updateMonitorConfig(job: unknown, monitorConfig: MonitorConfig | undefined): void { + if (typeof job === 'function' && monitorConfigs.has(job as AnyFn)) { + monitorConfigs.set(job as AnyFn, monitorConfig); + } } diff --git a/packages/server-utils/test/integrations/node-schedule.test.ts b/packages/server-utils/test/integrations/node-schedule.test.ts index cc2fdb957a50..c5a6d5339594 100644 --- a/packages/server-utils/test/integrations/node-schedule.test.ts +++ b/packages/server-utils/test/integrations/node-schedule.test.ts @@ -1,5 +1,9 @@ -import { describe, expect, it } from 'vitest'; -import { getMonitorConfig, isOneOffSpec } from '../../src/integrations/node-schedule'; +import type { MonitorConfig } from '@sentry/core'; +import { setCurrentClient } from '@sentry/core'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { getMonitorConfig, isOneOffSpec, onReschedule } from '../../src/integrations/node-schedule'; +import { wrapScheduledJob } from '../../src/utils/cronMonitoring'; +import { getDefaultTestClientOptions, TestClient } from '../mocks/client'; describe('isOneOffSpec', () => { it.each([ @@ -49,3 +53,46 @@ describe('getMonitorConfig', () => { expect(getMonitorConfig(spec)).toBeUndefined(); }); }); + +describe('onReschedule', () => { + let sentConfigs: Array; + + beforeEach(() => { + sentConfigs = []; + const client = new TestClient(getDefaultTestClientOptions()); + client.captureCheckIn = (_checkIn, monitorConfig) => { + sentConfigs.push(monitorConfig); + return 'check-in-id'; + }; + setCurrentClient(client); + }); + + const createJob = () => ({ + job: wrapScheduledJob(() => {}, 'my-job', getMonitorConfig('0 9 * * *'), 'auto.function.node-schedule'), + }); + + it('sends the new schedule after a reschedule', () => { + const job = createJob(); + onReschedule({ arguments: [{ rule: '30 10 * * 1-5', tz: 'UTC' }], self: job, result: true }); + job.job(); + + expect(sentConfigs[0]).toEqual({ schedule: { type: 'crontab', value: '30 10 * * 1-5' }, timezone: 'UTC' }); + }); + + it('sends no config after a reschedule to a spec it cannot convert', () => { + const job = createJob(); + onReschedule({ arguments: [{ hour: 3, minute: 0 }], self: job, result: true }); + job.job(); + + expect(sentConfigs).toHaveLength(2); + expect(sentConfigs[0]).toBeUndefined(); + }); + + it('keeps the old schedule when the reschedule fails', () => { + const job = createJob(); + onReschedule({ arguments: ['not a spec'], self: job, result: false }); + job.job(); + + expect(sentConfigs[0]?.schedule).toEqual({ type: 'crontab', value: '0 9 * * *' }); + }); +}); From 86acf7ff0c8335828c127b02d225115a8342db6e Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Wed, 28 Oct 2026 12:00:55 -0700 Subject: [PATCH 26/33] fix(node): Send the new schedule after a cron job's setTime cronJobIntegration kept sending the config from the CronJob constructor after job.setTime, so Sentry reverted the monitor and reported false misses. Update the job's config after setTime. --- .../suites/cron/cron-integration/scenario.mjs | 6 +- .../suites/cron/cron-integration/test.ts | 8 +++ .../server-utils/src/integrations/cron.ts | 22 +++++- .../src/orchestrion/config/cron.ts | 6 ++ .../test/integrations/cron.test.ts | 70 +++++++++++++++++++ 5 files changed, 108 insertions(+), 4 deletions(-) create mode 100644 packages/server-utils/test/integrations/cron.test.ts diff --git a/dev-packages/node-integration-tests/suites/cron/cron-integration/scenario.mjs b/dev-packages/node-integration-tests/suites/cron/cron-integration/scenario.mjs index ad4b46558324..0a665ee9727c 100644 --- a/dev-packages/node-integration-tests/suites/cron/cron-integration/scenario.mjs +++ b/dev-packages/node-integration-tests/suites/cron/cron-integration/scenario.mjs @@ -1,4 +1,4 @@ -import { CronJob } from 'cron'; +import { CronJob, CronTime } from 'cron'; const excluded = CronJob.from({ cronTime: '0 0 * * *', onTick: () => {}, name: 'excluded-job' }); const unnamed = new CronJob('0 0 * * *', () => {}); @@ -15,7 +15,7 @@ const job = CronJob.from({ errorHandler: () => {}, onTick: async () => { runs++; - if (runs > 1) { + if (runs === 2) { throw new Error('Error in cron job'); } }, @@ -30,6 +30,8 @@ const job = CronJob.from({ await subMinute.fireOnTick(); await job.fireOnTick(); await job.fireOnTick(); + job.setTime(new CronTime('0 8 * * *', 'UTC')); + await job.fireOnTick(); })(); setTimeout(() => process.exit(), 10_000); diff --git a/dev-packages/node-integration-tests/suites/cron/cron-integration/test.ts b/dev-packages/node-integration-tests/suites/cron/cron-integration/test.ts index 3b1efd64f2e3..640ac8b24041 100644 --- a/dev-packages/node-integration-tests/suites/cron/cron-integration/test.ts +++ b/dev-packages/node-integration-tests/suites/cron/cron-integration/test.ts @@ -33,6 +33,14 @@ describe('cronJobIntegration', () => { check_in: { monitor_slug: 'my-cron-job', status: 'in_progress', monitor_config: MONITOR_CONFIG }, }) .expect({ check_in: { monitor_slug: 'my-cron-job', status: 'error', duration: expect.any(Number) } }) + .expect({ + check_in: { + monitor_slug: 'my-cron-job', + status: 'in_progress', + monitor_config: { schedule: { type: 'crontab', value: '0 8 * * *' }, timezone: 'UTC' }, + }, + }) + .expect({ check_in: { monitor_slug: 'my-cron-job', status: 'ok', duration: expect.any(Number) } }) .start() .completed(); }); diff --git a/packages/server-utils/src/integrations/cron.ts b/packages/server-utils/src/integrations/cron.ts index 93a5a20807b8..52b2f945b2c6 100644 --- a/packages/server-utils/src/integrations/cron.ts +++ b/packages/server-utils/src/integrations/cron.ts @@ -1,13 +1,15 @@ -import type { IntegrationFn } from '@sentry/core'; +import type { IntegrationFn, MonitorConfig } from '@sentry/core'; import { defineIntegration } from '@sentry/core'; import { CHANNELS } from '../orchestrion/channels'; import { cronModuleNames } from '../orchestrion/config/cron'; import { invokeOrchestrionInstrumentation } from '../orchestrion/instrumentation'; -import type { AnyFn } from '../utils/cronMonitoring'; +import type { AnyFn, ScheduleChange } from '../utils/cronMonitoring'; import { cronExpressionToMonitorConfig, shouldMonitorJob, subscribeToJobCreation, + subscribeToScheduleChange, + updateMonitorConfig, wrapScheduledJob, } from '../utils/cronMonitoring'; @@ -37,6 +39,22 @@ export const cronJobIntegration = defineIntegration(_cronJobIntegration); function subscribe(options: CronJobIntegrationOptions): void { subscribeToJobCreation(CHANNELS.CRON_JOB_CONSTRUCTOR, INTEGRATION_NAME, args => wrapJob(args, options)); + subscribeToScheduleChange(CHANNELS.CRON_JOB_SET_TIME, INTEGRATION_NAME, onSetTime); +} + +/** Sends the new time's config after `job.setTime(cronTime)`. */ +export function onSetTime({ arguments: [cronTime], self }: ScheduleChange): void { + const job = self as { cronTime?: unknown; _callbacks?: unknown[] } | undefined; + // `setTime` throws and keeps the old time when it isn't given a `CronTime`. + if (!job || job.cronTime !== cronTime) { + return; + } + const { source, timeZone, utcOffset } = cronTime as { source?: unknown; timeZone?: unknown; utcOffset?: unknown }; + const monitorConfig: MonitorConfig | undefined = + typeof source === 'string' && utcOffset == null ? cronExpressionToMonitorConfig(source, timeZone, 'or') : undefined; + for (const callback of job._callbacks || []) { + updateMonitorConfig(callback, monitorConfig); + } } // `new CronJob(cronTime, onTick, onComplete, start, timeZone, context, runOnInit, utcOffset, ..., name)` diff --git a/packages/server-utils/src/orchestrion/config/cron.ts b/packages/server-utils/src/orchestrion/config/cron.ts index d15c1d90821a..499d224aad9e 100644 --- a/packages/server-utils/src/orchestrion/config/cron.ts +++ b/packages/server-utils/src/orchestrion/config/cron.ts @@ -8,10 +8,16 @@ export const cronConfig = [ module: { name: 'cron', versionRange: '>=4.3.0 <5', filePath: 'dist/job.js' }, functionQuery: { className: 'CronJob' }, }, + { + channelName: 'cronJobSetTime', + module: { name: 'cron', versionRange: '>=4.3.0 <5', filePath: 'dist/job.js' }, + functionQuery: { className: 'CronJob', methodName: 'setTime', kind: 'Sync' }, + }, ] satisfies InstrumentationConfig[]; export const cronModuleNames = getModuleNames(cronConfig); export const cronChannels = { CRON_JOB_CONSTRUCTOR: 'orchestrion:cron:cronJobConstructor', + CRON_JOB_SET_TIME: 'orchestrion:cron:cronJobSetTime', } as const; diff --git a/packages/server-utils/test/integrations/cron.test.ts b/packages/server-utils/test/integrations/cron.test.ts new file mode 100644 index 000000000000..92ec1d110762 --- /dev/null +++ b/packages/server-utils/test/integrations/cron.test.ts @@ -0,0 +1,70 @@ +import type { MonitorConfig } from '@sentry/core'; +import { setCurrentClient } from '@sentry/core'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { onSetTime } from '../../src/integrations/cron'; +import { cronExpressionToMonitorConfig, wrapScheduledJob } from '../../src/utils/cronMonitoring'; +import { getDefaultTestClientOptions, TestClient } from '../mocks/client'; + +describe('onSetTime', () => { + let sentConfigs: Array; + + beforeEach(() => { + sentConfigs = []; + const client = new TestClient(getDefaultTestClientOptions()); + client.captureCheckIn = (_checkIn, monitorConfig) => { + sentConfigs.push(monitorConfig); + return 'check-in-id'; + }; + setCurrentClient(client); + }); + + const createJob = () => { + const onTick = wrapScheduledJob( + () => {}, + 'my-job', + cronExpressionToMonitorConfig('0 9 * * *', 'UTC', 'or'), + 'auto.function.cron', + ); + return { cronTime: {} as unknown, _callbacks: [onTick], onTick }; + }; + + // `setTime` assigns the new `CronTime` before the channel's `end` fires. + const setTime = (job: ReturnType, cronTime: unknown, applied = true): void => { + if (applied) { + job.cronTime = cronTime; + } + onSetTime({ arguments: [cronTime], self: job }); + }; + + it('sends the new schedule after setTime', () => { + const job = createJob(); + setTime(job, { source: '30 10 * * 1-5', timeZone: 'Europe/Vienna' }); + job.onTick(); + + expect(sentConfigs[0]).toEqual({ + schedule: { type: 'crontab', value: '30 10 * * 1-5' }, + timezone: 'Europe/Vienna', + }); + }); + + it.each([ + ['a date', { source: new Date(), timeZone: 'UTC' }], + ['a UTC offset', { source: '0 9 * * *', utcOffset: 60 }], + ['a sub-minute cron', { source: '*/10 * * * * *', timeZone: 'UTC' }], + ])('sends no config after setTime with %s', (_, cronTime) => { + const job = createJob(); + setTime(job, cronTime); + job.onTick(); + + expect(sentConfigs).toHaveLength(2); + expect(sentConfigs[0]).toBeUndefined(); + }); + + it('keeps the old schedule when setTime throws', () => { + const job = createJob(); + setTime(job, '30 10 * * *', false); + job.onTick(); + + expect(sentConfigs[0]?.schedule).toEqual({ type: 'crontab', value: '0 9 * * *' }); + }); +}); From 7826628d444dc922651a7651d9b2baa2f84e9eb8 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Wed, 28 Oct 2026 16:52:12 -0700 Subject: [PATCH 27/33] fix(node): Start a new trace per cron job run and send Sunday range ends as 7 Each run of a monitored job gets its own trace, so runs no longer share one. A weekday range ending on Sunday (`sat-sun`, `5-0`) is sent as `6-7`, which Sentry accepts. Other reversed ranges get no monitor config, and node-cron 3 gets none for ranges ending on Sunday since it reads them differently. --- .../src/integrations/node-cron.ts | 4 +- .../server-utils/src/utils/cronMonitoring.ts | 56 ++++++++++++++----- .../test/integrations/node-cron.test.ts | 5 +- .../test/utils/cronMonitoring.test.ts | 32 ++++++++++- 4 files changed, 79 insertions(+), 18 deletions(-) diff --git a/packages/server-utils/src/integrations/node-cron.ts b/packages/server-utils/src/integrations/node-cron.ts index a735d143b3d9..71522d86a876 100644 --- a/packages/server-utils/src/integrations/node-cron.ts +++ b/packages/server-utils/src/integrations/node-cron.ts @@ -59,13 +59,13 @@ const NODE_CRON_3_FIELD_MINIMUMS = [0, 0, 1, 1, 0]; /** * node-cron 3 keeps the values of a stepped field that are multiples of the step (`*\/2` days are 2, 4, ...) - * and reads `7` in a weekday range as `0`. Returns `false` where that differs from standard cron. + * and gets weekday ranges with `7` or ending on Sunday (`sat-sun`) wrong. Returns `false` where that differs from standard cron. */ export function isReadAlikeByNodeCron3(expression: string): boolean { const fields = expression.trim().split(/\s+/); const minimums = fields.length === 6 ? [0, ...NODE_CRON_3_FIELD_MINIMUMS] : NODE_CRON_3_FIELD_MINIMUMS; const weekday = fields[fields.length - 1] || ''; - if (weekday.includes('-') && weekday.includes('7')) { + if (weekday.includes('-') && (weekday.includes('7') || /-(?:0|sun)/i.test(weekday))) { return false; } return fields.every((field, index) => { diff --git a/packages/server-utils/src/utils/cronMonitoring.ts b/packages/server-utils/src/utils/cronMonitoring.ts index 09c532449814..a6a513ee2d68 100644 --- a/packages/server-utils/src/utils/cronMonitoring.ts +++ b/packages/server-utils/src/utils/cronMonitoring.ts @@ -1,5 +1,12 @@ import type { MonitorConfig } from '@sentry/core'; -import { captureException, debug, isThenable, stringMatchesSomePattern, withMonitor } from '@sentry/core'; +import { + captureException, + debug, + isThenable, + startNewTrace, + stringMatchesSomePattern, + withMonitor, +} from '@sentry/core'; import { DEBUG_BUILD } from '../debug-build'; import * as diagnosticsChannel from './diagnosticsChannel'; @@ -86,8 +93,14 @@ export function cronExpressionToMonitorConfig( return undefined; } - const crontab = [minute, hour, dayOfMonth, replaceNames(month, MONTHS, 1), replaceNames(dayOfWeek, WEEKDAYS, 0)]; - if (crontab.some(field => /[a-z]/i.test(field))) { + const crontab = [ + minute, + hour, + dayOfMonth, + replaceNames(month, MONTHS, 1), + endRangesOnSundayAsSeven(replaceNames(dayOfWeek, WEEKDAYS, 0)), + ]; + if (crontab.some(field => /[a-z]/i.test(field) || hasReversedRange(field))) { return undefined; } @@ -122,6 +135,19 @@ function replaceNames(field: string, names: string[], offset: number): string { }); } +// `sat-sun` is `6-0`, which node-cron runs on Saturday and Sunday. Sentry only accepts it as `6-7`. +function endRangesOnSundayAsSeven(field: string): string { + return field.replace(/(\d+)-0(?!\d)/g, (range, start: string) => (Number(start) ? `${start}-7` : range)); +} + +// node-cron wraps these around (`22-2` hours), the other schedulers reject them, and so does Sentry. +function hasReversedRange(field: string): boolean { + return field.split(',').some(part => { + const range = /^(\d+)-(\d+)/.exec(part); + return !!range && Number(range[1]) > Number(range[2]); + }); +} + function toSentryTimezone(timezone: string | undefined): string | undefined { if (timezone && /^(?:utc|gmt)?[+-]/i.test(timezone)) { return undefined; @@ -149,17 +175,19 @@ export function wrapScheduledJob( }; return function (this: unknown, ...args: unknown[]): unknown { - return withMonitor( - monitorSlug, - () => { - try { - const result: unknown = func.apply(this, args); - return isThenable(result) ? result.then(undefined, captureAndRethrow) : result; - } catch (e) { - return captureAndRethrow(e); - } - }, - monitorConfig, + return startNewTrace(() => + withMonitor( + monitorSlug, + () => { + try { + const result: unknown = func.apply(this, args); + return isThenable(result) ? result.then(undefined, captureAndRethrow) : result; + } catch (e) { + return captureAndRethrow(e); + } + }, + monitorConfig, + ), ); }; } diff --git a/packages/server-utils/test/integrations/node-cron.test.ts b/packages/server-utils/test/integrations/node-cron.test.ts index 3daae36b71f1..ed98edcc9573 100644 --- a/packages/server-utils/test/integrations/node-cron.test.ts +++ b/packages/server-utils/test/integrations/node-cron.test.ts @@ -9,13 +9,16 @@ describe('isReadAlikeByNodeCron3', () => { }, ); - // node-cron 3 runs these on 2,4,...; 10,12,...; 3,6,...; and Sunday-Monday. + // node-cron 3 runs these on 2,4,...; 10,12,...; 3,6,...; Sunday-Monday; and every day but Saturday. it.each([ '0 9 */2 * *', '0 9-17/2 * * *', '0 9 * */3 *', '0 9 * * 1-7', '0 9 * * 5-7', + '0 9 * * sat-sun', + '0 9 * * 6-0', + '0 9 * * fri-Sun', '5/15 * * * *', '0,30/15 * * * *', ])('is false for %s', expression => { diff --git a/packages/server-utils/test/utils/cronMonitoring.test.ts b/packages/server-utils/test/utils/cronMonitoring.test.ts index 8d0c5d863416..06c2c37527ba 100644 --- a/packages/server-utils/test/utils/cronMonitoring.test.ts +++ b/packages/server-utils/test/utils/cronMonitoring.test.ts @@ -1,5 +1,6 @@ +import { getCurrentScope } from '@sentry/core'; import { describe, expect, it } from 'vitest'; -import { cronExpressionToMonitorConfig, shouldMonitorJob } from '../../src/utils/cronMonitoring'; +import { cronExpressionToMonitorConfig, shouldMonitorJob, wrapScheduledJob } from '../../src/utils/cronMonitoring'; const toMonitorConfig = (expression: string, timezone: unknown) => cronExpressionToMonitorConfig(expression, timezone, 'and'); @@ -14,6 +15,14 @@ describe('cronExpressionToMonitorConfig with node-cron day matching', () => { ['@daily', '0 0 * * *'], ['@Weekly', '0 0 * * 0'], ['*/15 * * * *', '*/15 * * * *'], + ['0 9 * * sun', '0 9 * * 0'], + ['0 9 * * sat-sun', '0 9 * * 6-7'], + ['0 9 * * fri-sun', '0 9 * * 5-7'], + ['0 9 * * sun-tue', '0 9 * * 0-2'], + ['0 9 * * 6-7', '0 9 * * 6-7'], + ['0 9 * * 5-0', '0 9 * * 5-7'], + ['0 9 * * 5-0/2', '0 9 * * 5-7/2'], + ['0 9 * * 1-3,sat-sun', '0 9 * * 1-3,6-7'], ])('sends %s as %s', (expression, crontab) => { expect(toMonitorConfig(expression, 'Europe/Vienna')).toEqual({ schedule: { type: 'crontab', value: crontab }, @@ -31,6 +40,10 @@ describe('cronExpressionToMonitorConfig with node-cron day matching', () => { '0 9 * * 1#2', '* * * *', '0 9 * foo *', + '0 9 * * tue-mon', + '0 9 * * 7-2', + '0 22-2 * * *', + '0 9 * nov-feb *', ])('sends no config for %s', expression => { expect(toMonitorConfig(expression, 'UTC')).toBeUndefined(); }); @@ -93,3 +106,20 @@ describe('shouldMonitorJob', () => { expect(shouldMonitorJob('job', monitored, undefined)).toBe(false); }); }); + +describe('wrapScheduledJob', () => { + it('runs each job in its own trace', () => { + const job = wrapScheduledJob( + () => getCurrentScope().getPropagationContext().traceId, + 'job', + undefined, + 'auto.function.test', + ); + const outerTraceId = getCurrentScope().getPropagationContext().traceId; + const firstTraceId = job(); + const secondTraceId = job(); + + expect(firstTraceId).not.toBe(secondTraceId); + expect(firstTraceId).not.toBe(outerTraceId); + }); +}); From c87c128256fe645e3de9fdbfc832a6394619cda4 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Wed, 28 Oct 2026 17:15:10 -0700 Subject: [PATCH 28/33] fix(node): Send no monitor config for weekday ranges ending on Sunday before node-cron 4.6 node-cron 4.0-4.5 swaps reversed ranges, so `sat-sun` runs every day there. Only 4.6+ wraps it around to the weekend. --- .../src/integrations/node-cron.ts | 65 +++++++++++++------ .../src/orchestrion/config/node-cron.ts | 20 ++++-- .../server-utils/src/utils/cronMonitoring.ts | 5 +- .../test/integrations/node-cron.test.ts | 41 +++++++++++- 4 files changed, 104 insertions(+), 27 deletions(-) diff --git a/packages/server-utils/src/integrations/node-cron.ts b/packages/server-utils/src/integrations/node-cron.ts index 71522d86a876..18b6a73e9d76 100644 --- a/packages/server-utils/src/integrations/node-cron.ts +++ b/packages/server-utils/src/integrations/node-cron.ts @@ -1,4 +1,4 @@ -import type { IntegrationFn } from '@sentry/core'; +import type { IntegrationFn, MonitorConfig } from '@sentry/core'; import { defineIntegration } from '@sentry/core'; import { CHANNELS } from '../orchestrion/channels'; import { nodeCronModuleNames } from '../orchestrion/config/node-cron'; @@ -37,42 +37,67 @@ const _nodeCronIntegration = ((options: NodeCronIntegrationOptions = {}) => { export const nodeCronIntegration = defineIntegration(_nodeCronIntegration); function subscribe(options: NodeCronIntegrationOptions): void { - subscribeToJobCreation(CHANNELS.NODE_CRON_CREATE_TASK, INTEGRATION_NAME, args => wrapTask(args, false, options)); - subscribeToJobCreation(CHANNELS.NODE_CRON_V3_CREATE_TASK, INTEGRATION_NAME, args => wrapTask(args, true, options)); + subscribeToJobCreation(CHANNELS.NODE_CRON_CREATE_TASK, INTEGRATION_NAME, args => wrapTask(args, options)); + subscribeToJobCreation(CHANNELS.NODE_CRON_V4_CREATE_TASK, INTEGRATION_NAME, args => + wrapTask(args, options, isReadAlikeByNodeCron4), + ); + subscribeToJobCreation(CHANNELS.NODE_CRON_V3_CREATE_TASK, INTEGRATION_NAME, args => + wrapTask(args, options, isReadAlikeByNodeCron3), + ); } // `createTask(expression, func, options)` -function wrapTask(args: unknown[], isNodeCron3: boolean, options: NodeCronIntegrationOptions): void { +function wrapTask( + args: unknown[], + options: NodeCronIntegrationOptions, + isReadAlike?: (expression: string) => boolean, +): void { const [expression, func, taskOptions] = args; const { name, timezone } = (taskOptions || {}) as { name?: unknown; timezone?: unknown }; if (!shouldMonitorJob(name, func, options.excludeJobs)) { return; } - const monitorConfig = - isNodeCron3 && !isReadAlikeByNodeCron3(String(expression)) - ? undefined - : cronExpressionToMonitorConfig(String(expression), timezone, 'and'); + const monitorConfig = getMonitorConfig(String(expression), timezone, isReadAlike); args[1] = wrapScheduledJob(func as AnyFn, name, monitorConfig, 'auto.function.node-cron'); } +/** The monitor config for a task, or `undefined` if `isReadAlike` says this node-cron version reads it differently. */ +export function getMonitorConfig( + expression: string, + timezone: unknown, + isReadAlike?: (expression: string) => boolean, +): MonitorConfig | undefined { + return isReadAlike && !isReadAlike(expression) + ? undefined + : cronExpressionToMonitorConfig(expression, timezone, 'and'); +} + const NODE_CRON_3_FIELD_MINIMUMS = [0, 0, 1, 1, 0]; +/** + * node-cron before 4.6 reads `7` as `0` and swaps reversed ranges, so it runs `sat-sun`, `6-0` and `6-7` every day. + * Returns `false` for weekday ranges that could hit that. + */ +export function isReadAlikeByNodeCron4(expression: string): boolean { + const weekday = expression.trim().split(/\s+/).pop() || ''; + return !(weekday.includes('-') && (weekday.includes('7') || /-(?:0|sun)/i.test(weekday))); +} + /** * node-cron 3 keeps the values of a stepped field that are multiples of the step (`*\/2` days are 2, 4, ...) - * and gets weekday ranges with `7` or ending on Sunday (`sat-sun`) wrong. Returns `false` where that differs from standard cron. + * and reads weekday ranges like node-cron 4 before 4.6. Returns `false` where that differs from standard cron. */ export function isReadAlikeByNodeCron3(expression: string): boolean { const fields = expression.trim().split(/\s+/); const minimums = fields.length === 6 ? [0, ...NODE_CRON_3_FIELD_MINIMUMS] : NODE_CRON_3_FIELD_MINIMUMS; - const weekday = fields[fields.length - 1] || ''; - if (weekday.includes('-') && (weekday.includes('7') || /-(?:0|sun)/i.test(weekday))) { - return false; - } - return fields.every((field, index) => { - if (!field.includes('/')) { - return true; - } - const match = /^(?:\*|(\d+)-\d+)\/(\d+)$/.exec(field); - return !!match && Number(match[1] ?? minimums[index]) % Number(match[2]) === 0; - }); + return ( + isReadAlikeByNodeCron4(expression) && + fields.every((field, index) => { + if (!field.includes('/')) { + return true; + } + const match = /^(?:\*|(\d+)-\d+)\/(\d+)$/.exec(field); + return !!match && Number(match[1] ?? minimums[index]) % Number(match[2]) === 0; + }) + ); } diff --git a/packages/server-utils/src/orchestrion/config/node-cron.ts b/packages/server-utils/src/orchestrion/config/node-cron.ts index 0f841ce297cc..f0688dcc483d 100644 --- a/packages/server-utils/src/orchestrion/config/node-cron.ts +++ b/packages/server-utils/src/orchestrion/config/node-cron.ts @@ -4,24 +4,35 @@ import { getModuleNames } from './module-names'; const createTask = { functionName: 'createTask', kind: 'Sync' } as const; // `schedule()` calls `createTask(expression, func, options)`, so wrapping `createTask` covers both. +// Before 4.6, node-cron reads weekday ranges like `sat-sun` differently, so those versions get their own channel. export const nodeCronConfig = [ { channelName: 'createTask', - module: { name: 'node-cron', versionRange: '>=4.0.0 <5', filePath: 'dist/node-cron.cjs' }, + module: { name: 'node-cron', versionRange: '>=4.6.0 <5', filePath: 'dist/node-cron.cjs' }, functionQuery: createTask, }, { channelName: 'createTask', - module: { name: 'node-cron', versionRange: '>=4.0.0 <5', filePath: 'dist/node-cron.js' }, + module: { name: 'node-cron', versionRange: '>=4.6.0 <5', filePath: 'dist/node-cron.js' }, functionQuery: createTask, }, { - channelName: 'createTask', + channelName: 'v4_createTask', + module: { name: 'node-cron', versionRange: '>=4.3.0 <4.6.0', filePath: 'dist/node-cron.cjs' }, + functionQuery: createTask, + }, + { + channelName: 'v4_createTask', + module: { name: 'node-cron', versionRange: '>=4.0.0 <4.6.0', filePath: 'dist/node-cron.js' }, + functionQuery: createTask, + }, + { + channelName: 'v4_createTask', module: { name: 'node-cron', versionRange: '>=4.0.0 <4.3.0', filePath: 'dist/cjs/node-cron.js' }, functionQuery: createTask, }, { - channelName: 'createTask', + channelName: 'v4_createTask', module: { name: 'node-cron', versionRange: '>=4.0.0 <4.3.0', filePath: 'dist/esm/node-cron.js' }, functionQuery: createTask, }, @@ -36,5 +47,6 @@ export const nodeCronModuleNames = getModuleNames(nodeCronConfig); export const nodeCronChannels = { NODE_CRON_CREATE_TASK: 'orchestrion:node-cron:createTask', + NODE_CRON_V4_CREATE_TASK: 'orchestrion:node-cron:v4_createTask', NODE_CRON_V3_CREATE_TASK: 'orchestrion:node-cron:v3_createTask', } as const; diff --git a/packages/server-utils/src/utils/cronMonitoring.ts b/packages/server-utils/src/utils/cronMonitoring.ts index a6a513ee2d68..03303db160b1 100644 --- a/packages/server-utils/src/utils/cronMonitoring.ts +++ b/packages/server-utils/src/utils/cronMonitoring.ts @@ -135,12 +135,13 @@ function replaceNames(field: string, names: string[], offset: number): string { }); } -// `sat-sun` is `6-0`, which node-cron runs on Saturday and Sunday. Sentry only accepts it as `6-7`. +// `sat-sun` is `6-0`, which node-cron 4.6+ runs on Saturday and Sunday. Sentry only accepts it as `6-7`. function endRangesOnSundayAsSeven(field: string): string { return field.replace(/(\d+)-0(?!\d)/g, (range, start: string) => (Number(start) ? `${start}-7` : range)); } -// node-cron wraps these around (`22-2` hours), the other schedulers reject them, and so does Sentry. +// node-cron 4.6+ wraps these around (`22-2` hours), earlier versions swap them, the other schedulers reject them, +// and so does Sentry. function hasReversedRange(field: string): boolean { return field.split(',').some(part => { const range = /^(\d+)-(\d+)/.exec(part); diff --git a/packages/server-utils/test/integrations/node-cron.test.ts b/packages/server-utils/test/integrations/node-cron.test.ts index ed98edcc9573..c3c78dc701f9 100644 --- a/packages/server-utils/test/integrations/node-cron.test.ts +++ b/packages/server-utils/test/integrations/node-cron.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { isReadAlikeByNodeCron3 } from '../../src/integrations/node-cron'; +import { getMonitorConfig, isReadAlikeByNodeCron3, isReadAlikeByNodeCron4 } from '../../src/integrations/node-cron'; describe('isReadAlikeByNodeCron3', () => { it.each(['* * * * *', '*/15 * * * *', '0 */2 * * *', '0 8-18/2 * * *', '0 0 9 * * 1-5', '0 9 * * 7', '0 9 * * 0,7'])( @@ -25,3 +25,42 @@ describe('isReadAlikeByNodeCron3', () => { expect(isReadAlikeByNodeCron3(expression)).toBe(false); }); }); + +describe('isReadAlikeByNodeCron4', () => { + it.each(['* * * * *', '0 9 * * 1-5', '0 9 * * sun-tue', '0 9 * * 0,7', '0 9 */2 * *', '0 22-2 * * *'])( + 'is true for %s', + expression => { + expect(isReadAlikeByNodeCron4(expression)).toBe(true); + }, + ); + + it.each(['0 9 * * sat-sun', '0 9 * * 6-0', '0 9 * * 6-7', '0 9 * * 5-0/2', '0 9 * * 1-3,sat-sun'])( + 'is false for %s', + expression => { + expect(isReadAlikeByNodeCron4(expression)).toBe(false); + }, + ); +}); + +describe('getMonitorConfig', () => { + // node-cron 4.6 runs these on the weekend; earlier versions run them every day. + it.each(['0 9 * * sat-sun', '0 9 * * 6-0'])('sends %s as 6-7 for node-cron 4.6', expression => { + expect(getMonitorConfig(expression, 'UTC')?.schedule).toEqual({ type: 'crontab', value: '0 9 * * 6-7' }); + }); + + it.each(['0 9 * * sat-sun', '0 9 * * 6-0'])('sends no config for %s before node-cron 4.6', expression => { + expect(getMonitorConfig(expression, 'UTC', isReadAlikeByNodeCron4)).toBeUndefined(); + }); + + it('sends no config for a reversed hour range', () => { + expect(getMonitorConfig('0 22-2 * * *', 'UTC')).toBeUndefined(); + expect(getMonitorConfig('0 22-2 * * *', 'UTC', isReadAlikeByNodeCron4)).toBeUndefined(); + }); + + it('sends the config for other ranges before node-cron 4.6', () => { + expect(getMonitorConfig('0 9 * * mon-fri', 'UTC', isReadAlikeByNodeCron4)?.schedule).toEqual({ + type: 'crontab', + value: '0 9 * * 1-5', + }); + }); +}); From 754fd55f775849173130fa066256f8df220a7582 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Wed, 28 Oct 2026 17:27:01 -0700 Subject: [PATCH 29/33] feat(node): Monitor node-schedule jobs started with new Job().schedule() Hook Job#schedule instead of scheduleJob and reschedule, since both call it. Also test that instrumentNodeSchedule marks its callback as monitored. --- .../node-schedule-integration/scenario.mjs | 3 + .../cron/node-schedule-integration/test.ts | 8 ++ packages/node/test/cron.test.ts | 9 +++ .../src/integrations/node-schedule.ts | 51 ++++++++---- .../src/orchestrion/config/node-schedule.ts | 25 +++--- .../server-utils/src/utils/cronMonitoring.ts | 20 +++-- .../test/integrations/node-schedule.test.ts | 78 ++++++++++++++++--- 7 files changed, 146 insertions(+), 48 deletions(-) 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 index a8cdd91ed74a..7cb179a7483a 100644 --- 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 @@ -5,6 +5,8 @@ const unnamed = schedule.scheduleJob('0 0 * * *', () => {}); const oneOff = schedule.scheduleJob('one-off-job', Date.now() + 86_400_000, () => {}); 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 }, () => {}); +const newJob = new schedule.Job('new-job', () => {}); +newJob.schedule({ rule: '0 0 * * *', tz: 'UTC' }); let runs = 0; const job = schedule.scheduleJob('my-schedule-job', { rule: '30 9 * * 1-5', tz: 'Europe/Vienna' }, () => { @@ -20,6 +22,7 @@ unnamed.invoke(); oneOff.invoke(); recurrenceRule.invoke(); ending.invoke(); +newJob.invoke(); job.invoke(); try { job.invoke(); 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 dbe3c9ac5fd7..de5b683e9b35 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 @@ -25,6 +25,14 @@ describe('nodeScheduleIntegration', () => { .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: 'new-job', + status: 'in_progress', + monitor_config: { schedule: { type: 'crontab', value: '0 0 * * *' }, timezone: 'UTC' }, + }, + }) + .expect({ check_in: { monitor_slug: 'new-job', status: 'ok' } }) .expect({ check_in: { monitor_slug: 'my-schedule-job', status: 'in_progress', monitor_config: MONITOR_CONFIG }, }) diff --git a/packages/node/test/cron.test.ts b/packages/node/test/cron.test.ts index bf1e83d315e5..f5178dc98e88 100644 --- a/packages/node/test/cron.test.ts +++ b/packages/node/test/cron.test.ts @@ -220,5 +220,14 @@ describe('cron check-ins', () => { "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('marks the callback as monitored, so nodeScheduleIntegration skips it', () => { + const scheduleWithCheckIn = cron.instrumentNodeSchedule({ + scheduleJob: (_name: unknown, _expression: unknown, callback: unknown) => callback, + }); + + const callback = scheduleWithCheckIn.scheduleJob('my-cron-job', '* * * * *', () => {}) as Record; + expect(callback[Symbol.for('sentry.cron.monitored')]).toBe(true); + }); }); }); diff --git a/packages/server-utils/src/integrations/node-schedule.ts b/packages/server-utils/src/integrations/node-schedule.ts index 1c532c2fb0ba..1d86113a0706 100644 --- a/packages/server-utils/src/integrations/node-schedule.ts +++ b/packages/server-utils/src/integrations/node-schedule.ts @@ -7,7 +7,6 @@ import type { AnyFn, ScheduleChange } from '../utils/cronMonitoring'; import { cronExpressionToMonitorConfig, shouldMonitorJob, - subscribeToJobCreation, subscribeToScheduleChange, updateMonitorConfig, wrapScheduledJob, @@ -32,32 +31,52 @@ const _nodeScheduleIntegration = ((options: NodeScheduleIntegrationOptions = {}) }) satisfies IntegrationFn; /** - * Sends Crons check-ins for every named `node-schedule` job created with `scheduleJob`. + * Sends Crons check-ins for every named `node-schedule` job, whether it's created with `scheduleJob` or `new Job()`. * 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(options: NodeScheduleIntegrationOptions): void { - subscribeToJobCreation(CHANNELS.NODE_SCHEDULE_SCHEDULE_JOB, INTEGRATION_NAME, args => wrapJob(args, options)); - subscribeToScheduleChange(CHANNELS.NODE_SCHEDULE_RESCHEDULE, INTEGRATION_NAME, onReschedule); + subscribeToScheduleChange(CHANNELS.NODE_SCHEDULE_SCHEDULE, INTEGRATION_NAME, onSchedule, change => + beforeSchedule(change, options), + ); } -/** Sends the new spec's config after `job.reschedule(spec)`, which `rescheduleJob` calls. */ -export function onReschedule({ arguments: [spec], self, result }: ScheduleChange): void { - // node-schedule keeps the old schedule when this returns false. - if (result === true) { - updateMonitorConfig((self as { job?: unknown } | undefined)?.job, getMonitorConfig(spec)); - } +interface Job { + name?: unknown; + job?: unknown; + nextInvocation?: () => unknown; } -// `scheduleJob([name], spec, method, [callback])`, like node-schedule reads it. -function wrapJob(args: unknown[], options: NodeScheduleIntegrationOptions): 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 (isOneOffSpec(spec) || !shouldMonitorJob(name, method, options.excludeJobs)) { +// The name node-schedule gives jobs created without one. +const ANONYMOUS_NAME = /^]/; + +// `job.schedule(spec)` calls that add to runs the job already has pending, rather than replace them. +const addsToPendingRuns = new WeakSet(); + +/** Wraps a named job's method before `job.schedule(spec)` with a recurring spec, unless it's already wrapped. */ +export function beforeSchedule(change: ScheduleChange, options: NodeScheduleIntegrationOptions): void { + const job = change.self as Job | undefined; + if (!job) { + return; + } + if (typeof job.nextInvocation === 'function' && job.nextInvocation() != null) { + addsToPendingRuns.add(change); + } + const name = typeof job.name === 'string' && !ANONYMOUS_NAME.test(job.name) ? job.name : undefined; + if (isOneOffSpec(change.arguments[0]) || !shouldMonitorJob(name, job.job, options.excludeJobs)) { return; } - args[2] = wrapScheduledJob(method as AnyFn, name, getMonitorConfig(spec), 'auto.function.node-schedule'); + job.job = wrapScheduledJob(job.job as AnyFn, name, undefined, 'auto.function.node-schedule'); +} + +/** Sends the spec's config after `job.schedule(spec)`, or none when the job also keeps runs from an earlier spec. */ +export function onSchedule(change: ScheduleChange): void { + // `schedule` returns false when it scheduled nothing, and `reschedule` then keeps the old schedule. + if (change.result === true) { + const job = change.self as Job | undefined; + updateMonitorConfig(job?.job, addsToPendingRuns.has(change) ? undefined : getMonitorConfig(change.arguments[0])); + } } /** Whether node-schedule runs a job with this spec once: a `Date`, a timestamp, or a date string. */ diff --git a/packages/server-utils/src/orchestrion/config/node-schedule.ts b/packages/server-utils/src/orchestrion/config/node-schedule.ts index 8ed2398df597..acb3733a1b69 100644 --- a/packages/server-utils/src/orchestrion/config/node-schedule.ts +++ b/packages/server-utils/src/orchestrion/config/node-schedule.ts @@ -1,25 +1,23 @@ import type { InstrumentationConfig } from '../apmTypes'; import { getModuleNames } from './module-names'; -// `Job` sets `reschedule` on each instance in its constructor. It moved from `lib/schedule.js` to `lib/Job.js` in 2.0.0. -const reschedule = { - channelName: 'reschedule', - astQuery: "AssignmentExpression[left.property.name='reschedule'] > FunctionExpression", - functionQuery: { kind: 'Sync' }, +// `scheduleJob`, `rescheduleJob` and `job.reschedule` all call `job.schedule(spec)`. `Job` moved from +// `lib/schedule.js` to `lib/Job.js` in 2.0.0. +const schedule = { + channelName: 'schedule', + astQuery: + "AssignmentExpression[left.object.property.name='prototype'][left.property.name='schedule'] > FunctionExpression", + // `methodName` gives subscribers `self` before the call, not just after. `astQuery` still picks the function. + functionQuery: { methodName: 'schedule', kind: 'Sync' }, } as const; export const nodeScheduleConfig = [ { - channelName: 'scheduleJob', - module: { name: 'node-schedule', versionRange: '>=1.3.0 <3', filePath: 'lib/schedule.js' }, - functionQuery: { functionName: 'scheduleJob', kind: 'Sync' }, - }, - { - ...reschedule, + ...schedule, module: { name: 'node-schedule', versionRange: '>=1.3.0 <2', filePath: 'lib/schedule.js' }, }, { - ...reschedule, + ...schedule, module: { name: 'node-schedule', versionRange: '>=2.0.0 <3', filePath: 'lib/Job.js' }, }, ] satisfies InstrumentationConfig[]; @@ -27,6 +25,5 @@ export const nodeScheduleConfig = [ export const nodeScheduleModuleNames = getModuleNames(nodeScheduleConfig); export const nodeScheduleChannels = { - NODE_SCHEDULE_SCHEDULE_JOB: 'orchestrion:node-schedule:scheduleJob', - NODE_SCHEDULE_RESCHEDULE: 'orchestrion:node-schedule:reschedule', + NODE_SCHEDULE_SCHEDULE: 'orchestrion:node-schedule:schedule', } as const; diff --git a/packages/server-utils/src/utils/cronMonitoring.ts b/packages/server-utils/src/utils/cronMonitoring.ts index fc337255ed77..aabbbf0d9d79 100644 --- a/packages/server-utils/src/utils/cronMonitoring.ts +++ b/packages/server-utils/src/utils/cronMonitoring.ts @@ -1,5 +1,6 @@ import type { MonitorConfig } from '@sentry/core'; import { + addNonEnumerableProperty, captureException, debug, isThenable, @@ -12,7 +13,7 @@ import * as diagnosticsChannel from './diagnosticsChannel'; export type AnyFn = (this: unknown, ...args: unknown[]) => unknown; -// Set by `Sentry.cron.instrumentNodeCron` and `Sentry.cron.instrumentNodeSchedule`, which already send check-ins. +// Set by `Sentry.cron.instrumentNodeCron`, `Sentry.cron.instrumentNodeSchedule` and `wrapScheduledJob`, which already send check-ins. const MONITORED = Symbol.for('sentry.cron.monitored'); /** @@ -42,23 +43,29 @@ export interface ScheduleChange { /** * Calls `onChange` after each call on a scheduler's schedule-change channel, so it can update the job's monitor config. - * Errors are logged and the job keeps its config. + * `onStart` gets the same object before the call. Errors are logged and the job keeps its config. */ export function subscribeToScheduleChange( channelName: string, integrationName: string, onChange: (change: ScheduleChange) => void, + onStart?: (change: ScheduleChange) => void, ): void { - diagnosticsChannel.tracingChannel(channelName).end.subscribe(message => { + const channel = diagnosticsChannel.tracingChannel(channelName); + const handle = (handler: (change: ScheduleChange) => void) => (message: unknown) => { try { - onChange(message as ScheduleChange); + handler(message as ScheduleChange); } catch (e) { DEBUG_BUILD && debug.error(`[${integrationName}] Failed to update the cron monitor:`, e); } - }); + }; + if (onStart) { + channel.start.subscribe(handle(onStart)); + } + channel.end.subscribe(handle(onChange)); } -/** Whether a job is named, not excluded, and not already monitored by `Sentry.cron.instrumentNodeCron` or `instrumentNodeSchedule`. */ +/** Whether a job is named, not excluded, and not already monitored by `Sentry.cron.instrumentNode*` or `wrapScheduledJob`. */ export function shouldMonitorJob( name: unknown, job: unknown, @@ -219,6 +226,7 @@ export function wrapScheduledJob( ); }; monitorConfigs.set(wrapped, monitorConfig); + addNonEnumerableProperty(wrapped, MONITORED, true); return wrapped; } diff --git a/packages/server-utils/test/integrations/node-schedule.test.ts b/packages/server-utils/test/integrations/node-schedule.test.ts index c5a6d5339594..52e1fb586b9c 100644 --- a/packages/server-utils/test/integrations/node-schedule.test.ts +++ b/packages/server-utils/test/integrations/node-schedule.test.ts @@ -1,8 +1,7 @@ import type { MonitorConfig } from '@sentry/core'; import { setCurrentClient } from '@sentry/core'; import { beforeEach, describe, expect, it } from 'vitest'; -import { getMonitorConfig, isOneOffSpec, onReschedule } from '../../src/integrations/node-schedule'; -import { wrapScheduledJob } from '../../src/utils/cronMonitoring'; +import { beforeSchedule, getMonitorConfig, isOneOffSpec, onSchedule } from '../../src/integrations/node-schedule'; import { getDefaultTestClientOptions, TestClient } from '../mocks/client'; describe('isOneOffSpec', () => { @@ -54,7 +53,7 @@ describe('getMonitorConfig', () => { }); }); -describe('onReschedule', () => { +describe('job.schedule', () => { let sentConfigs: Array; beforeEach(() => { @@ -67,22 +66,66 @@ describe('onReschedule', () => { setCurrentClient(client); }); - const createJob = () => ({ - job: wrapScheduledJob(() => {}, 'my-job', getMonitorConfig('0 9 * * *'), 'auto.function.node-schedule'), + const createJob = (name = 'my-job') => { + let pending = false; + return { + name, + job: (() => {}) as unknown, + nextInvocation: () => (pending ? new Date() : null), + schedule(spec: unknown) { + const change = { arguments: [spec], self: this, result: undefined as unknown }; + beforeSchedule(change, { excludeJobs: ['excluded-job'] }); + change.result = spec !== 'not a spec'; + pending ||= change.result === true; + onSchedule(change); + return change.result; + }, + reschedule(spec: unknown) { + pending = false; + return this.schedule(spec); + }, + run() { + (this.job as () => void)(); + }, + }; + }; + + it('sends the schedule of a named job', () => { + const job = createJob(); + job.schedule({ rule: '30 9 * * 1-5', tz: 'UTC' }); + job.run(); + + expect(sentConfigs[0]).toEqual({ schedule: { type: 'crontab', value: '30 9 * * 1-5' }, timezone: 'UTC' }); + }); + + it.each([ + ['an unnamed job', createJob(''), '0 9 * * *'], + ['an excluded job', createJob('excluded-job'), '0 9 * * *'], + ['a one-off job', createJob(), new Date(Date.now() + 86_400_000)], + ])('does not monitor %s', (_, job, spec) => { + job.schedule(spec); + job.run(); + + expect(sentConfigs).toEqual([]); }); - it('sends the new schedule after a reschedule', () => { + it('wraps the job once and sends the new schedule after a reschedule', () => { const job = createJob(); - onReschedule({ arguments: [{ rule: '30 10 * * 1-5', tz: 'UTC' }], self: job, result: true }); - job.job(); + job.schedule('0 9 * * *'); + const wrapped = job.job; + job.reschedule({ rule: '30 10 * * 1-5', tz: 'UTC' }); + job.run(); + expect(job.job).toBe(wrapped); + expect(sentConfigs).toHaveLength(2); expect(sentConfigs[0]).toEqual({ schedule: { type: 'crontab', value: '30 10 * * 1-5' }, timezone: 'UTC' }); }); it('sends no config after a reschedule to a spec it cannot convert', () => { const job = createJob(); - onReschedule({ arguments: [{ hour: 3, minute: 0 }], self: job, result: true }); - job.job(); + job.schedule('0 9 * * *'); + job.reschedule({ hour: 3, minute: 0 }); + job.run(); expect(sentConfigs).toHaveLength(2); expect(sentConfigs[0]).toBeUndefined(); @@ -90,9 +133,20 @@ describe('onReschedule', () => { it('keeps the old schedule when the reschedule fails', () => { const job = createJob(); - onReschedule({ arguments: ['not a spec'], self: job, result: false }); - job.job(); + job.schedule('0 9 * * *'); + job.reschedule('not a spec'); + job.run(); expect(sentConfigs[0]?.schedule).toEqual({ type: 'crontab', value: '0 9 * * *' }); }); + + it('sends no config when a second schedule adds to the first', () => { + const job = createJob(); + job.schedule('0 9 * * *'); + job.schedule('0 18 * * *'); + job.run(); + + expect(sentConfigs).toHaveLength(2); + expect(sentConfigs[0]).toBeUndefined(); + }); }); From 5efee250bfba7a9acd86ef7d5018b2531678a5bf Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Thu, 29 Oct 2026 11:02:10 -0700 Subject: [PATCH 30/33] fix(node): Skip jobs with unsupported schedules when monitoring all jobs --- .../src/integrations/node-cron.ts | 3 ++- .../server-utils/src/utils/cronMonitoring.ts | 4 ++++ .../test/utils/cronMonitoring.test.ts | 23 ++++++++++++++++--- 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/packages/server-utils/src/integrations/node-cron.ts b/packages/server-utils/src/integrations/node-cron.ts index 18b6a73e9d76..330870117aca 100644 --- a/packages/server-utils/src/integrations/node-cron.ts +++ b/packages/server-utils/src/integrations/node-cron.ts @@ -32,7 +32,8 @@ const _nodeCronIntegration = ((options: NodeCronIntegrationOptions = {}) => { /** * Sends Crons check-ins for every named `node-cron` task, with its schedule as the monitor config. - * The monitor slug is the task's `name` option; tasks without a name are not monitored. + * The monitor slug is the task's `name` option. Tasks without a name, or with a schedule Sentry can't express, are not + * monitored. */ export const nodeCronIntegration = defineIntegration(_nodeCronIntegration); diff --git a/packages/server-utils/src/utils/cronMonitoring.ts b/packages/server-utils/src/utils/cronMonitoring.ts index 03303db160b1..0bc1c9eaccf2 100644 --- a/packages/server-utils/src/utils/cronMonitoring.ts +++ b/packages/server-utils/src/utils/cronMonitoring.ts @@ -163,6 +163,7 @@ function toSentryTimezone(timezone: string | undefined): string | undefined { /** * Wraps a scheduled job with check-ins. Errors are captured, because schedulers catch them, and rethrown. + * Jobs without a monitor config run as they are, with no check-ins. */ export function wrapScheduledJob( func: AnyFn, @@ -176,6 +177,9 @@ export function wrapScheduledJob( }; return function (this: unknown, ...args: unknown[]): unknown { + if (!monitorConfig) { + return func.apply(this, args); + } return startNewTrace(() => withMonitor( monitorSlug, diff --git a/packages/server-utils/test/utils/cronMonitoring.test.ts b/packages/server-utils/test/utils/cronMonitoring.test.ts index 06c2c37527ba..6dd76e352646 100644 --- a/packages/server-utils/test/utils/cronMonitoring.test.ts +++ b/packages/server-utils/test/utils/cronMonitoring.test.ts @@ -1,6 +1,7 @@ -import { getCurrentScope } from '@sentry/core'; -import { describe, expect, it } from 'vitest'; +import { getCurrentScope, setCurrentClient } from '@sentry/core'; +import { describe, expect, it, vi } from 'vitest'; import { cronExpressionToMonitorConfig, shouldMonitorJob, wrapScheduledJob } from '../../src/utils/cronMonitoring'; +import { getDefaultTestClientOptions, TestClient } from '../mocks/client'; const toMonitorConfig = (expression: string, timezone: unknown) => cronExpressionToMonitorConfig(expression, timezone, 'and'); @@ -112,7 +113,7 @@ describe('wrapScheduledJob', () => { const job = wrapScheduledJob( () => getCurrentScope().getPropagationContext().traceId, 'job', - undefined, + toMonitorConfig('* * * * *', 'UTC'), 'auto.function.test', ); const outerTraceId = getCurrentScope().getPropagationContext().traceId; @@ -122,4 +123,20 @@ describe('wrapScheduledJob', () => { expect(firstTraceId).not.toBe(secondTraceId); expect(firstTraceId).not.toBe(outerTraceId); }); + + it('sends no check-ins for jobs without a monitor config', () => { + const client = new TestClient(getDefaultTestClientOptions()); + setCurrentClient(client); + client.init(); + const captureCheckIn = vi.fn(() => 'check-in-id'); + Object.assign(client, { captureCheckIn }); + + const job = wrapScheduledJob(() => 'done', 'job', undefined, 'auto.function.test'); + + expect(job()).toBe('done'); + expect(captureCheckIn).not.toHaveBeenCalled(); + + wrapScheduledJob(() => {}, 'job', toMonitorConfig('* * * * *', 'UTC'), 'auto.function.test')(); + expect(captureCheckIn).toHaveBeenCalled(); + }); }); From c03a72c2c24f511a52930cb17099b2eed4f47366 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Thu, 29 Oct 2026 11:03:14 -0700 Subject: [PATCH 31/33] fix(node): Skip jobs with unsupported schedules when monitoring all jobs --- .../suites/cron/node-schedule-integration/test.ts | 10 ---------- .../server-utils/src/integrations/node-schedule.ts | 10 +++++++--- .../test/integrations/node-schedule.test.ts | 11 +++++------ 3 files changed, 12 insertions(+), 19 deletions(-) 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 de5b683e9b35..3a136ea82d02 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 @@ -1,4 +1,3 @@ -import type { SerializedCheckIn } from '@sentry/core'; import { afterAll, describe, expect } from 'vitest'; import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; @@ -7,11 +6,6 @@ const MONITOR_CONFIG = { 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(); @@ -21,10 +15,6 @@ describe('nodeScheduleIntegration', () => { 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: 'new-job', diff --git a/packages/server-utils/src/integrations/node-schedule.ts b/packages/server-utils/src/integrations/node-schedule.ts index 1d86113a0706..426c362d0021 100644 --- a/packages/server-utils/src/integrations/node-schedule.ts +++ b/packages/server-utils/src/integrations/node-schedule.ts @@ -32,7 +32,8 @@ const _nodeScheduleIntegration = ((options: NodeScheduleIntegrationOptions = {}) /** * Sends Crons check-ins for every named `node-schedule` job, whether it's created with `scheduleJob` or `new Job()`. - * The monitor slug is the job name; jobs without a name, and one-off jobs, are not monitored. + * The monitor slug is the job name. Jobs without a name, one-off jobs, and jobs with a schedule Crons can't express + * are not monitored. */ export const nodeScheduleIntegration = defineIntegration(_nodeScheduleIntegration); @@ -70,7 +71,10 @@ export function beforeSchedule(change: ScheduleChange, options: NodeScheduleInte job.job = wrapScheduledJob(job.job as AnyFn, name, undefined, 'auto.function.node-schedule'); } -/** Sends the spec's config after `job.schedule(spec)`, or none when the job also keeps runs from an earlier spec. */ +/** + * Sets the spec's config after `job.schedule(spec)`. Without one, from a spec Crons can't express or a job that also + * keeps runs from an earlier spec, the job doesn't check in. + */ export function onSchedule(change: ScheduleChange): void { // `schedule` returns false when it scheduled nothing, and `reschedule` then keeps the old schedule. if (change.result === true) { @@ -98,7 +102,7 @@ function isCronShaped(value: string): boolean { ); } -// Only cron strings are sent; recurrence rules and rules limited by `start` or `end` check in without a config. +// Only cron strings convert; recurrence rules and rules limited by `start` or `end` don't. export function getMonitorConfig(spec: unknown): MonitorConfig | undefined { if (typeof spec === 'string') { return cronExpressionToMonitorConfig(spec, undefined, 'or'); diff --git a/packages/server-utils/test/integrations/node-schedule.test.ts b/packages/server-utils/test/integrations/node-schedule.test.ts index 52e1fb586b9c..7071d955fc8b 100644 --- a/packages/server-utils/test/integrations/node-schedule.test.ts +++ b/packages/server-utils/test/integrations/node-schedule.test.ts @@ -102,6 +102,7 @@ describe('job.schedule', () => { ['an unnamed job', createJob(''), '0 9 * * *'], ['an excluded job', createJob('excluded-job'), '0 9 * * *'], ['a one-off job', createJob(), new Date(Date.now() + 86_400_000)], + ['a job with a recurrence rule', createJob(), { hour: 3, minute: 0 }], ])('does not monitor %s', (_, job, spec) => { job.schedule(spec); job.run(); @@ -121,14 +122,13 @@ describe('job.schedule', () => { expect(sentConfigs[0]).toEqual({ schedule: { type: 'crontab', value: '30 10 * * 1-5' }, timezone: 'UTC' }); }); - it('sends no config after a reschedule to a spec it cannot convert', () => { + it('stops checking in after a reschedule to a spec it cannot convert', () => { const job = createJob(); job.schedule('0 9 * * *'); job.reschedule({ hour: 3, minute: 0 }); job.run(); - expect(sentConfigs).toHaveLength(2); - expect(sentConfigs[0]).toBeUndefined(); + expect(sentConfigs).toEqual([]); }); it('keeps the old schedule when the reschedule fails', () => { @@ -140,13 +140,12 @@ describe('job.schedule', () => { expect(sentConfigs[0]?.schedule).toEqual({ type: 'crontab', value: '0 9 * * *' }); }); - it('sends no config when a second schedule adds to the first', () => { + it('stops checking in when a second schedule adds to the first', () => { const job = createJob(); job.schedule('0 9 * * *'); job.schedule('0 18 * * *'); job.run(); - expect(sentConfigs).toHaveLength(2); - expect(sentConfigs[0]).toBeUndefined(); + expect(sentConfigs).toEqual([]); }); }); From edf2eb327163e1bdf6de7c68c668e8321d6a7dcd Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Thu, 29 Oct 2026 11:03:58 -0700 Subject: [PATCH 32/33] fix(node): Skip jobs with unsupported schedules when monitoring all jobs --- .../suites/cron/cron-integration/test.ts | 10 ---------- packages/server-utils/src/integrations/cron.ts | 5 +++-- packages/server-utils/test/integrations/cron.test.ts | 5 ++--- 3 files changed, 5 insertions(+), 15 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/cron/cron-integration/test.ts b/dev-packages/node-integration-tests/suites/cron/cron-integration/test.ts index 640ac8b24041..87a0286c5309 100644 --- a/dev-packages/node-integration-tests/suites/cron/cron-integration/test.ts +++ b/dev-packages/node-integration-tests/suites/cron/cron-integration/test.ts @@ -1,4 +1,3 @@ -import type { SerializedCheckIn } from '@sentry/core'; import { afterAll, describe, expect } from 'vitest'; import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; @@ -7,11 +6,6 @@ const MONITOR_CONFIG = { timezone: 'Europe/Vienna', }; -const expectNoConfig = (slug: string) => (checkIn: SerializedCheckIn) => { - expect(checkIn).toMatchObject({ monitor_slug: slug, status: 'in_progress' }); - expect(checkIn.monitor_config).toBeUndefined(); -}; - describe('cronJobIntegration', () => { afterAll(() => { cleanupChildProcesses(); @@ -21,10 +15,6 @@ describe('cronJobIntegration', () => { test('sends check-ins for named jobs', async () => { await createRunner() .ignore('event') - .expect({ check_in: expectNoConfig('offset-job') }) - .expect({ check_in: { monitor_slug: 'offset-job', status: 'ok' } }) - .expect({ check_in: expectNoConfig('sub-minute-job') }) - .expect({ check_in: { monitor_slug: 'sub-minute-job', status: 'ok' } }) .expect({ check_in: { monitor_slug: 'my-cron-job', status: 'in_progress', monitor_config: MONITOR_CONFIG }, }) diff --git a/packages/server-utils/src/integrations/cron.ts b/packages/server-utils/src/integrations/cron.ts index 52b2f945b2c6..8a08c2fede4f 100644 --- a/packages/server-utils/src/integrations/cron.ts +++ b/packages/server-utils/src/integrations/cron.ts @@ -33,7 +33,8 @@ const _cronJobIntegration = ((options: CronJobIntegrationOptions = {}) => { /** * Sends Crons check-ins for every `CronJob` of the `cron` package (4.3+) that has a `name`. - * The monitor slug is the job name; jobs without a name, and one-off jobs, are not monitored. + * The monitor slug is the job name. Jobs without a name, one-off jobs, and jobs with a schedule Crons can't express + * are not monitored. */ export const cronJobIntegration = defineIntegration(_cronJobIntegration); @@ -42,7 +43,7 @@ function subscribe(options: CronJobIntegrationOptions): void { subscribeToScheduleChange(CHANNELS.CRON_JOB_SET_TIME, INTEGRATION_NAME, onSetTime); } -/** Sends the new time's config after `job.setTime(cronTime)`. */ +/** Sets the new time's config after `job.setTime(cronTime)`. Without one, the job doesn't check in. */ export function onSetTime({ arguments: [cronTime], self }: ScheduleChange): void { const job = self as { cronTime?: unknown; _callbacks?: unknown[] } | undefined; // `setTime` throws and keeps the old time when it isn't given a `CronTime`. diff --git a/packages/server-utils/test/integrations/cron.test.ts b/packages/server-utils/test/integrations/cron.test.ts index 92ec1d110762..ec83e6882d98 100644 --- a/packages/server-utils/test/integrations/cron.test.ts +++ b/packages/server-utils/test/integrations/cron.test.ts @@ -51,13 +51,12 @@ describe('onSetTime', () => { ['a date', { source: new Date(), timeZone: 'UTC' }], ['a UTC offset', { source: '0 9 * * *', utcOffset: 60 }], ['a sub-minute cron', { source: '*/10 * * * * *', timeZone: 'UTC' }], - ])('sends no config after setTime with %s', (_, cronTime) => { + ])('stops checking in after setTime with %s', (_, cronTime) => { const job = createJob(); setTime(job, cronTime); job.onTick(); - expect(sentConfigs).toHaveLength(2); - expect(sentConfigs[0]).toBeUndefined(); + expect(sentConfigs).toEqual([]); }); it('keeps the old schedule when setTime throws', () => { From 01f6696d1f2859c83366440b6cbad129671bcd0d Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Thu, 29 Oct 2026 11:24:58 -0700 Subject: [PATCH 33/33] fix(node): Capture errors from jobs that send no check-ins --- .../server-utils/src/utils/cronMonitoring.ts | 28 ++++++---------- .../test/utils/cronMonitoring.test.ts | 32 +++++++++++++++++++ 2 files changed, 42 insertions(+), 18 deletions(-) diff --git a/packages/server-utils/src/utils/cronMonitoring.ts b/packages/server-utils/src/utils/cronMonitoring.ts index 0bc1c9eaccf2..975763d3c0a7 100644 --- a/packages/server-utils/src/utils/cronMonitoring.ts +++ b/packages/server-utils/src/utils/cronMonitoring.ts @@ -163,7 +163,7 @@ function toSentryTimezone(timezone: string | undefined): string | undefined { /** * Wraps a scheduled job with check-ins. Errors are captured, because schedulers catch them, and rethrown. - * Jobs without a monitor config run as they are, with no check-ins. + * Jobs without a monitor config send no check-ins. */ export function wrapScheduledJob( func: AnyFn, @@ -177,22 +177,14 @@ export function wrapScheduledJob( }; return function (this: unknown, ...args: unknown[]): unknown { - if (!monitorConfig) { - return func.apply(this, args); - } - return startNewTrace(() => - withMonitor( - monitorSlug, - () => { - try { - const result: unknown = func.apply(this, args); - return isThenable(result) ? result.then(undefined, captureAndRethrow) : result; - } catch (e) { - return captureAndRethrow(e); - } - }, - monitorConfig, - ), - ); + const run = (): unknown => { + try { + const result: unknown = func.apply(this, args); + return isThenable(result) ? result.then(undefined, captureAndRethrow) : result; + } catch (e) { + return captureAndRethrow(e); + } + }; + return startNewTrace(() => (monitorConfig ? withMonitor(monitorSlug, run, monitorConfig) : run())); }; } diff --git a/packages/server-utils/test/utils/cronMonitoring.test.ts b/packages/server-utils/test/utils/cronMonitoring.test.ts index 6dd76e352646..0950a592cc87 100644 --- a/packages/server-utils/test/utils/cronMonitoring.test.ts +++ b/packages/server-utils/test/utils/cronMonitoring.test.ts @@ -139,4 +139,36 @@ describe('wrapScheduledJob', () => { wrapScheduledJob(() => {}, 'job', toMonitorConfig('* * * * *', 'UTC'), 'auto.function.test')(); expect(captureCheckIn).toHaveBeenCalled(); }); + + it('captures errors from jobs without a monitor config', async () => { + const client = new TestClient(getDefaultTestClientOptions()); + setCurrentClient(client); + client.init(); + const captureCheckIn = vi.fn(() => 'check-in-id'); + Object.assign(client, { captureCheckIn }); + const captureException = vi.spyOn(client, 'captureException'); + + const syncError = new Error('sync'); + const job = wrapScheduledJob( + () => { + throw syncError; + }, + 'job', + undefined, + 'auto.function.test', + ); + expect(() => job()).toThrow(syncError); + + const asyncError = new Error('async'); + const asyncJob = wrapScheduledJob(() => Promise.reject(asyncError), 'job', undefined, 'auto.function.test'); + await expect(asyncJob()).rejects.toBe(asyncError); + + expect(captureException).toHaveBeenCalledWith( + syncError, + expect.objectContaining({ mechanism: { handled: false, type: 'auto.function.test' } }), + expect.anything(), + ); + expect(captureException).toHaveBeenCalledWith(asyncError, expect.anything(), expect.anything()); + expect(captureCheckIn).not.toHaveBeenCalled(); + }); });