From cf8b5be3d56d3877186f79b841995fbc373c2ec1 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Fri, 16 Oct 2026 17:54:55 -0700 Subject: [PATCH 01/10] feat(node): Add cronJobIntegration to monitor cron jobs Sends check-ins for every named CronJob of the cron package (4.3+) through the orchestrion channel injection, with the cron expression and time zone as monitor config when Sentry reads it the same way. Bumps the cron dev dependency of the node integration tests to 4.x. Co-Authored-By: Claude --- .../node-integration-tests/package.json | 2 +- .../cron/cron-integration/instrument.mjs | 9 +++ .../suites/cron/cron-integration/scenario.mjs | 29 ++++++++ .../suites/cron/cron-integration/test.ts | 50 +++++++++++++ .../suites/cron/cron/scenario.ts | 4 +- packages/astro/src/index.server.ts | 1 + packages/aws-serverless/src/index.ts | 1 + packages/bun/src/index.ts | 1 + packages/elysia/src/index.ts | 1 + packages/google-cloud-serverless/src/index.ts | 1 + packages/node/src/cron/cron.ts | 7 ++ packages/node/src/index.ts | 2 + packages/remix/src/server/index.ts | 1 + packages/server-utils/src/index.ts | 2 + .../server-utils/src/integrations/cron.ts | 73 +++++++++++++++++++ .../server-utils/src/orchestrion/channels.ts | 2 + .../src/orchestrion/config/cron.ts | 17 +++++ .../src/orchestrion/config/index.ts | 2 + .../server-utils/src/utils/cronMonitoring.ts | 3 + .../test/utils/cronMonitoring.test.ts | 1 + packages/solidstart/src/server/index.ts | 1 + packages/sveltekit/src/server/index.ts | 1 + 22 files changed, 208 insertions(+), 3 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/cron/cron-integration/instrument.mjs create mode 100644 dev-packages/node-integration-tests/suites/cron/cron-integration/scenario.mjs create mode 100644 dev-packages/node-integration-tests/suites/cron/cron-integration/test.ts create mode 100644 packages/server-utils/src/integrations/cron.ts create mode 100644 packages/server-utils/src/orchestrion/config/cron.ts diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index fdef92a3ba24..e1b6ef2606af 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -65,7 +65,7 @@ "body-parser": "^2.3.0", "consola": "^3.2.3", "cors": "^2.8.5", - "cron": "^3.1.6", + "cron": "^4.4.0", "dataloader": "2.2.2", "express": "^4.21.2", "fastify": "^5.12.5", diff --git a/dev-packages/node-integration-tests/suites/cron/cron-integration/instrument.mjs b/dev-packages/node-integration-tests/suites/cron/cron-integration/instrument.mjs new file mode 100644 index 000000000000..3ecfc0097afc --- /dev/null +++ b/dev-packages/node-integration-tests/suites/cron/cron-integration/instrument.mjs @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + transport: loggingTransport, + integrations: [Sentry.cronJobIntegration({ excludeJobs: ['excluded-job'] })], +}); diff --git a/dev-packages/node-integration-tests/suites/cron/cron-integration/scenario.mjs b/dev-packages/node-integration-tests/suites/cron/cron-integration/scenario.mjs new file mode 100644 index 000000000000..a608fd44d135 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/cron/cron-integration/scenario.mjs @@ -0,0 +1,29 @@ +import { CronJob } from 'cron'; + +const excluded = CronJob.from({ cronTime: '0 0 * * *', onTick: () => {}, name: 'excluded-job' }); +const unnamed = new CronJob('0 0 * * *', () => {}); + +let runs = 0; +const job = CronJob.from({ + cronTime: '0 30 9 * * 1-5', + timeZone: 'Europe/Vienna', + name: 'my-cron-job', + waitForCompletion: true, + errorHandler: () => {}, + onTick: async () => { + runs++; + if (runs > 1) { + throw new Error('Error in cron job'); + } + }, +}); + +// Run the jobs without waiting for their schedule. +(async () => { + await excluded.fireOnTick(); + await unnamed.fireOnTick(); + await job.fireOnTick(); + await job.fireOnTick(); +})(); + +setTimeout(() => process.exit(), 10_000); diff --git a/dev-packages/node-integration-tests/suites/cron/cron-integration/test.ts b/dev-packages/node-integration-tests/suites/cron/cron-integration/test.ts new file mode 100644 index 000000000000..7c658b22408d --- /dev/null +++ b/dev-packages/node-integration-tests/suites/cron/cron-integration/test.ts @@ -0,0 +1,50 @@ +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; + +const MONITOR_CONFIG = { + schedule: { type: 'crontab', value: '30 9 * * 1-5' }, + timezone: 'Europe/Vienna', +}; + +describe('cronJobIntegration', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + test('sends check-ins for named jobs', async () => { + await createRunner() + .ignore('event') + .expect({ + check_in: { monitor_slug: 'my-cron-job', status: 'in_progress', monitor_config: MONITOR_CONFIG }, + }) + .expect({ check_in: { monitor_slug: 'my-cron-job', status: 'ok', duration: expect.any(Number) } }) + .expect({ + check_in: { monitor_slug: 'my-cron-job', status: 'in_progress', monitor_config: MONITOR_CONFIG }, + }) + .expect({ check_in: { monitor_slug: 'my-cron-job', status: 'error', duration: expect.any(Number) } }) + .start() + .completed(); + }); + + test('captures job errors', async () => { + await createRunner() + .unordered() + .expect({ + event: { + exception: { + values: [ + { + type: 'Error', + value: 'Error in cron job', + mechanism: { type: 'auto.function.cron', handled: false }, + }, + ], + }, + }, + }) + .start() + .completed(); + }); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/cron/cron/scenario.ts b/dev-packages/node-integration-tests/suites/cron/cron/scenario.ts index b3966b050d89..68cf28bcde94 100644 --- a/dev-packages/node-integration-tests/suites/cron/cron/scenario.ts +++ b/dev-packages/node-integration-tests/suites/cron/cron/scenario.ts @@ -28,11 +28,11 @@ cron.start(); // is enough to hit it, and the job is left scheduled for nothing: no ticks, no check-ins, and the // scenario exits on the timeout below having sent nothing at all. Starting again re-reads the clock // away from the boundary. -for (let attempt = 0; attempt < 5 && !cron.running; attempt++) { +for (let attempt = 0; attempt < 5 && !cron.isActive; attempt++) { cron.start(); } -if (!cron.running) { +if (!cron.isActive) { throw new Error('`cron` refused to schedule the job'); } diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index 69d9dee08287..e7675e02ab85 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -47,6 +47,7 @@ export { fsIntegration, functionToStringIntegration, genericPoolIntegration, + cronJobIntegration, nodeCronIntegration, nodeScheduleIntegration, getActiveSpan, diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index 8c45d2f267d9..3ba287a78bcf 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -116,6 +116,7 @@ export { firebaseIntegration, fsIntegration, genericPoolIntegration, + cronJobIntegration, nodeCronIntegration, nodeScheduleIntegration, graphqlIntegration, diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index 4c4e7c48b010..3122d4857bbc 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -138,6 +138,7 @@ export { // oxlint-disable-next-line typescript/no-deprecated setupKoaErrorHandler, genericPoolIntegration, + cronJobIntegration, nodeCronIntegration, nodeScheduleIntegration, graphqlIntegration, diff --git a/packages/elysia/src/index.ts b/packages/elysia/src/index.ts index a796fc84ecc7..60863f006a52 100644 --- a/packages/elysia/src/index.ts +++ b/packages/elysia/src/index.ts @@ -117,6 +117,7 @@ export { // oxlint-disable-next-line typescript/no-deprecated setupKoaErrorHandler, genericPoolIntegration, + cronJobIntegration, nodeCronIntegration, nodeScheduleIntegration, graphqlIntegration, diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index 317424507421..55aa4f7692dd 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -116,6 +116,7 @@ export { fastifyIntegration, firebaseIntegration, genericPoolIntegration, + cronJobIntegration, nodeCronIntegration, nodeScheduleIntegration, graphqlIntegration, diff --git a/packages/node/src/cron/cron.ts b/packages/node/src/cron/cron.ts index 2c764c877c46..e92625ebee18 100644 --- a/packages/node/src/cron/cron.ts +++ b/packages/node/src/cron/cron.ts @@ -51,6 +51,8 @@ export type CronJobConstructor = { ): CronJob; }; +const MONITORED = Symbol.for('sentry.cron.monitored'); + const ERROR_TEXT = 'Automatic instrumentation of CronJob only supports crontab string'; /** @@ -115,6 +117,9 @@ export function instrumentCron(lib: T & CronJobConstructor, monitorSlug: stri ); } + // Tells `cronJobIntegration` that this job is already monitored. + (monitoredTick as unknown as Record)[MONITORED] = true; + return new target(cronTime, monitoredTick, onComplete, start, timeZone, ...rest); }, get(target, prop: keyof CronJobConstructor) { @@ -157,6 +162,8 @@ export function instrumentCron(lib: T & CronJobConstructor, monitorSlug: stri ); }; + (param.onTick as unknown as Record)[MONITORED] = true; + return target.from(param); }; } else { diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 5e4c60e9706e..3afae9e27db9 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -11,6 +11,8 @@ export { type ExpressIntegrationOptions, firebaseIntegration, genericPoolIntegration, + cronJobIntegration, + type CronJobIntegrationOptions, nodeCronIntegration, type NodeCronIntegrationOptions, nodeScheduleIntegration, diff --git a/packages/remix/src/server/index.ts b/packages/remix/src/server/index.ts index 88ef6a841518..b842efd5f534 100644 --- a/packages/remix/src/server/index.ts +++ b/packages/remix/src/server/index.ts @@ -37,6 +37,7 @@ export { flush, functionToStringIntegration, genericPoolIntegration, + cronJobIntegration, nodeCronIntegration, nodeScheduleIntegration, getActiveSpan, diff --git a/packages/server-utils/src/index.ts b/packages/server-utils/src/index.ts index 005e8a4a8b79..2550a7fd6884 100644 --- a/packages/server-utils/src/index.ts +++ b/packages/server-utils/src/index.ts @@ -38,6 +38,8 @@ export { anthropicAIIntegration } from './integrations/anthropic'; export { awsIntegration } from './integrations/aws-sdk'; export { dataloaderIntegration } from './integrations/dataloader'; export { genericPoolIntegration } from './integrations/generic-pool'; +export { cronJobIntegration } from './integrations/cron'; +export type { CronJobIntegrationOptions } from './integrations/cron'; export { nodeCronIntegration } from './integrations/node-cron'; export type { NodeCronIntegrationOptions } from './integrations/node-cron'; export { nodeScheduleIntegration } from './integrations/node-schedule'; diff --git a/packages/server-utils/src/integrations/cron.ts b/packages/server-utils/src/integrations/cron.ts new file mode 100644 index 000000000000..c15968bc17c7 --- /dev/null +++ b/packages/server-utils/src/integrations/cron.ts @@ -0,0 +1,73 @@ +import * as diagnosticsChannel from '../utils/diagnosticsChannel'; +import type { IntegrationFn } from '@sentry/core'; +import { debug, defineIntegration, stringMatchesSomePattern } from '@sentry/core'; +import { DEBUG_BUILD } from '../debug-build'; +import { CHANNELS } from '../orchestrion/channels'; +import { cronModuleNames } from '../orchestrion/config/cron'; +import { invokeOrchestrionInstrumentation } from '../orchestrion/instrumentation'; +import type { AnyFn } from '../utils/cronMonitoring'; +import { cronExpressionToMonitorConfig, wrapScheduledJob } from '../utils/cronMonitoring'; + +const INTEGRATION_NAME = 'CronJob' as const; + +// Set by `Sentry.cron.instrumentCron`, which already sends check-ins. +const MONITORED = Symbol.for('sentry.cron.monitored'); + +const NOOP = (): void => {}; + +export interface CronJobIntegrationOptions { + /** Job names not to monitor. Strings must match the name exactly. */ + excludeJobs?: Array; +} + +let options: CronJobIntegrationOptions = {}; + +const _cronJobIntegration = ((integrationOptions: CronJobIntegrationOptions = {}) => { + return { + name: INTEGRATION_NAME, + setup(client) { + options = integrationOptions; + invokeOrchestrionInstrumentation(client, cronModuleNames, subscribe, [], { + requiresTracingChannelBinding: false, + }); + }, + }; +}) satisfies IntegrationFn; + +/** + * Sends Crons check-ins for every `CronJob` of the `cron` package (4.3+) that has a `name`. + * The monitor slug is the job name; jobs without a name, and one-off jobs, are not monitored. + */ +export const cronJobIntegration = defineIntegration(_cronJobIntegration); + +function subscribe(): void { + diagnosticsChannel.tracingChannel<{ arguments: unknown[] }>(CHANNELS.CRON_JOB_CONSTRUCTOR).subscribe({ + start(data) { + try { + // `new CronJob(cronTime, onTick, onComplete, start, timeZone, context, runOnInit, utcOffset, ..., name)` + const args = data.arguments; + const [cronTime, onTick, , , timeZone] = args; + const utcOffset = args[7]; + const name = args[11]; + if ( + typeof name !== 'string' || + !name || + typeof onTick !== 'function' || + (onTick as { [MONITORED]?: boolean })[MONITORED] || + typeof cronTime !== 'string' || + stringMatchesSomePattern(name, options.excludeJobs, true) + ) { + return; + } + const monitorConfig = utcOffset == null ? cronExpressionToMonitorConfig(cronTime, timeZone, 'or') : undefined; + args[1] = wrapScheduledJob(onTick as AnyFn, name, monitorConfig, 'auto.function.cron'); + } catch (e) { + DEBUG_BUILD && debug.error('[CronJob] Failed to set up the cron monitor:', e); + } + }, + end: NOOP, + asyncStart: NOOP, + asyncEnd: NOOP, + error: NOOP, + }); +} diff --git a/packages/server-utils/src/orchestrion/channels.ts b/packages/server-utils/src/orchestrion/channels.ts index 67a88bbb3825..f593fefe0ebd 100644 --- a/packages/server-utils/src/orchestrion/channels.ts +++ b/packages/server-utils/src/orchestrion/channels.ts @@ -1,6 +1,7 @@ import { amqplibChannels } from './config/amqplib'; import { anthropicAiChannels } from './config/anthropic-ai'; import { awsSdkChannels } from './config/aws-sdk'; +import { cronChannels } from './config/cron'; import { dataloaderChannels } from './config/dataloader'; import { expressChannels } from './config/express'; import { firebaseChannels } from './config/firebase'; @@ -60,6 +61,7 @@ export const CHANNELS = { ...amqplibChannels, ...anthropicAiChannels, ...awsSdkChannels, + ...cronChannels, ...dataloaderChannels, ...expressChannels, ...firebaseChannels, diff --git a/packages/server-utils/src/orchestrion/config/cron.ts b/packages/server-utils/src/orchestrion/config/cron.ts new file mode 100644 index 000000000000..d15c1d90821a --- /dev/null +++ b/packages/server-utils/src/orchestrion/config/cron.ts @@ -0,0 +1,17 @@ +import type { InstrumentationConfig } from '../apmTypes'; +import { getModuleNames } from './module-names'; + +// `CronJob.from()` calls the constructor. Job names were added in 4.3.0. +export const cronConfig = [ + { + channelName: 'cronJobConstructor', + module: { name: 'cron', versionRange: '>=4.3.0 <5', filePath: 'dist/job.js' }, + functionQuery: { className: 'CronJob' }, + }, +] satisfies InstrumentationConfig[]; + +export const cronModuleNames = getModuleNames(cronConfig); + +export const cronChannels = { + CRON_JOB_CONSTRUCTOR: 'orchestrion:cron:cronJobConstructor', +} as const; diff --git a/packages/server-utils/src/orchestrion/config/index.ts b/packages/server-utils/src/orchestrion/config/index.ts index bf2c4b50b178..7636fbef80fc 100644 --- a/packages/server-utils/src/orchestrion/config/index.ts +++ b/packages/server-utils/src/orchestrion/config/index.ts @@ -5,6 +5,7 @@ import { uniq } from '@sentry/core'; import { awsSdkConfig } from './aws-sdk'; import { amqplibConfig } from './amqplib'; import { anthropicAiConfig } from './anthropic-ai'; +import { cronConfig } from './cron'; import { dataloaderConfig } from './dataloader'; import { expressConfig } from './express'; import { firebaseConfig } from './firebase'; @@ -66,6 +67,7 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ ...amqplibConfig, ...anthropicAiConfig, ...awsSdkConfig, + ...cronConfig, ...dataloaderConfig, ...expressConfig, ...firebaseConfig, diff --git a/packages/server-utils/src/utils/cronMonitoring.ts b/packages/server-utils/src/utils/cronMonitoring.ts index 56d7fe97e321..82c0e8dc3814 100644 --- a/packages/server-utils/src/utils/cronMonitoring.ts +++ b/packages/server-utils/src/utils/cronMonitoring.ts @@ -11,6 +11,9 @@ const NICKNAMES: Record = { '@daily': '0 0 * * *', '@midnight': '0 0 * * *', '@hourly': '0 * * * *', + '@minutely': '* * * * *', + '@weekdays': '0 0 * * 1-5', + '@weekends': '0 0 * * 0,6', }; const MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']; diff --git a/packages/server-utils/test/utils/cronMonitoring.test.ts b/packages/server-utils/test/utils/cronMonitoring.test.ts index 63495f8512f0..084641817259 100644 --- a/packages/server-utils/test/utils/cronMonitoring.test.ts +++ b/packages/server-utils/test/utils/cronMonitoring.test.ts @@ -13,6 +13,7 @@ describe('cronExpressionToMonitorConfig with node-cron day matching', () => { ['0 9 */2 * 1', '0 9 */2 * 1'], ['@daily', '0 0 * * *'], ['@Weekly', '0 0 * * 0'], + ['@weekdays', '0 0 * * 1-5'], ['*/15 * * * *', '*/15 * * * *'], ])('sends %s as %s', (expression, crontab) => { expect(toMonitorConfig(expression, 'Europe/Vienna')).toEqual({ diff --git a/packages/solidstart/src/server/index.ts b/packages/solidstart/src/server/index.ts index 2237235f5c1b..e0339440f4c9 100644 --- a/packages/solidstart/src/server/index.ts +++ b/packages/solidstart/src/server/index.ts @@ -40,6 +40,7 @@ export { flush, functionToStringIntegration, genericPoolIntegration, + cronJobIntegration, nodeCronIntegration, nodeScheduleIntegration, getActiveSpan, diff --git a/packages/sveltekit/src/server/index.ts b/packages/sveltekit/src/server/index.ts index e5b73bcd2acc..c6fed60ea263 100644 --- a/packages/sveltekit/src/server/index.ts +++ b/packages/sveltekit/src/server/index.ts @@ -38,6 +38,7 @@ export { flush, functionToStringIntegration, genericPoolIntegration, + cronJobIntegration, nodeCronIntegration, nodeScheduleIntegration, getActiveSpan, From 1908bf3f7505d75c363abeaf87b2c7fbbef5f289 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Fri, 16 Oct 2026 18:11:31 -0700 Subject: [PATCH 02/10] test(node): Skip cron integration suite on bun run Co-Authored-By: Claude --- dev-packages/bun-integration-tests/node-suites/excludes.ts | 1 + .../test/orchestrion/moduleInjectedTransform.test.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/dev-packages/bun-integration-tests/node-suites/excludes.ts b/dev-packages/bun-integration-tests/node-suites/excludes.ts index e38eeeb56384..039e0b07f684 100644 --- a/dev-packages/bun-integration-tests/node-suites/excludes.ts +++ b/dev-packages/bun-integration-tests/node-suites/excludes.ts @@ -62,6 +62,7 @@ const NO_FETCH_INSTRUMENTATION = [ // instrumentation creates no spans. Apps must be built with `@sentry/bun/plugin`. // See https://github.com/getsentry/sentry-javascript/issues/23882 export const NO_AUTO_INSTRUMENTATION = [ + 'suites/cron/cron-integration/test.ts', 'suites/cron/node-cron/integration/test.ts', 'suites/cron/node-schedule-integration/test.ts', 'suites/express/**', diff --git a/packages/server-utils/test/orchestrion/moduleInjectedTransform.test.ts b/packages/server-utils/test/orchestrion/moduleInjectedTransform.test.ts index ed55b1bec34e..5780411ff4c3 100644 --- a/packages/server-utils/test/orchestrion/moduleInjectedTransform.test.ts +++ b/packages/server-utils/test/orchestrion/moduleInjectedTransform.test.ts @@ -44,7 +44,7 @@ describe('channel integration definitions', () => { // `@nestjs/*` and `@remix-run/*` are covered by listeners in their own SDK packages. // Crons integrations are opt-in, so bundler-only SDKs must not install them for every app. - const optIn = new Set(['node-cron', 'node-schedule']); + const optIn = new Set(['cron', 'node-cron', 'node-schedule']); const uncovered = [...configured].filter( name => !defined.has(name) && !optIn.has(name) && !name.startsWith('@nestjs/') && !name.startsWith('@remix-run/'), ); From 487dc0c86292ff95bdfcdbfc7ee5d143230c8ad6 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Mon, 19 Oct 2026 17:49:42 -0700 Subject: [PATCH 03/10] chore(deps): Update pnpm-lock.yaml for cron 4 and dedupe luxon Co-Authored-By: Claude --- pnpm-lock.yaml | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cfdd2832b6d3..34148cbb88e8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -831,8 +831,8 @@ importers: specifier: ^2.8.5 version: 2.8.6 cron: - specifier: ^3.1.6 - version: 3.1.6 + specifier: ^4.4.0 + version: 4.4.0 dataloader: specifier: 2.2.2 version: 2.2.2 @@ -8702,8 +8702,8 @@ packages: '@types/long@4.0.2': resolution: {integrity: sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==} - '@types/luxon@3.3.8': - resolution: {integrity: sha512-jYvz8UMLDgy3a5SkGJne8H7VA7zPV2Lwohjx0V8V31+SqAjNmurWMkk9cQhfvlcnXWudBpK9xPM1n4rljOcHYQ==} + '@types/luxon@3.7.6': + resolution: {integrity: sha512-6KSjliQAXK8ZLgFQO4B7iE4Rpf/B3rItzCFuXezBY/XIAGxOdLd7vRNK9/StRPwiS/yJsVbB7HKpW46B8tcEuQ==} '@types/mdast@4.0.1': resolution: {integrity: sha512-IlKct1rUTJ1T81d8OHzyop15kGv9A/ff7Gz7IJgrk6jDb4Udw77pCJ+vq8oxZf4Ghpm+616+i1s/LNg/Vh7d+g==} @@ -10753,8 +10753,9 @@ packages: engines: {node: '>=12.0.0'} deprecated: v4 is no longer maintained, upgrade to v5 - cron@3.1.6: - resolution: {integrity: sha512-cvFiQCeVzsA+QPM6fhjBtlKGij7tLLISnTSvFxVdnFGLdz+ZdXN37kNe0i2gefmdD17XuZA6n2uPVwzl4FxW/w==} + cron@4.4.0: + resolution: {integrity: sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==} + engines: {node: '>=18.x'} croner@10.0.1: resolution: {integrity: sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g==} @@ -14009,8 +14010,8 @@ packages: resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==} engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} - luxon@3.4.4: - resolution: {integrity: sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA==} + luxon@3.7.2: + resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} engines: {node: '>=12'} lz-string@1.5.0: @@ -27027,7 +27028,7 @@ snapshots: '@types/long@4.0.2': {} - '@types/luxon@3.3.8': {} + '@types/luxon@3.7.6': {} '@types/mdast@4.0.1': dependencies: @@ -29587,12 +29588,12 @@ snapshots: cron-parser@4.9.0: dependencies: - luxon: 3.4.4 + luxon: 3.7.2 - cron@3.1.6: + cron@4.4.0: dependencies: - '@types/luxon': 3.3.8 - luxon: 3.4.4 + '@types/luxon': 3.7.6 + luxon: 3.7.2 croner@10.0.1: {} @@ -33527,7 +33528,7 @@ snapshots: lru.min@1.1.4: {} - luxon@3.4.4: {} + luxon@3.7.2: {} lz-string@1.5.0: {} From c11e2fdf45140977bb8a328510c024ece75e4948 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Tue, 20 Oct 2026 10:48:11 -0700 Subject: [PATCH 04/10] ref(node): Use the shared job-creation helpers in cronJobIntegration Co-Authored-By: Claude --- .../server-utils/src/integrations/cron.ts | 58 +++++++------------ 1 file changed, 20 insertions(+), 38 deletions(-) diff --git a/packages/server-utils/src/integrations/cron.ts b/packages/server-utils/src/integrations/cron.ts index c15968bc17c7..d815ef084cc6 100644 --- a/packages/server-utils/src/integrations/cron.ts +++ b/packages/server-utils/src/integrations/cron.ts @@ -1,20 +1,18 @@ -import * as diagnosticsChannel from '../utils/diagnosticsChannel'; import type { IntegrationFn } from '@sentry/core'; -import { debug, defineIntegration, stringMatchesSomePattern } from '@sentry/core'; -import { DEBUG_BUILD } from '../debug-build'; +import { defineIntegration } from '@sentry/core'; import { CHANNELS } from '../orchestrion/channels'; import { cronModuleNames } from '../orchestrion/config/cron'; import { invokeOrchestrionInstrumentation } from '../orchestrion/instrumentation'; import type { AnyFn } from '../utils/cronMonitoring'; -import { cronExpressionToMonitorConfig, wrapScheduledJob } from '../utils/cronMonitoring'; +import { + cronExpressionToMonitorConfig, + shouldMonitorJob, + subscribeToJobCreation, + wrapScheduledJob, +} from '../utils/cronMonitoring'; const INTEGRATION_NAME = 'CronJob' as const; -// Set by `Sentry.cron.instrumentCron`, which already sends check-ins. -const MONITORED = Symbol.for('sentry.cron.monitored'); - -const NOOP = (): void => {}; - export interface CronJobIntegrationOptions { /** Job names not to monitor. Strings must match the name exactly. */ excludeJobs?: Array; @@ -41,33 +39,17 @@ const _cronJobIntegration = ((integrationOptions: CronJobIntegrationOptions = {} export const cronJobIntegration = defineIntegration(_cronJobIntegration); function subscribe(): void { - diagnosticsChannel.tracingChannel<{ arguments: unknown[] }>(CHANNELS.CRON_JOB_CONSTRUCTOR).subscribe({ - start(data) { - try { - // `new CronJob(cronTime, onTick, onComplete, start, timeZone, context, runOnInit, utcOffset, ..., name)` - const args = data.arguments; - const [cronTime, onTick, , , timeZone] = args; - const utcOffset = args[7]; - const name = args[11]; - if ( - typeof name !== 'string' || - !name || - typeof onTick !== 'function' || - (onTick as { [MONITORED]?: boolean })[MONITORED] || - typeof cronTime !== 'string' || - stringMatchesSomePattern(name, options.excludeJobs, true) - ) { - return; - } - const monitorConfig = utcOffset == null ? cronExpressionToMonitorConfig(cronTime, timeZone, 'or') : undefined; - args[1] = wrapScheduledJob(onTick as AnyFn, name, monitorConfig, 'auto.function.cron'); - } catch (e) { - DEBUG_BUILD && debug.error('[CronJob] Failed to set up the cron monitor:', e); - } - }, - end: NOOP, - asyncStart: NOOP, - asyncEnd: NOOP, - error: NOOP, - }); + subscribeToJobCreation(CHANNELS.CRON_JOB_CONSTRUCTOR, INTEGRATION_NAME, wrapJob); +} + +// `new CronJob(cronTime, onTick, onComplete, start, timeZone, context, runOnInit, utcOffset, ..., name)` +function wrapJob(args: unknown[]): void { + const [cronTime, onTick, , , timeZone] = args; + const utcOffset = args[7]; + const name = args[11]; + if (typeof cronTime !== 'string' || !shouldMonitorJob(name, onTick, options.excludeJobs)) { + return; + } + const monitorConfig = utcOffset == null ? cronExpressionToMonitorConfig(cronTime, timeZone, 'or') : undefined; + args[1] = wrapScheduledJob(onTick as AnyFn, name, monitorConfig, 'auto.function.cron'); } From dbd9ecb96aeb1ce4ac88f56129efce1cda202c5a Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Wed, 21 Oct 2026 09:00:40 -0700 Subject: [PATCH 05/10] ref(node): Mark monitored cron jobs without a double cast Co-Authored-By: Claude --- packages/node/src/cron/cron.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/node/src/cron/cron.ts b/packages/node/src/cron/cron.ts index e92625ebee18..f95c1a1b6f9d 100644 --- a/packages/node/src/cron/cron.ts +++ b/packages/node/src/cron/cron.ts @@ -1,4 +1,4 @@ -import { captureException, withMonitor } from '@sentry/core'; +import { addNonEnumerableProperty, captureException, withMonitor } from '@sentry/core'; import { replaceCronNames } from './common'; export type CronJobParams = { @@ -118,7 +118,7 @@ export function instrumentCron(lib: T & CronJobConstructor, monitorSlug: stri } // Tells `cronJobIntegration` that this job is already monitored. - (monitoredTick as unknown as Record)[MONITORED] = true; + addNonEnumerableProperty(monitoredTick, MONITORED, true); return new target(cronTime, monitoredTick, onComplete, start, timeZone, ...rest); }, @@ -162,7 +162,7 @@ export function instrumentCron(lib: T & CronJobConstructor, monitorSlug: stri ); }; - (param.onTick as unknown as Record)[MONITORED] = true; + addNonEnumerableProperty(param.onTick, MONITORED, true); return target.from(param); }; From aa654d6abef242ee632302f1b0add9cc0f0eb87e Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Wed, 21 Oct 2026 17:31:15 -0700 Subject: [PATCH 06/10] test(node): Cover cron jobs sent without a config or skipped as one-off Co-Authored-By: Claude --- .../suites/cron/cron-integration/scenario.mjs | 6 ++++++ .../suites/cron/cron-integration/test.ts | 10 ++++++++++ 2 files changed, 16 insertions(+) diff --git a/dev-packages/node-integration-tests/suites/cron/cron-integration/scenario.mjs b/dev-packages/node-integration-tests/suites/cron/cron-integration/scenario.mjs index a608fd44d135..ad4b46558324 100644 --- a/dev-packages/node-integration-tests/suites/cron/cron-integration/scenario.mjs +++ b/dev-packages/node-integration-tests/suites/cron/cron-integration/scenario.mjs @@ -2,6 +2,9 @@ import { CronJob } from 'cron'; const excluded = CronJob.from({ cronTime: '0 0 * * *', onTick: () => {}, name: 'excluded-job' }); const unnamed = new CronJob('0 0 * * *', () => {}); +const oneOff = CronJob.from({ cronTime: new Date(Date.now() + 86_400_000), onTick: () => {}, name: 'one-off-job' }); +const offset = CronJob.from({ cronTime: '0 0 * * *', utcOffset: 60, onTick: () => {}, name: 'offset-job' }); +const subMinute = CronJob.from({ cronTime: '*/10 * * * * *', onTick: () => {}, name: 'sub-minute-job' }); let runs = 0; const job = CronJob.from({ @@ -22,6 +25,9 @@ const job = CronJob.from({ (async () => { await excluded.fireOnTick(); await unnamed.fireOnTick(); + await oneOff.fireOnTick(); + await offset.fireOnTick(); + await subMinute.fireOnTick(); await job.fireOnTick(); await job.fireOnTick(); })(); diff --git a/dev-packages/node-integration-tests/suites/cron/cron-integration/test.ts b/dev-packages/node-integration-tests/suites/cron/cron-integration/test.ts index 7c658b22408d..3b1efd64f2e3 100644 --- a/dev-packages/node-integration-tests/suites/cron/cron-integration/test.ts +++ b/dev-packages/node-integration-tests/suites/cron/cron-integration/test.ts @@ -1,3 +1,4 @@ +import type { SerializedCheckIn } from '@sentry/core'; import { afterAll, describe, expect } from 'vitest'; import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; @@ -6,6 +7,11 @@ const MONITOR_CONFIG = { timezone: 'Europe/Vienna', }; +const expectNoConfig = (slug: string) => (checkIn: SerializedCheckIn) => { + expect(checkIn).toMatchObject({ monitor_slug: slug, status: 'in_progress' }); + expect(checkIn.monitor_config).toBeUndefined(); +}; + describe('cronJobIntegration', () => { afterAll(() => { cleanupChildProcesses(); @@ -15,6 +21,10 @@ describe('cronJobIntegration', () => { test('sends check-ins for named jobs', async () => { await createRunner() .ignore('event') + .expect({ check_in: expectNoConfig('offset-job') }) + .expect({ check_in: { monitor_slug: 'offset-job', status: 'ok' } }) + .expect({ check_in: expectNoConfig('sub-minute-job') }) + .expect({ check_in: { monitor_slug: 'sub-minute-job', status: 'ok' } }) .expect({ check_in: { monitor_slug: 'my-cron-job', status: 'in_progress', monitor_config: MONITOR_CONFIG }, }) From a1f362d7cb8c08b5cb55f6b3ec0d8b021a7994e2 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Fri, 23 Oct 2026 09:56:15 -0700 Subject: [PATCH 07/10] ref(node): Pass cronJobIntegration options to the subscriber --- packages/server-utils/src/integrations/cron.ts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/packages/server-utils/src/integrations/cron.ts b/packages/server-utils/src/integrations/cron.ts index d815ef084cc6..93a5a20807b8 100644 --- a/packages/server-utils/src/integrations/cron.ts +++ b/packages/server-utils/src/integrations/cron.ts @@ -18,14 +18,11 @@ export interface CronJobIntegrationOptions { excludeJobs?: Array; } -let options: CronJobIntegrationOptions = {}; - -const _cronJobIntegration = ((integrationOptions: CronJobIntegrationOptions = {}) => { +const _cronJobIntegration = ((options: CronJobIntegrationOptions = {}) => { return { name: INTEGRATION_NAME, setup(client) { - options = integrationOptions; - invokeOrchestrionInstrumentation(client, cronModuleNames, subscribe, [], { + invokeOrchestrionInstrumentation(client, cronModuleNames, subscribe, [options], { requiresTracingChannelBinding: false, }); }, @@ -38,12 +35,12 @@ const _cronJobIntegration = ((integrationOptions: CronJobIntegrationOptions = {} */ export const cronJobIntegration = defineIntegration(_cronJobIntegration); -function subscribe(): void { - subscribeToJobCreation(CHANNELS.CRON_JOB_CONSTRUCTOR, INTEGRATION_NAME, wrapJob); +function subscribe(options: CronJobIntegrationOptions): void { + subscribeToJobCreation(CHANNELS.CRON_JOB_CONSTRUCTOR, INTEGRATION_NAME, args => wrapJob(args, options)); } // `new CronJob(cronTime, onTick, onComplete, start, timeZone, context, runOnInit, utcOffset, ..., name)` -function wrapJob(args: unknown[]): void { +function wrapJob(args: unknown[], options: CronJobIntegrationOptions): void { const [cronTime, onTick, , , timeZone] = args; const utcOffset = args[7]; const name = args[11]; From d13ffdd4c66947a61b0c6739b6147c9b729d6703 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Wed, 28 Oct 2026 09:52:35 -0700 Subject: [PATCH 08/10] ref(node): Export cronJobIntegration from Deno --- packages/deno/src/index.ts | 1 + packages/server-utils/src/utils/cronMonitoring.ts | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/deno/src/index.ts b/packages/deno/src/index.ts index 8d922f286434..ad4a32a4454a 100644 --- a/packages/deno/src/index.ts +++ b/packages/deno/src/index.ts @@ -183,6 +183,7 @@ export { postgresIntegration, postgresJsIntegration, neonIntegration, + cronJobIntegration, nodeCronIntegration, nodeScheduleIntegration, tediousIntegration, diff --git a/packages/server-utils/src/utils/cronMonitoring.ts b/packages/server-utils/src/utils/cronMonitoring.ts index f4e8d14540e3..21c42fcb8b26 100644 --- a/packages/server-utils/src/utils/cronMonitoring.ts +++ b/packages/server-utils/src/utils/cronMonitoring.ts @@ -5,7 +5,7 @@ import * as diagnosticsChannel from './diagnosticsChannel'; export type AnyFn = (this: unknown, ...args: unknown[]) => unknown; -// Set by `Sentry.cron.instrumentNodeCron` and `Sentry.cron.instrumentNodeSchedule`, which already send check-ins. +// Set by `Sentry.cron.instrumentCron`, `instrumentNodeCron` and `instrumentNodeSchedule`, which already send check-ins. const MONITORED = Symbol.for('sentry.cron.monitored'); /** @@ -26,7 +26,7 @@ export function subscribeToJobCreation( }); } -/** Whether a job is named, not excluded, and not already monitored by `Sentry.cron.instrumentNodeCron` or `instrumentNodeSchedule`. */ +/** Whether a job is named, not excluded, and not already monitored by `Sentry.cron.instrumentCron`, `instrumentNodeCron` or `instrumentNodeSchedule`. */ export function shouldMonitorJob( name: unknown, job: unknown, From 86acf7ff0c8335828c127b02d225115a8342db6e Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Wed, 28 Oct 2026 12:00:55 -0700 Subject: [PATCH 09/10] fix(node): Send the new schedule after a cron job's setTime cronJobIntegration kept sending the config from the CronJob constructor after job.setTime, so Sentry reverted the monitor and reported false misses. Update the job's config after setTime. --- .../suites/cron/cron-integration/scenario.mjs | 6 +- .../suites/cron/cron-integration/test.ts | 8 +++ .../server-utils/src/integrations/cron.ts | 22 +++++- .../src/orchestrion/config/cron.ts | 6 ++ .../test/integrations/cron.test.ts | 70 +++++++++++++++++++ 5 files changed, 108 insertions(+), 4 deletions(-) create mode 100644 packages/server-utils/test/integrations/cron.test.ts diff --git a/dev-packages/node-integration-tests/suites/cron/cron-integration/scenario.mjs b/dev-packages/node-integration-tests/suites/cron/cron-integration/scenario.mjs index ad4b46558324..0a665ee9727c 100644 --- a/dev-packages/node-integration-tests/suites/cron/cron-integration/scenario.mjs +++ b/dev-packages/node-integration-tests/suites/cron/cron-integration/scenario.mjs @@ -1,4 +1,4 @@ -import { CronJob } from 'cron'; +import { CronJob, CronTime } from 'cron'; const excluded = CronJob.from({ cronTime: '0 0 * * *', onTick: () => {}, name: 'excluded-job' }); const unnamed = new CronJob('0 0 * * *', () => {}); @@ -15,7 +15,7 @@ const job = CronJob.from({ errorHandler: () => {}, onTick: async () => { runs++; - if (runs > 1) { + if (runs === 2) { throw new Error('Error in cron job'); } }, @@ -30,6 +30,8 @@ const job = CronJob.from({ await subMinute.fireOnTick(); await job.fireOnTick(); await job.fireOnTick(); + job.setTime(new CronTime('0 8 * * *', 'UTC')); + await job.fireOnTick(); })(); setTimeout(() => process.exit(), 10_000); diff --git a/dev-packages/node-integration-tests/suites/cron/cron-integration/test.ts b/dev-packages/node-integration-tests/suites/cron/cron-integration/test.ts index 3b1efd64f2e3..640ac8b24041 100644 --- a/dev-packages/node-integration-tests/suites/cron/cron-integration/test.ts +++ b/dev-packages/node-integration-tests/suites/cron/cron-integration/test.ts @@ -33,6 +33,14 @@ describe('cronJobIntegration', () => { check_in: { monitor_slug: 'my-cron-job', status: 'in_progress', monitor_config: MONITOR_CONFIG }, }) .expect({ check_in: { monitor_slug: 'my-cron-job', status: 'error', duration: expect.any(Number) } }) + .expect({ + check_in: { + monitor_slug: 'my-cron-job', + status: 'in_progress', + monitor_config: { schedule: { type: 'crontab', value: '0 8 * * *' }, timezone: 'UTC' }, + }, + }) + .expect({ check_in: { monitor_slug: 'my-cron-job', status: 'ok', duration: expect.any(Number) } }) .start() .completed(); }); diff --git a/packages/server-utils/src/integrations/cron.ts b/packages/server-utils/src/integrations/cron.ts index 93a5a20807b8..52b2f945b2c6 100644 --- a/packages/server-utils/src/integrations/cron.ts +++ b/packages/server-utils/src/integrations/cron.ts @@ -1,13 +1,15 @@ -import type { IntegrationFn } from '@sentry/core'; +import type { IntegrationFn, MonitorConfig } from '@sentry/core'; import { defineIntegration } from '@sentry/core'; import { CHANNELS } from '../orchestrion/channels'; import { cronModuleNames } from '../orchestrion/config/cron'; import { invokeOrchestrionInstrumentation } from '../orchestrion/instrumentation'; -import type { AnyFn } from '../utils/cronMonitoring'; +import type { AnyFn, ScheduleChange } from '../utils/cronMonitoring'; import { cronExpressionToMonitorConfig, shouldMonitorJob, subscribeToJobCreation, + subscribeToScheduleChange, + updateMonitorConfig, wrapScheduledJob, } from '../utils/cronMonitoring'; @@ -37,6 +39,22 @@ export const cronJobIntegration = defineIntegration(_cronJobIntegration); function subscribe(options: CronJobIntegrationOptions): void { subscribeToJobCreation(CHANNELS.CRON_JOB_CONSTRUCTOR, INTEGRATION_NAME, args => wrapJob(args, options)); + subscribeToScheduleChange(CHANNELS.CRON_JOB_SET_TIME, INTEGRATION_NAME, onSetTime); +} + +/** Sends the new time's config after `job.setTime(cronTime)`. */ +export function onSetTime({ arguments: [cronTime], self }: ScheduleChange): void { + const job = self as { cronTime?: unknown; _callbacks?: unknown[] } | undefined; + // `setTime` throws and keeps the old time when it isn't given a `CronTime`. + if (!job || job.cronTime !== cronTime) { + return; + } + const { source, timeZone, utcOffset } = cronTime as { source?: unknown; timeZone?: unknown; utcOffset?: unknown }; + const monitorConfig: MonitorConfig | undefined = + typeof source === 'string' && utcOffset == null ? cronExpressionToMonitorConfig(source, timeZone, 'or') : undefined; + for (const callback of job._callbacks || []) { + updateMonitorConfig(callback, monitorConfig); + } } // `new CronJob(cronTime, onTick, onComplete, start, timeZone, context, runOnInit, utcOffset, ..., name)` diff --git a/packages/server-utils/src/orchestrion/config/cron.ts b/packages/server-utils/src/orchestrion/config/cron.ts index d15c1d90821a..499d224aad9e 100644 --- a/packages/server-utils/src/orchestrion/config/cron.ts +++ b/packages/server-utils/src/orchestrion/config/cron.ts @@ -8,10 +8,16 @@ export const cronConfig = [ module: { name: 'cron', versionRange: '>=4.3.0 <5', filePath: 'dist/job.js' }, functionQuery: { className: 'CronJob' }, }, + { + channelName: 'cronJobSetTime', + module: { name: 'cron', versionRange: '>=4.3.0 <5', filePath: 'dist/job.js' }, + functionQuery: { className: 'CronJob', methodName: 'setTime', kind: 'Sync' }, + }, ] satisfies InstrumentationConfig[]; export const cronModuleNames = getModuleNames(cronConfig); export const cronChannels = { CRON_JOB_CONSTRUCTOR: 'orchestrion:cron:cronJobConstructor', + CRON_JOB_SET_TIME: 'orchestrion:cron:cronJobSetTime', } as const; diff --git a/packages/server-utils/test/integrations/cron.test.ts b/packages/server-utils/test/integrations/cron.test.ts new file mode 100644 index 000000000000..92ec1d110762 --- /dev/null +++ b/packages/server-utils/test/integrations/cron.test.ts @@ -0,0 +1,70 @@ +import type { MonitorConfig } from '@sentry/core'; +import { setCurrentClient } from '@sentry/core'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { onSetTime } from '../../src/integrations/cron'; +import { cronExpressionToMonitorConfig, wrapScheduledJob } from '../../src/utils/cronMonitoring'; +import { getDefaultTestClientOptions, TestClient } from '../mocks/client'; + +describe('onSetTime', () => { + let sentConfigs: Array; + + beforeEach(() => { + sentConfigs = []; + const client = new TestClient(getDefaultTestClientOptions()); + client.captureCheckIn = (_checkIn, monitorConfig) => { + sentConfigs.push(monitorConfig); + return 'check-in-id'; + }; + setCurrentClient(client); + }); + + const createJob = () => { + const onTick = wrapScheduledJob( + () => {}, + 'my-job', + cronExpressionToMonitorConfig('0 9 * * *', 'UTC', 'or'), + 'auto.function.cron', + ); + return { cronTime: {} as unknown, _callbacks: [onTick], onTick }; + }; + + // `setTime` assigns the new `CronTime` before the channel's `end` fires. + const setTime = (job: ReturnType, cronTime: unknown, applied = true): void => { + if (applied) { + job.cronTime = cronTime; + } + onSetTime({ arguments: [cronTime], self: job }); + }; + + it('sends the new schedule after setTime', () => { + const job = createJob(); + setTime(job, { source: '30 10 * * 1-5', timeZone: 'Europe/Vienna' }); + job.onTick(); + + expect(sentConfigs[0]).toEqual({ + schedule: { type: 'crontab', value: '30 10 * * 1-5' }, + timezone: 'Europe/Vienna', + }); + }); + + it.each([ + ['a date', { source: new Date(), timeZone: 'UTC' }], + ['a UTC offset', { source: '0 9 * * *', utcOffset: 60 }], + ['a sub-minute cron', { source: '*/10 * * * * *', timeZone: 'UTC' }], + ])('sends no config after setTime with %s', (_, cronTime) => { + const job = createJob(); + setTime(job, cronTime); + job.onTick(); + + expect(sentConfigs).toHaveLength(2); + expect(sentConfigs[0]).toBeUndefined(); + }); + + it('keeps the old schedule when setTime throws', () => { + const job = createJob(); + setTime(job, '30 10 * * *', false); + job.onTick(); + + expect(sentConfigs[0]?.schedule).toEqual({ type: 'crontab', value: '0 9 * * *' }); + }); +}); From edf2eb327163e1bdf6de7c68c668e8321d6a7dcd Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Thu, 29 Oct 2026 11:03:58 -0700 Subject: [PATCH 10/10] fix(node): Skip jobs with unsupported schedules when monitoring all jobs --- .../suites/cron/cron-integration/test.ts | 10 ---------- packages/server-utils/src/integrations/cron.ts | 5 +++-- packages/server-utils/test/integrations/cron.test.ts | 5 ++--- 3 files changed, 5 insertions(+), 15 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/cron/cron-integration/test.ts b/dev-packages/node-integration-tests/suites/cron/cron-integration/test.ts index 640ac8b24041..87a0286c5309 100644 --- a/dev-packages/node-integration-tests/suites/cron/cron-integration/test.ts +++ b/dev-packages/node-integration-tests/suites/cron/cron-integration/test.ts @@ -1,4 +1,3 @@ -import type { SerializedCheckIn } from '@sentry/core'; import { afterAll, describe, expect } from 'vitest'; import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; @@ -7,11 +6,6 @@ const MONITOR_CONFIG = { timezone: 'Europe/Vienna', }; -const expectNoConfig = (slug: string) => (checkIn: SerializedCheckIn) => { - expect(checkIn).toMatchObject({ monitor_slug: slug, status: 'in_progress' }); - expect(checkIn.monitor_config).toBeUndefined(); -}; - describe('cronJobIntegration', () => { afterAll(() => { cleanupChildProcesses(); @@ -21,10 +15,6 @@ describe('cronJobIntegration', () => { test('sends check-ins for named jobs', async () => { await createRunner() .ignore('event') - .expect({ check_in: expectNoConfig('offset-job') }) - .expect({ check_in: { monitor_slug: 'offset-job', status: 'ok' } }) - .expect({ check_in: expectNoConfig('sub-minute-job') }) - .expect({ check_in: { monitor_slug: 'sub-minute-job', status: 'ok' } }) .expect({ check_in: { monitor_slug: 'my-cron-job', status: 'in_progress', monitor_config: MONITOR_CONFIG }, }) diff --git a/packages/server-utils/src/integrations/cron.ts b/packages/server-utils/src/integrations/cron.ts index 52b2f945b2c6..8a08c2fede4f 100644 --- a/packages/server-utils/src/integrations/cron.ts +++ b/packages/server-utils/src/integrations/cron.ts @@ -33,7 +33,8 @@ const _cronJobIntegration = ((options: CronJobIntegrationOptions = {}) => { /** * Sends Crons check-ins for every `CronJob` of the `cron` package (4.3+) that has a `name`. - * The monitor slug is the job name; jobs without a name, and one-off jobs, are not monitored. + * The monitor slug is the job name. Jobs without a name, one-off jobs, and jobs with a schedule Crons can't express + * are not monitored. */ export const cronJobIntegration = defineIntegration(_cronJobIntegration); @@ -42,7 +43,7 @@ function subscribe(options: CronJobIntegrationOptions): void { subscribeToScheduleChange(CHANNELS.CRON_JOB_SET_TIME, INTEGRATION_NAME, onSetTime); } -/** Sends the new time's config after `job.setTime(cronTime)`. */ +/** Sets the new time's config after `job.setTime(cronTime)`. Without one, the job doesn't check in. */ export function onSetTime({ arguments: [cronTime], self }: ScheduleChange): void { const job = self as { cronTime?: unknown; _callbacks?: unknown[] } | undefined; // `setTime` throws and keeps the old time when it isn't given a `CronTime`. diff --git a/packages/server-utils/test/integrations/cron.test.ts b/packages/server-utils/test/integrations/cron.test.ts index 92ec1d110762..ec83e6882d98 100644 --- a/packages/server-utils/test/integrations/cron.test.ts +++ b/packages/server-utils/test/integrations/cron.test.ts @@ -51,13 +51,12 @@ describe('onSetTime', () => { ['a date', { source: new Date(), timeZone: 'UTC' }], ['a UTC offset', { source: '0 9 * * *', utcOffset: 60 }], ['a sub-minute cron', { source: '*/10 * * * * *', timeZone: 'UTC' }], - ])('sends no config after setTime with %s', (_, cronTime) => { + ])('stops checking in after setTime with %s', (_, cronTime) => { const job = createJob(); setTime(job, cronTime); job.onTick(); - expect(sentConfigs).toHaveLength(2); - expect(sentConfigs[0]).toBeUndefined(); + expect(sentConfigs).toEqual([]); }); it('keeps the old schedule when setTime throws', () => {