Skip to content
Open
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, [], {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

instead of doing this and storing this in module scope, it would be better to pass the options to subscribe like this:

Suggested change
invokeOrchestrionInstrumentation(client, nodeCronModuleNames, subscribe, [], {
invokeOrchestrionInstrumentation(client, nodeCronModuleNames, subscribe, [options], {

and then pass this through to wrapTask, IMHO. keeping options in module scope is brittle and should be avoided if possible!

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