From 7e027dab77ee7fe4d315fb6be9ffb281d0c8436c Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Fri, 16 Oct 2026 17:46:56 -0700 Subject: [PATCH 1/4] 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 dc77f7d5f6b9..722337ac28eb 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 cd302d5523f0..8e6b17bba404 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 c0cc191e2850..e7c340f6386c 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 865ace1fc978..5e6d69788125 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 5bbf486e2342..af8e84be7a44 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 df5d309ee9e6..f4a6ca7b51d4 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 d35dceb18969..375ec0597b74 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 41eb3845cf1a..c818a99b623f 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 0cf0e363728f..362a5806b8e5 100644 --- a/packages/server-utils/src/orchestrion/config/index.ts +++ b/packages/server-utils/src/orchestrion/config/index.ts @@ -32,6 +32,7 @@ import { mysql2Config } from './mysql2'; import { mysqlConfig } from './mysql'; import { neonConfig } from './neon'; import { nestjsConfig } from './nestjs'; +import { nodeCronConfig } from './node-cron'; import { openaiConfig } from './openai'; import { pgConfig } from './pg'; import { postgresJsConfig } from './postgres'; @@ -89,6 +90,7 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ ...mysqlConfig, ...neonConfig, ...nestjsConfig, + ...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 fc4f29d21181..bfc6b401411c 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 d01ea0d03fc2..f8707c82bb9a 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 edffc0c05d82689380dddc61082c71f5c4fd1193 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Fri, 16 Oct 2026 17:50:03 -0700 Subject: [PATCH 2/4] 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 e448e02b6865a62e7306a46f6386a3f817ce44fd Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Fri, 16 Oct 2026 17:54:07 -0700 Subject: [PATCH 3/4] 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 2c2c60020735f6c45478e4f0d00a92b915739812 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Fri, 16 Oct 2026 18:11:25 -0700 Subject: [PATCH 4/4] 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([]); });