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,26 @@
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 }, () => {});

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();
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,52 @@
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',
};

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: { monitor_slug: 'rule-job', status: 'in_progress' } })
.expect({ check_in: { monitor_slug: 'rule-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
79 changes: 79 additions & 0 deletions packages/server-utils/src/integrations/node-schedule.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import * as diagnosticsChannel from '../utils/diagnosticsChannel';
import type { IntegrationFn, MonitorConfig } from '@sentry/core';
import { debug, defineIntegration, stringMatchesSomePattern } from '@sentry/core';
import { DEBUG_BUILD } from '../debug-build';
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, wrapScheduledJob } from '../utils/cronMonitoring';

const INTEGRATION_NAME = 'NodeSchedule' as const;

// Set by `Sentry.cron.instrumentNodeSchedule`, which already sends check-ins.
const MONITORED = Symbol.for('sentry.cron.monitored');

const NOOP = (): void => {};

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 {
diagnosticsChannel.tracingChannel<{ arguments: unknown[] }>(CHANNELS.NODE_SCHEDULE_SCHEDULE_JOB).subscribe({
start(data) {
try {
// `scheduleJob([name], spec, method, [callback])`, like node-schedule reads it.
const args = data.arguments;
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 (
!name ||
typeof method !== 'function' ||
(method as { [MONITORED]?: boolean })[MONITORED] ||
spec instanceof Date ||
stringMatchesSomePattern(name, options.excludeJobs, true)
) {
return;
}
args[2] = wrapScheduledJob(method as AnyFn, name, getMonitorConfig(spec), 'auto.function.node-schedule');
} catch (e) {
DEBUG_BUILD && debug.error('[NodeSchedule] Failed to set up the cron monitor:', e);
}
},
end: NOOP,
asyncStart: NOOP,
asyncEnd: NOOP,
error: NOOP,
});
}

// Only cron strings are sent; recurrence rules check in without a config.
function getMonitorConfig(spec: unknown): MonitorConfig | undefined {
if (typeof spec === 'string') {
return cronExpressionToMonitorConfig(spec, undefined, 'or');
}
const { rule, tz } = (spec || {}) as { rule?: unknown; tz?: unknown };
return typeof rule === 'string' ? 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 @@ -33,6 +33,7 @@ import { mysqlConfig } from './mysql';
import { neonConfig } from './neon';
import { nestjsConfig } from './nestjs';
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 @@ -91,6 +92,7 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [
...neonConfig,
...nestjsConfig,
...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