From db27775ac65042d7ce058be682a720ab37e9d9bd Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Fri, 16 Oct 2026 17:46:56 -0700 Subject: [PATCH 1/6] 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 2/6] 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 3/6] 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 4/6] 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 5/6] 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 6/6] 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]); },