Skip to content
Draft
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,28 @@
import schedule from 'node-schedule';

const excluded = schedule.scheduleJob('excluded-job', '0 0 * * *', () => {});
const unnamed = schedule.scheduleJob('0 0 * * *', () => {});
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();
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
3 changes: 3 additions & 0 deletions packages/node/src/cron/node-schedule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ export function instrumentNodeSchedule<T>(lib: T & NodeSchedule): T {
);
}

// Tells `nodeScheduleIntegration` that this job is already monitored.
(monitoredCallback as unknown as Record<symbol, boolean>)[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
62 changes: 62 additions & 0 deletions packages/server-utils/src/integrations/node-schedule.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
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 (spec instanceof Date || !shouldMonitorJob(name, method, options.excludeJobs)) {
return;
}
args[2] = wrapScheduledJob(method as AnyFn, name, getMonitorConfig(spec), 'auto.function.node-schedule');
}

// Only cron strings are sent; recurrence rules and rules limited by `start` or `end` check in without a config.
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;
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