From 34f02f1a7bab7e5b0d457f4a353d912c6e25728c Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Fri, 16 Oct 2026 17:34:38 -0700 Subject: [PATCH] feat(nestjs): Add option to monitor every @Cron method `nestIntegration({ monitorCronJobs: true })` sends check-ins for every `@Cron()` method, with the schedule from `@Cron()` as monitor config. The slug is the `@Cron()` name, else `ClassName.methodName`. `excludeCronJobs` skips jobs; methods with `@SentryCron` keep their slug. Co-Authored-By: Claude --- .../nestjs-basic/src/app.controller.ts | 6 + .../nestjs-basic/src/app.service.ts | 6 + .../nestjs-basic/src/instrument.ts | 1 + .../nestjs-basic/tests/cron-decorator.test.ts | 23 +++ packages/nestjs/src/decorators.ts | 6 +- packages/nestjs/src/index.ts | 1 + .../src/integrations/cron-monitoring.ts | 96 ++++++++++++ packages/nestjs/src/integrations/helpers.ts | 12 ++ packages/nestjs/src/integrations/nest.ts | 9 +- .../integrations/orchestrion-subscriber.ts | 13 +- .../nestjs/src/integrations/wrap-handlers.ts | 29 +++- .../test/integrations/cron-monitoring.test.ts | 148 ++++++++++++++++++ 12 files changed, 340 insertions(+), 10 deletions(-) create mode 100644 packages/nestjs/src/integrations/cron-monitoring.ts create mode 100644 packages/nestjs/test/integrations/cron-monitoring.test.ts diff --git a/dev-packages/e2e-tests/test-applications/nestjs-basic/src/app.controller.ts b/dev-packages/e2e-tests/test-applications/nestjs-basic/src/app.controller.ts index 33584dea5e78..5a30cf575da8 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-basic/src/app.controller.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-basic/src/app.controller.ts @@ -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); diff --git a/dev-packages/e2e-tests/test-applications/nestjs-basic/src/app.service.ts b/dev-packages/e2e-tests/test-applications/nestjs-basic/src/app.service.ts index e9bb0c7f69c6..712f6777ccf5 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-basic/src/app.service.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-basic/src/app.service.ts @@ -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); } diff --git a/dev-packages/e2e-tests/test-applications/nestjs-basic/src/instrument.ts b/dev-packages/e2e-tests/test-applications/nestjs-basic/src/instrument.ts index 07454fc8d8c8..7b07f60a3252 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-basic/src/instrument.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-basic/src/instrument.ts @@ -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, diff --git a/dev-packages/e2e-tests/test-applications/nestjs-basic/tests/cron-decorator.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-basic/tests/cron-decorator.test.ts index e1d006ccdb5d..2302bd6bdb9c 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-basic/tests/cron-decorator.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-basic/tests/cron-decorator.test.ts @@ -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 ( diff --git a/packages/nestjs/src/decorators.ts b/packages/nestjs/src/decorators.ts index 653bca5c41be..7a3ee6f2ae8d 100644 --- a/packages/nestjs/src/decorators.ts +++ b/packages/nestjs/src/decorators.ts @@ -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 } from './integrations/helpers'; +import { copyReflectMetadata, markSentryCronMethod } from './integrations/helpers'; /** * Monitor settings for `@SentryCron` whose schedule and time zone come from the `@Cron()` decorator @@ -60,6 +60,8 @@ export const SentryCron = ( }; descriptor.value = wrappedMethod; + markSentryCronMethod(originalMethod); + markSentryCronMethod(wrappedMethod); copyFunctionNameAndMetadata({ originalMethod, descriptor }); @@ -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(cronOptions: NestCronOptions): MonitorConfig | undefined { +export function nestCronOptionsToMonitorConfig(cronOptions: NestCronOptions): MonitorConfig | undefined { const { cronTime, timeZone, utcOffset } = cronOptions; // A fixed UTC offset has no IANA time zone equivalent. diff --git a/packages/nestjs/src/index.ts b/packages/nestjs/src/index.ts index 10a3400ee400..683239d3c908 100644 --- a/packages/nestjs/src/index.ts +++ b/packages/nestjs/src/index.ts @@ -4,6 +4,7 @@ export * from '@sentry/node'; export { nestIntegration } from './integrations/nest'; +export type { NestIntegrationOptions } from './integrations/nest'; export { getDefaultIntegrations, init } from './sdk'; diff --git a/packages/nestjs/src/integrations/cron-monitoring.ts b/packages/nestjs/src/integrations/cron-monitoring.ts new file mode 100644 index 000000000000..6052c2fef811 --- /dev/null +++ b/packages/nestjs/src/integrations/cron-monitoring.ts @@ -0,0 +1,96 @@ +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. + * + * Defaults to `false`. + */ + monitorCronJobs?: boolean; + + /** + * Monitor slugs not to monitor when `monitorCronJobs` is enabled. Strings must match the slug exactly. + */ + excludeCronJobs?: Array; +} + +interface CronMonitor { + slug: string; + config: MonitorConfig | undefined; +} + +const MONITORING_OPTIONS = new WeakMap(); + +/** 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, +): (handler: AnyFn, wrapped: AnyFn) => CronMonitor | undefined { + let monitor: CronMonitor | null | undefined; + + return (handler, wrapped) => { + const client = getClient(); + const options = client && MONITORING_OPTIONS.get(client); + if (!options || isSentryCronMethod(handler) || isSentryCronMethod(wrapped)) { + return undefined; + } + + if (monitor === undefined) { + try { + monitor = getCronMonitor(cronArgs, target, propertyKey); + } 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, +): 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; + 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; + } + + return { slug, config: nestCronOptionsToMonitorConfig({ ...options, cronTime }) }; +} diff --git a/packages/nestjs/src/integrations/helpers.ts b/packages/nestjs/src/integrations/helpers.ts index 5056413e911a..6aae403f5f5e 100644 --- a/packages/nestjs/src/integrations/helpers.ts +++ b/packages/nestjs/src/integrations/helpers.ts @@ -31,6 +31,18 @@ export function markWrapped(fn: AnyFn): void { (fn as AnyFn & Record)[SENTRY_WRAPPED] = true; } +const SENTRY_CRON_METHODS = new WeakSet(); + +/** Marks a method that `@SentryCron()` monitors, so `@Cron()` monitoring skips it. */ +export function markSentryCronMethod(fn: object): void { + SENTRY_CRON_METHODS.add(fn); +} + +/** Whether `fn` is monitored by `@SentryCron()` (see {@link markSentryCronMethod}). */ +export function isSentryCronMethod(fn: object): boolean { + return SENTRY_CRON_METHODS.has(fn); +} + /** * The subset of `reflect-metadata`'s `Reflect` augmentation that NestJS * relies on. Methods are optional because `reflect-metadata` may not be diff --git a/packages/nestjs/src/integrations/nest.ts b/packages/nestjs/src/integrations/nest.ts index 01ef7d722e9c..853caf825d71 100644 --- a/packages/nestjs/src/integrations/nest.ts +++ b/packages/nestjs/src/integrations/nest.ts @@ -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); + }, }; }); diff --git a/packages/nestjs/src/integrations/orchestrion-subscriber.ts b/packages/nestjs/src/integrations/orchestrion-subscriber.ts index e2ac9e7524f2..8bd6993c32f2 100644 --- a/packages/nestjs/src/integrations/orchestrion-subscriber.ts +++ b/packages/nestjs/src/integrations/orchestrion-subscriber.ts @@ -9,11 +9,11 @@ import type { CatchTarget, InjectableTarget, MinimalNestJsExecutionContext } fro import { patchCatchTarget, patchInjectableTarget } from './wrap-components'; import { extractQueueName, - MECHANISM_CRON, MECHANISM_INTERVAL, MECHANISM_TIMEOUT, patchMethodDescriptor, patchProcessorTarget, + wrapCronHandler, wrapEventHandler, wrapScheduleHandler, } from './wrap-handlers'; @@ -61,7 +61,10 @@ function subscribeDecoratorChannel(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, @@ -189,8 +192,10 @@ export function subscribeToNestChannels(): void { subscribeDecoratorChannel(CHANNELS.NESTJS_CATCH, patchCatchTarget); // @Cron/@Interval/@Timeout (schedule), @OnEvent (event), @Processor (bullmq) - subscribeFactoryDecorator(CHANNELS.NESTJS_SCHEDULE_CRON, decorator => - makeMethodDecorator(decorator, handler => wrapScheduleHandler(handler, MECHANISM_CRON)), + subscribeFactoryDecorator(CHANNELS.NESTJS_SCHEDULE_CRON, (decorator, data) => + makeMethodDecorator(decorator, (handler, target, propertyKey) => + wrapCronHandler(handler, [...(data.arguments || [])], target, propertyKey), + ), ); subscribeFactoryDecorator(CHANNELS.NESTJS_SCHEDULE_INTERVAL, decorator => makeMethodDecorator(decorator, handler => wrapScheduleHandler(handler, MECHANISM_INTERVAL)), diff --git a/packages/nestjs/src/integrations/wrap-handlers.ts b/packages/nestjs/src/integrations/wrap-handlers.ts index 62f677635eeb..09b794644258 100644 --- a/packages/nestjs/src/integrations/wrap-handlers.ts +++ b/packages/nestjs/src/integrations/wrap-handlers.ts @@ -1,4 +1,5 @@ -import { captureException, isObjectLike, isThenable, startSpan, withIsolationScope } from '@sentry/core'; +import { captureException, isObjectLike, isThenable, startSpan, withIsolationScope, withMonitor } from '@sentry/core'; +import { makeCronMonitorResolver } from './cron-monitoring'; import type { AnyFn, ReflectWithMetadata } from './helpers'; import { getBullMQProcessSpanOptions, getEventSpanOptions, isWrapped, markWrapped } from './helpers'; @@ -56,6 +57,28 @@ export function wrapScheduleHandler(handler: AnyFn, mechanismType: string): AnyF }; } +/** + * Wrap a `@Cron` handler like {@link wrapScheduleHandler}, sending check-ins when `monitorCronJobs` is enabled. + */ +export function wrapCronHandler( + handler: AnyFn, + cronArgs: unknown[], + target: unknown, + propertyKey: string | symbol | undefined, +): AnyFn { + const scheduleHandler = wrapScheduleHandler(handler, MECHANISM_CRON); + const resolveMonitor = makeCronMonitorResolver(cronArgs, target, propertyKey); + + const wrapped = function (this: unknown, ...args: unknown[]): unknown { + const monitor = resolveMonitor(handler, wrapped); + if (!monitor) { + return scheduleHandler.apply(this, args); + } + return withMonitor(monitor.slug, () => scheduleHandler.apply(this, args), monitor.config); + }; + return wrapped; +} + function eventNameFromEvent(event: unknown): string { if (typeof event === 'string') { return event; @@ -140,11 +163,11 @@ export function patchMethodDescriptor( target: { __SENTRY_INTERNAL__?: boolean } | undefined, propertyKey: string | symbol | undefined, descriptor: PropertyDescriptor | undefined, - wrapHandler: (handler: AnyFn) => AnyFn, + wrapHandler: (handler: AnyFn, target: unknown, propertyKey: string | symbol | undefined) => AnyFn, ): void { const handler = descriptor?.value as AnyFn | undefined; if (descriptor && handler && typeof handler === 'function' && !target?.__SENTRY_INTERNAL__ && !isWrapped(handler)) { - const wrapped = wrapHandler(handler); + const wrapped = wrapHandler(handler, target, propertyKey); Object.defineProperty(wrapped, 'name', { value: handler.name || String(propertyKey), configurable: true, diff --git a/packages/nestjs/test/integrations/cron-monitoring.test.ts b/packages/nestjs/test/integrations/cron-monitoring.test.ts new file mode 100644 index 000000000000..6f08f311dc40 --- /dev/null +++ b/packages/nestjs/test/integrations/cron-monitoring.test.ts @@ -0,0 +1,148 @@ +import 'reflect-metadata'; +import { tracingChannel } from 'node:diagnostics_channel'; +import * as SentryCore from '@sentry/core'; +import { Client, createTransport, initAndBind, resolvedSyncPromise } from '@sentry/core'; +import { nestjsChannels as CHANNELS } from '@sentry/server-utils/orchestrion/config'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { SentryCron } from '../../src/decorators'; +import type { NestCronMonitoringOptions } from '../../src/integrations/cron-monitoring'; +import { setCronMonitoringOptions } from '../../src/integrations/cron-monitoring'; +import { subscribeToNestChannels } from '../../src/integrations/orchestrion-subscriber'; + +type AnyFn = (this: unknown, ...args: unknown[]) => unknown; + +class TestClient extends Client { + public eventFromException(): PromiseLike { + return resolvedSyncPromise({}); + } + + public eventFromMessage(): PromiseLike { + return resolvedSyncPromise({}); + } +} + +let captureCheckIn: ReturnType string>>; + +function initClient(options: NestCronMonitoringOptions): void { + //@ts-expect-error - just a mock for the test + initAndBind(TestClient, { + dsn: 'https://username@domain/123', + integrations: [], + stackParser: () => [], + transport: () => createTransport({ recordDroppedEvent: () => undefined }, () => resolvedSyncPromise({})), + }); + const client = SentryCore.getClient() as Client & { captureCheckIn?: unknown }; + captureCheckIn = vi.fn((_checkIn: SentryCore.CheckIn, _config?: unknown) => 'check-in-id'); + client.captureCheckIn = captureCheckIn; + setCronMonitoringOptions(client, options); +} + +// Drives the `@Cron()` factory channel and returns the decorator `@Cron()` would apply. +function cron(cronTime: unknown, options?: Record): MethodDecorator { + const data: { arguments: unknown[]; result?: unknown } = { arguments: [cronTime, options] }; + tracingChannel<{ arguments: unknown[]; result?: unknown }>(CHANNELS.NESTJS_SCHEDULE_CRON).traceSync( + () => (_target: unknown, _key: unknown, descriptor: PropertyDescriptor) => descriptor, + data, + ); + return data.result as MethodDecorator; +} + +function decorate(className: string, decorators: MethodDecorator[]): AnyFn { + const target = Object.create({ constructor: { name: className } }) as object; + const descriptor: PropertyDescriptor = { value: function run(): void {}, configurable: true }; + for (const decorator of [...decorators].reverse()) { + decorator(target, 'run', descriptor); + } + return descriptor.value as AnyFn; +} + +function sentCheckIns(): Array<[string, string, unknown]> { + return captureCheckIn.mock.calls.map(([checkIn, config]) => [checkIn.monitorSlug, checkIn.status, config]); +} + +describe('@Cron() monitoring', () => { + beforeEach(() => { + subscribeToNestChannels(); + }); + + afterEach(() => { + SentryCore.getMainCarrier().__SENTRY__ = undefined; + vi.restoreAllMocks(); + }); + + it('sends check-ins with the schedule, using the @Cron() name as slug', () => { + initClient({ monitorCronJobs: true }); + decorate('Jobs', [cron('0 30 9 * * 1-5', { name: 'daily-report', timeZone: 'Europe/Vienna' })])(); + + expect(sentCheckIns()).toEqual([ + [ + 'daily-report', + 'in_progress', + { schedule: { type: 'crontab', value: '30 9 * * 1-5' }, timezone: 'Europe/Vienna' }, + ], + ['daily-report', 'ok', undefined], + ]); + }); + + it('uses ClassName.methodName without a name', () => { + initClient({ monitorCronJobs: true }); + decorate('Jobs', [cron('* * * * *', { timeZone: 'UTC' })])(); + + expect(sentCheckIns()[0]?.[0]).toBe('Jobs.run'); + }); + + it('sends error check-ins and rethrows', async () => { + initClient({ monitorCronJobs: true }); + vi.spyOn(SentryCore, 'captureException').mockReturnValue('event-id'); + const target = Object.create({ constructor: { name: 'Jobs' } }) as object; + const descriptor: PropertyDescriptor = { + value: async function run(): Promise { + throw new Error('boom'); + }, + }; + cron('* * * * *', { timeZone: 'UTC' })(target, 'run', descriptor); + + await expect((descriptor.value as AnyFn)()).rejects.toThrow('boom'); + expect(sentCheckIns().map(([, status]) => status)).toEqual(['in_progress', 'error']); + }); + + it('checks in without a config when the schedule cannot be sent', () => { + initClient({ monitorCronJobs: true }); + decorate('Jobs', [cron('*/10 * * * * *', { name: 'every-10s' })])(); + + expect(sentCheckIns()[0]).toEqual(['every-10s', 'in_progress', undefined]); + }); + + it('does not monitor by default', () => { + initClient({}); + decorate('Jobs', [cron('* * * * *', { name: 'job' })])(); + + expect(captureCheckIn).not.toHaveBeenCalled(); + }); + + it('skips excluded jobs', () => { + initClient({ monitorCronJobs: true, excludeCronJobs: ['job', /^Other\./] }); + decorate('Jobs', [cron('* * * * *', { name: 'job' })])(); + decorate('Other', [cron('* * * * *')])(); + decorate('Jobs', [cron('* * * * *', { name: 'job-2' })])(); + + expect(sentCheckIns().map(([slug]) => slug)).toEqual(['job-2', 'job-2']); + }); + + it('skips one-off jobs', () => { + initClient({ monitorCronJobs: true }); + decorate('Jobs', [cron(new Date(), { name: 'once' })])(); + + expect(captureCheckIn).not.toHaveBeenCalled(); + }); + + it.each([ + ['@Cron() above @SentryCron()', (c: MethodDecorator, s: MethodDecorator) => [c, s]], + ['@SentryCron() above @Cron()', (c: MethodDecorator, s: MethodDecorator) => [s, c]], + ])('only sends the @SentryCron() check-ins with %s', async (_, order) => { + initClient({ monitorCronJobs: true }); + await decorate('Jobs', order(cron('* * * * *', { name: 'job' }), SentryCron('explicit-slug')))(); + + expect(sentCheckIns().map(([slug]) => slug)).toEqual(['explicit-slug', 'explicit-slug']); + }); +});