Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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/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
3 changes: 3 additions & 0 deletions packages/node/src/cron/node-cron.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@ export function instrumentNodeCron<T>(
);
};

// Tells `nodeCronIntegration` that this task is already monitored.
(monitoredCallback as unknown as Record<symbol, boolean>)[Symbol.for('sentry.cron.monitored')] = true;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Uncommented double cast on monitored callback

Low Severity

This was flagged because the review rules call out new as unknown as double-casts in SDK source. The mark on monitoredCallback uses that pattern with no comment on why a safer type is not possible.

Fix in Cursor Fix in Web

Triggered by project rule: PR Review Guidelines for Cursor Bot

Reviewed by Cursor Bugbot for commit ffb2c5d. Configure here.


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