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 @@ -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/cron-integration/test.ts',
'suites/cron/node-cron/integration/test.ts',
'suites/cron/node-schedule-integration/test.ts',
'suites/express/**',
Expand Down
2 changes: 1 addition & 1 deletion dev-packages/node-integration-tests/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@
"body-parser": "^2.3.0",
"consola": "^3.2.3",
"cors": "^2.8.5",
"cron": "^3.1.6",
"cron": "^4.4.0",
"dataloader": "2.2.2",
"express": "^4.21.2",
"fastify": "^5.12.5",
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.cronJobIntegration({ excludeJobs: ['excluded-job'] })],
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { CronJob } from 'cron';

const excluded = CronJob.from({ cronTime: '0 0 * * *', onTick: () => {}, name: 'excluded-job' });
const unnamed = new CronJob('0 0 * * *', () => {});

let runs = 0;
const job = CronJob.from({
cronTime: '0 30 9 * * 1-5',
timeZone: 'Europe/Vienna',
name: 'my-cron-job',
waitForCompletion: true,
errorHandler: () => {},
onTick: async () => {
runs++;
if (runs > 1) {
throw new Error('Error in cron job');
}
},
});

// Run the jobs without waiting for their schedule.
(async () => {
await excluded.fireOnTick();
await unnamed.fireOnTick();
await job.fireOnTick();
await job.fireOnTick();
})();

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: '30 9 * * 1-5' },
timezone: 'Europe/Vienna',
};

describe('cronJobIntegration', () => {
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: '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.cron', handled: false },
},
],
},
},
})
.start()
.completed();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,11 @@ cron.start();
// is enough to hit it, and the job is left scheduled for nothing: no ticks, no check-ins, and the
// scenario exits on the timeout below having sent nothing at all. Starting again re-reads the clock
// away from the boundary.
for (let attempt = 0; attempt < 5 && !cron.running; attempt++) {
for (let attempt = 0; attempt < 5 && !cron.isActive; attempt++) {
cron.start();
}

if (!cron.running) {
if (!cron.isActive) {
throw new Error('`cron` refused to schedule the job');
}

Expand Down
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,
cronJobIntegration,
nodeCronIntegration,
nodeScheduleIntegration,
getActiveSpan,
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,
cronJobIntegration,
nodeCronIntegration,
nodeScheduleIntegration,
graphqlIntegration,
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,
cronJobIntegration,
nodeCronIntegration,
nodeScheduleIntegration,
graphqlIntegration,
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,
cronJobIntegration,
nodeCronIntegration,
nodeScheduleIntegration,
graphqlIntegration,
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,
cronJobIntegration,
nodeCronIntegration,
nodeScheduleIntegration,
graphqlIntegration,
Expand Down
7 changes: 7 additions & 0 deletions packages/node/src/cron/cron.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ export type CronJobConstructor = {
): CronJob;
};

const MONITORED = Symbol.for('sentry.cron.monitored');

const ERROR_TEXT = 'Automatic instrumentation of CronJob only supports crontab string';

/**
Expand Down Expand Up @@ -115,6 +117,9 @@ export function instrumentCron<T>(lib: T & CronJobConstructor, monitorSlug: stri
);
}

// Tells `cronJobIntegration` that this job is already monitored.
(monitoredTick as unknown as Record<symbol, boolean>)[MONITORED] = true;

return new target(cronTime, monitoredTick, onComplete, start, timeZone, ...rest);
},
get(target, prop: keyof CronJobConstructor) {
Expand Down Expand Up @@ -157,6 +162,8 @@ export function instrumentCron<T>(lib: T & CronJobConstructor, monitorSlug: stri
);
};

(param.onTick as unknown as Record<symbol, boolean>)[MONITORED] = true;

return target.from(param);
};
} else {
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,
cronJobIntegration,
type CronJobIntegrationOptions,
nodeCronIntegration,
type NodeCronIntegrationOptions,
nodeScheduleIntegration,
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,
cronJobIntegration,
nodeCronIntegration,
nodeScheduleIntegration,
getActiveSpan,
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 { cronJobIntegration } from './integrations/cron';
export type { CronJobIntegrationOptions } from './integrations/cron';
export { nodeCronIntegration } from './integrations/node-cron';
export type { NodeCronIntegrationOptions } from './integrations/node-cron';
export { nodeScheduleIntegration } from './integrations/node-schedule';
Expand Down
73 changes: 73 additions & 0 deletions packages/server-utils/src/integrations/cron.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import * as diagnosticsChannel from '../utils/diagnosticsChannel';
import type { IntegrationFn } from '@sentry/core';
import { debug, defineIntegration, stringMatchesSomePattern } from '@sentry/core';
import { DEBUG_BUILD } from '../debug-build';
import { CHANNELS } from '../orchestrion/channels';
import { cronModuleNames } from '../orchestrion/config/cron';
import { invokeOrchestrionInstrumentation } from '../orchestrion/instrumentation';
import type { AnyFn } from '../utils/cronMonitoring';
import { cronExpressionToMonitorConfig, wrapScheduledJob } from '../utils/cronMonitoring';

const INTEGRATION_NAME = 'CronJob' as const;

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

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

export interface CronJobIntegrationOptions {
/** Job names not to monitor. Strings must match the name exactly. */
excludeJobs?: Array<string | RegExp>;
}

let options: CronJobIntegrationOptions = {};

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

/**
* Sends Crons check-ins for every `CronJob` of the `cron` package (4.3+) that has a `name`.
* The monitor slug is the job name; jobs without a name, and one-off jobs, are not monitored.
*/
export const cronJobIntegration = defineIntegration(_cronJobIntegration);

function subscribe(): void {
diagnosticsChannel.tracingChannel<{ arguments: unknown[] }>(CHANNELS.CRON_JOB_CONSTRUCTOR).subscribe({
start(data) {
try {
// `new CronJob(cronTime, onTick, onComplete, start, timeZone, context, runOnInit, utcOffset, ..., name)`
const args = data.arguments;
const [cronTime, onTick, , , timeZone] = args;
const utcOffset = args[7];
const name = args[11];
if (
typeof name !== 'string' ||
!name ||
typeof onTick !== 'function' ||
(onTick as { [MONITORED]?: boolean })[MONITORED] ||
typeof cronTime !== 'string' ||
stringMatchesSomePattern(name, options.excludeJobs, true)
) {
return;
}
const monitorConfig = utcOffset == null ? cronExpressionToMonitorConfig(cronTime, timeZone, 'or') : undefined;
args[1] = wrapScheduledJob(onTick as AnyFn, name, monitorConfig, 'auto.function.cron');
} catch (e) {
DEBUG_BUILD && debug.error('[CronJob] Failed to set up the cron monitor:', e);
}
},
end: NOOP,
asyncStart: NOOP,
asyncEnd: NOOP,
error: NOOP,
});
}
2 changes: 2 additions & 0 deletions packages/server-utils/src/orchestrion/channels.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { amqplibChannels } from './config/amqplib';
import { anthropicAiChannels } from './config/anthropic-ai';
import { awsSdkChannels } from './config/aws-sdk';
import { cronChannels } from './config/cron';
import { dataloaderChannels } from './config/dataloader';
import { expressChannels } from './config/express';
import { firebaseChannels } from './config/firebase';
Expand Down Expand Up @@ -60,6 +61,7 @@ export const CHANNELS = {
...amqplibChannels,
...anthropicAiChannels,
...awsSdkChannels,
...cronChannels,
...dataloaderChannels,
...expressChannels,
...firebaseChannels,
Expand Down
17 changes: 17 additions & 0 deletions packages/server-utils/src/orchestrion/config/cron.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { InstrumentationConfig } from '../apmTypes';
import { getModuleNames } from './module-names';

// `CronJob.from()` calls the constructor. Job names were added in 4.3.0.
export const cronConfig = [
{
channelName: 'cronJobConstructor',
module: { name: 'cron', versionRange: '>=4.3.0 <5', filePath: 'dist/job.js' },
functionQuery: { className: 'CronJob' },
},
] satisfies InstrumentationConfig[];

export const cronModuleNames = getModuleNames(cronConfig);

export const cronChannels = {
CRON_JOB_CONSTRUCTOR: 'orchestrion:cron:cronJobConstructor',
} as const;
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 @@ -5,6 +5,7 @@ import { uniq } from '@sentry/core';
import { awsSdkConfig } from './aws-sdk';
import { amqplibConfig } from './amqplib';
import { anthropicAiConfig } from './anthropic-ai';
import { cronConfig } from './cron';
import { dataloaderConfig } from './dataloader';
import { expressConfig } from './express';
import { firebaseConfig } from './firebase';
Expand Down Expand Up @@ -66,6 +67,7 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [
...amqplibConfig,
...anthropicAiConfig,
...awsSdkConfig,
...cronConfig,
...dataloaderConfig,
...expressConfig,
...firebaseConfig,
Expand Down
3 changes: 3 additions & 0 deletions packages/server-utils/src/utils/cronMonitoring.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ const NICKNAMES: Record<string, string> = {
'@daily': '0 0 * * *',
'@midnight': '0 0 * * *',
'@hourly': '0 * * * *',
'@minutely': '* * * * *',
'@weekdays': '0 0 * * 1-5',
'@weekends': '0 0 * * 0,6',
};

const MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
Expand Down
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', 'node-schedule']);
const optIn = new Set(['cron', '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/server-utils/test/utils/cronMonitoring.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ describe('cronExpressionToMonitorConfig with node-cron day matching', () => {
['0 9 */2 * 1', '0 9 */2 * 1'],
['@daily', '0 0 * * *'],
['@Weekly', '0 0 * * 0'],
['@weekdays', '0 0 * * 1-5'],
['*/15 * * * *', '*/15 * * * *'],
])('sends %s as %s', (expression, crontab) => {
expect(toMonitorConfig(expression, 'Europe/Vienna')).toEqual({
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 @@ -40,6 +40,7 @@ export {
flush,
functionToStringIntegration,
genericPoolIntegration,
cronJobIntegration,
nodeCronIntegration,
nodeScheduleIntegration,
getActiveSpan,
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 @@ -38,6 +38,7 @@ export {
flush,
functionToStringIntegration,
genericPoolIntegration,
cronJobIntegration,
nodeCronIntegration,
nodeScheduleIntegration,
getActiveSpan,
Expand Down
Loading
Loading