diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d95711c3678..1cd97aa38ba7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,10 @@ Work in this release was contributed by @lazerg, @chiliec, @61465, @jpdefo, and `@SentryCron` now sends the schedule and time zone from the method's `@Cron()` decorator, so Sentry creates the monitor on the first run. If you use `@SentryCron` without a `schedule`, Sentry now creates missing monitors, which are billed, and overwrites schedules and time zones set by hand. Pass `fromCronDecorator: false` to turn this off. +- **feat(node): Add cronJobIntegration to monitor cron jobs ([#25477](https://github.com/getsentry/sentry-javascript/pull/25477))** + + Add `Sentry.cronJobIntegration()` to `integrations` to send check-ins for every named `CronJob` of the `cron` package (4.3+), with the job name as the monitor slug. It's off by default. Each monitored job creates a monitor in Sentry, and monitors are billed. Use `excludeJobs` to skip jobs by name. `Sentry.cron.instrumentCron` is deprecated; use `cronJobIntegration` instead. + - **feat(nextjs): Mark pageloads of cached pages ([#25318](https://github.com/getsentry/sentry-javascript/pull/25318))** When Next.js serves a page from its cache, for example an ISR page or a `cacheComponents` shell, the pageload now has the `page.server_rendering.type` attribute. It is `static` if the whole page came from the cache, and `partially_static` if parts of it rendered for this request. Then the pageload stays connected to this request, as before. If the request that rendered the cached page was sampled, the pageload also links to it. So in Sentry, a cached page no longer looks like missing server instrumentation. diff --git a/dev-packages/bun-integration-tests/node-suites/excludes.ts b/dev-packages/bun-integration-tests/node-suites/excludes.ts index 0e868ffeba36..c16bf072fc8b 100644 --- a/dev-packages/bun-integration-tests/node-suites/excludes.ts +++ b/dev-packages/bun-integration-tests/node-suites/excludes.ts @@ -65,6 +65,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/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index c07ba4ce829a..4c2842fc6d5a 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -78,7 +78,7 @@ "bullmq": "^5.79.1", "consola": "^3.2.3", "cors": "^2.8.5", - "cron": "^3.1.6", + "cron": "^4.4.0", "dataloader": "2.2.3", "esbuild": "0.28.1", "express": "^4.21.2", 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..0a665ee9727c --- /dev/null +++ b/dev-packages/node-integration-tests/suites/cron/cron-integration/scenario.mjs @@ -0,0 +1,37 @@ +import { CronJob, CronTime } 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({ + cronTime: '0 30 9 * * 1-5', + timeZone: 'Europe/Vienna', + name: 'my-cron-job', + waitForCompletion: true, + errorHandler: () => {}, + onTick: async () => { + runs++; + if (runs === 2) { + throw new Error('Error in cron job'); + } + }, +}); + +// Run the jobs without waiting for their schedule. +(async () => { + await excluded.fireOnTick(); + await unnamed.fireOnTick(); + await oneOff.fireOnTick(); + await offset.fireOnTick(); + 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 new file mode 100644 index 000000000000..87a0286c5309 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/cron/cron-integration/test.ts @@ -0,0 +1,58 @@ +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) } }) + .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(); + }); + + 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 6837722ba5b6..f407a5723a5b 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -49,6 +49,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 0e684c45dc2d..f7310775b554 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -119,6 +119,7 @@ export { firebaseIntegration, fsIntegration, genericPoolIntegration, + cronJobIntegration, nodeCronIntegration, nodeScheduleIntegration, graphqlIntegration, diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index e33986f88bba..a8e107ffdfe8 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -143,6 +143,7 @@ export { // oxlint-disable-next-line typescript/no-deprecated setupKoaErrorHandler, genericPoolIntegration, + cronJobIntegration, nodeCronIntegration, nodeScheduleIntegration, graphqlIntegration, diff --git a/packages/deno/src/index.ts b/packages/deno/src/index.ts index 9f2aac8b30cb..4d5fb75365e7 100644 --- a/packages/deno/src/index.ts +++ b/packages/deno/src/index.ts @@ -195,6 +195,7 @@ export { postgresIntegration, postgresJsIntegration, neonIntegration, + cronJobIntegration, nodeCronIntegration, nodeScheduleIntegration, tediousIntegration, diff --git a/packages/elysia/src/index.ts b/packages/elysia/src/index.ts index 014bd9145c4a..107545f0cb43 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 b92984d1b6b6..107674b83b95 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -119,6 +119,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..dd3edf1c95b5 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 = { @@ -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'; /** @@ -72,6 +74,8 @@ const ERROR_TEXT = 'Automatic instrumentation of CronJob only supports crontab s * console.log('You will see this message every minute'); * }); * ``` + * + * @deprecated Use `cronJobIntegration` instead. */ export function instrumentCron(lib: T & CronJobConstructor, monitorSlug: string): T { let jobScheduled = false; @@ -115,6 +119,9 @@ export function instrumentCron(lib: T & CronJobConstructor, monitorSlug: stri ); } + // Tells `cronJobIntegration` that this job is already monitored. + addNonEnumerableProperty(monitoredTick, MONITORED, true); + return new target(cronTime, monitoredTick, onComplete, start, timeZone, ...rest); }, get(target, prop: keyof CronJobConstructor) { @@ -157,6 +164,8 @@ export function instrumentCron(lib: T & CronJobConstructor, monitorSlug: stri ); }; + addNonEnumerableProperty(param.onTick, MONITORED, true); + return target.from(param); }; } else { diff --git a/packages/node/src/cron/index.ts b/packages/node/src/cron/index.ts index eb4b915cff66..890d88441ec1 100644 --- a/packages/node/src/cron/index.ts +++ b/packages/node/src/cron/index.ts @@ -4,6 +4,7 @@ import { instrumentNodeSchedule } from './node-schedule'; /** Methods to instrument cron libraries for Sentry check-ins */ export const cron = { + // oxlint-disable-next-line typescript/no-deprecated instrumentCron, instrumentNodeCron, instrumentNodeSchedule, diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 4cf8b6e4b9eb..859d3b02719a 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -13,6 +13,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 10fc1a596daa..e034d9404d05 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 cb666c2fc181..74e4dd25e64a 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..8a08c2fede4f --- /dev/null +++ b/packages/server-utils/src/integrations/cron.ts @@ -0,0 +1,71 @@ +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, ScheduleChange } from '../utils/cronMonitoring'; +import { + cronExpressionToMonitorConfig, + shouldMonitorJob, + subscribeToJobCreation, + subscribeToScheduleChange, + updateMonitorConfig, + wrapScheduledJob, +} from '../utils/cronMonitoring'; + +const INTEGRATION_NAME = 'CronJob' as const; + +export interface CronJobIntegrationOptions { + /** Job names not to monitor. Strings must match the name exactly. */ + excludeJobs?: Array; +} + +const _cronJobIntegration = ((options: CronJobIntegrationOptions = {}) => { + return { + name: INTEGRATION_NAME, + setup(client) { + invokeOrchestrionInstrumentation(client, cronModuleNames, subscribe, [options], { + 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, one-off jobs, and jobs with a schedule Crons can't express + * are not monitored. + */ +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); +} + +/** 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`. + 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)` +function wrapJob(args: unknown[], options: CronJobIntegrationOptions): 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'); +} 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..499d224aad9e --- /dev/null +++ b/packages/server-utils/src/orchestrion/config/cron.ts @@ -0,0 +1,23 @@ +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' }, + }, + { + 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/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 aa751eed9442..117e37c513ef 100644 --- a/packages/server-utils/src/utils/cronMonitoring.ts +++ b/packages/server-utils/src/utils/cronMonitoring.ts @@ -13,7 +13,8 @@ import * as diagnosticsChannel from './diagnosticsChannel'; export type AnyFn = (this: unknown, ...args: unknown[]) => unknown; -// Set by `Sentry.cron.instrumentNodeCron`, `Sentry.cron.instrumentNodeSchedule` and `wrapScheduledJob`, which already send check-ins. +// Set by `Sentry.cron.instrumentCron`, `instrumentNodeCron`, `instrumentNodeSchedule` and `wrapScheduledJob`, which already +// send check-ins. const MONITORED = Symbol.for('sentry.cron.monitored'); /** @@ -65,7 +66,7 @@ export function subscribeToScheduleChange( channel.end.subscribe(handle(onChange)); } -/** Whether a job is named, not excluded, and not already monitored by `Sentry.cron.instrumentNode*` or `wrapScheduledJob`. */ +/** Whether a job is named, not excluded, and not already monitored by `Sentry.cron.instrument*` or `wrapScheduledJob`. */ export function shouldMonitorJob( name: unknown, job: unknown, @@ -88,6 +89,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']; @@ -120,18 +124,13 @@ export function cronExpressionToMonitorConfig( return undefined; } - const [minute, hour, dayOfMonth, month, dayOfWeek] = fields as [string, string, string, string, string]; + const [minute, hour, dayOfMonth, month, dayOfWeekWithNames] = fields as [string, string, string, string, string]; + const dayOfWeek = replaceNames(dayOfWeekWithNames, WEEKDAYS, 0); if (!isSameDayMatching(dayOfMonth, dayOfWeek, daySemantics)) { return undefined; } - const crontab = [ - minute, - hour, - dayOfMonth, - replaceNames(month, MONTHS, 1), - endRangesOnSundayAsSeven(replaceNames(dayOfWeek, WEEKDAYS, 0)), - ]; + const crontab = [minute, hour, dayOfMonth, replaceNames(month, MONTHS, 1), endRangesOnSundayAsSeven(dayOfWeek)]; if (crontab.some(field => /[a-z]/i.test(field) || hasReversedRange(field))) { return undefined; } @@ -152,14 +151,36 @@ function isSameDayMatching(dayOfMonth: string, dayOfWeek: string, daySemantics: if (daySemantics === 'and') { return dayOfMonth.startsWith('*') || dayOfWeek.startsWith('*'); } + // `cron` reads a field that lists every day like `*`. `cron-parser` does so for a day-of-month that lists every day + // of the current month, and February has 29 there. return !( dayOfMonth.startsWith('*') || dayOfWeek.startsWith('*') || - dayOfMonth === '1-31' || - ['0-6', '0-7', '1-7'].includes(dayOfWeek) + countValues(dayOfMonth, 1, 31) >= 29 || + countValues(dayOfWeek, 0, 7, day => day % 7) === 7 ); } +function countValues(field: string, min: number, max: number, normalize = (value: number): number => value): number { + const values = new Set(); + for (const part of field.split(',')) { + const match = /^(?:\*|(\d+)(?:-(\d+))?)(?:\/(\d+))?$/.exec(part); + if (!match) { + continue; + } + const [, start, end, step] = match; + const first = start === undefined ? min : Number(start); + const last = Math.min( + end !== undefined ? Number(end) : start !== undefined && step === undefined ? first : max, + max, + ); + for (let value = first; value <= last; value += Number(step) || 1) { + values.add(normalize(value)); + } + } + return values.size; +} + 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()); 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..ec83e6882d98 --- /dev/null +++ b/packages/server-utils/test/integrations/cron.test.ts @@ -0,0 +1,69 @@ +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' }], + ])('stops checking in after setTime with %s', (_, cronTime) => { + const job = createJob(); + setTime(job, cronTime); + job.onTick(); + + expect(sentConfigs).toEqual([]); + }); + + 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 * * *' }); + }); +}); diff --git a/packages/server-utils/test/orchestrion/moduleInjectedTransform.test.ts b/packages/server-utils/test/orchestrion/moduleInjectedTransform.test.ts index 0feac3d58b47..06fa8e34704d 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. // Each cron monitor is billed, so bundler-only SDKs must not install crons integrations 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/'), ); diff --git a/packages/server-utils/test/utils/cronMonitoring.test.ts b/packages/server-utils/test/utils/cronMonitoring.test.ts index 0950a592cc87..d21b12fa7fe4 100644 --- a/packages/server-utils/test/utils/cronMonitoring.test.ts +++ b/packages/server-utils/test/utils/cronMonitoring.test.ts @@ -15,6 +15,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 * * * *'], ['0 9 * * sun', '0 9 * * 0'], ['0 9 * * sat-sun', '0 9 * * 6-7'], @@ -68,6 +69,12 @@ describe('cronExpressionToMonitorConfig with cron-parser day matching', () => { ['0 9 1,15 * mon', '0 9 1,15 * 1'], ['0 9 */2 * *', '0 9 */2 * *'], ['0 9 * * 1-5', '0 9 * * 1-5'], + ['0 9 15 * sun-fri', '0 9 15 * 0-5'], + ['0 9 15 * 1,2,3,4,5,6', '0 9 15 * 1,2,3,4,5,6'], + ['0 9 1-28 * 1', '0 9 1-28 * 1'], + ['0 9 15 * 0', '0 9 15 * 0'], + ['0 9 15 * sun', '0 9 15 * 0'], + ['0 9 15 * 0,6', '0 9 15 * 0,6'], ])('sends %s as %s', (expression, crontab) => { expect(cronExpressionToMonitorConfig(expression, 'UTC', 'or')?.schedule).toEqual({ type: 'crontab', @@ -75,12 +82,25 @@ describe('cronExpressionToMonitorConfig with cron-parser day matching', () => { }); }); - 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(); - }, - ); + it.each([ + '0 9 */2 * 1', + '0 9 1 * */2', + '0 9 1-31 * 1', + '0 9 1-30 * 1', + '0 9 1-15,16-29 * 1', + '0 9 15 * 0-6', + '0 9 15 * 0-7', + '0 9 15 * 1-7', + '0 9 15 * sun-sat', + '0 9 15 * SUN-SAT', + '0 9 15 * 0,1,2,3,4,5,6', + '0 9 15 * 1,2,3,4,5,6,7', + '0 9 15 * sun,mon,tue,wed,thu,fri,sat', + '0 9 15 * 0-3,4-6', + '0 9 15 * 0-6/1', + ])('sends no config for %s', expression => { + expect(cronExpressionToMonitorConfig(expression, 'UTC', 'or')).toBeUndefined(); + }); }); describe('shouldMonitorJob', () => { diff --git a/packages/solidstart/src/server/index.ts b/packages/solidstart/src/server/index.ts index 4ac8bff68e34..39c5b31b1d38 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 ff3d3ac7b5dd..711a9b4ae3bf 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, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7d6a36f58858..9b1cdda37df2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -751,8 +751,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.3 version: 2.2.3 @@ -8254,6 +8254,7 @@ packages: '@smithy/core@3.38.1': resolution: {integrity: sha512-U/mB2XiwTLaf+NSgLSWwJfptsMT/H8aBi0aTAA/E5e2/TagFeGCqAyhKGG0w0A9IWTkm4qYEmLoD/HB6aYfnRA==} engines: {node: '>=18.0.0'} + deprecated: Deprecated due to a bug in custom logger data redaction. See https://github.com/aws/aws-sdk-js-v3/issues/8393. '@smithy/credential-provider-imds@4.5.2': resolution: {integrity: sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==} @@ -8858,8 +8859,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==} @@ -10890,8 +10891,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==} @@ -14135,8 +14137,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: @@ -28057,7 +28059,7 @@ snapshots: '@types/long@4.0.2': {} - '@types/luxon@3.3.8': {} + '@types/luxon@3.7.6': {} '@types/mdast@4.0.1': dependencies: @@ -30651,12 +30653,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: {} @@ -34576,7 +34578,7 @@ snapshots: lru.min@1.1.4: {} - luxon@3.4.4: {} + luxon@3.7.2: {} lz-string@1.5.0: {}