Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
cf8b5be
feat(node): Add cronJobIntegration to monitor cron jobs
wedamija Oct 17, 2026
1908bf3
test(node): Skip cron integration suite on bun run
wedamija Oct 17, 2026
487dc0c
chore(deps): Update pnpm-lock.yaml for cron 4 and dedupe luxon
wedamija Oct 20, 2026
0fa3047
Merge branch 'danf/node-schedule-monitor-jobs' into danf/cron-monitor…
wedamija Oct 20, 2026
c11e2fd
ref(node): Use the shared job-creation helpers in cronJobIntegration
wedamija Oct 20, 2026
be15930
Merge branch 'danf/node-schedule-monitor-jobs' into danf/cron-monitor…
wedamija Oct 21, 2026
dbd9ecb
ref(node): Mark monitored cron jobs without a double cast
wedamija Oct 21, 2026
0b0c3e1
Merge branch 'danf/node-schedule-monitor-jobs' into danf/cron-monitor…
wedamija Oct 21, 2026
8cd6820
Merge remote-tracking branch 'origin/danf/node-schedule-monitor-jobs'…
wedamija Oct 22, 2026
aa654d6
test(node): Cover cron jobs sent without a config or skipped as one-off
wedamija Oct 22, 2026
2c35a5c
Merge remote-tracking branch 'origin/danf/node-schedule-monitor-jobs'…
wedamija Oct 22, 2026
01a7c4a
Merge danf/node-schedule-monitor-jobs into danf/cron-monitor-jobs
wedamija Oct 22, 2026
4c444cf
Merge remote-tracking branch 'origin/danf/node-schedule-monitor-jobs'…
wedamija Oct 23, 2026
a1f362d
ref(node): Pass cronJobIntegration options to the subscriber
wedamija Oct 23, 2026
c5ea0a9
Merge danf/node-schedule-monitor-jobs into danf/cron-monitor-jobs
wedamija Oct 28, 2026
d13ffdd
ref(node): Export cronJobIntegration from Deno
wedamija Oct 28, 2026
b4bbbe4
Merge danf/node-schedule-monitor-jobs into danf/cron-monitor-jobs
wedamija Oct 28, 2026
86acf7f
fix(node): Send the new schedule after a cron job's setTime
wedamija Oct 28, 2026
92466ee
Merge remote-tracking branch 'origin/danf/node-schedule-monitor-jobs'…
wedamija Oct 28, 2026
b065781
Merge branch 'danf/node-schedule-monitor-jobs' into danf/cron-monitor…
wedamija Oct 29, 2026
cba31f0
Merge branch 'danf/node-schedule-monitor-jobs' into danf/cron-monitor…
wedamija Oct 29, 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 @@ -62,6 +62,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/**',
Expand Down
2 changes: 1 addition & 1 deletion dev-packages/node-integration-tests/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@
"body-parser": "^2.3.0",
"consola": "^3.2.3",
"cors": "^2.8.5",
"cron": "^3.1.6",
"cron": "^4.4.0",
"dataloader": "2.2.2",
"express": "^4.21.2",
"fastify": "^5.12.5",
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.cronJobIntegration({ excludeJobs: ['excluded-job'] })],
});
Original file line number Diff line number Diff line change
@@ -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);
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
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('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: expectNoConfig('offset-job') })
.expect({ check_in: { monitor_slug: 'offset-job', status: 'ok' } })
.expect({ check_in: expectNoConfig('sub-minute-job') })
.expect({ check_in: { monitor_slug: 'sub-minute-job', status: 'ok' } })
.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();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}

Expand Down
1 change: 1 addition & 0 deletions packages/astro/src/index.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export {
fsIntegration,
functionToStringIntegration,
genericPoolIntegration,
cronJobIntegration,
nodeCronIntegration,
nodeScheduleIntegration,
getActiveSpan,
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 @@ -116,6 +116,7 @@ export {
firebaseIntegration,
fsIntegration,
genericPoolIntegration,
cronJobIntegration,
nodeCronIntegration,
nodeScheduleIntegration,
graphqlIntegration,
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 @@ -138,6 +138,7 @@ export {
// oxlint-disable-next-line typescript/no-deprecated
setupKoaErrorHandler,
genericPoolIntegration,
cronJobIntegration,
nodeCronIntegration,
nodeScheduleIntegration,
graphqlIntegration,
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 @@ -183,6 +183,7 @@ export {
postgresIntegration,
postgresJsIntegration,
neonIntegration,
cronJobIntegration,
nodeCronIntegration,
nodeScheduleIntegration,
tediousIntegration,
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 @@ -117,6 +117,7 @@ export {
// oxlint-disable-next-line typescript/no-deprecated
setupKoaErrorHandler,
genericPoolIntegration,
cronJobIntegration,
nodeCronIntegration,
nodeScheduleIntegration,
graphqlIntegration,
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 @@ -116,6 +116,7 @@ export {
fastifyIntegration,
firebaseIntegration,
genericPoolIntegration,
cronJobIntegration,
nodeCronIntegration,
nodeScheduleIntegration,
graphqlIntegration,
Expand Down
9 changes: 8 additions & 1 deletion packages/node/src/cron/cron.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { captureException, withMonitor } from '@sentry/core';
import { addNonEnumerableProperty, captureException, withMonitor } from '@sentry/core';
import { replaceCronNames } from './common';

export type CronJobParams = {
Expand Down Expand Up @@ -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';

/**
Expand Down Expand Up @@ -115,6 +117,9 @@ export function instrumentCron<T>(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) {
Expand Down Expand Up @@ -157,6 +162,8 @@ export function instrumentCron<T>(lib: T & CronJobConstructor, monitorSlug: stri
);
};

addNonEnumerableProperty(param.onTick, MONITORED, true);

return target.from(param);
};
} else {
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 @@ -11,6 +11,8 @@ export {
type ExpressIntegrationOptions,
firebaseIntegration,
genericPoolIntegration,
cronJobIntegration,
type CronJobIntegrationOptions,
nodeCronIntegration,
type NodeCronIntegrationOptions,
nodeScheduleIntegration,
Expand Down
1 change: 1 addition & 0 deletions packages/remix/src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export {
flush,
functionToStringIntegration,
genericPoolIntegration,
cronJobIntegration,
nodeCronIntegration,
nodeScheduleIntegration,
getActiveSpan,
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 @@ -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';
Expand Down
70 changes: 70 additions & 0 deletions packages/server-utils/src/integrations/cron.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
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<string | RegExp>;
}

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, and one-off jobs, 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);
}

/** Sends the new time's config after `job.setTime(cronTime)`. */
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');
Comment thread
wedamija marked this conversation as resolved.
}
2 changes: 2 additions & 0 deletions packages/server-utils/src/orchestrion/channels.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -60,6 +61,7 @@ export const CHANNELS = {
...amqplibChannels,
...anthropicAiChannels,
...awsSdkChannels,
...cronChannels,
...dataloaderChannels,
...expressChannels,
...firebaseChannels,
Expand Down
23 changes: 23 additions & 0 deletions packages/server-utils/src/orchestrion/config/cron.ts
Original file line number Diff line number Diff line change
@@ -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;
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 @@ -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';
Expand Down Expand Up @@ -66,6 +67,7 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [
...amqplibConfig,
...anthropicAiConfig,
...awsSdkConfig,
...cronConfig,
...dataloaderConfig,
...expressConfig,
...firebaseConfig,
Expand Down
Loading
Loading