diff --git a/dev-packages/bun-integration-tests/node-suites/excludes.ts b/dev-packages/bun-integration-tests/node-suites/excludes.ts index 13edde1d44bd..6e2ae93fe6c6 100644 --- a/dev-packages/bun-integration-tests/node-suites/excludes.ts +++ b/dev-packages/bun-integration-tests/node-suites/excludes.ts @@ -63,6 +63,7 @@ const NO_FETCH_INSTRUMENTATION = [ // See https://github.com/getsentry/sentry-javascript/issues/23882 export const NO_AUTO_INSTRUMENTATION = [ 'suites/cron/node-cron/integration/test.ts', + 'suites/cron/node-schedule-integration/test.ts', 'suites/express/**', 'suites/fs-instrumentation/test.ts', 'suites/hono/test.ts', diff --git a/dev-packages/node-integration-tests/suites/cron/node-schedule-integration/instrument.mjs b/dev-packages/node-integration-tests/suites/cron/node-schedule-integration/instrument.mjs new file mode 100644 index 000000000000..a3011f204b5e --- /dev/null +++ b/dev-packages/node-integration-tests/suites/cron/node-schedule-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.nodeScheduleIntegration({ excludeJobs: ['excluded-job'] })], +}); diff --git a/dev-packages/node-integration-tests/suites/cron/node-schedule-integration/scenario.mjs b/dev-packages/node-integration-tests/suites/cron/node-schedule-integration/scenario.mjs new file mode 100644 index 000000000000..7cb179a7483a --- /dev/null +++ b/dev-packages/node-integration-tests/suites/cron/node-schedule-integration/scenario.mjs @@ -0,0 +1,35 @@ +import schedule from 'node-schedule'; + +const excluded = schedule.scheduleJob('excluded-job', '0 0 * * *', () => {}); +const unnamed = schedule.scheduleJob('0 0 * * *', () => {}); +const oneOff = schedule.scheduleJob('one-off-job', Date.now() + 86_400_000, () => {}); +const recurrenceRule = schedule.scheduleJob('rule-job', { hour: 3, minute: 0 }, () => {}); +const ending = schedule.scheduleJob('ending-job', { rule: '0 0 * * *', end: Date.now() + 86_400_000 }, () => {}); +const newJob = new schedule.Job('new-job', () => {}); +newJob.schedule({ rule: '0 0 * * *', tz: 'UTC' }); + +let runs = 0; +const job = schedule.scheduleJob('my-schedule-job', { rule: '30 9 * * 1-5', tz: 'Europe/Vienna' }, () => { + runs++; + if (runs === 2) { + throw new Error('Error in scheduled job'); + } +}); + +// Run the jobs without waiting for their schedule. +excluded.invoke(); +unnamed.invoke(); +oneOff.invoke(); +recurrenceRule.invoke(); +ending.invoke(); +newJob.invoke(); +job.invoke(); +try { + job.invoke(); +} catch { + // node-schedule catches errors when it runs the job itself +} +schedule.rescheduleJob(job, { rule: '0 8 * * *', tz: 'UTC' }); +job.invoke(); + +setTimeout(() => process.exit(), 10_000); diff --git a/dev-packages/node-integration-tests/suites/cron/node-schedule-integration/test.ts b/dev-packages/node-integration-tests/suites/cron/node-schedule-integration/test.ts new file mode 100644 index 000000000000..de5b683e9b35 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/cron/node-schedule-integration/test.ts @@ -0,0 +1,76 @@ +import type { SerializedCheckIn } from '@sentry/core'; +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', +}; + +const expectNoConfig = (slug: string) => (checkIn: SerializedCheckIn) => { + expect(checkIn).toMatchObject({ monitor_slug: slug, status: 'in_progress' }); + expect(checkIn.monitor_config).toBeUndefined(); +}; + +describe('nodeScheduleIntegration', () => { + 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: expectNoConfig('rule-job') }) + .expect({ check_in: { monitor_slug: 'rule-job', status: 'ok' } }) + .expect({ check_in: expectNoConfig('ending-job') }) + .expect({ check_in: { monitor_slug: 'ending-job', status: 'ok' } }) + .expect({ + check_in: { + monitor_slug: 'new-job', + status: 'in_progress', + monitor_config: { schedule: { type: 'crontab', value: '0 0 * * *' }, timezone: 'UTC' }, + }, + }) + .expect({ check_in: { monitor_slug: 'new-job', status: 'ok' } }) + .expect({ + check_in: { monitor_slug: 'my-schedule-job', status: 'in_progress', monitor_config: MONITOR_CONFIG }, + }) + .expect({ check_in: { monitor_slug: 'my-schedule-job', status: 'ok', duration: expect.any(Number) } }) + .expect({ + check_in: { monitor_slug: 'my-schedule-job', status: 'in_progress', monitor_config: MONITOR_CONFIG }, + }) + .expect({ check_in: { monitor_slug: 'my-schedule-job', status: 'error', duration: expect.any(Number) } }) + .expect({ + check_in: { + monitor_slug: 'my-schedule-job', + status: 'in_progress', + monitor_config: { schedule: { type: 'crontab', value: '0 8 * * *' }, timezone: 'UTC' }, + }, + }) + .expect({ check_in: { monitor_slug: 'my-schedule-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 scheduled job', + mechanism: { type: 'auto.function.node-schedule', handled: false }, + }, + ], + }, + }, + }) + .start() + .completed(); + }); + }); +}); diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index 5bf4270cc686..69d9dee08287 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -48,6 +48,7 @@ export { functionToStringIntegration, genericPoolIntegration, nodeCronIntegration, + nodeScheduleIntegration, getActiveSpan, getAutoPerformanceIntegrations, getClient, diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index b1d9f3118ffd..8c45d2f267d9 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -117,6 +117,7 @@ export { fsIntegration, genericPoolIntegration, nodeCronIntegration, + nodeScheduleIntegration, graphqlIntegration, knexIntegration, kafkaIntegration, diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index 3fe12fa763f0..4c4e7c48b010 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -139,6 +139,7 @@ export { setupKoaErrorHandler, genericPoolIntegration, nodeCronIntegration, + nodeScheduleIntegration, graphqlIntegration, knexIntegration, kafkaIntegration, diff --git a/packages/deno/src/index.ts b/packages/deno/src/index.ts index b22aac2bdf6c..8d922f286434 100644 --- a/packages/deno/src/index.ts +++ b/packages/deno/src/index.ts @@ -184,6 +184,7 @@ export { postgresJsIntegration, neonIntegration, nodeCronIntegration, + nodeScheduleIntegration, tediousIntegration, eveConversationHook, eveIntegration, diff --git a/packages/elysia/src/index.ts b/packages/elysia/src/index.ts index 7ce2acd76db9..a796fc84ecc7 100644 --- a/packages/elysia/src/index.ts +++ b/packages/elysia/src/index.ts @@ -118,6 +118,7 @@ export { setupKoaErrorHandler, genericPoolIntegration, nodeCronIntegration, + nodeScheduleIntegration, graphqlIntegration, knexIntegration, kafkaIntegration, diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index d6479c4bdc7c..317424507421 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -117,6 +117,7 @@ export { firebaseIntegration, genericPoolIntegration, nodeCronIntegration, + nodeScheduleIntegration, graphqlIntegration, knexIntegration, kafkaIntegration, diff --git a/packages/node/src/cron/node-schedule.ts b/packages/node/src/cron/node-schedule.ts index f174ef7c904b..548f60ac48f8 100644 --- a/packages/node/src/cron/node-schedule.ts +++ b/packages/node/src/cron/node-schedule.ts @@ -1,4 +1,4 @@ -import { withMonitor } from '@sentry/core'; +import { addNonEnumerableProperty, withMonitor } from '@sentry/core'; import { replaceCronNames } from './common'; export interface NodeSchedule { @@ -58,6 +58,9 @@ export function instrumentNodeSchedule(lib: T & NodeSchedule): T { ); } + // Tells `nodeScheduleIntegration` that this job is already monitored. + addNonEnumerableProperty(monitoredCallback, Symbol.for('sentry.cron.monitored'), true); + return target.apply(thisArg, [monitorSlug, expression, monitoredCallback]); }, }); diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index a03c2953ceb5..5e4c60e9706e 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -13,6 +13,8 @@ export { genericPoolIntegration, nodeCronIntegration, type NodeCronIntegrationOptions, + nodeScheduleIntegration, + type NodeScheduleIntegrationOptions, googleGenAIIntegration, graphqlIntegration, groqIntegration, diff --git a/packages/node/test/cron.test.ts b/packages/node/test/cron.test.ts index bf1e83d315e5..f5178dc98e88 100644 --- a/packages/node/test/cron.test.ts +++ b/packages/node/test/cron.test.ts @@ -220,5 +220,14 @@ describe('cron check-ins', () => { "Automatic instrumentation of 'node-schedule' requires the first parameter of 'scheduleJob' to be a job name string and the second parameter to be a crontab string", ); }); + + test('marks the callback as monitored, so nodeScheduleIntegration skips it', () => { + const scheduleWithCheckIn = cron.instrumentNodeSchedule({ + scheduleJob: (_name: unknown, _expression: unknown, callback: unknown) => callback, + }); + + const callback = scheduleWithCheckIn.scheduleJob('my-cron-job', '* * * * *', () => {}) as Record; + expect(callback[Symbol.for('sentry.cron.monitored')]).toBe(true); + }); }); }); diff --git a/packages/remix/src/server/index.ts b/packages/remix/src/server/index.ts index ae0ea866bfc3..88ef6a841518 100644 --- a/packages/remix/src/server/index.ts +++ b/packages/remix/src/server/index.ts @@ -38,6 +38,7 @@ export { functionToStringIntegration, genericPoolIntegration, nodeCronIntegration, + nodeScheduleIntegration, getActiveSpan, getAutoPerformanceIntegrations, getClient, diff --git a/packages/server-utils/src/index.ts b/packages/server-utils/src/index.ts index acb0ffb47c75..005e8a4a8b79 100644 --- a/packages/server-utils/src/index.ts +++ b/packages/server-utils/src/index.ts @@ -40,6 +40,8 @@ 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 { nodeScheduleIntegration } from './integrations/node-schedule'; +export type { NodeScheduleIntegrationOptions } from './integrations/node-schedule'; 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-schedule.ts b/packages/server-utils/src/integrations/node-schedule.ts new file mode 100644 index 000000000000..1d86113a0706 --- /dev/null +++ b/packages/server-utils/src/integrations/node-schedule.ts @@ -0,0 +1,108 @@ +import type { IntegrationFn, MonitorConfig } from '@sentry/core'; +import { defineIntegration } from '@sentry/core'; +import { CHANNELS } from '../orchestrion/channels'; +import { nodeScheduleModuleNames } from '../orchestrion/config/node-schedule'; +import { invokeOrchestrionInstrumentation } from '../orchestrion/instrumentation'; +import type { AnyFn, ScheduleChange } from '../utils/cronMonitoring'; +import { + cronExpressionToMonitorConfig, + shouldMonitorJob, + subscribeToScheduleChange, + updateMonitorConfig, + wrapScheduledJob, +} from '../utils/cronMonitoring'; + +const INTEGRATION_NAME = 'NodeSchedule' as const; + +export interface NodeScheduleIntegrationOptions { + /** Job names not to monitor. Strings must match the name exactly. */ + excludeJobs?: Array; +} + +const _nodeScheduleIntegration = ((options: NodeScheduleIntegrationOptions = {}) => { + return { + name: INTEGRATION_NAME, + setup(client) { + invokeOrchestrionInstrumentation(client, nodeScheduleModuleNames, subscribe, [options], { + requiresTracingChannelBinding: false, + }); + }, + }; +}) satisfies IntegrationFn; + +/** + * Sends Crons check-ins for every named `node-schedule` job, whether it's created with `scheduleJob` or `new Job()`. + * The monitor slug is the job name; jobs without a name, and one-off jobs, are not monitored. + */ +export const nodeScheduleIntegration = defineIntegration(_nodeScheduleIntegration); + +function subscribe(options: NodeScheduleIntegrationOptions): void { + subscribeToScheduleChange(CHANNELS.NODE_SCHEDULE_SCHEDULE, INTEGRATION_NAME, onSchedule, change => + beforeSchedule(change, options), + ); +} + +interface Job { + name?: unknown; + job?: unknown; + nextInvocation?: () => unknown; +} + +// The name node-schedule gives jobs created without one. +const ANONYMOUS_NAME = /^]/; + +// `job.schedule(spec)` calls that add to runs the job already has pending, rather than replace them. +const addsToPendingRuns = new WeakSet(); + +/** Wraps a named job's method before `job.schedule(spec)` with a recurring spec, unless it's already wrapped. */ +export function beforeSchedule(change: ScheduleChange, options: NodeScheduleIntegrationOptions): void { + const job = change.self as Job | undefined; + if (!job) { + return; + } + if (typeof job.nextInvocation === 'function' && job.nextInvocation() != null) { + addsToPendingRuns.add(change); + } + const name = typeof job.name === 'string' && !ANONYMOUS_NAME.test(job.name) ? job.name : undefined; + if (isOneOffSpec(change.arguments[0]) || !shouldMonitorJob(name, job.job, options.excludeJobs)) { + return; + } + job.job = wrapScheduledJob(job.job as AnyFn, name, undefined, 'auto.function.node-schedule'); +} + +/** Sends the spec's config after `job.schedule(spec)`, or none when the job also keeps runs from an earlier spec. */ +export function onSchedule(change: ScheduleChange): void { + // `schedule` returns false when it scheduled nothing, and `reschedule` then keeps the old schedule. + if (change.result === true) { + const job = change.self as Job | undefined; + updateMonitorConfig(job?.job, addsToPendingRuns.has(change) ? undefined : getMonitorConfig(change.arguments[0])); + } +} + +/** Whether node-schedule runs a job with this spec once: a `Date`, a timestamp, or a date string. */ +export function isOneOffSpec(spec: unknown): boolean { + const { rule } = (spec || {}) as { rule?: unknown }; + const when = typeof spec === 'object' && rule ? rule : spec; + if (typeof when === 'string') { + // `Date.parse` also accepts some crons, like `0 9 * * *`. + return !isCronShaped(when) && !Number.isNaN(Date.parse(when)); + } + return when instanceof Date || typeof when === 'number'; +} + +function isCronShaped(value: string): boolean { + const fields = value.trim().split(/\s+/); + return ( + /^@[a-z]+$/i.test(value.trim()) || + ((fields.length === 5 || fields.length === 6) && fields.every(field => /^[\d*,/?#a-z-]+$/i.test(field))) + ); +} + +// Only cron strings are sent; recurrence rules and rules limited by `start` or `end` check in without a config. +export function getMonitorConfig(spec: unknown): MonitorConfig | undefined { + if (typeof spec === 'string') { + return cronExpressionToMonitorConfig(spec, undefined, 'or'); + } + const { rule, tz, start, end } = (spec || {}) as { rule?: unknown; tz?: unknown; start?: unknown; end?: unknown }; + return typeof rule === 'string' && !start && !end ? cronExpressionToMonitorConfig(rule, tz, 'or') : undefined; +} diff --git a/packages/server-utils/src/orchestrion/channels.ts b/packages/server-utils/src/orchestrion/channels.ts index 81f9b34d2e27..67a88bbb3825 100644 --- a/packages/server-utils/src/orchestrion/channels.ts +++ b/packages/server-utils/src/orchestrion/channels.ts @@ -27,6 +27,7 @@ import { mysqlChannels } from './config/mysql'; import { neonChannels } from './config/neon'; import { nestjsChannels } from './config/nestjs'; import { nodeCronChannels } from './config/node-cron'; +import { nodeScheduleChannels } from './config/node-schedule'; import { openaiChannels } from './config/openai'; import { pgChannels } from './config/pg'; import { piDurableChannels } from './config/pi-durable'; @@ -85,6 +86,7 @@ export const CHANNELS = { ...neonChannels, ...nestjsChannels, ...nodeCronChannels, + ...nodeScheduleChannels, ...openaiChannels, ...pgChannels, ...piDurableChannels, diff --git a/packages/server-utils/src/orchestrion/config/index.ts b/packages/server-utils/src/orchestrion/config/index.ts index 56b7bf008c56..bf2c4b50b178 100644 --- a/packages/server-utils/src/orchestrion/config/index.ts +++ b/packages/server-utils/src/orchestrion/config/index.ts @@ -34,6 +34,7 @@ import { neonConfig } from './neon'; import { nestjsConfig } from './nestjs'; import { nitroConfig } from './nitro'; import { nodeCronConfig } from './node-cron'; +import { nodeScheduleConfig } from './node-schedule'; import { openaiConfig } from './openai'; import { pgConfig } from './pg'; import { postgresJsConfig } from './postgres'; @@ -93,6 +94,7 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ ...nestjsConfig, ...nitroConfig, ...nodeCronConfig, + ...nodeScheduleConfig, ...openaiConfig, ...pgConfig, ...piDurableConfig, diff --git a/packages/server-utils/src/orchestrion/config/node-schedule.ts b/packages/server-utils/src/orchestrion/config/node-schedule.ts new file mode 100644 index 000000000000..acb3733a1b69 --- /dev/null +++ b/packages/server-utils/src/orchestrion/config/node-schedule.ts @@ -0,0 +1,29 @@ +import type { InstrumentationConfig } from '../apmTypes'; +import { getModuleNames } from './module-names'; + +// `scheduleJob`, `rescheduleJob` and `job.reschedule` all call `job.schedule(spec)`. `Job` moved from +// `lib/schedule.js` to `lib/Job.js` in 2.0.0. +const schedule = { + channelName: 'schedule', + astQuery: + "AssignmentExpression[left.object.property.name='prototype'][left.property.name='schedule'] > FunctionExpression", + // `methodName` gives subscribers `self` before the call, not just after. `astQuery` still picks the function. + functionQuery: { methodName: 'schedule', kind: 'Sync' }, +} as const; + +export const nodeScheduleConfig = [ + { + ...schedule, + module: { name: 'node-schedule', versionRange: '>=1.3.0 <2', filePath: 'lib/schedule.js' }, + }, + { + ...schedule, + module: { name: 'node-schedule', versionRange: '>=2.0.0 <3', filePath: 'lib/Job.js' }, + }, +] satisfies InstrumentationConfig[]; + +export const nodeScheduleModuleNames = getModuleNames(nodeScheduleConfig); + +export const nodeScheduleChannels = { + NODE_SCHEDULE_SCHEDULE: 'orchestrion:node-schedule:schedule', +} as const; diff --git a/packages/server-utils/src/utils/cronMonitoring.ts b/packages/server-utils/src/utils/cronMonitoring.ts index 03303db160b1..aabbbf0d9d79 100644 --- a/packages/server-utils/src/utils/cronMonitoring.ts +++ b/packages/server-utils/src/utils/cronMonitoring.ts @@ -1,5 +1,6 @@ import type { MonitorConfig } from '@sentry/core'; import { + addNonEnumerableProperty, captureException, debug, isThenable, @@ -12,7 +13,7 @@ import * as diagnosticsChannel from './diagnosticsChannel'; export type AnyFn = (this: unknown, ...args: unknown[]) => unknown; -// Set by `Sentry.cron.instrumentNodeCron`, which already sends check-ins. +// Set by `Sentry.cron.instrumentNodeCron`, `Sentry.cron.instrumentNodeSchedule` and `wrapScheduledJob`, which already send check-ins. const MONITORED = Symbol.for('sentry.cron.monitored'); /** @@ -33,7 +34,38 @@ export function subscribeToJobCreation( }); } -/** Whether a job is named, not excluded, and not already monitored by `Sentry.cron.instrumentNodeCron`. */ +/** A call on a schedule-change channel: its arguments, the job it was called on, and what it returned. */ +export interface ScheduleChange { + arguments: unknown[]; + self?: unknown; + result?: unknown; +} + +/** + * Calls `onChange` after each call on a scheduler's schedule-change channel, so it can update the job's monitor config. + * `onStart` gets the same object before the call. Errors are logged and the job keeps its config. + */ +export function subscribeToScheduleChange( + channelName: string, + integrationName: string, + onChange: (change: ScheduleChange) => void, + onStart?: (change: ScheduleChange) => void, +): void { + const channel = diagnosticsChannel.tracingChannel(channelName); + const handle = (handler: (change: ScheduleChange) => void) => (message: unknown) => { + try { + handler(message as ScheduleChange); + } catch (e) { + DEBUG_BUILD && debug.error(`[${integrationName}] Failed to update the cron monitor:`, e); + } + }; + if (onStart) { + channel.start.subscribe(handle(onStart)); + } + channel.end.subscribe(handle(onChange)); +} + +/** Whether a job is named, not excluded, and not already monitored by `Sentry.cron.instrumentNode*` or `wrapScheduledJob`. */ export function shouldMonitorJob( name: unknown, job: unknown, @@ -161,6 +193,8 @@ function toSentryTimezone(timezone: string | undefined): string | undefined { } } +const monitorConfigs = new WeakMap(); + /** * Wraps a scheduled job with check-ins. Errors are captured, because schedulers catch them, and rethrown. */ @@ -175,7 +209,7 @@ export function wrapScheduledJob( throw error; }; - return function (this: unknown, ...args: unknown[]): unknown { + const wrapped = function (this: unknown, ...args: unknown[]): unknown { return startNewTrace(() => withMonitor( monitorSlug, @@ -187,8 +221,18 @@ export function wrapScheduledJob( return captureAndRethrow(e); } }, - monitorConfig, + monitorConfigs.get(wrapped), ), ); }; + monitorConfigs.set(wrapped, monitorConfig); + addNonEnumerableProperty(wrapped, MONITORED, true); + return wrapped; +} + +/** Sets the monitor config a job from `wrapScheduledJob` sends on its next runs. Other functions are ignored. */ +export function updateMonitorConfig(job: unknown, monitorConfig: MonitorConfig | undefined): void { + if (typeof job === 'function' && monitorConfigs.has(job as AnyFn)) { + monitorConfigs.set(job as AnyFn, monitorConfig); + } } diff --git a/packages/server-utils/test/integrations/node-schedule.test.ts b/packages/server-utils/test/integrations/node-schedule.test.ts new file mode 100644 index 000000000000..52e1fb586b9c --- /dev/null +++ b/packages/server-utils/test/integrations/node-schedule.test.ts @@ -0,0 +1,152 @@ +import type { MonitorConfig } from '@sentry/core'; +import { setCurrentClient } from '@sentry/core'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { beforeSchedule, getMonitorConfig, isOneOffSpec, onSchedule } from '../../src/integrations/node-schedule'; +import { getDefaultTestClientOptions, TestClient } from '../mocks/client'; + +describe('isOneOffSpec', () => { + it.each([ + ['a Date', new Date()], + ['a timestamp', 1_793_527_200_000], + ['a date string', '2026-11-01T10:00:00Z'], + ['a long date string', 'Sun Nov 01 2026 10:00:00 GMT+0000'], + ['a Date rule', { rule: new Date(), tz: 'UTC' }], + ])('is true for %s', (_, spec) => { + expect(isOneOffSpec(spec)).toBe(true); + }); + + it.each([ + ['a cron string', '0 9 * * *'], + ['a cron with names', '0 9 * jan mon-fri'], + ['a cron with seconds', '*/10 * * * * *'], + ['a nickname', '@daily'], + ['a cron rule', { rule: '0 9 * * *', tz: 'UTC' }], + ['a recurrence rule', { hour: 3, minute: 0 }], + ])('is false for %s', (_, spec) => { + expect(isOneOffSpec(spec)).toBe(false); + }); +}); + +describe('getMonitorConfig', () => { + it('sends a cron rule with its time zone', () => { + expect(getMonitorConfig({ rule: '30 9 * * 1-5', tz: 'Europe/Vienna' })).toEqual({ + schedule: { type: 'crontab', value: '30 9 * * 1-5' }, + timezone: 'Europe/Vienna', + }); + }); + + it('drops a fixed second', () => { + expect(getMonitorConfig({ rule: '0 30 9 * * *', tz: 'UTC' })?.schedule).toEqual({ + type: 'crontab', + value: '30 9 * * *', + }); + }); + + it.each([ + ['a sub-minute cron', '*/10 * * * * *'], + ['a rule with a start', { rule: '0 9 * * *', start: Date.now() + 1000 }], + ['a rule with an end', { rule: '0 9 * * *', end: Date.now() + 1000 }], + ['a recurrence rule', { hour: 3, minute: 0 }], + ['a UTC offset', { rule: '0 9 * * *', tz: '+02:00' }], + ])('is undefined for %s', (_, spec) => { + expect(getMonitorConfig(spec)).toBeUndefined(); + }); +}); + +describe('job.schedule', () => { + 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 = (name = 'my-job') => { + let pending = false; + return { + name, + job: (() => {}) as unknown, + nextInvocation: () => (pending ? new Date() : null), + schedule(spec: unknown) { + const change = { arguments: [spec], self: this, result: undefined as unknown }; + beforeSchedule(change, { excludeJobs: ['excluded-job'] }); + change.result = spec !== 'not a spec'; + pending ||= change.result === true; + onSchedule(change); + return change.result; + }, + reschedule(spec: unknown) { + pending = false; + return this.schedule(spec); + }, + run() { + (this.job as () => void)(); + }, + }; + }; + + it('sends the schedule of a named job', () => { + const job = createJob(); + job.schedule({ rule: '30 9 * * 1-5', tz: 'UTC' }); + job.run(); + + expect(sentConfigs[0]).toEqual({ schedule: { type: 'crontab', value: '30 9 * * 1-5' }, timezone: 'UTC' }); + }); + + it.each([ + ['an unnamed job', createJob(''), '0 9 * * *'], + ['an excluded job', createJob('excluded-job'), '0 9 * * *'], + ['a one-off job', createJob(), new Date(Date.now() + 86_400_000)], + ])('does not monitor %s', (_, job, spec) => { + job.schedule(spec); + job.run(); + + expect(sentConfigs).toEqual([]); + }); + + it('wraps the job once and sends the new schedule after a reschedule', () => { + const job = createJob(); + job.schedule('0 9 * * *'); + const wrapped = job.job; + job.reschedule({ rule: '30 10 * * 1-5', tz: 'UTC' }); + job.run(); + + expect(job.job).toBe(wrapped); + expect(sentConfigs).toHaveLength(2); + expect(sentConfigs[0]).toEqual({ schedule: { type: 'crontab', value: '30 10 * * 1-5' }, timezone: 'UTC' }); + }); + + it('sends no config after a reschedule to a spec it cannot convert', () => { + const job = createJob(); + job.schedule('0 9 * * *'); + job.reschedule({ hour: 3, minute: 0 }); + job.run(); + + expect(sentConfigs).toHaveLength(2); + expect(sentConfigs[0]).toBeUndefined(); + }); + + it('keeps the old schedule when the reschedule fails', () => { + const job = createJob(); + job.schedule('0 9 * * *'); + job.reschedule('not a spec'); + job.run(); + + expect(sentConfigs[0]?.schedule).toEqual({ type: 'crontab', value: '0 9 * * *' }); + }); + + it('sends no config when a second schedule adds to the first', () => { + const job = createJob(); + job.schedule('0 9 * * *'); + job.schedule('0 18 * * *'); + job.run(); + + expect(sentConfigs).toHaveLength(2); + expect(sentConfigs[0]).toBeUndefined(); + }); +}); diff --git a/packages/server-utils/test/orchestrion/moduleInjectedTransform.test.ts b/packages/server-utils/test/orchestrion/moduleInjectedTransform.test.ts index 5dac489035d1..0feac3d58b47 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']); + const optIn = new Set(['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/solidstart/src/server/index.ts b/packages/solidstart/src/server/index.ts index 90710a604eb5..2237235f5c1b 100644 --- a/packages/solidstart/src/server/index.ts +++ b/packages/solidstart/src/server/index.ts @@ -41,6 +41,7 @@ export { functionToStringIntegration, genericPoolIntegration, nodeCronIntegration, + nodeScheduleIntegration, getActiveSpan, getAutoPerformanceIntegrations, getClient, diff --git a/packages/sveltekit/src/server/index.ts b/packages/sveltekit/src/server/index.ts index 75f610ba0789..e5b73bcd2acc 100644 --- a/packages/sveltekit/src/server/index.ts +++ b/packages/sveltekit/src/server/index.ts @@ -39,6 +39,7 @@ export { functionToStringIntegration, genericPoolIntegration, nodeCronIntegration, + nodeScheduleIntegration, getActiveSpan, getAutoPerformanceIntegrations, getClient,