Skip to content
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/node-cron/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.nodeCronIntegration({ excludeJobs: ['excluded-job'] })],
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import cron from 'node-cron';

const excluded = cron.schedule('0 * * * * *', () => {}, { name: 'excluded-job' });
const unnamed = cron.schedule('0 * * * * *', () => {});

let runs = 0;
const task = cron.schedule(
'0 * * * * *',
() => {
runs++;
if (runs > 1) {
throw new Error('Error in cron job');
}
},
{ name: 'my-cron-job', timezone: 'America/New_York' },
);

// Run the task without waiting for the next minute.
excluded
.execute()
.then(() => unnamed.execute())
.then(() => task.execute())
.then(() => task.execute());

setTimeout(() => process.exit(), 10_000);
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: '* * * * *' },
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();
});
});
});
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,
nodeCronIntegration,
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 @@ -116,6 +116,7 @@ export {
firebaseIntegration,
fsIntegration,
genericPoolIntegration,
nodeCronIntegration,
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 @@ -138,6 +138,7 @@ export {
// oxlint-disable-next-line typescript/no-deprecated
setupKoaErrorHandler,
genericPoolIntegration,
nodeCronIntegration,
graphqlIntegration,
knexIntegration,
kafkaIntegration,
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,
nodeCronIntegration,
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 @@ -116,6 +116,7 @@ export {
fastifyIntegration,
firebaseIntegration,
genericPoolIntegration,
nodeCronIntegration,
graphqlIntegration,
knexIntegration,
kafkaIntegration,
Expand Down
5 changes: 4 additions & 1 deletion packages/node/src/cron/node-cron.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { captureException, type MonitorConfig, withMonitor } from '@sentry/core';
import { addNonEnumerableProperty, captureException, type MonitorConfig, withMonitor } from '@sentry/core';
import { replaceCronNames } from './common';

export interface NodeCronOptions {
Expand Down Expand Up @@ -78,6 +78,9 @@ export function instrumentNodeCron<T>(
);
};

// Tells `nodeCronIntegration` that this task is already monitored.
addNonEnumerableProperty(monitoredCallback, Symbol.for('sentry.cron.monitored'), true);

return target.apply(thisArg, [expression, monitoredCallback, options]);
},
});
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,
nodeCronIntegration,
type NodeCronIntegrationOptions,
googleGenAIIntegration,
graphqlIntegration,
groqIntegration,
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,
nodeCronIntegration,
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 @@ -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 { nodeCronIntegration } from './integrations/node-cron';
export type { NodeCronIntegrationOptions } from './integrations/node-cron';
export { googleGenAIIntegration } from './integrations/google-genai';
export { graphqlIntegration } from './integrations/graphql';
export { hapiIntegration } from './integrations/hapi';
Expand Down
80 changes: 80 additions & 0 deletions packages/server-utils/src/integrations/node-cron.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import type { IntegrationFn } 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>;
}

let options: NodeCronIntegrationOptions = {};

const _nodeCronIntegration = ((integrationOptions: NodeCronIntegrationOptions = {}) => {
return {
name: INTEGRATION_NAME,
setup(client) {
options = integrationOptions;
invokeOrchestrionInstrumentation(client, nodeCronModuleNames, subscribe, [], {
requiresTracingChannelBinding: false,
});
},
};
}) 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 are not monitored.
*/
export const nodeCronIntegration = defineIntegration(_nodeCronIntegration);

function subscribe(): void {
subscribeToJobCreation(CHANNELS.NODE_CRON_CREATE_TASK, INTEGRATION_NAME, args => wrapTask(args, false));
subscribeToJobCreation(CHANNELS.NODE_CRON_V3_CREATE_TASK, INTEGRATION_NAME, args => wrapTask(args, true));
}

// `createTask(expression, func, options)`
function wrapTask(args: unknown[], isNodeCron3: 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 =
isNodeCron3 && !isReadAlikeByNodeCron3(String(expression))
? undefined
: cronExpressionToMonitorConfig(String(expression), timezone, 'and');
args[1] = wrapScheduledJob(func as AnyFn, name, monitorConfig, 'auto.function.node-cron');
}

const NODE_CRON_3_FIELD_MINIMUMS = [0, 0, 1, 1, 0];

/**
* node-cron 3 keeps the values of a stepped field that are multiples of the step (`*\/2` days are 2, 4, ...)
* and reads `7` in a weekday range as `0`. 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;
const weekday = fields[fields.length - 1] || '';
if (weekday.includes('-') && weekday.includes('7')) {
return false;
}
return 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;
});
}
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 @@ -26,6 +26,7 @@ import { mysql2Channels } from './config/mysql2';
import { mysqlChannels } from './config/mysql';
import { neonChannels } from './config/neon';
import { nestjsChannels } from './config/nestjs';
import { nodeCronChannels } from './config/node-cron';
import { openaiChannels } from './config/openai';
import { pgChannels } from './config/pg';
import { piDurableChannels } from './config/pi-durable';
Expand Down Expand Up @@ -83,6 +84,7 @@ export const CHANNELS = {
...mysqlChannels,
...neonChannels,
...nestjsChannels,
...nodeCronChannels,
...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 @@ -33,6 +33,7 @@ import { mysqlConfig } from './mysql';
import { neonConfig } from './neon';
import { nestjsConfig } from './nestjs';
import { nitroConfig } from './nitro';
import { nodeCronConfig } from './node-cron';
import { openaiConfig } from './openai';
import { pgConfig } from './pg';
import { postgresJsConfig } from './postgres';
Expand Down Expand Up @@ -91,6 +92,7 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [
...neonConfig,
...nestjsConfig,
...nitroConfig,
...nodeCronConfig,
...openaiConfig,
...pgConfig,
...piDurableConfig,
Expand Down
40 changes: 40 additions & 0 deletions packages/server-utils/src/orchestrion/config/node-cron.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
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.
export const nodeCronConfig = [
{
channelName: 'createTask',
module: { name: 'node-cron', versionRange: '>=4.0.0 <5', filePath: 'dist/node-cron.cjs' },
functionQuery: createTask,
},
{
channelName: 'createTask',
module: { name: 'node-cron', versionRange: '>=4.0.0 <5', filePath: 'dist/node-cron.js' },
functionQuery: createTask,
},
{
channelName: 'createTask',
module: { name: 'node-cron', versionRange: '>=4.0.0 <4.3.0', filePath: 'dist/cjs/node-cron.js' },
functionQuery: createTask,
},
{
channelName: '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_V3_CREATE_TASK: 'orchestrion:node-cron:v3_createTask',
} as const;
Loading
Loading