Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
5833c8f
feat(nestjs): Derive SentryCron monitor config from @Cron
wedamija Oct 2, 2026
20a2edd
ref(nestjs): Make deriving the SentryCron config from @Cron opt-in
wedamija Oct 2, 2026
2bd91a3
feat(nestjs): Send the @Cron schedule from SentryCron by default
wedamija Oct 2, 2026
3c1da94
feat(nestjs): Send @Cron presets and warn when no config is derived
wedamija Oct 2, 2026
6413911
fix(nestjs): Only send @Cron schedules Sentry reads the same way
wedamija Oct 2, 2026
8cd5e98
fix(nestjs): Send @Cron month steps and canonical time zones
wedamija Oct 5, 2026
258a3fc
fix(nestjs): Keep isolateTrace when no monitor config is sent
wedamija Oct 5, 2026
b253af4
ref(nestjs): Shorten SentryCron comments
wedamija Oct 15, 2026
34f02f1
feat(nestjs): Add option to monitor every @Cron method
wedamija Oct 17, 2026
a191b86
fix(nestjs): Skip monitored @Cron jobs in the cron package integration
wedamija Oct 28, 2026
47086d7
Merge remote-tracking branch 'origin/develop' into danf/nestjs-cron-c…
wedamija Oct 28, 2026
9173773
Merge remote-tracking branch 'origin/danf/nestjs-cron-config' into da…
wedamija Oct 28, 2026
cb47dab
fix(nestjs): Use OrchestrionChannelContext in schedule explorer subsc…
wedamija Oct 28, 2026
0c123c6
fix(nestjs): Mark the CronJob onTick when a decorator wraps @Cron()
wedamija Oct 28, 2026
0c5e61c
fix(nestjs): Detect @SentryCron when another decorator sits between i…
wedamija Oct 28, 2026
0576af0
fix(nestjs): Expand @Cron day fields like cron and send numeric month…
wedamija Oct 29, 2026
a9a89d7
docs(nestjs): Add changelog entry for SentryCron monitor config
wedamija Oct 29, 2026
7a3b942
Merge remote-tracking branch 'origin/danf/nestjs-cron-config' into wt…
wedamija Oct 29, 2026
481a052
fix(nestjs): Skip jobs with unsupported schedules when monitoring all…
wedamija Oct 29, 2026
f23a26f
Merge remote-tracking branch 'origin/develop' into wt-nestjs-cron-aut…
wedamija Oct 29, 2026
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 @@ -92,6 +92,12 @@ export class AppController {
return {};
}

@Get('test-auto-monitored-cron')
async testAutoMonitoredCron() {
await this.appService.testAutoMonitoredCron();
return {};
}

@Get('kill-test-cron/:job')
async killTestCron(@Param('job') job: string) {
this.appService.killTestCron(job);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,12 @@ export class AppService {
console.log('Test derived cron!');
}

// Monitored through `monitorCronJobs`, without `@SentryCron`.
@Cron('0 0 * * *', { name: 'test-auto-monitored-cron', timeZone: 'America/New_York', disabled: true })
async testAutoMonitoredCron() {
console.log('Test auto-monitored cron!');
}

async killTestCron(job: string) {
this.schedulerRegistry.deleteCronJob(job);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ Sentry.init({
dsn: process.env.E2E_TEST_DSN,
tunnel: `http://localhost:3031/`, // proxy server
tracesSampleRate: 1,
integrations: [Sentry.nestIntegration({ monitorCronJobs: true })],
transportOptions: {
// We expect the app to send a lot of events in a short time
bufferSize: 1000,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,29 @@ test('Sends the schedule and time zone of @Cron with the check-in', async ({ bas
);
});

test('Monitors @Cron methods without @SentryCron when monitorCronJobs is enabled', async ({ baseURL }) => {
const inProgressEnvelopePromise = waitForEnvelopeItem('nestjs-basic', envelope => {
return (
envelope[0].type === 'check_in' &&
envelope[1]['monitor_slug'] === 'test-auto-monitored-cron' &&
envelope[1]['status'] === 'in_progress'
);
});

await fetch(`${baseURL}/test-auto-monitored-cron`);

expect((await inProgressEnvelopePromise)[1]).toEqual(
expect.objectContaining({
monitor_slug: 'test-auto-monitored-cron',
status: 'in_progress',
monitor_config: {
schedule: { type: 'crontab', value: '0 0 * * *' },
timezone: 'America/New_York',
},
}),
);
});

test('Sends exceptions to Sentry on error in async cron job', async ({ baseURL }) => {
const errorEventPromise = waitForError('nestjs-basic', event => {
return (
Expand Down
6 changes: 4 additions & 2 deletions packages/nestjs/src/decorators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { startSpan } from '@sentry/node';
import { DEBUG_BUILD } from './debug-build';
import { isExpectedError } from './helpers';
import type { ReflectWithMetadata } from './integrations/helpers';
import { copyReflectMetadata, getNestScheduleVersion } from './integrations/helpers';
import { copyReflectMetadata, getNestScheduleVersion, markSentryCronMethod } from './integrations/helpers';

/**
* Monitor settings for `@SentryCron` whose schedule and time zone come from the `@Cron()` decorator
Expand Down Expand Up @@ -60,6 +60,8 @@ export const SentryCron = (
};

descriptor.value = wrappedMethod;
markSentryCronMethod(originalMethod);
markSentryCronMethod(wrappedMethod);

copyFunctionNameAndMetadata({ originalMethod, descriptor });

Expand Down Expand Up @@ -138,7 +140,7 @@ function getMonitorConfigFromNestCron(candidates: unknown[]): MonitorConfig | un
* Converts the options `@Cron()` stores as metadata into a Sentry monitor config.
* Returns `undefined` for schedules Sentry can't represent.
*/
function nestCronOptionsToMonitorConfig(
export function nestCronOptionsToMonitorConfig(
cronOptions: NestCronOptions,
scheduleVersion: string | undefined,
): MonitorConfig | undefined {
Expand Down
1 change: 1 addition & 0 deletions packages/nestjs/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
export * from '@sentry/node';

export { nestIntegration } from './integrations/nest';
export type { NestIntegrationOptions } from './integrations/nest';

export { getDefaultIntegrations, init } from './sdk';

Expand Down
105 changes: 105 additions & 0 deletions packages/nestjs/src/integrations/cron-monitoring.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import type { Client, MonitorConfig } from '@sentry/core';
import { debug, getClient, stringMatchesSomePattern } from '@sentry/core';
import { DEBUG_BUILD } from '../debug-build';
import { nestCronOptionsToMonitorConfig } from '../decorators';
import type { AnyFn } from './helpers';
import { isSentryCronMethod } from './helpers';

export interface NestCronMonitoringOptions {
/**
* Send check-ins for every `@Cron()` method of `@nestjs/schedule`. The monitor slug is the `name`
* option of `@Cron()`, or `ClassName.methodName`. Methods with `@SentryCron()` keep their own slug.
* Methods whose schedule Sentry can't represent, like every 10 seconds, are not monitored.
*
* Defaults to `false`.
*/
monitorCronJobs?: boolean;

/**
* Monitor slugs not to monitor when `monitorCronJobs` is enabled. Strings must match the slug exactly.
*/
excludeCronJobs?: Array<string | RegExp>;
}

interface CronMonitor {
slug: string;
config: MonitorConfig;
}

const MONITORING_OPTIONS = new WeakMap<Client, NestCronMonitoringOptions>();

/** Enables `@Cron()` monitoring for the client. */
export function setCronMonitoringOptions(client: Client, options: NestCronMonitoringOptions): void {
if (options.monitorCronJobs) {
MONITORING_OPTIONS.set(client, options);
}
}

/**
* Returns a function that resolves the monitor for a `@Cron()` method on each run,
* or `undefined` if the run should not be monitored.
*/
export function makeCronMonitorResolver(
cronArgs: unknown[],
target: unknown,
propertyKey: string | symbol | undefined,
scheduleVersion?: string,
): (handler: AnyFn, wrapped: AnyFn) => CronMonitor | undefined {
let monitor: CronMonitor | null | undefined;

return (handler, wrapped) => {
const client = getClient();
const options = client && MONITORING_OPTIONS.get(client);
Comment thread
wedamija marked this conversation as resolved.
if (!options || isSentryCronMethod(handler) || isSentryCronMethod(wrapped)) {
return undefined;
}
Comment thread
wedamija marked this conversation as resolved.

if (monitor === undefined) {
try {
monitor = getCronMonitor(cronArgs, target, propertyKey, scheduleVersion);
} catch (e) {
DEBUG_BUILD && debug.error('[Nest] Failed to set up the cron monitor:', e);
monitor = null;
}
}

if (!monitor || stringMatchesSomePattern(monitor.slug, options.excludeCronJobs, true)) {
return undefined;
}
return monitor;
};
}

function getCronMonitor(
[cronTime, cronOptions]: unknown[],
target: unknown,
propertyKey: string | symbol | undefined,
scheduleVersion: string | undefined,
): CronMonitor | null {
// A `Date` runs once, so there is nothing to monitor.
if (typeof cronTime !== 'string') {
return null;
}

const options = (cronOptions && typeof cronOptions === 'object' ? cronOptions : {}) as Record<string, unknown>;
const className = (target as { constructor?: { name?: unknown } } | undefined)?.constructor?.name;
const slug =
typeof options.name === 'string' && options.name
? options.name
: typeof className === 'string' && className && propertyKey !== undefined
? `${className}.${String(propertyKey)}`
: undefined;

if (!slug) {
DEBUG_BUILD && debug.warn('[Nest] Not monitoring a @Cron() method without a name.');
return null;
}

const config = nestCronOptionsToMonitorConfig({ ...options, cronTime }, scheduleVersion);
if (!config) {
DEBUG_BUILD && debug.log(`[Nest] Not monitoring ${slug}: Sentry can't represent its schedule.`);
return null;
}

return { slug, config };
}
53 changes: 48 additions & 5 deletions packages/nestjs/src/integrations/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,13 @@ import {
} from '@sentry/conventions/attributes';
import { FUNCTION, MIDDLEWARE, QUEUE_PROCESS } from '@sentry/conventions/op';
import type { Span } from '@sentry/core';
import { addNonEnumerableProperty, getClient, hasSpanStreamingEnabled, withActiveSpan } from '@sentry/core';
import {
addNonEnumerableProperty,
getClient,
hasSpanStreamingEnabled,
isObjectLike,
withActiveSpan,
} from '@sentry/core';
import type { CatchTarget, InjectableTarget, NextFunction, Observable, Subscription } from './types';

/** A function of unknown signature, matching the methods/handlers we wrap. */
Expand All @@ -33,17 +39,54 @@ export function markWrapped(fn: AnyFn): void {

// `@nestjs/schedule`'s metadata key for the `@Cron()` options.
const SCHEDULE_CRON_OPTIONS = 'SCHEDULE_CRON_OPTIONS';
const SENTRY_CRON_METADATA = 'sentry:nestjs:sentry_cron';

const SENTRY_CRON_METHODS = new WeakSet<object>();

/** The options `@Cron()` stored as metadata on `fn`, if any. */
export function getCronOptions(fn: object): object | undefined {
const R = Reflect as ReflectWithMetadata;
const options = typeof R.getMetadata === 'function' ? R.getMetadata(SCHEDULE_CRON_OPTIONS, fn) : undefined;
return isObjectLike(options) ? options : undefined;
}

/**
* Marks a method that `@SentryCron()` monitors, so `@Cron()` monitoring skips it. Other decorators may wrap the
* method on either side of `@SentryCron()`, so this also sets metadata they copy outward and marks the `@Cron()`
* options that are already there.
*/
export function markSentryCronMethod(fn: object): void {
SENTRY_CRON_METHODS.add(fn);
const R = Reflect as ReflectWithMetadata;
if (typeof R.defineMetadata === 'function') {
R.defineMetadata(SENTRY_CRON_METADATA, true, fn);
}
const cronOptions = getCronOptions(fn);
if (cronOptions) {
SENTRY_CRON_METHODS.add(cronOptions);
}
}

/** Whether `fn` is monitored by `@SentryCron()` (see {@link markSentryCronMethod}). */
export function isSentryCronMethod(fn: object): boolean {
const R = Reflect as ReflectWithMetadata;
const cronOptions = getCronOptions(fn);
return (
SENTRY_CRON_METHODS.has(fn) ||
(typeof R.getMetadata === 'function' && R.getMetadata(SENTRY_CRON_METADATA, fn) === true) ||
(!!cronOptions && SENTRY_CRON_METHODS.has(cronOptions))
);
}

const NEST_SCHEDULE_VERSIONS = new WeakMap<object, string>();

/** Remembers the `@nestjs/schedule` version whose `@Cron()` stored the options on `method`. */
export function setNestScheduleVersion(method: unknown, version: string | undefined): void {
const R = Reflect as ReflectWithMetadata;
if (typeof method !== 'function' || !version || typeof R.getMetadata !== 'function') {
if (typeof method !== 'function' || !version) {
return;
}
const cronOptions = R.getMetadata(SCHEDULE_CRON_OPTIONS, method);
if (cronOptions && typeof cronOptions === 'object') {
const cronOptions = getCronOptions(method);
if (cronOptions) {
NEST_SCHEDULE_VERSIONS.set(cronOptions, version);
}
}
Expand Down
9 changes: 8 additions & 1 deletion packages/nestjs/src/integrations/nest.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,23 @@
import { defineIntegration } from '@sentry/core';
import type { NestCronMonitoringOptions } from './cron-monitoring';
import { setCronMonitoringOptions } from './cron-monitoring';
import { subscribeToNestChannels } from './orchestrion-subscriber';

const INTEGRATION_NAME = 'Nest' as const;

export type NestIntegrationOptions = NestCronMonitoringOptions;

/**
* Integration capturing tracing data for NestJS.
*/
export const nestIntegration = defineIntegration(() => {
export const nestIntegration = defineIntegration((options: NestIntegrationOptions = {}) => {
return {
name: INTEGRATION_NAME,
setupOnce() {
subscribeToNestChannels();
},
setup(client) {
setCronMonitoringOptions(client, options);
},
};
});
32 changes: 27 additions & 5 deletions packages/nestjs/src/integrations/orchestrion-subscriber.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as diagnosticsChannel from 'node:diagnostics_channel';
import { debug, startInactiveSpan, waitForTracingChannelBinding } from '@sentry/core';
import { addNonEnumerableProperty, debug, startInactiveSpan, waitForTracingChannelBinding } from '@sentry/core';
import type { OrchestrionChannelContext } from '@sentry/server-utils';
import { bindTracingChannelToSpan } from '@sentry/server-utils';
import { nestjsChannels as CHANNELS } from '@sentry/server-utils/orchestrion/config';
Expand All @@ -10,11 +10,13 @@ import type { CatchTarget, InjectableTarget, MinimalNestJsExecutionContext } fro
import { patchCatchTarget, patchInjectableTarget } from './wrap-components';
import {
extractQueueName,
MECHANISM_CRON,
linkCronOptionsToMonitor,
MECHANISM_INTERVAL,
MECHANISM_TIMEOUT,
patchMethodDescriptor,
patchProcessorTarget,
sendsCronCheckIns,
wrapCronHandler,
wrapEventHandler,
wrapScheduleHandler,
} from './wrap-handlers';
Expand Down Expand Up @@ -50,7 +52,10 @@ function subscribeDecoratorChannel<T>(channelName: string, patch: (target: T) =>
* `descriptor.value` with a wrapped handler before delegating to the
* original decorator.
*/
function makeMethodDecorator(original: AnyFn, wrapHandler: (handler: AnyFn) => AnyFn): AnyFn {
function makeMethodDecorator(
original: AnyFn,
wrapHandler: (handler: AnyFn, target: unknown, propertyKey: string | symbol | undefined) => AnyFn,
): AnyFn {
return function (this: unknown, ...args: unknown[]): unknown {
patchMethodDescriptor(
args[0] as { __SENTRY_INTERNAL__?: boolean } | undefined,
Expand Down Expand Up @@ -185,13 +190,30 @@ export function subscribeToNestChannels(): void {

// @Cron/@Interval/@Timeout (schedule), @OnEvent (event), @Processor (bullmq)
subscribeFactoryDecorator(CHANNELS.NESTJS_SCHEDULE_CRON, (decorator, data) => {
const cronDecorator = makeMethodDecorator(decorator, handler => wrapScheduleHandler(handler, MECHANISM_CRON));
const cronDecorator = makeMethodDecorator(decorator, (handler, target, propertyKey) =>
wrapCronHandler(handler, [...(data.arguments || [])], target, propertyKey, data.moduleVersion),
);
return function (this: unknown, ...args: unknown[]): unknown {
const result = cronDecorator.apply(this, args);
setNestScheduleVersion((args[2] as PropertyDescriptor | undefined)?.value, data.moduleVersion);
const method = (args[2] as PropertyDescriptor | undefined)?.value;
setNestScheduleVersion(method, data.moduleVersion);
linkCronOptionsToMonitor(method);
return result;
};
});
// `@nestjs/schedule` passes this function to `CronJob` as `onTick`. Mark it when the method already
// sends check-ins, so `cronJobIntegration` doesn't send a second set.
diagnosticsChannel.tracingChannel<OrchestrionChannelContext>(CHANNELS.NESTJS_SCHEDULE_EXPLORER_WRAP).subscribe({
start: NOOP,
end(data) {
if (typeof data.result === 'function' && sendsCronCheckIns(data.arguments?.[0])) {
addNonEnumerableProperty(data.result, Symbol.for('sentry.cron.monitored'), true);
}
Comment thread
cursor[bot] marked this conversation as resolved.
},
asyncStart: NOOP,
asyncEnd: NOOP,
error: NOOP,
});
subscribeFactoryDecorator(CHANNELS.NESTJS_SCHEDULE_INTERVAL, decorator =>
makeMethodDecorator(decorator, handler => wrapScheduleHandler(handler, MECHANISM_INTERVAL)),
);
Expand Down
Loading
Loading