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/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;

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
73 changes: 73 additions & 0 deletions packages/server-utils/src/integrations/node-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 { nodeCronModuleNames } from '../orchestrion/config/node-cron';
import { invokeOrchestrionInstrumentation } from '../orchestrion/instrumentation';
import type { AnyFn } from '../utils/cronMonitoring';
import { cronExpressionToMonitorConfig, wrapScheduledJob } from '../utils/cronMonitoring';

const INTEGRATION_NAME = 'NodeCron' as const;

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

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

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

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 {
diagnosticsChannel.tracingChannel<{ arguments: unknown[] }>(CHANNELS.NODE_CRON_CREATE_TASK).subscribe({
start(data) {
try {
const [expression, func, taskOptions] = data.arguments;
const { name, timezone } = (taskOptions || {}) as { name?: unknown; timezone?: unknown };
if (
typeof func !== 'function' ||
(func as { [MONITORED]?: boolean })[MONITORED] ||
typeof name !== 'string' ||
!name ||
stringMatchesSomePattern(name, options.excludeJobs, true)
) {
return;
}
data.arguments[1] = wrapScheduledJob(
func as AnyFn,
name,
cronExpressionToMonitorConfig(String(expression), timezone, 'and'),
'auto.function.node-cron',
);
} catch (e) {
DEBUG_BUILD && debug.error('[NodeCron] 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
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 @@ -32,6 +32,7 @@ import { mysql2Config } from './mysql2';
import { mysqlConfig } from './mysql';
import { neonConfig } from './neon';
import { nestjsConfig } from './nestjs';
import { nodeCronConfig } from './node-cron';
import { openaiConfig } from './openai';
import { pgConfig } from './pg';
import { postgresJsConfig } from './postgres';
Expand Down Expand Up @@ -89,6 +90,7 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [
...mysqlConfig,
...neonConfig,
...nestjsConfig,
...nodeCronConfig,
...openaiConfig,
...pgConfig,
...piDurableConfig,
Expand Down
27 changes: 27 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,27 @@
import type { InstrumentationConfig } from '../apmTypes';
import { getModuleNames } from './module-names';

// `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: { functionName: 'createTask', kind: 'Sync' },
},
{
channelName: 'createTask',
module: { name: 'node-cron', versionRange: '>=4.0.0 <5', filePath: 'dist/node-cron.js' },
functionQuery: { functionName: 'createTask', kind: 'Sync' },
},
{
channelName: 'createTask',
module: { name: 'node-cron', versionRange: '>=3.0.0 <4', filePath: 'src/node-cron.js' },
functionQuery: { functionName: 'createTask', kind: 'Sync' },
},
] satisfies InstrumentationConfig[];

export const nodeCronModuleNames = getModuleNames(nodeCronConfig);

export const nodeCronChannels = {
NODE_CRON_CREATE_TASK: 'orchestrion:node-cron:createTask',
} as const;
Loading
Loading