From db27775ac65042d7ce058be682a720ab37e9d9bd Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Fri, 16 Oct 2026 17:46:56 -0700 Subject: [PATCH 01/12] 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/12] 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/12] 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/12] 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 ffb2c5d165771a1a30c2731eb0631b1eeadc1fd7 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Tue, 20 Oct 2026 10:45:53 -0700 Subject: [PATCH 05/12] 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 3d72a0b7d8304abb2dd15ee8eec3d535eba978b1 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Wed, 21 Oct 2026 08:59:23 -0700 Subject: [PATCH 06/12] 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 ea0509e4724fbe38a79a8704cd7d93462f251616 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Fri, 23 Oct 2026 09:54:28 -0700 Subject: [PATCH 07/12] 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 46495506d2801846aa2d1dcbd44df868c523a442 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Wed, 28 Oct 2026 09:51:18 -0700 Subject: [PATCH 08/12] 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 7826628d444dc922651a7651d9b2baa2f84e9eb8 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Wed, 28 Oct 2026 16:52:12 -0700 Subject: [PATCH 09/12] 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 10/12] 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 5efee250bfba7a9acd86ef7d5018b2531678a5bf Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Thu, 29 Oct 2026 11:02:10 -0700 Subject: [PATCH 11/12] 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 01f6696d1f2859c83366440b6cbad129671bcd0d Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Thu, 29 Oct 2026 11:24:58 -0700 Subject: [PATCH 12/12] 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(); + }); });