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 ead5d10338f0..33a56168db55 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, 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 @@ -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( +export function nestCronOptionsToMonitorConfig( cronOptions: NestCronOptions, scheduleVersion: string | undefined, ): MonitorConfig | undefined { 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..450dbd61de7b --- /dev/null +++ b/packages/nestjs/src/integrations/cron-monitoring.ts @@ -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; +} + +interface CronMonitor { + slug: string; + config: MonitorConfig; +} + +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, + 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); + if (!options || isSentryCronMethod(handler) || isSentryCronMethod(wrapped)) { + return undefined; + } + + 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; + 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 }; +} diff --git a/packages/nestjs/src/integrations/helpers.ts b/packages/nestjs/src/integrations/helpers.ts index 8c2b5755d910..25f0131fd3ac 100644 --- a/packages/nestjs/src/integrations/helpers.ts +++ b/packages/nestjs/src/integrations/helpers.ts @@ -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. */ @@ -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(); + +/** 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(); /** 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); } } 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 0f207438d716..e28bd941cc8d 100644 --- a/packages/nestjs/src/integrations/orchestrion-subscriber.ts +++ b/packages/nestjs/src/integrations/orchestrion-subscriber.ts @@ -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'; @@ -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'; @@ -50,7 +52,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, @@ -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(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); + } + }, + asyncStart: NOOP, + asyncEnd: NOOP, + error: NOOP, + }); 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..2b70d9fe932e 100644 --- a/packages/nestjs/src/integrations/wrap-handlers.ts +++ b/packages/nestjs/src/integrations/wrap-handlers.ts @@ -1,6 +1,14 @@ -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'; +import { + getBullMQProcessSpanOptions, + getCronOptions, + getEventSpanOptions, + isSentryCronMethod, + isWrapped, + markWrapped, +} from './helpers'; /** * Span-emitting / error-capturing logic for the `@Cron`/`@Interval`/ @@ -56,6 +64,62 @@ export function wrapScheduleHandler(handler: AnyFn, mechanismType: string): AnyF }; } +const CRON_HANDLER_MONITORS = new WeakMap boolean>(); +const CRON_OPTIONS_MONITORS = new WeakMap boolean>(); + +/** + * Link the `@Cron()` options that `@nestjs/schedule` set on `method` to its monitor. Decorators applied above + * `@Cron()` replace the method but must copy this metadata for Nest to schedule it, so it identifies the job. + */ +export function linkCronOptionsToMonitor(method: unknown): void { + const isMonitored = typeof method === 'function' ? CRON_HANDLER_MONITORS.get(method as AnyFn) : undefined; + const options = isMonitored && getCronOptions(method as AnyFn); + if (isMonitored && options) { + CRON_OPTIONS_MONITORS.set(options, isMonitored); + } +} + +/** Whether a `@Cron()` method sends check-ins when it runs, from `monitorCronJobs` or `@SentryCron()`. */ +export function sendsCronCheckIns(method: unknown): boolean { + if (typeof method !== 'function') { + return false; + } + const fn = method as AnyFn; + const options = getCronOptions(fn); + return ( + isSentryCronMethod(fn) || + !!CRON_HANDLER_MONITORS.get(fn)?.() || + !!(options && CRON_OPTIONS_MONITORS.get(options)?.()) + ); +} + +/** + * 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, + scheduleVersion?: string, +): AnyFn { + const scheduleHandler = wrapScheduleHandler(handler, MECHANISM_CRON); + const resolveMonitor = makeCronMonitorResolver(cronArgs, target, propertyKey, scheduleVersion); + + 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); + }; + CRON_HANDLER_MONITORS.set( + wrapped, + () => isSentryCronMethod(handler) || isSentryCronMethod(wrapped) || !!resolveMonitor(handler, wrapped), + ); + return wrapped; +} + function eventNameFromEvent(event: unknown): string { if (typeof event === 'string') { return event; @@ -140,11 +204,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..c1931f0cdddb --- /dev/null +++ b/packages/nestjs/test/integrations/cron-monitoring.test.ts @@ -0,0 +1,258 @@ +import 'reflect-metadata'; +import { tracingChannel } from 'node:diagnostics_channel'; +import * as SentryCore from '@sentry/core'; +import { Client, createTransport, initAndBind, resolvedSyncPromise } from '@sentry/core'; +import type { OrchestrionChannelContext } from '@sentry/server-utils'; +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, moduleVersion?: string): MethodDecorator { + const data: OrchestrionChannelContext = { arguments: [cronTime, options], moduleVersion }; + tracingChannel(CHANNELS.NESTJS_SCHEDULE_CRON).traceSync( + () => (_target: unknown, _key: unknown, descriptor: PropertyDescriptor) => { + Reflect.defineMetadata('SCHEDULE_CRON_OPTIONS', { ...options, cronTime }, descriptor.value); + return descriptor; + }, + data, + ); + return data.result as MethodDecorator; +} + +// A decorator that replaces the method and copies its metadata, as Nest needs to still schedule it. +const wrapMethod: MethodDecorator = (_target, _key, descriptor: PropertyDescriptor) => { + const original = descriptor.value as AnyFn; + descriptor.value = function (this: unknown, ...args: unknown[]): unknown { + return original.apply(this, args); + }; + for (const key of Reflect.getMetadataKeys(original)) { + Reflect.defineMetadata(key, Reflect.getMetadata(key, original), descriptor.value); + } +}; + +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; +} + +// Drives the channel for the function `@nestjs/schedule` passes to `CronJob` as `onTick`, and returns it. +function cronJobOnTick(method: AnyFn): AnyFn { + const data: { arguments: unknown[]; result?: unknown } = { arguments: [method, {}] }; + tracingChannel<{ arguments: unknown[]; result?: unknown }>(CHANNELS.NESTJS_SCHEDULE_EXPLORER_WRAP).traceSync( + () => async () => {}, + data, + ); + return data.result as AnyFn; +} + +const isMarkedMonitored = (onTick: AnyFn): boolean => + !!(onTick as AnyFn & Record)[Symbol.for('sentry.cron.monitored')]; + +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('skips jobs whose schedule cannot be sent', () => { + initClient({ monitorCronJobs: true }); + decorate('Jobs', [cron('*/10 * * * * *', { name: 'every-10s' })])(); + + expect(captureCheckIn).not.toHaveBeenCalled(); + }); + + it('skips a numeric month on @nestjs/schedule 3', () => { + initClient({ monitorCronJobs: true }); + decorate('Jobs', [cron('0 0 1 1 *', { name: 'yearly', timeZone: 'UTC' }, '3.0.4')])(); + + expect(captureCheckIn).not.toHaveBeenCalled(); + }); + + it('sends a numeric month from @nestjs/schedule 4', () => { + initClient({ monitorCronJobs: true }); + decorate('Jobs', [cron('0 0 1 1 *', { name: 'yearly', timeZone: 'UTC' }, '4.1.2')])(); + + expect(sentCheckIns()[0]).toEqual([ + 'yearly', + 'in_progress', + { schedule: { type: 'crontab', value: '0 0 1 1 *' }, timezone: 'UTC' }, + ]); + }); + + 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]], + ['@Cron(), another decorator, @SentryCron()', (c: MethodDecorator, s: MethodDecorator) => [c, wrapMethod, s]], + ['@SentryCron(), another decorator, @Cron()', (c: MethodDecorator, s: MethodDecorator) => [s, wrapMethod, c]], + ['another decorator, @Cron(), @SentryCron()', (c: MethodDecorator, s: MethodDecorator) => [wrapMethod, c, s]], + ['another decorator, @SentryCron(), @Cron()', (c: MethodDecorator, s: MethodDecorator) => [wrapMethod, 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']); + }); + + describe('marks the CronJob onTick so the cron package integration skips it', () => { + it('when the method is monitored', () => { + initClient({ monitorCronJobs: true }); + const onTick = cronJobOnTick(decorate('Jobs', [cron('* * * * *', { name: 'job' })])); + + expect(isMarkedMonitored(onTick)).toBe(true); + expect(Object.keys(onTick)).toEqual([]); + }); + + it('when a decorator above @Cron() replaces the method', () => { + initClient({ monitorCronJobs: true }); + const onTick = cronJobOnTick(decorate('Jobs', [wrapMethod, cron('* * * * *', { name: 'job' })])); + + expect(isMarkedMonitored(onTick)).toBe(true); + }); + + it.each([ + ['@SentryCron(), another decorator, @Cron()', (c: MethodDecorator, s: MethodDecorator) => [s, wrapMethod, c]], + ['another decorator, @SentryCron(), @Cron()', (c: MethodDecorator, s: MethodDecorator) => [wrapMethod, s, c]], + ['another decorator, @Cron(), @SentryCron()', (c: MethodDecorator, s: MethodDecorator) => [wrapMethod, c, s]], + ])('when the method has %s', (_, order) => { + initClient({}); + const onTick = cronJobOnTick( + decorate('Jobs', order(cron('* * * * *', { name: 'job' }), SentryCron('explicit-slug'))), + ); + + expect(isMarkedMonitored(onTick)).toBe(true); + }); + + it('not when a decorator above @Cron() replaces an excluded method', () => { + initClient({ monitorCronJobs: true, excludeCronJobs: ['job'] }); + const onTick = cronJobOnTick(decorate('Jobs', [wrapMethod, cron('* * * * *', { name: 'job' })])); + + expect(isMarkedMonitored(onTick)).toBe(false); + }); + + it('when the method has @SentryCron()', () => { + initClient({}); + const onTick = cronJobOnTick(decorate('Jobs', [cron('* * * * *', { name: 'job' }), SentryCron('explicit-slug')])); + + expect(isMarkedMonitored(onTick)).toBe(true); + }); + + it.each([ + ['monitorCronJobs is off', {}], + ['the job is excluded', { monitorCronJobs: true, excludeCronJobs: ['job'] }], + ])('not when %s', (_, options) => { + initClient(options); + const onTick = cronJobOnTick(decorate('Jobs', [cron('* * * * *', { name: 'job' })])); + + expect(isMarkedMonitored(onTick)).toBe(false); + }); + + it('not for other scheduled methods', () => { + initClient({ monitorCronJobs: true }); + + expect(isMarkedMonitored(cronJobOnTick(function interval(): void {}))).toBe(false); + }); + }); +}); diff --git a/packages/server-utils/src/orchestrion/config/nestjs.ts b/packages/server-utils/src/orchestrion/config/nestjs.ts index 344b35913ede..50f355d14dc2 100644 --- a/packages/server-utils/src/orchestrion/config/nestjs.ts +++ b/packages/server-utils/src/orchestrion/config/nestjs.ts @@ -93,6 +93,13 @@ export const nestjsConfig = [ module: { name: '@nestjs/schedule', versionRange: '>=2.0.0', filePath: 'dist/decorators/timeout.decorator.js' }, functionQuery: { functionName: 'Timeout', kind: 'Sync' }, }, + { + // The function `ScheduleExplorer` returns here is what `@nestjs/schedule` passes to `CronJob` as `onTick`. + // From 6.0.0 it uses `cron` 4.3+, which has job names, so a named job can be monitored by the `cron` package too. + channelName: 'scheduleExplorerWrap', + module: { name: '@nestjs/schedule', versionRange: '>=6.0.0', filePath: 'dist/schedule.explorer.js' }, + functionQuery: { className: 'ScheduleExplorer', methodName: 'wrapFunctionInTryCatchBlocks', kind: 'Sync' }, + }, { // @nestjs/event-emitter @OnEvent: // `const OnEvent = (event, options) => { @@ -138,6 +145,7 @@ export const nestjsChannels = { NESTJS_SCHEDULE_CRON: 'orchestrion:@nestjs/schedule:cronDecorator', NESTJS_SCHEDULE_INTERVAL: 'orchestrion:@nestjs/schedule:intervalDecorator', NESTJS_SCHEDULE_TIMEOUT: 'orchestrion:@nestjs/schedule:timeoutDecorator', + NESTJS_SCHEDULE_EXPLORER_WRAP: 'orchestrion:@nestjs/schedule:scheduleExplorerWrap', NESTJS_ONEVENT: 'orchestrion:@nestjs/event-emitter:onEventDecorator', NESTJS_PROCESSOR: 'orchestrion:@nestjs/bullmq:processorDecorator', } as const;