Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 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
c319f9f
Merge branch 'develop' into danf/nestjs-cron-auto-monitor
nicohrubec Oct 30, 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.
Resolved
if (!options || isSentryCronMethod(handler) || isSentryCronMethod(wrapped)) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than calling the isSentryCronMethod() twice here, we could cache the value like we do for monitor, no? Would save a few Reflect calls.

return undefined;
}
Comment thread
wedamija marked this conversation as resolved.
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;
Comment on lines +86 to +91

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm, relay truncates these to 50 chars, so we should probably at least document that users should pass an explicit name for long class/method names? I'm not sure if it's likely enough to be worth a harder gate than that, but it could be a weird gotcha.


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>();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's three overlapping ways to mark @SentryCron methods in this file, and I think we can streamline it a bit.

markSentryCronMethod (line 58, below) records the method in three places. We've got a WeakSet of functions, a sentry:nestjs:sentry_cron metadata key, and a WeakSet that holds the @Cron() options object. wrap-handlers.ts adds two more maps (CRON_HANDLER_MONITORS, CRON_OPTIONS_MONITORS). Nest needs reflect-metadata, so the metadata key covers every case that the function WeakSet covers. A wrapper that doesn't copy metadata also breaks @Cron() scheduling, so that case isn't a concern. The options-object mark is what handles @SentryCron above @Cron() with a decorator in between, so it is needed.

Suggestion: drop the function WeakSet and the typeof R.defineMetadata === 'function' guards, and keep the metadata key plus the options-object mark.


/** 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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, is this supposed to be stacked on #25477? It seems like this comment is maybe premature, since the cronJobIntegration doesn't exist in develop yet?

Whether that's the case, or if #25477 is intended to stack on top of this, probably we should make these a PR stack so that we can land them together and not have develop in a halfway state?

(It's low severity, I mean, if we land both, it's fine, and I wouldn't sweat the ordering over a premature comment, as long as the code works at all points along the develop history.)

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.
Resolved
},
Comment on lines +208 to +212

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sendsCronCheckIns calls Reflect.getMetadata and the monitor resolver, which calls getClient() and stringMatchesSomePattern.

The risk is pretty low, but this code runs once for every scheduled method at bootstrap, inside the user's app. subscribeToJobCreation in packages/server-utils/src/utils/cronMonitoring.ts uses a try/catch for this reason.

Suggested change
end(data) {
if (typeof data.result === 'function' && sendsCronCheckIns(data.arguments?.[0])) {
addNonEnumerableProperty(data.result, Symbol.for('sentry.cron.monitored'), true);
}
},
end(data) {
try {
if (typeof data.result === 'function' && sendsCronCheckIns(data.arguments?.[0])) {
addNonEnumerableProperty(data.result, Symbol.for('sentry.cron.monitored'), true);
}
} catch (er) {
DEBUG_BUILD && debug.error('[Nest] Failed to check whether a @Cron() job sends check-ins:', e);
}
},

asyncStart: NOOP,
asyncEnd: NOOP,
error: NOOP,
});
subscribeFactoryDecorator(CHANNELS.NESTJS_SCHEDULE_INTERVAL, decorator =>
makeMethodDecorator(decorator, handler => wrapScheduleHandler(handler, MECHANISM_INTERVAL)),
);
Expand Down
Loading
Loading