diff --git a/dev-packages/bun-integration-tests/node-suites/excludes.ts b/dev-packages/bun-integration-tests/node-suites/excludes.ts index 5d88d630c7c0..e38eeeb56384 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..3b05251a223d --- /dev/null +++ b/dev-packages/node-integration-tests/suites/cron/node-schedule-integration/scenario.mjs @@ -0,0 +1,26 @@ +import schedule from 'node-schedule'; + +const excluded = schedule.scheduleJob('excluded-job', '0 0 * * *', () => {}); +const unnamed = schedule.scheduleJob('0 0 * * *', () => {}); +const recurrenceRule = schedule.scheduleJob('rule-job', { hour: 3, minute: 0 }, () => {}); + +let runs = 0; +const job = schedule.scheduleJob('my-schedule-job', { rule: '30 9 * * 1-5', tz: 'Europe/Vienna' }, () => { + runs++; + if (runs > 1) { + throw new Error('Error in scheduled job'); + } +}); + +// Run the jobs without waiting for their schedule. +excluded.invoke(); +unnamed.invoke(); +recurrenceRule.invoke(); +job.invoke(); +try { + job.invoke(); +} catch { + // node-schedule catches errors when it runs the job itself +} + +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..c74df35d4f21 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/cron/node-schedule-integration/test.ts @@ -0,0 +1,52 @@ +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('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: { monitor_slug: 'rule-job', status: 'in_progress' } }) + .expect({ check_in: { monitor_slug: 'rule-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) } }) + .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 722337ac28eb..1c1dd201df7e 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 8e6b17bba404..b096018db11a 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 e7c340f6386c..a7f8b5f78f5f 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/elysia/src/index.ts b/packages/elysia/src/index.ts index 5e6d69788125..94fd4b880836 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 af8e84be7a44..e78c35d7976e 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..df7c775fc27b 100644 --- a/packages/node/src/cron/node-schedule.ts +++ b/packages/node/src/cron/node-schedule.ts @@ -58,6 +58,9 @@ export function instrumentNodeSchedule(lib: T & NodeSchedule): T { ); } + // Tells `nodeScheduleIntegration` that this job is already monitored. + (monitoredCallback as unknown as Record)[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 f4a6ca7b51d4..30c41c98bd48 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/remix/src/server/index.ts b/packages/remix/src/server/index.ts index 375ec0597b74..695226b43bba 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 c818a99b623f..589d37bd3ae7 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..7fea20bb3b7f --- /dev/null +++ b/packages/server-utils/src/integrations/node-schedule.ts @@ -0,0 +1,79 @@ +import * as diagnosticsChannel from '../utils/diagnosticsChannel'; +import type { IntegrationFn, MonitorConfig } from '@sentry/core'; +import { debug, defineIntegration, stringMatchesSomePattern } from '@sentry/core'; +import { DEBUG_BUILD } from '../debug-build'; +import { CHANNELS } from '../orchestrion/channels'; +import { nodeScheduleModuleNames } from '../orchestrion/config/node-schedule'; +import { invokeOrchestrionInstrumentation } from '../orchestrion/instrumentation'; +import type { AnyFn } from '../utils/cronMonitoring'; +import { cronExpressionToMonitorConfig, wrapScheduledJob } from '../utils/cronMonitoring'; + +const INTEGRATION_NAME = 'NodeSchedule' as const; + +// Set by `Sentry.cron.instrumentNodeSchedule`, which already sends check-ins. +const MONITORED = Symbol.for('sentry.cron.monitored'); + +const NOOP = (): void => {}; + +export interface NodeScheduleIntegrationOptions { + /** Job names not to monitor. Strings must match the name exactly. */ + excludeJobs?: Array; +} + +let options: NodeScheduleIntegrationOptions = {}; + +const _nodeScheduleIntegration = ((integrationOptions: NodeScheduleIntegrationOptions = {}) => { + return { + name: INTEGRATION_NAME, + setup(client) { + options = integrationOptions; + invokeOrchestrionInstrumentation(client, nodeScheduleModuleNames, subscribe, [], { + requiresTracingChannelBinding: false, + }); + }, + }; +}) satisfies IntegrationFn; + +/** + * Sends Crons check-ins for every named `node-schedule` job created with `scheduleJob`. + * 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(): void { + diagnosticsChannel.tracingChannel<{ arguments: unknown[] }>(CHANNELS.NODE_SCHEDULE_SCHEDULE_JOB).subscribe({ + start(data) { + try { + // `scheduleJob([name], spec, method, [callback])`, like node-schedule reads it. + const args = data.arguments; + const name = args.length >= 3 && typeof args[0] === 'string' ? args[0] : undefined; + const [spec, method] = name ? [args[1], args[2]] : [args[0], args[1]]; + if ( + !name || + typeof method !== 'function' || + (method as { [MONITORED]?: boolean })[MONITORED] || + spec instanceof Date || + stringMatchesSomePattern(name, options.excludeJobs, true) + ) { + return; + } + args[2] = wrapScheduledJob(method as AnyFn, name, getMonitorConfig(spec), 'auto.function.node-schedule'); + } catch (e) { + DEBUG_BUILD && debug.error('[NodeSchedule] Failed to set up the cron monitor:', e); + } + }, + end: NOOP, + asyncStart: NOOP, + asyncEnd: NOOP, + error: NOOP, + }); +} + +// Only cron strings are sent; recurrence rules check in without a config. +function getMonitorConfig(spec: unknown): MonitorConfig | undefined { + if (typeof spec === 'string') { + return cronExpressionToMonitorConfig(spec, undefined, 'or'); + } + const { rule, tz } = (spec || {}) as { rule?: unknown; tz?: unknown }; + return typeof rule === 'string' ? 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 362a5806b8e5..31d3eb146db0 100644 --- a/packages/server-utils/src/orchestrion/config/index.ts +++ b/packages/server-utils/src/orchestrion/config/index.ts @@ -33,6 +33,7 @@ import { mysqlConfig } from './mysql'; import { neonConfig } from './neon'; import { nestjsConfig } from './nestjs'; import { nodeCronConfig } from './node-cron'; +import { nodeScheduleConfig } from './node-schedule'; import { openaiConfig } from './openai'; import { pgConfig } from './pg'; import { postgresJsConfig } from './postgres'; @@ -91,6 +92,7 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ ...neonConfig, ...nestjsConfig, ...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..3dd231617e64 --- /dev/null +++ b/packages/server-utils/src/orchestrion/config/node-schedule.ts @@ -0,0 +1,16 @@ +import type { InstrumentationConfig } from '../apmTypes'; +import { getModuleNames } from './module-names'; + +export const nodeScheduleConfig = [ + { + channelName: 'scheduleJob', + module: { name: 'node-schedule', versionRange: '>=1.3.0 <3', filePath: 'lib/schedule.js' }, + functionQuery: { functionName: 'scheduleJob', kind: 'Sync' }, + }, +] satisfies InstrumentationConfig[]; + +export const nodeScheduleModuleNames = getModuleNames(nodeScheduleConfig); + +export const nodeScheduleChannels = { + NODE_SCHEDULE_SCHEDULE_JOB: 'orchestrion:node-schedule:scheduleJob', +} as const; diff --git a/packages/server-utils/test/orchestrion/moduleInjectedTransform.test.ts b/packages/server-utils/test/orchestrion/moduleInjectedTransform.test.ts index 1697ea30d7c0..ed55b1bec34e 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']); + 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 bfc6b401411c..31873220da81 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 f8707c82bb9a..0ab80374e7f8 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,