Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
db27775
feat(node): Add nodeCronIntegration to monitor node-cron tasks
wedamija Oct 17, 2026
0cb322d
ref(node): Move cron schedule conversion to a shared helper
wedamija Oct 17, 2026
d9e3b62
test(node): Assert node-cron check-ins and errors separately
wedamija Oct 17, 2026
905ed6b
test(node): Skip node-cron integration suite on bun run and allow opt…
wedamija Oct 17, 2026
1aa125d
feat(node): Add nodeScheduleIntegration to monitor node-schedule jobs
wedamija Oct 17, 2026
7f4f833
test(node): Assert node-schedule check-ins and errors separately
wedamija Oct 17, 2026
231f7bc
test(node): Skip node-schedule integration suite on bun run
wedamija Oct 17, 2026
ffb2c5d
fix(node): Instrument node-cron 4.0-4.2 and skip configs node-cron 3 …
wedamija Oct 20, 2026
676b738
Merge branch 'danf/node-cron-monitor-jobs' into danf/node-schedule-mo…
wedamija Oct 20, 2026
4f360eb
fix(node): Send no monitor config for node-schedule rules with a star…
wedamija Oct 20, 2026
3d72a0b
ref(node): Mark monitored node-cron callbacks without a double cast
wedamija Oct 21, 2026
e7e6339
Merge branch 'danf/node-cron-monitor-jobs' into danf/node-schedule-mo…
wedamija Oct 21, 2026
8184b78
ref(node): Mark monitored node-schedule callbacks without a double cast
wedamija Oct 21, 2026
9df41c3
Merge branch 'develop' into danf/node-cron-monitor-jobs
wedamija Oct 21, 2026
59979d9
Merge branch 'danf/node-cron-monitor-jobs' into danf/node-schedule-mo…
wedamija Oct 21, 2026
6f712c7
fix(node): Don't monitor one-off node-schedule jobs from timestamps o…
wedamija Oct 22, 2026
69f6913
Merge remote-tracking branch 'origin/develop' into HEAD
wedamija Oct 22, 2026
3a22e99
Merge commit '69f6913ab53b1bb9b7bc4724fa421e45ac1f7f9f' into danf/nod…
wedamija Oct 22, 2026
5c38418
Merge develop into danf/node-cron-monitor-jobs
wedamija Oct 22, 2026
78e2e7d
Merge danf/node-cron-monitor-jobs into danf/node-schedule-monitor-jobs
wedamija Oct 22, 2026
ea0509e
ref(node): Pass nodeCronIntegration options to the subscriber
wedamija Oct 23, 2026
07741fb
Merge remote-tracking branch 'origin/danf/node-cron-monitor-jobs' int…
wedamija Oct 23, 2026
afaaa48
ref(node): Pass nodeScheduleIntegration options to the subscriber
wedamija Oct 23, 2026
4649550
ref(node): Address node-cron review comments
wedamija Oct 28, 2026
7a2b83f
Merge remote-tracking branch 'origin/danf/node-cron-monitor-jobs' int…
wedamija Oct 28, 2026
82b2bff
ref(node): Export nodeScheduleIntegration from Deno
wedamija Oct 28, 2026
0af113c
fix(node): Send the new schedule after a node-schedule job is resched…
wedamija Oct 28, 2026
7826628
fix(node): Start a new trace per cron job run and send Sunday range e…
wedamija Oct 28, 2026
0913bc2
Merge remote-tracking branch 'origin/danf/node-cron-monitor-jobs' int…
wedamija Oct 28, 2026
c87c128
fix(node): Send no monitor config for weekday ranges ending on Sunday…
wedamija Oct 29, 2026
3a13940
Merge branch 'danf/node-cron-monitor-jobs-fix' into danf/node-schedul…
wedamija Oct 29, 2026
754fd55
feat(node): Monitor node-schedule jobs started with new Job().schedule()
wedamija Oct 29, 2026
5efee25
fix(node): Skip jobs with unsupported schedules when monitoring all jobs
wedamija Oct 29, 2026
0df863e
Merge branch 'danf/node-cron-monitor-jobs' into danf/node-schedule-mo…
wedamija Oct 29, 2026
c03a72c
fix(node): Skip jobs with unsupported schedules when monitoring all jobs
wedamija Oct 29, 2026
01f6696
fix(node): Capture errors from jobs that send no check-ins
wedamija Oct 29, 2026
774829f
Merge branch 'danf/node-cron-monitor-jobs' into danf/node-schedule-mo…
wedamija Oct 29, 2026
d21b712
Merge remote-tracking branch 'origin/develop' into danf/node-schedule…
wedamija Oct 29, 2026
eac0c09
Merge remote-tracking branch 'origin/develop' into danf/node-schedule…
wedamija Oct 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,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',
Expand Down
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.nodeScheduleIntegration({ excludeJobs: ['excluded-job'] })],
});
Original file line number Diff line number Diff line change
@@ -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' });
Comment thread
wedamija marked this conversation as resolved.
Resolved
job.invoke();

setTimeout(() => process.exit(), 10_000);
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
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: '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();
});
});
});
1 change: 1 addition & 0 deletions packages/astro/src/index.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export {
functionToStringIntegration,
genericPoolIntegration,
nodeCronIntegration,
nodeScheduleIntegration,
getActiveSpan,
getAutoPerformanceIntegrations,
getClient,
Expand Down
1 change: 1 addition & 0 deletions packages/aws-serverless/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ export {
fsIntegration,
genericPoolIntegration,
nodeCronIntegration,
nodeScheduleIntegration,
graphqlIntegration,
knexIntegration,
kafkaIntegration,
Expand Down
1 change: 1 addition & 0 deletions packages/bun/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ export {
setupKoaErrorHandler,
genericPoolIntegration,
nodeCronIntegration,
nodeScheduleIntegration,
graphqlIntegration,
knexIntegration,
kafkaIntegration,
Expand Down
1 change: 1 addition & 0 deletions packages/deno/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ export {
postgresJsIntegration,
neonIntegration,
nodeCronIntegration,
nodeScheduleIntegration,
tediousIntegration,
eveConversationHook,
eveIntegration,
Expand Down
1 change: 1 addition & 0 deletions packages/elysia/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ export {
setupKoaErrorHandler,
genericPoolIntegration,
nodeCronIntegration,
nodeScheduleIntegration,
graphqlIntegration,
knexIntegration,
kafkaIntegration,
Expand Down
1 change: 1 addition & 0 deletions packages/google-cloud-serverless/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ export {
firebaseIntegration,
genericPoolIntegration,
nodeCronIntegration,
nodeScheduleIntegration,
graphqlIntegration,
knexIntegration,
kafkaIntegration,
Expand Down
5 changes: 4 additions & 1 deletion packages/node/src/cron/node-schedule.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { withMonitor } from '@sentry/core';
import { addNonEnumerableProperty, withMonitor } from '@sentry/core';
import { replaceCronNames } from './common';

export interface NodeSchedule {
Expand Down Expand Up @@ -58,6 +58,9 @@ export function instrumentNodeSchedule<T>(lib: T & NodeSchedule): T {
);
}

// Tells `nodeScheduleIntegration` that this job is already monitored.
addNonEnumerableProperty(monitoredCallback, Symbol.for('sentry.cron.monitored'), true);
Comment thread
wedamija marked this conversation as resolved.
Resolved

return target.apply(thisArg, [monitorSlug, expression, monitoredCallback]);
},
});
Expand Down
2 changes: 2 additions & 0 deletions packages/node/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export {
genericPoolIntegration,
nodeCronIntegration,
type NodeCronIntegrationOptions,
nodeScheduleIntegration,
type NodeScheduleIntegrationOptions,
googleGenAIIntegration,
graphqlIntegration,
groqIntegration,
Expand Down
9 changes: 9 additions & 0 deletions packages/node/test/cron.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<symbol, unknown>;
expect(callback[Symbol.for('sentry.cron.monitored')]).toBe(true);
});
});
});
1 change: 1 addition & 0 deletions packages/remix/src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export {
functionToStringIntegration,
genericPoolIntegration,
nodeCronIntegration,
nodeScheduleIntegration,
getActiveSpan,
getAutoPerformanceIntegrations,
getClient,
Expand Down
2 changes: 2 additions & 0 deletions packages/server-utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
112 changes: 112 additions & 0 deletions packages/server-utils/src/integrations/node-schedule.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
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<string | RegExp>;
}

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, one-off jobs, and jobs with a schedule Crons can't express
* 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 = /^<Anonymous Job \d+[ >]/;

// `job.schedule(spec)` calls that add to runs the job already has pending, rather than replace them.
const addsToPendingRuns = new WeakSet<ScheduleChange>();

/** 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');
}

/**
* Sets the spec's config after `job.schedule(spec)`. Without one, from a spec Crons can't express or a job that also
* keeps runs from an earlier spec, the job doesn't check in.
*/
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 convert; recurrence rules and rules limited by `start` or `end` don't.
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;
}
2 changes: 2 additions & 0 deletions packages/server-utils/src/orchestrion/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -85,6 +86,7 @@ export const CHANNELS = {
...neonChannels,
...nestjsChannels,
...nodeCronChannels,
...nodeScheduleChannels,
...openaiChannels,
...pgChannels,
...piDurableChannels,
Expand Down
2 changes: 2 additions & 0 deletions packages/server-utils/src/orchestrion/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -93,6 +94,7 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [
...nestjsConfig,
...nitroConfig,
...nodeCronConfig,
...nodeScheduleConfig,
...openaiConfig,
...pgConfig,
...piDurableConfig,
Expand Down
Loading
Loading