Repository navigation
feat(node): Add nodeCronIntegration to monitor node-cron tasks #25473
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
15 commits
Select commit
Hold shift + click to select a range
db27775
feat(node): Add nodeCronIntegration to monitor node-cron tasks
wedamija 0cb322d
ref(node): Move cron schedule conversion to a shared helper
wedamija d9e3b62
test(node): Assert node-cron check-ins and errors separately
wedamija 905ed6b
test(node): Skip node-cron integration suite on bun run and allow opt…
wedamija ffb2c5d
fix(node): Instrument node-cron 4.0-4.2 and skip configs node-cron 3 …
wedamija 3d72a0b
ref(node): Mark monitored node-cron callbacks without a double cast
wedamija 9df41c3
Merge branch 'develop' into danf/node-cron-monitor-jobs
wedamija 69f6913
Merge remote-tracking branch 'origin/develop' into HEAD
wedamija 5c38418
Merge develop into danf/node-cron-monitor-jobs
wedamija ea0509e
ref(node): Pass nodeCronIntegration options to the subscriber
wedamija 4649550
ref(node): Address node-cron review comments
wedamija 7826628
fix(node): Start a new trace per cron job run and send Sunday range e…
wedamija c87c128
fix(node): Send no monitor config for weekday ranges ending on Sunday…
wedamija 5efee25
fix(node): Skip jobs with unsupported schedules when monitoring all jobs
wedamija 01f6696
fix(node): Capture errors from jobs that send no check-ins
wedamija File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
9 changes: 9 additions & 0 deletions
9
dev-packages/node-integration-tests/suites/cron/node-cron/integration/instrument.mjs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| import * as Sentry from '@sentry/node'; | ||
| import { loggingTransport } from '@sentry-internal/node-integration-tests'; | ||
|
|
||
| Sentry.init({ | ||
| dsn: 'https://public@dsn.ingest.sentry.io/1337', | ||
| release: '1.0', | ||
| transport: loggingTransport, | ||
| integrations: [Sentry.nodeCronIntegration({ excludeJobs: ['excluded-job'] })], | ||
| }); |
25 changes: 25 additions & 0 deletions
25
dev-packages/node-integration-tests/suites/cron/node-cron/integration/scenario.mjs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| import cron from 'node-cron'; | ||
|
|
||
| const excluded = cron.schedule('0 0 0 1 1 *', () => {}, { name: 'excluded-job' }); | ||
| const unnamed = cron.schedule('0 0 0 1 1 *', () => {}); | ||
|
|
||
| let runs = 0; | ||
| const task = cron.schedule( | ||
| '0 0 0 1 1 *', | ||
| () => { | ||
| runs++; | ||
| if (runs > 1) { | ||
| throw new Error('Error in cron job'); | ||
| } | ||
| }, | ||
| { name: 'my-cron-job', timezone: 'America/New_York' }, | ||
| ); | ||
|
|
||
| // Run the tasks by hand. Their schedules can't fire during the test. | ||
| excluded | ||
| .execute() | ||
| .then(() => unnamed.execute()) | ||
| .then(() => task.execute()) | ||
| .then(() => task.execute()); | ||
|
|
||
| setTimeout(() => process.exit(), 10_000); | ||
50 changes: 50 additions & 0 deletions
50
dev-packages/node-integration-tests/suites/cron/node-cron/integration/test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| import { afterAll, describe, expect } from 'vitest'; | ||
| import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/runner'; | ||
|
|
||
| const MONITOR_CONFIG = { | ||
| schedule: { type: 'crontab', value: '0 0 1 1 *' }, | ||
| timezone: 'America/New_York', | ||
| }; | ||
|
|
||
| describe('nodeCronIntegration', () => { | ||
| afterAll(() => { | ||
| cleanupChildProcesses(); | ||
| }); | ||
|
|
||
| createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { | ||
| test('sends check-ins for named tasks', async () => { | ||
| await createRunner() | ||
| .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.node-cron', handled: false }, | ||
| }, | ||
| ], | ||
| }, | ||
| }, | ||
| }) | ||
| .start() | ||
| .completed(); | ||
| }); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,104 @@ | ||
| import type { IntegrationFn, MonitorConfig } from '@sentry/core'; | ||
| import { defineIntegration } from '@sentry/core'; | ||
| import { CHANNELS } from '../orchestrion/channels'; | ||
| import { nodeCronModuleNames } from '../orchestrion/config/node-cron'; | ||
| import { invokeOrchestrionInstrumentation } from '../orchestrion/instrumentation'; | ||
| import type { AnyFn } from '../utils/cronMonitoring'; | ||
| import { | ||
| cronExpressionToMonitorConfig, | ||
| shouldMonitorJob, | ||
| subscribeToJobCreation, | ||
| wrapScheduledJob, | ||
| } from '../utils/cronMonitoring'; | ||
|
|
||
| const INTEGRATION_NAME = 'NodeCron' as const; | ||
|
|
||
| export interface NodeCronIntegrationOptions { | ||
| /** Task names not to monitor. Strings must match the name exactly. */ | ||
| excludeJobs?: Array<string | RegExp>; | ||
| } | ||
|
|
||
| const _nodeCronIntegration = ((options: NodeCronIntegrationOptions = {}) => { | ||
| return { | ||
| name: INTEGRATION_NAME, | ||
| setup(client) { | ||
| invokeOrchestrionInstrumentation(client, nodeCronModuleNames, subscribe, [options], { | ||
| // The binding only parents spans, and this integration creates none. | ||
| requiresTracingChannelBinding: false, | ||
|
wedamija marked this conversation as resolved.
Resolved
|
||
| }); | ||
| }, | ||
| }; | ||
| }) satisfies IntegrationFn; | ||
|
|
||
| /** | ||
| * Sends Crons check-ins for every named `node-cron` task, with its schedule as the monitor config. | ||
| * The monitor slug is the task's `name` option. Tasks without a name, or with a schedule Sentry can't express, are not | ||
| * monitored. | ||
| */ | ||
| export const nodeCronIntegration = defineIntegration(_nodeCronIntegration); | ||
|
|
||
| function subscribe(options: NodeCronIntegrationOptions): void { | ||
| subscribeToJobCreation(CHANNELS.NODE_CRON_CREATE_TASK, INTEGRATION_NAME, args => wrapTask(args, options)); | ||
| subscribeToJobCreation(CHANNELS.NODE_CRON_V4_CREATE_TASK, INTEGRATION_NAME, args => | ||
| wrapTask(args, options, isReadAlikeByNodeCron4), | ||
| ); | ||
| subscribeToJobCreation(CHANNELS.NODE_CRON_V3_CREATE_TASK, INTEGRATION_NAME, args => | ||
| wrapTask(args, options, isReadAlikeByNodeCron3), | ||
| ); | ||
| } | ||
|
|
||
| // `createTask(expression, func, options)` | ||
| function wrapTask( | ||
| args: unknown[], | ||
| options: NodeCronIntegrationOptions, | ||
| isReadAlike?: (expression: string) => boolean, | ||
| ): void { | ||
| const [expression, func, taskOptions] = args; | ||
| const { name, timezone } = (taskOptions || {}) as { name?: unknown; timezone?: unknown }; | ||
| if (!shouldMonitorJob(name, func, options.excludeJobs)) { | ||
| return; | ||
| } | ||
| const monitorConfig = getMonitorConfig(String(expression), timezone, isReadAlike); | ||
| args[1] = wrapScheduledJob(func as AnyFn, name, monitorConfig, 'auto.function.node-cron'); | ||
| } | ||
|
|
||
| /** The monitor config for a task, or `undefined` if `isReadAlike` says this node-cron version reads it differently. */ | ||
| export function getMonitorConfig( | ||
| expression: string, | ||
| timezone: unknown, | ||
| isReadAlike?: (expression: string) => boolean, | ||
| ): MonitorConfig | undefined { | ||
| return isReadAlike && !isReadAlike(expression) | ||
| ? undefined | ||
| : cronExpressionToMonitorConfig(expression, timezone, 'and'); | ||
| } | ||
|
|
||
| const NODE_CRON_3_FIELD_MINIMUMS = [0, 0, 1, 1, 0]; | ||
|
|
||
| /** | ||
| * node-cron before 4.6 reads `7` as `0` and swaps reversed ranges, so it runs `sat-sun`, `6-0` and `6-7` every day. | ||
| * Returns `false` for weekday ranges that could hit that. | ||
| */ | ||
| export function isReadAlikeByNodeCron4(expression: string): boolean { | ||
| const weekday = expression.trim().split(/\s+/).pop() || ''; | ||
| return !(weekday.includes('-') && (weekday.includes('7') || /-(?:0|sun)/i.test(weekday))); | ||
| } | ||
|
|
||
| /** | ||
| * node-cron 3 keeps the values of a stepped field that are multiples of the step (`*\/2` days are 2, 4, ...) | ||
| * and reads weekday ranges like node-cron 4 before 4.6. Returns `false` where that differs from standard cron. | ||
| */ | ||
| export function isReadAlikeByNodeCron3(expression: string): boolean { | ||
| const fields = expression.trim().split(/\s+/); | ||
| const minimums = fields.length === 6 ? [0, ...NODE_CRON_3_FIELD_MINIMUMS] : NODE_CRON_3_FIELD_MINIMUMS; | ||
| return ( | ||
| isReadAlikeByNodeCron4(expression) && | ||
| fields.every((field, index) => { | ||
| if (!field.includes('/')) { | ||
| return true; | ||
| } | ||
| const match = /^(?:\*|(\d+)-\d+)\/(\d+)$/.exec(field); | ||
| return !!match && Number(match[1] ?? minimums[index]) % Number(match[2]) === 0; | ||
| }) | ||
| ); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| import type { InstrumentationConfig } from '../apmTypes'; | ||
| import { getModuleNames } from './module-names'; | ||
|
|
||
| const createTask = { functionName: 'createTask', kind: 'Sync' } as const; | ||
|
|
||
| // `schedule()` calls `createTask(expression, func, options)`, so wrapping `createTask` covers both. | ||
| // Before 4.6, node-cron reads weekday ranges like `sat-sun` differently, so those versions get their own channel. | ||
| export const nodeCronConfig = [ | ||
| { | ||
| channelName: 'createTask', | ||
| module: { name: 'node-cron', versionRange: '>=4.6.0 <5', filePath: 'dist/node-cron.cjs' }, | ||
| functionQuery: createTask, | ||
| }, | ||
| { | ||
| channelName: 'createTask', | ||
| module: { name: 'node-cron', versionRange: '>=4.6.0 <5', filePath: 'dist/node-cron.js' }, | ||
| functionQuery: createTask, | ||
| }, | ||
| { | ||
| channelName: 'v4_createTask', | ||
| module: { name: 'node-cron', versionRange: '>=4.3.0 <4.6.0', filePath: 'dist/node-cron.cjs' }, | ||
| functionQuery: createTask, | ||
| }, | ||
| { | ||
| channelName: 'v4_createTask', | ||
| module: { name: 'node-cron', versionRange: '>=4.0.0 <4.6.0', filePath: 'dist/node-cron.js' }, | ||
| functionQuery: createTask, | ||
| }, | ||
| { | ||
| channelName: 'v4_createTask', | ||
| module: { name: 'node-cron', versionRange: '>=4.0.0 <4.3.0', filePath: 'dist/cjs/node-cron.js' }, | ||
| functionQuery: createTask, | ||
| }, | ||
| { | ||
| channelName: 'v4_createTask', | ||
| module: { name: 'node-cron', versionRange: '>=4.0.0 <4.3.0', filePath: 'dist/esm/node-cron.js' }, | ||
| functionQuery: createTask, | ||
| }, | ||
| { | ||
| channelName: 'v3_createTask', | ||
| module: { name: 'node-cron', versionRange: '>=3.0.0 <4', filePath: 'src/node-cron.js' }, | ||
| functionQuery: createTask, | ||
| }, | ||
| ] satisfies InstrumentationConfig[]; | ||
|
|
||
| export const nodeCronModuleNames = getModuleNames(nodeCronConfig); | ||
|
|
||
| export const nodeCronChannels = { | ||
| NODE_CRON_CREATE_TASK: 'orchestrion:node-cron:createTask', | ||
| NODE_CRON_V4_CREATE_TASK: 'orchestrion:node-cron:v4_createTask', | ||
| NODE_CRON_V3_CREATE_TASK: 'orchestrion:node-cron:v3_createTask', | ||
| } as const; |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.