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 @@ -63,6 +63,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,30 @@
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 }, () => {});

let runs = 0;
const job = schedule.scheduleJob('my-schedule-job', { rule: '30 9 * * 1-5', tz: 'Europe/Vienna' }, () => {
runs++;
if (runs > 1) {
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();
job.invoke();
try {
job.invoke();
} catch {
// node-schedule catches errors when it runs the job itself
}

setTimeout(() => process.exit(), 10_000);
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
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('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: expectNoConfig('rule-job') })
.expect({ check_in: { monitor_slug: 'rule-job', status: 'ok' } })
.expect({ check_in: expectNoConfig('ending-job') })
.expect({ check_in: { monitor_slug: 'ending-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) } })
.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 @@ -48,6 +48,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 @@ -117,6 +117,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 @@ -139,6 +139,7 @@ export {
setupKoaErrorHandler,
genericPoolIntegration,
nodeCronIntegration,
nodeScheduleIntegration,
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 @@ -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 @@ -117,6 +117,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);

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 @@ -13,6 +13,8 @@ export {
genericPoolIntegration,
nodeCronIntegration,
type NodeCronIntegrationOptions,
nodeScheduleIntegration,
type NodeScheduleIntegrationOptions,
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 @@ -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
81 changes: 81 additions & 0 deletions packages/server-utils/src/integrations/node-schedule.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
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 } from '../utils/cronMonitoring';
import {
cronExpressionToMonitorConfig,
shouldMonitorJob,
subscribeToJobCreation,
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>;
}

let options: NodeScheduleIntegrationOptions = {};

const _nodeScheduleIntegration = ((integrationOptions: NodeScheduleIntegrationOptions = {}) => {
return {
name: INTEGRATION_NAME,
setup(client) {
options = integrationOptions;
invokeOrchestrionInstrumentation(client, nodeScheduleModuleNames, subscribe, [], {
requiresTracingChannelBinding: false,
});
},
};
}) satisfies IntegrationFn;

/**
* Sends Crons check-ins for every named `node-schedule` job created with `scheduleJob`.
* The monitor slug is the job name; jobs without a name, and one-off jobs, are not monitored.
*/
export const nodeScheduleIntegration = defineIntegration(_nodeScheduleIntegration);

function subscribe(): void {
subscribeToJobCreation(CHANNELS.NODE_SCHEDULE_SCHEDULE_JOB, INTEGRATION_NAME, wrapJob);
}

// `scheduleJob([name], spec, method, [callback])`, like node-schedule reads it.
function wrapJob(args: unknown[]): void {
const name = args.length >= 3 && typeof args[0] === 'string' ? args[0] : undefined;
const [spec, method] = name ? [args[1], args[2]] : [args[0], args[1]];
if (isOneOffSpec(spec) || !shouldMonitorJob(name, method, options.excludeJobs)) {
return;
}
args[2] = wrapScheduledJob(method as AnyFn, name, getMonitorConfig(spec), 'auto.function.node-schedule');
}

/** 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 are sent; recurrence rules and rules limited by `start` or `end` check in without a config.
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
16 changes: 16 additions & 0 deletions packages/server-utils/src/orchestrion/config/node-schedule.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { InstrumentationConfig } from '../apmTypes';
import { getModuleNames } from './module-names';

export const nodeScheduleConfig = [
{
channelName: 'scheduleJob',
module: { name: 'node-schedule', versionRange: '>=1.3.0 <3', filePath: 'lib/schedule.js' },
functionQuery: { functionName: 'scheduleJob', kind: 'Sync' },
},
] satisfies InstrumentationConfig[];

export const nodeScheduleModuleNames = getModuleNames(nodeScheduleConfig);

export const nodeScheduleChannels = {
NODE_SCHEDULE_SCHEDULE_JOB: 'orchestrion:node-schedule:scheduleJob',
} as const;
51 changes: 51 additions & 0 deletions packages/server-utils/test/integrations/node-schedule.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest';
import { getMonitorConfig, isOneOffSpec } from '../../src/integrations/node-schedule';

describe('isOneOffSpec', () => {
it.each([
['a Date', new Date()],
['a timestamp', 1_793_527_200_000],
['a date string', '2026-11-01T10:00:00Z'],
['a long date string', 'Sun Nov 01 2026 10:00:00 GMT+0000'],
['a Date rule', { rule: new Date(), tz: 'UTC' }],
])('is true for %s', (_, spec) => {
expect(isOneOffSpec(spec)).toBe(true);
});

it.each([
['a cron string', '0 9 * * *'],
['a cron with names', '0 9 * jan mon-fri'],
['a cron with seconds', '*/10 * * * * *'],
['a nickname', '@daily'],
['a cron rule', { rule: '0 9 * * *', tz: 'UTC' }],
['a recurrence rule', { hour: 3, minute: 0 }],
])('is false for %s', (_, spec) => {
expect(isOneOffSpec(spec)).toBe(false);
});
});

describe('getMonitorConfig', () => {
it('sends a cron rule with its time zone', () => {
expect(getMonitorConfig({ rule: '30 9 * * 1-5', tz: 'Europe/Vienna' })).toEqual({
schedule: { type: 'crontab', value: '30 9 * * 1-5' },
timezone: 'Europe/Vienna',
});
});

it('drops a fixed second', () => {
expect(getMonitorConfig({ rule: '0 30 9 * * *', tz: 'UTC' })?.schedule).toEqual({
type: 'crontab',
value: '30 9 * * *',
});
});

it.each([
['a sub-minute cron', '*/10 * * * * *'],
['a rule with a start', { rule: '0 9 * * *', start: Date.now() + 1000 }],
['a rule with an end', { rule: '0 9 * * *', end: Date.now() + 1000 }],
['a recurrence rule', { hour: 3, minute: 0 }],
['a UTC offset', { rule: '0 9 * * *', tz: '+02:00' }],
])('is undefined for %s', (_, spec) => {
expect(getMonitorConfig(spec)).toBeUndefined();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ describe('channel integration definitions', () => {

// `@nestjs/*` and `@remix-run/*` are covered by listeners in their own SDK packages.
// Crons integrations are opt-in, so bundler-only SDKs must not install them for every app.
const optIn = new Set(['node-cron']);
const optIn = new Set(['node-cron', 'node-schedule']);
const uncovered = [...configured].filter(
name => !defined.has(name) && !optIn.has(name) && !name.startsWith('@nestjs/') && !name.startsWith('@remix-run/'),
);
Expand Down
1 change: 1 addition & 0 deletions packages/solidstart/src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export {
functionToStringIntegration,
genericPoolIntegration,
nodeCronIntegration,
nodeScheduleIntegration,
getActiveSpan,
getAutoPerformanceIntegrations,
getClient,
Expand Down
1 change: 1 addition & 0 deletions packages/sveltekit/src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export {
functionToStringIntegration,
genericPoolIntegration,
nodeCronIntegration,
nodeScheduleIntegration,
getActiveSpan,
getAutoPerformanceIntegrations,
getClient,
Expand Down
Loading