From 5833c8f82f0970c74efa5b3fde5dfd69f88bed97 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Fri, 2 Oct 2026 12:42:58 -0700 Subject: [PATCH 01/16] feat(nestjs): Derive SentryCron monitor config from @Cron When `@SentryCron` is used without a monitor config, read the schedule and time zone from the `@Cron()` decorator of `@nestjs/schedule` on the same method. Check-ins then carry a monitor config, so Sentry creates the monitor on the first run instead of dropping the check-ins. Co-Authored-By: Claude Opus 5.5 --- packages/nestjs/src/decorators.ts | 81 ++++++++++++++++++- packages/nestjs/test/decorators.test.ts | 101 ++++++++++++++++++++++++ 2 files changed, 180 insertions(+), 2 deletions(-) diff --git a/packages/nestjs/src/decorators.ts b/packages/nestjs/src/decorators.ts index 4d5762481d2f..6b1c0f47f3ab 100644 --- a/packages/nestjs/src/decorators.ts +++ b/packages/nestjs/src/decorators.ts @@ -4,31 +4,108 @@ import { captureException } from '@sentry/core'; import * as Sentry from '@sentry/node'; import { startSpan } from '@sentry/node'; import { isExpectedError } from './helpers'; +import type { ReflectWithMetadata } from './integrations/helpers'; import { copyReflectMetadata } from './integrations/helpers'; /** * A decorator wrapping the native nest Cron decorator, sending check-ins to Sentry. + * + * Without a `monitorConfig`, the schedule and time zone are taken from the `@Cron()` decorator of + * `@nestjs/schedule` on the same method, so Sentry can create the monitor on the first check-in. */ export const SentryCron = (monitorSlug: string, monitorConfig?: MonitorConfig): MethodDecorator => { return (target: unknown, propertyKey, descriptor: PropertyDescriptor) => { const originalMethod = descriptor.value as (...args: unknown[]) => Promise; - descriptor.value = function (...args: unknown[]) { + let resolvedMonitorConfig: MonitorConfig | undefined = monitorConfig; + let resolved = monitorConfig !== undefined; + + const wrappedMethod = function (this: unknown, ...args: unknown[]): unknown { + // `@Cron()` sets its metadata on whatever function is `descriptor.value` when it runs, which is + // this function if it is applied after `@SentryCron()`, so it is only readable at call time. + if (!resolved) { + resolved = true; + resolvedMonitorConfig = getMonitorConfigFromNestCron( + wrappedMethod, + (target as Record | undefined)?.[propertyKey], + ); + } + return Sentry.withMonitor( monitorSlug, () => { return originalMethod.apply(this, args); }, - monitorConfig, + resolvedMonitorConfig, ); }; + descriptor.value = wrappedMethod; + copyFunctionNameAndMetadata({ originalMethod, descriptor }); return descriptor; }; }; +const SCHEDULE_CRON_OPTIONS = 'SCHEDULE_CRON_OPTIONS'; + +interface NestCronOptions { + cronTime?: unknown; + timeZone?: unknown; + utcOffset?: unknown; +} + +function getMonitorConfigFromNestCron(...candidates: unknown[]): MonitorConfig | undefined { + const R = Reflect as ReflectWithMetadata; + if (typeof R.getMetadata !== 'function') { + return undefined; + } + + for (const candidate of candidates) { + if (typeof candidate !== 'function') { + continue; + } + const cronOptions = R.getMetadata(SCHEDULE_CRON_OPTIONS, candidate); + if (cronOptions && typeof cronOptions === 'object') { + return nestCronOptionsToMonitorConfig(cronOptions); + } + } + + return undefined; +} + +/** + * 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 { + const { cronTime, timeZone, utcOffset } = cronOptions; + + // A fixed UTC offset has no IANA time zone equivalent. + if (typeof cronTime !== 'string' || utcOffset != null) { + return undefined; + } + + const fields = cronTime.trim().split(/\s+/); + let crontab: string | undefined; + if (fields.length === 5) { + crontab = fields.join(' '); + } else if (fields.length === 6 && /^\d+$/.test(fields[0] as string)) { + // Sentry schedules have minute granularity, so only a fixed second can be dropped. + crontab = fields.slice(1).join(' '); + } + + if (!crontab) { + return undefined; + } + + return { + schedule: { type: 'crontab', value: crontab }, + ...(typeof timeZone === 'string' && timeZone ? { timezone: timeZone } : {}), + }; +} + /** * A decorator usable to wrap arbitrary functions with spans. */ diff --git a/packages/nestjs/test/decorators.test.ts b/packages/nestjs/test/decorators.test.ts index 661f00b89182..3dfd44afd324 100644 --- a/packages/nestjs/test/decorators.test.ts +++ b/packages/nestjs/test/decorators.test.ts @@ -1,4 +1,5 @@ import 'reflect-metadata'; +import { SetMetadata } from '@nestjs/common'; import { CODE_FUNCTION_NAME, SENTRY_OP, SENTRY_ORIGIN } from '@sentry/conventions/attributes'; import * as core from '@sentry/core'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -258,6 +259,106 @@ describe('SentryCron decorator', () => { }); }); +describe('SentryCron decorator with @Cron', () => { + // Mirrors `@Cron()` of `@nestjs/schedule`, which stores its options under this metadata key. + const Cron = (cronTime: unknown, options: Record = {}): MethodDecorator => + SetMetadata('SCHEDULE_CRON_OPTIONS', { ...options, cronTime }); + + // Mirrors the SDK's own `@Cron()` instrumentation, which swaps the method before nest stores the metadata. + const WrappingCron = + (cronTime: unknown): MethodDecorator => + (target, propertyKey, descriptor) => { + const original = descriptor.value as unknown as (...args: unknown[]) => unknown; + (descriptor as PropertyDescriptor).value = function (this: unknown, ...args: unknown[]) { + return original.apply(this, args); + }; + return Cron(cronTime)(target, propertyKey, descriptor); + }; + + // Applies decorators the way TypeScript does: listed top to bottom, applied bottom to top. + function decorate(...decorators: MethodDecorator[]): { job: () => Promise } { + class Service {} + let descriptor: PropertyDescriptor = { + value: async () => 'done', + writable: true, + enumerable: false, + configurable: true, + }; + for (const decorator of [...decorators].reverse()) { + descriptor = (decorator(Service.prototype, 'job', descriptor) as PropertyDescriptor | undefined) ?? descriptor; + } + Object.defineProperty(Service.prototype, 'job', descriptor); + return new Service() as { job: () => Promise }; + } + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('derives the monitor config when @SentryCron is above @Cron', async () => { + const withMonitorSpy = vi.spyOn(core, 'withMonitor'); + const service = decorate(SentryCron('my-job'), Cron('0 * * * *')); + + expect(await service.job()).toBe('done'); + expect(withMonitorSpy).toHaveBeenCalledWith('my-job', expect.any(Function), { + schedule: { type: 'crontab', value: '0 * * * *' }, + }); + }); + + it('derives the monitor config when @Cron is above @SentryCron', async () => { + const withMonitorSpy = vi.spyOn(core, 'withMonitor'); + const service = decorate(Cron('0 * * * *'), SentryCron('my-job')); + + await service.job(); + expect(withMonitorSpy).toHaveBeenCalledWith('my-job', expect.any(Function), { + schedule: { type: 'crontab', value: '0 * * * *' }, + }); + }); + + it('derives the monitor config when @Cron replaces the method', async () => { + const withMonitorSpy = vi.spyOn(core, 'withMonitor'); + const service = decorate(WrappingCron('0 * * * *'), SentryCron('my-job')); + + await service.job(); + expect(withMonitorSpy).toHaveBeenCalledWith('my-job', expect.any(Function), { + schedule: { type: 'crontab', value: '0 * * * *' }, + }); + }); + + it('drops a fixed seconds field and passes the time zone', async () => { + const withMonitorSpy = vi.spyOn(core, 'withMonitor'); + const service = decorate(SentryCron('my-job'), Cron('0 30 9 * * 1-5', { timeZone: 'Europe/Vienna' })); + + await service.job(); + expect(withMonitorSpy).toHaveBeenCalledWith('my-job', expect.any(Function), { + schedule: { type: 'crontab', value: '30 9 * * 1-5' }, + timezone: 'Europe/Vienna', + }); + }); + + it.each([ + ['a sub-minute schedule', Cron('*/5 * * * * *')], + ['a one-off date', Cron(new Date())], + ['a preset', Cron('@daily')], + ['a utc offset', Cron('0 * * * *', { utcOffset: 120 })], + ])('sends no monitor config for %s', async (_, cronDecorator) => { + const withMonitorSpy = vi.spyOn(core, 'withMonitor'); + const service = decorate(SentryCron('my-job'), cronDecorator); + + await service.job(); + expect(withMonitorSpy).toHaveBeenCalledWith('my-job', expect.any(Function), undefined); + }); + + it('prefers an explicit monitor config', async () => { + const withMonitorSpy = vi.spyOn(core, 'withMonitor'); + const monitorConfig: core.MonitorConfig = { schedule: { type: 'interval', value: 1, unit: 'hour' } }; + const service = decorate(SentryCron('my-job', monitorConfig), Cron('0 * * * *')); + + await service.job(); + expect(withMonitorSpy).toHaveBeenCalledWith('my-job', expect.any(Function), monitorConfig); + }); +}); + describe('SentryExceptionCaptured decorator', () => { beforeEach(() => { vi.clearAllMocks(); From 20a2edd09a8d1303b886dc128791317219d18d35 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Fri, 2 Oct 2026 12:57:17 -0700 Subject: [PATCH 02/16] ref(nestjs): Make deriving the SentryCron config from @Cron opt-in Only read the `@Cron()` schedule when `@SentryCron` gets `{ fromCronDecorator: true }`, so existing usage keeps sending check-ins without a monitor config. Other monitor settings passed alongside the flag are kept. Co-Authored-By: Claude Opus 5.5 --- packages/nestjs/src/decorators.ts | 49 ++++++++++++++++++------- packages/nestjs/src/index.ts | 1 + packages/nestjs/test/decorators.test.ts | 38 ++++++++++++++++--- 3 files changed, 69 insertions(+), 19 deletions(-) diff --git a/packages/nestjs/src/decorators.ts b/packages/nestjs/src/decorators.ts index 6b1c0f47f3ab..d402c0a390f7 100644 --- a/packages/nestjs/src/decorators.ts +++ b/packages/nestjs/src/decorators.ts @@ -7,28 +7,41 @@ import { isExpectedError } from './helpers'; import type { ReflectWithMetadata } from './integrations/helpers'; import { copyReflectMetadata } from './integrations/helpers'; +/** + * Monitor settings for `@SentryCron` that take the schedule and time zone from the `@Cron()` + * decorator of `@nestjs/schedule` on the same method. + */ +export type SentryCronFromCronDecoratorConfig = Omit & { + fromCronDecorator: true; +}; + /** * A decorator wrapping the native nest Cron decorator, sending check-ins to Sentry. * - * Without a `monitorConfig`, the schedule and time zone are taken from the `@Cron()` decorator of - * `@nestjs/schedule` on the same method, so Sentry can create the monitor on the first check-in. + * Pass `{ fromCronDecorator: true }` instead of a monitor config to send the schedule and time + * zone of the method's `@Cron()` decorator, so Sentry can create the monitor on the first check-in. */ -export const SentryCron = (monitorSlug: string, monitorConfig?: MonitorConfig): MethodDecorator => { +export const SentryCron = ( + monitorSlug: string, + monitorConfig?: MonitorConfig | SentryCronFromCronDecoratorConfig, +): MethodDecorator => { return (target: unknown, propertyKey, descriptor: PropertyDescriptor) => { const originalMethod = descriptor.value as (...args: unknown[]) => Promise; - let resolvedMonitorConfig: MonitorConfig | undefined = monitorConfig; - let resolved = monitorConfig !== undefined; + let resolvedMonitorConfig: MonitorConfig | undefined; + let resolved = false; const wrappedMethod = function (this: unknown, ...args: unknown[]): unknown { - // `@Cron()` sets its metadata on whatever function is `descriptor.value` when it runs, which is - // this function if it is applied after `@SentryCron()`, so it is only readable at call time. if (!resolved) { resolved = true; - resolvedMonitorConfig = getMonitorConfigFromNestCron( - wrappedMethod, - (target as Record | undefined)?.[propertyKey], - ); + resolvedMonitorConfig = isFromCronDecoratorConfig(monitorConfig) + ? // `@Cron()` sets its metadata on whatever function is `descriptor.value` when it runs, which is + // this function if it is applied after `@SentryCron()`, so it is only readable at call time. + getMonitorConfigFromNestCron(monitorConfig, [ + wrappedMethod, + (target as Record | undefined)?.[propertyKey], + ]) + : monitorConfig; } return Sentry.withMonitor( @@ -48,6 +61,12 @@ export const SentryCron = (monitorSlug: string, monitorConfig?: MonitorConfig): }; }; +function isFromCronDecoratorConfig( + monitorConfig: MonitorConfig | SentryCronFromCronDecoratorConfig | undefined, +): monitorConfig is SentryCronFromCronDecoratorConfig { + return !!monitorConfig && 'fromCronDecorator' in monitorConfig && monitorConfig.fromCronDecorator === true; +} + const SCHEDULE_CRON_OPTIONS = 'SCHEDULE_CRON_OPTIONS'; interface NestCronOptions { @@ -56,7 +75,10 @@ interface NestCronOptions { utcOffset?: unknown; } -function getMonitorConfigFromNestCron(...candidates: unknown[]): MonitorConfig | undefined { +function getMonitorConfigFromNestCron( + { fromCronDecorator: _, ...monitorSettings }: SentryCronFromCronDecoratorConfig, + candidates: unknown[], +): MonitorConfig | undefined { const R = Reflect as ReflectWithMetadata; if (typeof R.getMetadata !== 'function') { return undefined; @@ -68,7 +90,8 @@ function getMonitorConfigFromNestCron(...candidates: unknown[]): MonitorConfig | } const cronOptions = R.getMetadata(SCHEDULE_CRON_OPTIONS, candidate); if (cronOptions && typeof cronOptions === 'object') { - return nestCronOptionsToMonitorConfig(cronOptions); + const cronConfig = nestCronOptionsToMonitorConfig(cronOptions); + return cronConfig && { ...monitorSettings, ...cronConfig }; } } diff --git a/packages/nestjs/src/index.ts b/packages/nestjs/src/index.ts index b96fb4c5390a..a89e9aeeb14e 100644 --- a/packages/nestjs/src/index.ts +++ b/packages/nestjs/src/index.ts @@ -8,3 +8,4 @@ export { nestIntegration } from './integrations/nest'; export { getDefaultIntegrations, init } from './sdk'; export { SentryCron, SentryExceptionCaptured, SentryTraced } from './decorators'; +export type { SentryCronFromCronDecoratorConfig } from './decorators'; diff --git a/packages/nestjs/test/decorators.test.ts b/packages/nestjs/test/decorators.test.ts index 3dfd44afd324..ab129982bc60 100644 --- a/packages/nestjs/test/decorators.test.ts +++ b/packages/nestjs/test/decorators.test.ts @@ -297,7 +297,7 @@ describe('SentryCron decorator with @Cron', () => { it('derives the monitor config when @SentryCron is above @Cron', async () => { const withMonitorSpy = vi.spyOn(core, 'withMonitor'); - const service = decorate(SentryCron('my-job'), Cron('0 * * * *')); + const service = decorate(SentryCron('my-job', { fromCronDecorator: true }), Cron('0 * * * *')); expect(await service.job()).toBe('done'); expect(withMonitorSpy).toHaveBeenCalledWith('my-job', expect.any(Function), { @@ -307,7 +307,7 @@ describe('SentryCron decorator with @Cron', () => { it('derives the monitor config when @Cron is above @SentryCron', async () => { const withMonitorSpy = vi.spyOn(core, 'withMonitor'); - const service = decorate(Cron('0 * * * *'), SentryCron('my-job')); + const service = decorate(Cron('0 * * * *'), SentryCron('my-job', { fromCronDecorator: true })); await service.job(); expect(withMonitorSpy).toHaveBeenCalledWith('my-job', expect.any(Function), { @@ -317,7 +317,7 @@ describe('SentryCron decorator with @Cron', () => { it('derives the monitor config when @Cron replaces the method', async () => { const withMonitorSpy = vi.spyOn(core, 'withMonitor'); - const service = decorate(WrappingCron('0 * * * *'), SentryCron('my-job')); + const service = decorate(WrappingCron('0 * * * *'), SentryCron('my-job', { fromCronDecorator: true })); await service.job(); expect(withMonitorSpy).toHaveBeenCalledWith('my-job', expect.any(Function), { @@ -327,7 +327,10 @@ describe('SentryCron decorator with @Cron', () => { it('drops a fixed seconds field and passes the time zone', async () => { const withMonitorSpy = vi.spyOn(core, 'withMonitor'); - const service = decorate(SentryCron('my-job'), Cron('0 30 9 * * 1-5', { timeZone: 'Europe/Vienna' })); + const service = decorate( + SentryCron('my-job', { fromCronDecorator: true }), + Cron('0 30 9 * * 1-5', { timeZone: 'Europe/Vienna' }), + ); await service.job(); expect(withMonitorSpy).toHaveBeenCalledWith('my-job', expect.any(Function), { @@ -343,13 +346,36 @@ describe('SentryCron decorator with @Cron', () => { ['a utc offset', Cron('0 * * * *', { utcOffset: 120 })], ])('sends no monitor config for %s', async (_, cronDecorator) => { const withMonitorSpy = vi.spyOn(core, 'withMonitor'); - const service = decorate(SentryCron('my-job'), cronDecorator); + const service = decorate(SentryCron('my-job', { fromCronDecorator: true }), cronDecorator); + + await service.job(); + expect(withMonitorSpy).toHaveBeenCalledWith('my-job', expect.any(Function), undefined); + }); + + it('sends no monitor config without fromCronDecorator', async () => { + const withMonitorSpy = vi.spyOn(core, 'withMonitor'); + const service = decorate(SentryCron('my-job'), Cron('0 * * * *')); await service.job(); expect(withMonitorSpy).toHaveBeenCalledWith('my-job', expect.any(Function), undefined); }); - it('prefers an explicit monitor config', async () => { + it('keeps the other monitor settings', async () => { + const withMonitorSpy = vi.spyOn(core, 'withMonitor'); + const service = decorate( + SentryCron('my-job', { fromCronDecorator: true, checkinMargin: 2, maxRuntime: 10 }), + Cron('0 * * * *'), + ); + + await service.job(); + expect(withMonitorSpy).toHaveBeenCalledWith('my-job', expect.any(Function), { + schedule: { type: 'crontab', value: '0 * * * *' }, + checkinMargin: 2, + maxRuntime: 10, + }); + }); + + it('uses an explicit monitor config as is', async () => { const withMonitorSpy = vi.spyOn(core, 'withMonitor'); const monitorConfig: core.MonitorConfig = { schedule: { type: 'interval', value: 1, unit: 'hour' } }; const service = decorate(SentryCron('my-job', monitorConfig), Cron('0 * * * *')); From 2bd91a320d6fabf5e77e50c5dd8e719084c670bb Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Fri, 2 Oct 2026 13:08:38 -0700 Subject: [PATCH 03/16] feat(nestjs): Send the @Cron schedule from SentryCron by default `@SentryCron` now sends the schedule of the method's `@Cron()` decorator unless it gets a monitor config with a schedule. Other monitor settings can still be passed and are merged in, and `fromCronDecorator: false` turns this off. When `@Cron()` has no `timeZone`, the job runs in the server's local time zone, so that zone is sent instead of leaving Sentry to assume UTC. Co-Authored-By: Claude Opus 5.5 --- packages/nestjs/src/decorators.ts | 68 +++++++++++++++---------- packages/nestjs/src/index.ts | 2 +- packages/nestjs/test/decorators.test.ts | 37 ++++++++------ 3 files changed, 64 insertions(+), 43 deletions(-) diff --git a/packages/nestjs/src/decorators.ts b/packages/nestjs/src/decorators.ts index d402c0a390f7..ee7b21e428ee 100644 --- a/packages/nestjs/src/decorators.ts +++ b/packages/nestjs/src/decorators.ts @@ -8,22 +8,22 @@ import type { ReflectWithMetadata } from './integrations/helpers'; import { copyReflectMetadata } from './integrations/helpers'; /** - * Monitor settings for `@SentryCron` that take the schedule and time zone from the `@Cron()` - * decorator of `@nestjs/schedule` on the same method. + * Monitor settings for `@SentryCron` whose schedule and time zone come from the `@Cron()` decorator + * of `@nestjs/schedule` on the same method. Set `fromCronDecorator: false` to not send them. */ -export type SentryCronFromCronDecoratorConfig = Omit & { - fromCronDecorator: true; +export type SentryCronMonitorSettings = Omit & { + fromCronDecorator?: boolean; }; /** * A decorator wrapping the native nest Cron decorator, sending check-ins to Sentry. * - * Pass `{ fromCronDecorator: true }` instead of a monitor config to send the schedule and time - * zone of the method's `@Cron()` decorator, so Sentry can create the monitor on the first check-in. + * Unless a monitor config with a `schedule` is passed, the schedule and time zone of the method's + * `@Cron()` decorator are sent with each check-in, so Sentry can create the monitor on the first run. */ export const SentryCron = ( monitorSlug: string, - monitorConfig?: MonitorConfig | SentryCronFromCronDecoratorConfig, + monitorConfig?: MonitorConfig | SentryCronMonitorSettings, ): MethodDecorator => { return (target: unknown, propertyKey, descriptor: PropertyDescriptor) => { const originalMethod = descriptor.value as (...args: unknown[]) => Promise; @@ -34,14 +34,12 @@ export const SentryCron = ( const wrappedMethod = function (this: unknown, ...args: unknown[]): unknown { if (!resolved) { resolved = true; - resolvedMonitorConfig = isFromCronDecoratorConfig(monitorConfig) - ? // `@Cron()` sets its metadata on whatever function is `descriptor.value` when it runs, which is - // this function if it is applied after `@SentryCron()`, so it is only readable at call time. - getMonitorConfigFromNestCron(monitorConfig, [ - wrappedMethod, - (target as Record | undefined)?.[propertyKey], - ]) - : monitorConfig; + // `@Cron()` sets its metadata on whatever function is `descriptor.value` when it runs, which is + // this function if it is applied after `@SentryCron()`, so it is only readable at call time. + resolvedMonitorConfig = resolveMonitorConfig(monitorConfig, [ + wrappedMethod, + (target as Record | undefined)?.[propertyKey], + ]); } return Sentry.withMonitor( @@ -61,10 +59,21 @@ export const SentryCron = ( }; }; -function isFromCronDecoratorConfig( - monitorConfig: MonitorConfig | SentryCronFromCronDecoratorConfig | undefined, -): monitorConfig is SentryCronFromCronDecoratorConfig { - return !!monitorConfig && 'fromCronDecorator' in monitorConfig && monitorConfig.fromCronDecorator === true; +function resolveMonitorConfig( + monitorConfig: MonitorConfig | SentryCronMonitorSettings | undefined, + candidates: unknown[], +): MonitorConfig | undefined { + if (monitorConfig && 'schedule' in monitorConfig) { + return monitorConfig; + } + + const { fromCronDecorator = true, ...monitorSettings } = monitorConfig || {}; + if (!fromCronDecorator) { + return undefined; + } + + const cronConfig = getMonitorConfigFromNestCron(candidates); + return cronConfig && { ...monitorSettings, ...cronConfig }; } const SCHEDULE_CRON_OPTIONS = 'SCHEDULE_CRON_OPTIONS'; @@ -75,10 +84,7 @@ interface NestCronOptions { utcOffset?: unknown; } -function getMonitorConfigFromNestCron( - { fromCronDecorator: _, ...monitorSettings }: SentryCronFromCronDecoratorConfig, - candidates: unknown[], -): MonitorConfig | undefined { +function getMonitorConfigFromNestCron(candidates: unknown[]): MonitorConfig | undefined { const R = Reflect as ReflectWithMetadata; if (typeof R.getMetadata !== 'function') { return undefined; @@ -90,8 +96,7 @@ function getMonitorConfigFromNestCron( } const cronOptions = R.getMetadata(SCHEDULE_CRON_OPTIONS, candidate); if (cronOptions && typeof cronOptions === 'object') { - const cronConfig = nestCronOptionsToMonitorConfig(cronOptions); - return cronConfig && { ...monitorSettings, ...cronConfig }; + return nestCronOptionsToMonitorConfig(cronOptions); } } @@ -123,12 +128,23 @@ function nestCronOptionsToMonitorConfig(cronOptions: NestCronOptions): MonitorCo return undefined; } + // Without a `timeZone`, the job runs in the server's local time zone. + const timezone = typeof timeZone === 'string' && timeZone ? timeZone : getLocalTimeZone(); + return { schedule: { type: 'crontab', value: crontab }, - ...(typeof timeZone === 'string' && timeZone ? { timezone: timeZone } : {}), + ...(timezone ? { timezone } : {}), }; } +function getLocalTimeZone(): string | undefined { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone || undefined; + } catch { + return undefined; + } +} + /** * A decorator usable to wrap arbitrary functions with spans. */ diff --git a/packages/nestjs/src/index.ts b/packages/nestjs/src/index.ts index a89e9aeeb14e..10a3400ee400 100644 --- a/packages/nestjs/src/index.ts +++ b/packages/nestjs/src/index.ts @@ -8,4 +8,4 @@ export { nestIntegration } from './integrations/nest'; export { getDefaultIntegrations, init } from './sdk'; export { SentryCron, SentryExceptionCaptured, SentryTraced } from './decorators'; -export type { SentryCronFromCronDecoratorConfig } from './decorators'; +export type { SentryCronMonitorSettings } from './decorators'; diff --git a/packages/nestjs/test/decorators.test.ts b/packages/nestjs/test/decorators.test.ts index ab129982bc60..c1ed5f0dd8d4 100644 --- a/packages/nestjs/test/decorators.test.ts +++ b/packages/nestjs/test/decorators.test.ts @@ -2,7 +2,7 @@ import 'reflect-metadata'; import { SetMetadata } from '@nestjs/common'; import { CODE_FUNCTION_NAME, SENTRY_OP, SENTRY_ORIGIN } from '@sentry/conventions/attributes'; import * as core from '@sentry/core'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { SentryCron, SentryExceptionCaptured, SentryTraced } from '../src/decorators'; import * as helpers from '../src/helpers'; @@ -292,45 +292,52 @@ describe('SentryCron decorator with @Cron', () => { } beforeEach(() => { - vi.clearAllMocks(); + // `@Cron()` without a `timeZone` runs in the server's local time zone. + vi.spyOn(Intl.DateTimeFormat.prototype, 'resolvedOptions').mockReturnValue({ + timeZone: 'Asia/Tokyo', + } as Intl.ResolvedDateTimeFormatOptions); + }); + + afterEach(() => { + vi.restoreAllMocks(); }); it('derives the monitor config when @SentryCron is above @Cron', async () => { const withMonitorSpy = vi.spyOn(core, 'withMonitor'); - const service = decorate(SentryCron('my-job', { fromCronDecorator: true }), Cron('0 * * * *')); + const service = decorate(SentryCron('my-job'), Cron('0 * * * *')); expect(await service.job()).toBe('done'); expect(withMonitorSpy).toHaveBeenCalledWith('my-job', expect.any(Function), { schedule: { type: 'crontab', value: '0 * * * *' }, + timezone: 'Asia/Tokyo', }); }); it('derives the monitor config when @Cron is above @SentryCron', async () => { const withMonitorSpy = vi.spyOn(core, 'withMonitor'); - const service = decorate(Cron('0 * * * *'), SentryCron('my-job', { fromCronDecorator: true })); + const service = decorate(Cron('0 * * * *'), SentryCron('my-job')); await service.job(); expect(withMonitorSpy).toHaveBeenCalledWith('my-job', expect.any(Function), { schedule: { type: 'crontab', value: '0 * * * *' }, + timezone: 'Asia/Tokyo', }); }); it('derives the monitor config when @Cron replaces the method', async () => { const withMonitorSpy = vi.spyOn(core, 'withMonitor'); - const service = decorate(WrappingCron('0 * * * *'), SentryCron('my-job', { fromCronDecorator: true })); + const service = decorate(WrappingCron('0 * * * *'), SentryCron('my-job')); await service.job(); expect(withMonitorSpy).toHaveBeenCalledWith('my-job', expect.any(Function), { schedule: { type: 'crontab', value: '0 * * * *' }, + timezone: 'Asia/Tokyo', }); }); it('drops a fixed seconds field and passes the time zone', async () => { const withMonitorSpy = vi.spyOn(core, 'withMonitor'); - const service = decorate( - SentryCron('my-job', { fromCronDecorator: true }), - Cron('0 30 9 * * 1-5', { timeZone: 'Europe/Vienna' }), - ); + const service = decorate(SentryCron('my-job'), Cron('0 30 9 * * 1-5', { timeZone: 'Europe/Vienna' })); await service.job(); expect(withMonitorSpy).toHaveBeenCalledWith('my-job', expect.any(Function), { @@ -346,15 +353,15 @@ describe('SentryCron decorator with @Cron', () => { ['a utc offset', Cron('0 * * * *', { utcOffset: 120 })], ])('sends no monitor config for %s', async (_, cronDecorator) => { const withMonitorSpy = vi.spyOn(core, 'withMonitor'); - const service = decorate(SentryCron('my-job', { fromCronDecorator: true }), cronDecorator); + const service = decorate(SentryCron('my-job'), cronDecorator); await service.job(); expect(withMonitorSpy).toHaveBeenCalledWith('my-job', expect.any(Function), undefined); }); - it('sends no monitor config without fromCronDecorator', async () => { + it('sends no monitor config with fromCronDecorator: false', async () => { const withMonitorSpy = vi.spyOn(core, 'withMonitor'); - const service = decorate(SentryCron('my-job'), Cron('0 * * * *')); + const service = decorate(SentryCron('my-job', { fromCronDecorator: false, checkinMargin: 2 }), Cron('0 * * * *')); await service.job(); expect(withMonitorSpy).toHaveBeenCalledWith('my-job', expect.any(Function), undefined); @@ -362,14 +369,12 @@ describe('SentryCron decorator with @Cron', () => { it('keeps the other monitor settings', async () => { const withMonitorSpy = vi.spyOn(core, 'withMonitor'); - const service = decorate( - SentryCron('my-job', { fromCronDecorator: true, checkinMargin: 2, maxRuntime: 10 }), - Cron('0 * * * *'), - ); + const service = decorate(SentryCron('my-job', { checkinMargin: 2, maxRuntime: 10 }), Cron('0 * * * *')); await service.job(); expect(withMonitorSpy).toHaveBeenCalledWith('my-job', expect.any(Function), { schedule: { type: 'crontab', value: '0 * * * *' }, + timezone: 'Asia/Tokyo', checkinMargin: 2, maxRuntime: 10, }); From 3c1da9492498c9942a717d864f770275d1f175d8 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Fri, 2 Oct 2026 13:20:47 -0700 Subject: [PATCH 04/16] feat(nestjs): Send @Cron presets and warn when no config is derived `@Cron()` presets are now sent: the ones Sentry accepts as is, and `@midnight`, `@minutely`, `@weekdays` and `@weekends` as crontabs. When no schedule can be derived but monitor settings were passed, a debug warning says no monitor config is sent. The settings type now rejects `schedule` and `timezone`, and a full monitor config rejects `fromCronDecorator`, so mixed objects no longer drop fields silently. Adds an E2E case for a disabled `@Cron()` job triggered through an endpoint. Co-Authored-By: Claude Opus 5.5 --- .../nestjs-basic/src/app.controller.ts | 6 +++ .../nestjs-basic/src/app.service.ts | 7 +++ .../nestjs-basic/tests/cron-decorator.test.ts | 29 +++++++++++ packages/nestjs/src/decorators.ts | 43 +++++++++++++--- packages/nestjs/test/decorators.test.ts | 50 ++++++++++++++++++- 5 files changed, 126 insertions(+), 9 deletions(-) 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 9ffc328a2c30..33584dea5e78 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 @@ -86,6 +86,12 @@ export class AppController { return { result: await this.appService.testSpanDecoratorSync() }; } + @Get('test-derived-cron') + async testDerivedCron() { + await this.appService.testDerivedCron(); + 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 a7b91b7b3d98..e9bb0c7f69c6 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 @@ -113,6 +113,13 @@ export class AppService { throw new Error('Test error from cron sync job'); } + // Disabled so it only runs through the `test-derived-cron` endpoint, without waiting for the schedule. + @Cron('0 30 9 * * 1-5', { name: 'test-derived-cron', timeZone: 'Europe/Vienna', disabled: true }) + @SentryCron('test-derived-cron-slug', { checkinMargin: 2 }) + async testDerivedCron() { + console.log('Test derived cron!'); + } + async killTestCron(job: string) { this.schedulerRegistry.deleteCronJob(job); } 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 6aeeae723a64..e1d006ccdb5d 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 @@ -62,6 +62,35 @@ test('Cron job triggers send of in_progress envelope', async ({ baseURL }) => { await fetch(`${baseURL}/kill-test-cron/test-cron-job`); }); +test('Sends the schedule and time zone of @Cron with the check-in', async ({ baseURL }) => { + const inProgressEnvelopePromise = waitForEnvelopeItem('nestjs-basic', envelope => { + return ( + envelope[0].type === 'check_in' && + envelope[1]['monitor_slug'] === 'test-derived-cron-slug' && + envelope[1]['status'] === 'in_progress' + ); + }); + + await fetch(`${baseURL}/test-derived-cron`); + + const inProgressEnvelope = await inProgressEnvelopePromise; + + expect(inProgressEnvelope[1]).toEqual( + expect.objectContaining({ + monitor_slug: 'test-derived-cron-slug', + status: 'in_progress', + monitor_config: { + schedule: { + type: 'crontab', + value: '30 9 * * 1-5', + }, + timezone: 'Europe/Vienna', + checkin_margin: 2, + }, + }), + ); +}); + 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 ee7b21e428ee..d2f653e958f9 100644 --- a/packages/nestjs/src/decorators.ts +++ b/packages/nestjs/src/decorators.ts @@ -1,8 +1,9 @@ import type { MonitorConfig } from '@sentry/core'; import { CODE_FUNCTION_NAME, SENTRY_OP, SENTRY_ORIGIN } from '@sentry/conventions/attributes'; -import { captureException } from '@sentry/core'; +import { captureException, debug } from '@sentry/core'; import * as Sentry from '@sentry/node'; 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'; @@ -12,6 +13,8 @@ import { copyReflectMetadata } from './integrations/helpers'; * of `@nestjs/schedule` on the same method. Set `fromCronDecorator: false` to not send them. */ export type SentryCronMonitorSettings = Omit & { + schedule?: never; + timezone?: never; fromCronDecorator?: boolean; }; @@ -23,7 +26,7 @@ export type SentryCronMonitorSettings = Omit { return (target: unknown, propertyKey, descriptor: PropertyDescriptor) => { const originalMethod = descriptor.value as (...args: unknown[]) => Promise; @@ -36,7 +39,7 @@ export const SentryCron = ( resolved = true; // `@Cron()` sets its metadata on whatever function is `descriptor.value` when it runs, which is // this function if it is applied after `@SentryCron()`, so it is only readable at call time. - resolvedMonitorConfig = resolveMonitorConfig(monitorConfig, [ + resolvedMonitorConfig = resolveMonitorConfig(monitorSlug, monitorConfig, [ wrappedMethod, (target as Record | undefined)?.[propertyKey], ]); @@ -60,24 +63,46 @@ export const SentryCron = ( }; function resolveMonitorConfig( + monitorSlug: string, monitorConfig: MonitorConfig | SentryCronMonitorSettings | undefined, candidates: unknown[], ): MonitorConfig | undefined { - if (monitorConfig && 'schedule' in monitorConfig) { + if (monitorConfig?.schedule) { return monitorConfig; } const { fromCronDecorator = true, ...monitorSettings } = monitorConfig || {}; - if (!fromCronDecorator) { + const cronConfig = fromCronDecorator ? getMonitorConfigFromNestCron(candidates) : undefined; + + if (!cronConfig) { + if (DEBUG_BUILD && Object.keys(monitorSettings).length) { + debug.warn( + `[SentryCron] No schedule for monitor "${monitorSlug}" could be taken from @Cron(), so no monitor config (including the passed monitor settings) is sent.`, + ); + } return undefined; } - const cronConfig = getMonitorConfigFromNestCron(candidates); - return cronConfig && { ...monitorSettings, ...cronConfig }; + return { ...monitorSettings, ...cronConfig }; } const SCHEDULE_CRON_OPTIONS = 'SCHEDULE_CRON_OPTIONS'; +// Presets of the `cron` package (which also lowercases them), as sent to Sentry. Sentry accepts +// `@yearly`/`@annually`/`@monthly`/`@weekly`/`@daily`/`@hourly`; the others are sent as crontabs. +const CRON_PRESETS: Record = { + '@yearly': '@yearly', + '@annually': '@annually', + '@monthly': '@monthly', + '@weekly': '@weekly', + '@daily': '@daily', + '@hourly': '@hourly', + '@midnight': '0 0 * * *', + '@minutely': '* * * * *', + '@weekdays': '0 0 * * 1-5', + '@weekends': '0 0 * * 0,6', +}; + interface NestCronOptions { cronTime?: unknown; timeZone?: unknown; @@ -117,7 +142,9 @@ function nestCronOptionsToMonitorConfig(cronOptions: NestCronOptions): MonitorCo const fields = cronTime.trim().split(/\s+/); let crontab: string | undefined; - if (fields.length === 5) { + if (fields.length === 1) { + crontab = CRON_PRESETS[(fields[0] as string).toLowerCase()]; + } else if (fields.length === 5) { crontab = fields.join(' '); } else if (fields.length === 6 && /^\d+$/.test(fields[0] as string)) { // Sentry schedules have minute granularity, so only a fixed second can be dropped. diff --git a/packages/nestjs/test/decorators.test.ts b/packages/nestjs/test/decorators.test.ts index c1ed5f0dd8d4..a3ecdc3b7be7 100644 --- a/packages/nestjs/test/decorators.test.ts +++ b/packages/nestjs/test/decorators.test.ts @@ -349,7 +349,7 @@ describe('SentryCron decorator with @Cron', () => { it.each([ ['a sub-minute schedule', Cron('*/5 * * * * *')], ['a one-off date', Cron(new Date())], - ['a preset', Cron('@daily')], + ['an unknown preset', Cron('@reboot')], ['a utc offset', Cron('0 * * * *', { utcOffset: 120 })], ])('sends no monitor config for %s', async (_, cronDecorator) => { const withMonitorSpy = vi.spyOn(core, 'withMonitor'); @@ -359,6 +359,47 @@ describe('SentryCron decorator with @Cron', () => { expect(withMonitorSpy).toHaveBeenCalledWith('my-job', expect.any(Function), undefined); }); + it.each([ + ['@hourly', '@hourly'], + ['@daily', '@daily'], + ['@WEEKLY', '@weekly'], + ['@monthly', '@monthly'], + ['@yearly', '@yearly'], + ['@annually', '@annually'], + ['@midnight', '0 0 * * *'], + ['@weekdays', '0 0 * * 1-5'], + ])('sends the preset %s as %s', async (preset, value) => { + const withMonitorSpy = vi.spyOn(core, 'withMonitor'); + const service = decorate(SentryCron('my-job'), Cron(preset)); + + await service.job(); + expect(withMonitorSpy).toHaveBeenCalledWith('my-job', expect.any(Function), { + schedule: { type: 'crontab', value }, + timezone: 'Asia/Tokyo', + }); + }); + + it('warns when monitor settings are passed but no schedule can be derived', async () => { + const warnSpy = vi.spyOn(core.debug, 'warn').mockImplementation(() => undefined); + const withMonitorSpy = vi.spyOn(core, 'withMonitor').mockImplementation((_, callback) => callback()); + const service = decorate(SentryCron('my-job', { checkinMargin: 2 }), Cron('*/5 * * * * *')); + + await service.job(); + await service.job(); + expect(withMonitorSpy).toHaveBeenCalledWith('my-job', expect.any(Function), undefined); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('"my-job"')); + }); + + it('does not warn when no monitor settings are passed', async () => { + const warnSpy = vi.spyOn(core.debug, 'warn').mockImplementation(() => undefined); + vi.spyOn(core, 'withMonitor').mockImplementation((_, callback) => callback()); + const service = decorate(SentryCron('my-job'), Cron('*/5 * * * * *')); + + await service.job(); + expect(warnSpy).not.toHaveBeenCalled(); + }); + it('sends no monitor config with fromCronDecorator: false', async () => { const withMonitorSpy = vi.spyOn(core, 'withMonitor'); const service = decorate(SentryCron('my-job', { fromCronDecorator: false, checkinMargin: 2 }), Cron('0 * * * *')); @@ -388,6 +429,13 @@ describe('SentryCron decorator with @Cron', () => { await service.job(); expect(withMonitorSpy).toHaveBeenCalledWith('my-job', expect.any(Function), monitorConfig); }); + + it('does not accept a schedule or time zone together with fromCronDecorator', () => { + // @ts-expect-error - `fromCronDecorator` only applies to settings without a schedule + SentryCron('my-job', { schedule: { type: 'crontab', value: '0 * * * *' }, fromCronDecorator: false }); + // @ts-expect-error - the time zone comes from `@Cron()` + SentryCron('my-job', { timezone: 'Europe/Vienna', checkinMargin: 2 }); + }); }); describe('SentryExceptionCaptured decorator', () => { From 6413911977e71c8a2a5015ebc3cf20024aaccbec Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Fri, 2 Oct 2026 15:01:49 -0700 Subject: [PATCH 05/16] fix(nestjs): Only send @Cron schedules Sentry reads the same way Skip numeric months (cron 2.x counts from 0), day-of-month/day-of-week combinations Sentry would AND, and time zones Sentry rejects. Clarify the warning when fromCronDecorator is false. Co-Authored-By: Claude --- packages/nestjs/src/decorators.ts | 44 ++++++++++++++++++++++--- packages/nestjs/test/decorators.test.ts | 31 +++++++++++++++++ 2 files changed, 71 insertions(+), 4 deletions(-) diff --git a/packages/nestjs/src/decorators.ts b/packages/nestjs/src/decorators.ts index d2f653e958f9..2d44fd5d2420 100644 --- a/packages/nestjs/src/decorators.ts +++ b/packages/nestjs/src/decorators.ts @@ -76,8 +76,11 @@ function resolveMonitorConfig( if (!cronConfig) { if (DEBUG_BUILD && Object.keys(monitorSettings).length) { + const reason = fromCronDecorator + ? 'no schedule could be taken from @Cron()' + : 'fromCronDecorator is false and no schedule was passed'; debug.warn( - `[SentryCron] No schedule for monitor "${monitorSlug}" could be taken from @Cron(), so no monitor config (including the passed monitor settings) is sent.`, + `[SentryCron] The monitor settings for "${monitorSlug}" are not sent, because ${reason}. A monitor config needs a schedule.`, ); } return undefined; @@ -145,10 +148,10 @@ function nestCronOptionsToMonitorConfig(cronOptions: NestCronOptions): MonitorCo if (fields.length === 1) { crontab = CRON_PRESETS[(fields[0] as string).toLowerCase()]; } else if (fields.length === 5) { - crontab = fields.join(' '); + crontab = isSupportedCrontab(fields) ? fields.join(' ') : undefined; } else if (fields.length === 6 && /^\d+$/.test(fields[0] as string)) { // Sentry schedules have minute granularity, so only a fixed second can be dropped. - crontab = fields.slice(1).join(' '); + crontab = isSupportedCrontab(fields.slice(1)) ? fields.slice(1).join(' ') : undefined; } if (!crontab) { @@ -158,12 +161,45 @@ function nestCronOptionsToMonitorConfig(cronOptions: NestCronOptions): MonitorCo // Without a `timeZone`, the job runs in the server's local time zone. const timezone = typeof timeZone === 'string' && timeZone ? timeZone : getLocalTimeZone(); + // Without a time zone Sentry would assume UTC, which may not be when the job runs. + if (!timezone || !isSentryTimeZone(timezone)) { + return undefined; + } + return { schedule: { type: 'crontab', value: crontab }, - ...(timezone ? { timezone } : {}), + timezone, }; } +/** + * Whether Sentry reads the 5 crontab fields the same way `cron` (used by `@nestjs/schedule`) runs them. + */ +function isSupportedCrontab([, , dayOfMonth, month, dayOfWeek]: string[]): boolean { + // `cron` 2.x (`@nestjs/schedule` 3) counts months from 0, so a numeric month is ambiguous. + if (/\d/.test(month as string)) { + return false; + } + + // With both day fields set, `cron` runs on either, but Sentry needs both when one starts with `*` (like `*/2`). + return !(dayOfMonth !== '*' && dayOfWeek !== '*' && (dayOfMonth?.startsWith('*') || dayOfWeek?.startsWith('*'))); +} + +/** + * Whether Sentry accepts the time zone: an IANA name, not a fixed offset like `UTC+3`. + */ +function isSentryTimeZone(timezone: string): boolean { + if (timezone === 'Etc/Unknown' || /^(?:utc|gmt)?[+-]/i.test(timezone)) { + return false; + } + try { + new Intl.DateTimeFormat('en-US', { timeZone: timezone }); + return true; + } catch { + return false; + } +} + function getLocalTimeZone(): string | undefined { try { return Intl.DateTimeFormat().resolvedOptions().timeZone || undefined; diff --git a/packages/nestjs/test/decorators.test.ts b/packages/nestjs/test/decorators.test.ts index a3ecdc3b7be7..afbaf174f2db 100644 --- a/packages/nestjs/test/decorators.test.ts +++ b/packages/nestjs/test/decorators.test.ts @@ -351,6 +351,10 @@ describe('SentryCron decorator with @Cron', () => { ['a one-off date', Cron(new Date())], ['an unknown preset', Cron('@reboot')], ['a utc offset', Cron('0 * * * *', { utcOffset: 120 })], + ['a numeric month', Cron('0 9 1 5 *')], + ['a day-of-month step with a day of week', Cron('0 9 */2 * MON')], + ['a fixed-offset time zone', Cron('0 * * * *', { timeZone: 'UTC+3' })], + ['an unknown time zone', Cron('0 * * * *', { timeZone: 'Mars/Olympus' })], ])('sends no monitor config for %s', async (_, cronDecorator) => { const withMonitorSpy = vi.spyOn(core, 'withMonitor'); const service = decorate(SentryCron('my-job'), cronDecorator); @@ -359,6 +363,31 @@ describe('SentryCron decorator with @Cron', () => { expect(withMonitorSpy).toHaveBeenCalledWith('my-job', expect.any(Function), undefined); }); + it.each([ + ['a month name', '0 9 1 MAY *'], + ['both day fields', '0 9 1-7 * MON'], + ])('sends a crontab with %s', async (_, crontab) => { + const withMonitorSpy = vi.spyOn(core, 'withMonitor'); + const service = decorate(SentryCron('my-job'), Cron(crontab)); + + await service.job(); + expect(withMonitorSpy).toHaveBeenCalledWith('my-job', expect.any(Function), { + schedule: { type: 'crontab', value: crontab }, + timezone: 'Asia/Tokyo', + }); + }); + + it('sends no monitor config when the local time zone is unknown', async () => { + vi.spyOn(Intl.DateTimeFormat.prototype, 'resolvedOptions').mockReturnValue({ + timeZone: 'Etc/Unknown', + } as Intl.ResolvedDateTimeFormatOptions); + const withMonitorSpy = vi.spyOn(core, 'withMonitor'); + const service = decorate(SentryCron('my-job'), Cron('0 * * * *')); + + await service.job(); + expect(withMonitorSpy).toHaveBeenCalledWith('my-job', expect.any(Function), undefined); + }); + it.each([ ['@hourly', '@hourly'], ['@daily', '@daily'], @@ -401,11 +430,13 @@ describe('SentryCron decorator with @Cron', () => { }); it('sends no monitor config with fromCronDecorator: false', async () => { + const warnSpy = vi.spyOn(core.debug, 'warn').mockImplementation(() => undefined); const withMonitorSpy = vi.spyOn(core, 'withMonitor'); const service = decorate(SentryCron('my-job', { fromCronDecorator: false, checkinMargin: 2 }), Cron('0 * * * *')); await service.job(); expect(withMonitorSpy).toHaveBeenCalledWith('my-job', expect.any(Function), undefined); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('fromCronDecorator is false')); }); it('keeps the other monitor settings', async () => { From 8cd5e983c33d8d4b6c14c6fcfe5c38839d733444 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Mon, 5 Oct 2026 12:16:12 -0700 Subject: [PATCH 06/16] fix(nestjs): Send @Cron month steps and canonical time zones Allow month steps like */3 (CronExpression.EVERY_QUARTER), still skipping month numbers. Drop the @midnight and @annually presets, which cron rejects. Skip full day ranges like 1-31 when the other day field is set, and send the canonical time zone name. Co-Authored-By: Claude --- packages/nestjs/src/decorators.ts | 42 +++++++++++++++---------- packages/nestjs/test/decorators.test.ts | 39 ++++++++++++++++++----- 2 files changed, 56 insertions(+), 25 deletions(-) diff --git a/packages/nestjs/src/decorators.ts b/packages/nestjs/src/decorators.ts index 2d44fd5d2420..dce2f61d3518 100644 --- a/packages/nestjs/src/decorators.ts +++ b/packages/nestjs/src/decorators.ts @@ -92,15 +92,13 @@ function resolveMonitorConfig( const SCHEDULE_CRON_OPTIONS = 'SCHEDULE_CRON_OPTIONS'; // Presets of the `cron` package (which also lowercases them), as sent to Sentry. Sentry accepts -// `@yearly`/`@annually`/`@monthly`/`@weekly`/`@daily`/`@hourly`; the others are sent as crontabs. +// `@yearly`/`@monthly`/`@weekly`/`@daily`/`@hourly`; the others are sent as crontabs. const CRON_PRESETS: Record = { '@yearly': '@yearly', - '@annually': '@annually', '@monthly': '@monthly', '@weekly': '@weekly', '@daily': '@daily', '@hourly': '@hourly', - '@midnight': '0 0 * * *', '@minutely': '* * * * *', '@weekdays': '0 0 * * 1-5', '@weekends': '0 0 * * 0,6', @@ -159,10 +157,9 @@ function nestCronOptionsToMonitorConfig(cronOptions: NestCronOptions): MonitorCo } // Without a `timeZone`, the job runs in the server's local time zone. - const timezone = typeof timeZone === 'string' && timeZone ? timeZone : getLocalTimeZone(); - // Without a time zone Sentry would assume UTC, which may not be when the job runs. - if (!timezone || !isSentryTimeZone(timezone)) { + const timezone = toSentryTimeZone(typeof timeZone === 'string' && timeZone ? timeZone : getLocalTimeZone()); + if (!timezone) { return undefined; } @@ -175,28 +172,39 @@ function nestCronOptionsToMonitorConfig(cronOptions: NestCronOptions): MonitorCo /** * Whether Sentry reads the 5 crontab fields the same way `cron` (used by `@nestjs/schedule`) runs them. */ -function isSupportedCrontab([, , dayOfMonth, month, dayOfWeek]: string[]): boolean { - // `cron` 2.x (`@nestjs/schedule` 3) counts months from 0, so a numeric month is ambiguous. - if (/\d/.test(month as string)) { +function isSupportedCrontab([, , dayOfMonth = '', month = '', dayOfWeek = '']: string[]): boolean { + // `cron` 2.x (`@nestjs/schedule` 3) counts months from 0, so a month number is ambiguous. Steps are not. + if (/\d/.test(month.replace(/\/\d+/g, ''))) { return false; } + if (dayOfMonth === '*' || dayOfWeek === '*') { + return true; + } + // With both day fields set, `cron` runs on either, but Sentry needs both when one starts with `*` (like `*/2`). - return !(dayOfMonth !== '*' && dayOfWeek !== '*' && (dayOfMonth?.startsWith('*') || dayOfWeek?.startsWith('*'))); + // `cron` also treats a full range like `1-31` as `*`, which Sentry may not. + return !( + dayOfMonth.startsWith('*') || + dayOfWeek.startsWith('*') || + dayOfMonth === '1-31' || + FULL_DAY_OF_WEEK_RANGES.has(dayOfWeek) + ); } +const FULL_DAY_OF_WEEK_RANGES = new Set(['0-6', '0-7', '1-7']); + /** - * Whether Sentry accepts the time zone: an IANA name, not a fixed offset like `UTC+3`. + * The canonical IANA name of the time zone, or `undefined` if Sentry won't accept it (like `UTC+3`). */ -function isSentryTimeZone(timezone: string): boolean { - if (timezone === 'Etc/Unknown' || /^(?:utc|gmt)?[+-]/i.test(timezone)) { - return false; +function toSentryTimeZone(timezone: string | undefined): string | undefined { + if (!timezone || timezone === 'Etc/Unknown' || /^(?:utc|gmt)?[+-]/i.test(timezone)) { + return undefined; } try { - new Intl.DateTimeFormat('en-US', { timeZone: timezone }); - return true; + return new Intl.DateTimeFormat('en-US', { timeZone: timezone }).resolvedOptions().timeZone || undefined; } catch { - return false; + return undefined; } } diff --git a/packages/nestjs/test/decorators.test.ts b/packages/nestjs/test/decorators.test.ts index afbaf174f2db..0bdc4a56667e 100644 --- a/packages/nestjs/test/decorators.test.ts +++ b/packages/nestjs/test/decorators.test.ts @@ -293,9 +293,13 @@ describe('SentryCron decorator with @Cron', () => { beforeEach(() => { // `@Cron()` without a `timeZone` runs in the server's local time zone. - vi.spyOn(Intl.DateTimeFormat.prototype, 'resolvedOptions').mockReturnValue({ - timeZone: 'Asia/Tokyo', - } as Intl.ResolvedDateTimeFormatOptions); + const DateTimeFormat = Intl.DateTimeFormat; + vi.spyOn(Intl, 'DateTimeFormat').mockImplementation(function ( + locales?: string | string[], + options?: Intl.DateTimeFormatOptions, + ) { + return new DateTimeFormat(locales, { timeZone: 'Asia/Tokyo', ...options }); + } as typeof Intl.DateTimeFormat); }); afterEach(() => { @@ -346,13 +350,32 @@ describe('SentryCron decorator with @Cron', () => { }); }); + it('sends the canonical time zone name', async () => { + const withMonitorSpy = vi.spyOn(core, 'withMonitor'); + const service = decorate(SentryCron('my-job'), Cron('0 * * * *', { timeZone: 'europe/vienna' })); + + await service.job(); + expect(withMonitorSpy).toHaveBeenCalledWith('my-job', expect.any(Function), { + schedule: { type: 'crontab', value: '0 * * * *' }, + timezone: 'Europe/Vienna', + }); + }); + it.each([ ['a sub-minute schedule', Cron('*/5 * * * * *')], ['a one-off date', Cron(new Date())], ['an unknown preset', Cron('@reboot')], ['a utc offset', Cron('0 * * * *', { utcOffset: 120 })], ['a numeric month', Cron('0 9 1 5 *')], + ['a numeric month range', Cron('0 9 1 1-6 *')], + ['a numeric month list', Cron('0 9 1 1,7 *')], + ['a numeric month with a step', Cron('0 9 1 2/3 *')], + // `0 0 1 0 *` in `@nestjs/schedule` 3 + ['CronExpression.EVERY_YEAR', Cron('0 0 1 1 *')], ['a day-of-month step with a day of week', Cron('0 9 */2 * MON')], + ['a full day-of-month range with a day of week', Cron('0 9 1-31 * MON')], + ['a full day-of-week range with a day of month', Cron('0 9 1 * 0-6')], + ['a full 1-7 day-of-week range with a day of month', Cron('0 9 15 * 1-7')], ['a fixed-offset time zone', Cron('0 * * * *', { timeZone: 'UTC+3' })], ['an unknown time zone', Cron('0 * * * *', { timeZone: 'Mars/Olympus' })], ])('sends no monitor config for %s', async (_, cronDecorator) => { @@ -366,6 +389,8 @@ describe('SentryCron decorator with @Cron', () => { it.each([ ['a month name', '0 9 1 MAY *'], ['both day fields', '0 9 1-7 * MON'], + ['CronExpression.EVERY_QUARTER', '0 0 1 */3 *'], + ['CronExpression.EVERY_6_MONTHS', '0 0 1 */6 *'], ])('sends a crontab with %s', async (_, crontab) => { const withMonitorSpy = vi.spyOn(core, 'withMonitor'); const service = decorate(SentryCron('my-job'), Cron(crontab)); @@ -378,9 +403,9 @@ describe('SentryCron decorator with @Cron', () => { }); it('sends no monitor config when the local time zone is unknown', async () => { - vi.spyOn(Intl.DateTimeFormat.prototype, 'resolvedOptions').mockReturnValue({ - timeZone: 'Etc/Unknown', - } as Intl.ResolvedDateTimeFormatOptions); + vi.mocked(Intl.DateTimeFormat).mockImplementation(function () { + return { resolvedOptions: () => ({ timeZone: 'Etc/Unknown' }) }; + } as unknown as typeof Intl.DateTimeFormat); const withMonitorSpy = vi.spyOn(core, 'withMonitor'); const service = decorate(SentryCron('my-job'), Cron('0 * * * *')); @@ -394,8 +419,6 @@ describe('SentryCron decorator with @Cron', () => { ['@WEEKLY', '@weekly'], ['@monthly', '@monthly'], ['@yearly', '@yearly'], - ['@annually', '@annually'], - ['@midnight', '0 0 * * *'], ['@weekdays', '0 0 * * 1-5'], ])('sends the preset %s as %s', async (preset, value) => { const withMonitorSpy = vi.spyOn(core, 'withMonitor'); From 258a3fc3c6af78c377760847e4c83bbbf048c482 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Mon, 5 Oct 2026 16:43:43 -0700 Subject: [PATCH 07/16] fix(nestjs): Keep isolateTrace when no monitor config is sent Co-Authored-By: Claude --- packages/nestjs/src/decorators.ts | 23 +++++++++++++++-------- packages/nestjs/test/decorators.test.ts | 17 +++++++++++++++++ 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/packages/nestjs/src/decorators.ts b/packages/nestjs/src/decorators.ts index dce2f61d3518..da07c3eb928f 100644 --- a/packages/nestjs/src/decorators.ts +++ b/packages/nestjs/src/decorators.ts @@ -32,6 +32,7 @@ export const SentryCron = ( const originalMethod = descriptor.value as (...args: unknown[]) => Promise; let resolvedMonitorConfig: MonitorConfig | undefined; + let isolateTraceWithoutConfig = false; let resolved = false; const wrappedMethod = function (this: unknown, ...args: unknown[]): unknown { @@ -43,15 +44,20 @@ export const SentryCron = ( wrappedMethod, (target as Record | undefined)?.[propertyKey], ]); + isolateTraceWithoutConfig = !resolvedMonitorConfig && !!monitorConfig?.isolateTrace; } - return Sentry.withMonitor( - monitorSlug, - () => { - return originalMethod.apply(this, args); - }, - resolvedMonitorConfig, - ); + const runWithMonitor = (): unknown => + Sentry.withMonitor( + monitorSlug, + () => { + return originalMethod.apply(this, args); + }, + resolvedMonitorConfig, + ); + + // `withMonitor` reads `isolateTrace` from the monitor config, which is not sent without a schedule. + return isolateTraceWithoutConfig ? Sentry.startNewTrace(runWithMonitor) : runWithMonitor(); }; descriptor.value = wrappedMethod; @@ -75,7 +81,8 @@ function resolveMonitorConfig( const cronConfig = fromCronDecorator ? getMonitorConfigFromNestCron(candidates) : undefined; if (!cronConfig) { - if (DEBUG_BUILD && Object.keys(monitorSettings).length) { + const { isolateTrace: _isolateTrace, ...unsentSettings } = monitorSettings; + if (DEBUG_BUILD && Object.keys(unsentSettings).length) { const reason = fromCronDecorator ? 'no schedule could be taken from @Cron()' : 'fromCronDecorator is false and no schedule was passed'; diff --git a/packages/nestjs/test/decorators.test.ts b/packages/nestjs/test/decorators.test.ts index 0bdc4a56667e..405ace31e8f8 100644 --- a/packages/nestjs/test/decorators.test.ts +++ b/packages/nestjs/test/decorators.test.ts @@ -462,6 +462,23 @@ describe('SentryCron decorator with @Cron', () => { expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('fromCronDecorator is false')); }); + it('isolates the trace without a monitor config', async () => { + const warnSpy = vi.spyOn(core.debug, 'warn').mockImplementation(() => undefined); + const traceIds: string[] = []; + const withMonitorSpy = vi.spyOn(core, 'withMonitor').mockImplementation((_, callback) => { + traceIds.push(core.getCurrentScope().getPropagationContext().traceId); + return callback(); + }); + const service = decorate(SentryCron('my-job', { isolateTrace: true }), Cron('*/5 * * * * *')); + const parentTraceId = core.getCurrentScope().getPropagationContext().traceId; + + await service.job(); + expect(withMonitorSpy).toHaveBeenCalledWith('my-job', expect.any(Function), undefined); + expect(traceIds).toHaveLength(1); + expect(traceIds[0]).not.toBe(parentTraceId); + expect(warnSpy).not.toHaveBeenCalled(); + }); + it('keeps the other monitor settings', async () => { const withMonitorSpy = vi.spyOn(core, 'withMonitor'); const service = decorate(SentryCron('my-job', { checkinMargin: 2, maxRuntime: 10 }), Cron('0 * * * *')); From b253af47bbae223b995905a4bc7509a4212e88a3 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Thu, 15 Oct 2026 12:02:27 -0700 Subject: [PATCH 08/16] ref(nestjs): Shorten SentryCron comments Co-Authored-By: Claude --- packages/nestjs/src/decorators.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/nestjs/src/decorators.ts b/packages/nestjs/src/decorators.ts index da07c3eb928f..653bca5c41be 100644 --- a/packages/nestjs/src/decorators.ts +++ b/packages/nestjs/src/decorators.ts @@ -38,8 +38,7 @@ export const SentryCron = ( const wrappedMethod = function (this: unknown, ...args: unknown[]): unknown { if (!resolved) { resolved = true; - // `@Cron()` sets its metadata on whatever function is `descriptor.value` when it runs, which is - // this function if it is applied after `@SentryCron()`, so it is only readable at call time. + // A `@Cron()` applied after `@SentryCron()` puts its metadata on this wrapper, so read it at call time. resolvedMonitorConfig = resolveMonitorConfig(monitorSlug, monitorConfig, [ wrappedMethod, (target as Record | undefined)?.[propertyKey], @@ -98,8 +97,7 @@ function resolveMonitorConfig( const SCHEDULE_CRON_OPTIONS = 'SCHEDULE_CRON_OPTIONS'; -// Presets of the `cron` package (which also lowercases them), as sent to Sentry. Sentry accepts -// `@yearly`/`@monthly`/`@weekly`/`@daily`/`@hourly`; the others are sent as crontabs. +// `cron` presets (case-insensitive). Sentry accepts only the first five, so the others are sent as crontabs. const CRON_PRESETS: Record = { '@yearly': '@yearly', '@monthly': '@monthly', @@ -163,8 +161,7 @@ function nestCronOptionsToMonitorConfig(cronOptions: NestCronOptions): MonitorCo return undefined; } - // Without a `timeZone`, the job runs in the server's local time zone. - // Without a time zone Sentry would assume UTC, which may not be when the job runs. + // Without a `timeZone`, `cron` uses the server's time zone, but Sentry would assume UTC. const timezone = toSentryTimeZone(typeof timeZone === 'string' && timeZone ? timeZone : getLocalTimeZone()); if (!timezone) { return undefined; From 34f02f1a7bab7e5b0d457f4a353d912c6e25728c Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Fri, 16 Oct 2026 17:34:38 -0700 Subject: [PATCH 09/16] 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']); + }); +}); From a191b8655f064382eb35cc731ea197bee7404595 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Wed, 28 Oct 2026 12:20:12 -0700 Subject: [PATCH 10/16] fix(nestjs): Skip monitored @Cron jobs in the cron package integration @nestjs/schedule runs @Cron methods as cron CronJobs, so with monitorCronJobs and cronJobIntegration both on each run sent two sets of check-ins. Mark the onTick Nest passes to CronJob when the method already sends check-ins. --- .../integrations/orchestrion-subscriber.ts | 16 ++++++- .../nestjs/src/integrations/wrap-handlers.ts | 18 +++++++- .../test/integrations/cron-monitoring.test.ts | 46 +++++++++++++++++++ .../src/orchestrion/config/nestjs.ts | 8 ++++ 4 files changed, 86 insertions(+), 2 deletions(-) diff --git a/packages/nestjs/src/integrations/orchestrion-subscriber.ts b/packages/nestjs/src/integrations/orchestrion-subscriber.ts index 8bd6993c32f2..e6cb67ccb8b2 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 { bindTracingChannelToSpan } from '@sentry/server-utils'; import { nestjsChannels as CHANNELS } from '@sentry/server-utils/orchestrion/config'; import { DEBUG_BUILD } from '../debug-build'; @@ -13,6 +13,7 @@ import { MECHANISM_TIMEOUT, patchMethodDescriptor, patchProcessorTarget, + sendsCronCheckIns, wrapCronHandler, wrapEventHandler, wrapScheduleHandler, @@ -197,6 +198,19 @@ export function subscribeToNestChannels(): void { wrapCronHandler(handler, [...(data.arguments || [])], target, propertyKey), ), ); + // `@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 09b794644258..86b4ecc543cf 100644 --- a/packages/nestjs/src/integrations/wrap-handlers.ts +++ b/packages/nestjs/src/integrations/wrap-handlers.ts @@ -1,7 +1,13 @@ 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, + getEventSpanOptions, + isSentryCronMethod, + isWrapped, + markWrapped, +} from './helpers'; /** * Span-emitting / error-capturing logic for the `@Cron`/`@Interval`/ @@ -57,6 +63,15 @@ export function wrapScheduleHandler(handler: AnyFn, mechanismType: string): AnyF }; } +const CRON_HANDLER_MONITORS = new WeakMap boolean>(); + +/** Whether a `@Cron()` method sends check-ins when it runs, from `monitorCronJobs` or `@SentryCron()`. */ +export function sendsCronCheckIns(method: unknown): boolean { + return ( + typeof method === 'function' && (isSentryCronMethod(method) || !!CRON_HANDLER_MONITORS.get(method as AnyFn)?.()) + ); +} + /** * Wrap a `@Cron` handler like {@link wrapScheduleHandler}, sending check-ins when `monitorCronJobs` is enabled. */ @@ -76,6 +91,7 @@ export function wrapCronHandler( } return withMonitor(monitor.slug, () => scheduleHandler.apply(this, args), monitor.config); }; + CRON_HANDLER_MONITORS.set(wrapped, () => isSentryCronMethod(handler) || !!resolveMonitor(handler, wrapped)); return wrapped; } diff --git a/packages/nestjs/test/integrations/cron-monitoring.test.ts b/packages/nestjs/test/integrations/cron-monitoring.test.ts index 6f08f311dc40..3fb665b9dd43 100644 --- a/packages/nestjs/test/integrations/cron-monitoring.test.ts +++ b/packages/nestjs/test/integrations/cron-monitoring.test.ts @@ -56,6 +56,19 @@ function decorate(className: string, decorators: MethodDecorator[]): AnyFn { 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]); } @@ -145,4 +158,37 @@ describe('@Cron() monitoring', () => { 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 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; From cb47dab4d1e0e2411dabfeb58719e6837cb3f249 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Wed, 28 Oct 2026 12:49:24 -0700 Subject: [PATCH 11/16] fix(nestjs): Use OrchestrionChannelContext in schedule explorer subscriber --- packages/nestjs/src/integrations/orchestrion-subscriber.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/nestjs/src/integrations/orchestrion-subscriber.ts b/packages/nestjs/src/integrations/orchestrion-subscriber.ts index aea33b3792ae..f82c1ea14c16 100644 --- a/packages/nestjs/src/integrations/orchestrion-subscriber.ts +++ b/packages/nestjs/src/integrations/orchestrion-subscriber.ts @@ -195,10 +195,10 @@ export function subscribeToNestChannels(): void { ); // `@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({ + diagnosticsChannel.tracingChannel(CHANNELS.NESTJS_SCHEDULE_EXPLORER_WRAP).subscribe({ start: NOOP, end(data) { - if (typeof data.result === 'function' && sendsCronCheckIns(data.arguments[0])) { + if (typeof data.result === 'function' && sendsCronCheckIns(data.arguments?.[0])) { addNonEnumerableProperty(data.result, Symbol.for('sentry.cron.monitored'), true); } }, From 0c123c67c3ed706788ecd43719a3e7aeb540761f Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Wed, 28 Oct 2026 12:51:26 -0700 Subject: [PATCH 12/16] fix(nestjs): Mark the CronJob onTick when a decorator wraps @Cron() Key the monitored check on the @Cron() options metadata, which outer decorators copy, so it doesn't depend on decorator order. --- .../integrations/orchestrion-subscriber.ts | 14 ++++-- .../nestjs/src/integrations/wrap-handlers.ts | 36 +++++++++++++++- .../test/integrations/cron-monitoring.test.ts | 43 ++++++++++++++++++- 3 files changed, 86 insertions(+), 7 deletions(-) diff --git a/packages/nestjs/src/integrations/orchestrion-subscriber.ts b/packages/nestjs/src/integrations/orchestrion-subscriber.ts index f82c1ea14c16..2e7065bbfe73 100644 --- a/packages/nestjs/src/integrations/orchestrion-subscriber.ts +++ b/packages/nestjs/src/integrations/orchestrion-subscriber.ts @@ -10,6 +10,7 @@ import type { CatchTarget, InjectableTarget, MinimalNestJsExecutionContext } fro import { patchCatchTarget, patchInjectableTarget } from './wrap-components'; import { extractQueueName, + linkCronOptionsToMonitor, MECHANISM_INTERVAL, MECHANISM_TIMEOUT, patchMethodDescriptor, @@ -188,11 +189,16 @@ export function subscribeToNestChannels(): void { subscribeDecoratorChannel(CHANNELS.NESTJS_CATCH, patchCatchTarget); // @Cron/@Interval/@Timeout (schedule), @OnEvent (event), @Processor (bullmq) - subscribeFactoryDecorator(CHANNELS.NESTJS_SCHEDULE_CRON, (decorator, data) => - makeMethodDecorator(decorator, (handler, target, propertyKey) => + subscribeFactoryDecorator(CHANNELS.NESTJS_SCHEDULE_CRON, (decorator, data) => { + const cronDecorator = makeMethodDecorator(decorator, (handler, target, propertyKey) => wrapCronHandler(handler, [...(data.arguments || [])], target, propertyKey), - ), - ); + ); + return function (this: unknown, ...args: unknown[]): unknown { + const result = cronDecorator.apply(this, args); + linkCronOptionsToMonitor((args[2] as PropertyDescriptor | undefined)?.value); + 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({ diff --git a/packages/nestjs/src/integrations/wrap-handlers.ts b/packages/nestjs/src/integrations/wrap-handlers.ts index 86b4ecc543cf..8ce4d004df36 100644 --- a/packages/nestjs/src/integrations/wrap-handlers.ts +++ b/packages/nestjs/src/integrations/wrap-handlers.ts @@ -63,12 +63,41 @@ export function wrapScheduleHandler(handler: AnyFn, mechanismType: string): AnyF }; } +// `@nestjs/schedule`'s metadata key for the `@Cron()` options. +const SCHEDULE_CRON_OPTIONS = 'SCHEDULE_CRON_OPTIONS'; + const CRON_HANDLER_MONITORS = new WeakMap boolean>(); +const CRON_OPTIONS_MONITORS = new WeakMap boolean>(); + +function getCronOptions(method: AnyFn): object | undefined { + const R = Reflect as unknown as ReflectWithMetadata; + const options = typeof R.getMetadata === 'function' ? R.getMetadata(SCHEDULE_CRON_OPTIONS, method) : undefined; + return isObjectLike(options) ? options : undefined; +} + +/** + * 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 ( - typeof method === 'function' && (isSentryCronMethod(method) || !!CRON_HANDLER_MONITORS.get(method as AnyFn)?.()) + isSentryCronMethod(fn) || + !!CRON_HANDLER_MONITORS.get(fn)?.() || + !!(options && CRON_OPTIONS_MONITORS.get(options)?.()) ); } @@ -91,7 +120,10 @@ export function wrapCronHandler( } return withMonitor(monitor.slug, () => scheduleHandler.apply(this, args), monitor.config); }; - CRON_HANDLER_MONITORS.set(wrapped, () => isSentryCronMethod(handler) || !!resolveMonitor(handler, wrapped)); + CRON_HANDLER_MONITORS.set( + wrapped, + () => isSentryCronMethod(handler) || isSentryCronMethod(wrapped) || !!resolveMonitor(handler, wrapped), + ); return wrapped; } diff --git a/packages/nestjs/test/integrations/cron-monitoring.test.ts b/packages/nestjs/test/integrations/cron-monitoring.test.ts index 3fb665b9dd43..6c4979bd5bf0 100644 --- a/packages/nestjs/test/integrations/cron-monitoring.test.ts +++ b/packages/nestjs/test/integrations/cron-monitoring.test.ts @@ -41,12 +41,26 @@ function initClient(options: NestCronMonitoringOptions): void { 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, + () => (_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 }; @@ -168,6 +182,33 @@ describe('@Cron() monitoring', () => { 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')])); From 0c5e61cba17ed9de584a32f8814e1f5652d918da Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Wed, 28 Oct 2026 14:39:15 -0700 Subject: [PATCH 13/16] fix(nestjs): Detect @SentryCron when another decorator sits between it and @Cron() Mark @SentryCron in metadata that outer decorators copy and on the @Cron() options it wraps, so @Cron() monitoring skips it in any decorator order. --- packages/nestjs/src/integrations/helpers.ts | 41 +++++++++++++++++-- .../nestjs/src/integrations/wrap-handlers.ts | 10 +---- .../test/integrations/cron-monitoring.test.ts | 4 ++ 3 files changed, 43 insertions(+), 12 deletions(-) diff --git a/packages/nestjs/src/integrations/helpers.ts b/packages/nestjs/src/integrations/helpers.ts index 6aae403f5f5e..673e83383e2b 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. */ @@ -31,16 +37,45 @@ export function markWrapped(fn: AnyFn): void { (fn as AnyFn & Record)[SENTRY_WRAPPED] = true; } +// `@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(); -/** Marks a method that `@SentryCron()` monitors, so `@Cron()` monitoring skips it. */ +/** 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 { - return SENTRY_CRON_METHODS.has(fn); + 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)) + ); } /** diff --git a/packages/nestjs/src/integrations/wrap-handlers.ts b/packages/nestjs/src/integrations/wrap-handlers.ts index 8ce4d004df36..61fd8b451beb 100644 --- a/packages/nestjs/src/integrations/wrap-handlers.ts +++ b/packages/nestjs/src/integrations/wrap-handlers.ts @@ -3,6 +3,7 @@ import { makeCronMonitorResolver } from './cron-monitoring'; import type { AnyFn, ReflectWithMetadata } from './helpers'; import { getBullMQProcessSpanOptions, + getCronOptions, getEventSpanOptions, isSentryCronMethod, isWrapped, @@ -63,18 +64,9 @@ export function wrapScheduleHandler(handler: AnyFn, mechanismType: string): AnyF }; } -// `@nestjs/schedule`'s metadata key for the `@Cron()` options. -const SCHEDULE_CRON_OPTIONS = 'SCHEDULE_CRON_OPTIONS'; - const CRON_HANDLER_MONITORS = new WeakMap boolean>(); const CRON_OPTIONS_MONITORS = new WeakMap boolean>(); -function getCronOptions(method: AnyFn): object | undefined { - const R = Reflect as unknown as ReflectWithMetadata; - const options = typeof R.getMetadata === 'function' ? R.getMetadata(SCHEDULE_CRON_OPTIONS, method) : undefined; - return isObjectLike(options) ? options : undefined; -} - /** * 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. diff --git a/packages/nestjs/test/integrations/cron-monitoring.test.ts b/packages/nestjs/test/integrations/cron-monitoring.test.ts index 6c4979bd5bf0..1ad374f8430c 100644 --- a/packages/nestjs/test/integrations/cron-monitoring.test.ts +++ b/packages/nestjs/test/integrations/cron-monitoring.test.ts @@ -166,6 +166,10 @@ describe('@Cron() monitoring', () => { 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')))(); From 0576af012fab2f1c4eb04dc918d38a25087f8136 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Wed, 28 Oct 2026 17:26:41 -0700 Subject: [PATCH 14/16] fix(nestjs): Expand @Cron day fields like cron and send numeric months on @nestjs/schedule 4+ A day field that covers every day (like sun-sat or 0,1,2,3,4,5,6) is ignored by cron but ORed by Sentry, so those schedules are now found by counting the expanded values. Numeric months are sent when the @Cron() instrumentation saw @nestjs/schedule 4 or later, where cron counts months from 1. --- packages/nestjs/src/decorators.ts | 81 ++++++++++++++----- packages/nestjs/src/integrations/helpers.ts | 22 +++++ .../integrations/orchestrion-subscriber.ts | 13 ++- packages/nestjs/test/decorators.test.ts | 39 +++++++++ 4 files changed, 132 insertions(+), 23 deletions(-) diff --git a/packages/nestjs/src/decorators.ts b/packages/nestjs/src/decorators.ts index 653bca5c41be..ead5d10338f0 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, getNestScheduleVersion } from './integrations/helpers'; /** * Monitor settings for `@SentryCron` whose schedule and time zone come from the `@Cron()` decorator @@ -127,7 +127,7 @@ function getMonitorConfigFromNestCron(candidates: unknown[]): MonitorConfig | un } const cronOptions = R.getMetadata(SCHEDULE_CRON_OPTIONS, candidate); if (cronOptions && typeof cronOptions === 'object') { - return nestCronOptionsToMonitorConfig(cronOptions); + return nestCronOptionsToMonitorConfig(cronOptions, getNestScheduleVersion(cronOptions)); } } @@ -138,7 +138,10 @@ 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 { +function nestCronOptionsToMonitorConfig( + cronOptions: NestCronOptions, + scheduleVersion: string | undefined, +): MonitorConfig | undefined { const { cronTime, timeZone, utcOffset } = cronOptions; // A fixed UTC offset has no IANA time zone equivalent. @@ -151,10 +154,10 @@ function nestCronOptionsToMonitorConfig(cronOptions: NestCronOptions): MonitorCo if (fields.length === 1) { crontab = CRON_PRESETS[(fields[0] as string).toLowerCase()]; } else if (fields.length === 5) { - crontab = isSupportedCrontab(fields) ? fields.join(' ') : undefined; + crontab = isSupportedCrontab(fields, scheduleVersion) ? fields.join(' ') : undefined; } else if (fields.length === 6 && /^\d+$/.test(fields[0] as string)) { // Sentry schedules have minute granularity, so only a fixed second can be dropped. - crontab = isSupportedCrontab(fields.slice(1)) ? fields.slice(1).join(' ') : undefined; + crontab = isSupportedCrontab(fields.slice(1), scheduleVersion) ? fields.slice(1).join(' ') : undefined; } if (!crontab) { @@ -176,27 +179,67 @@ function nestCronOptionsToMonitorConfig(cronOptions: NestCronOptions): MonitorCo /** * Whether Sentry reads the 5 crontab fields the same way `cron` (used by `@nestjs/schedule`) runs them. */ -function isSupportedCrontab([, , dayOfMonth = '', month = '', dayOfWeek = '']: string[]): boolean { - // `cron` 2.x (`@nestjs/schedule` 3) counts months from 0, so a month number is ambiguous. Steps are not. - if (/\d/.test(month.replace(/\/\d+/g, ''))) { +function isSupportedCrontab( + [, , dayOfMonth = '', month = '', dayOfWeek = '']: string[], + scheduleVersion: string | undefined, +): boolean { + // `cron` 2.x (`@nestjs/schedule` 2 and 3) counts months from 0. Steps don't depend on that. + const countsMonthsFromOne = parseInt(scheduleVersion ?? '', 10) >= 4; + if (!countsMonthsFromOne && /\d/.test(month.replace(/\/\d+/g, ''))) { return false; } - if (dayOfMonth === '*' || dayOfWeek === '*') { - return true; + const daysOfMonth = countCronValues(dayOfMonth, 1, 31); + const daysOfWeek = countCronValues(dayOfWeek, 0, 7); + if (daysOfMonth === undefined || daysOfWeek === undefined) { + return false; } - // With both day fields set, `cron` runs on either, but Sentry needs both when one starts with `*` (like `*/2`). - // `cron` also treats a full range like `1-31` as `*`, which Sentry may not. - return !( - dayOfMonth.startsWith('*') || - dayOfWeek.startsWith('*') || - dayOfMonth === '1-31' || - FULL_DAY_OF_WEEK_RANGES.has(dayOfWeek) - ); + // `cron` ignores a day field that covers every day (like `1-31` or `sun-sat`) and runs on either day field + // otherwise. Sentry reads full ranges as they are and needs both day fields when one starts with `*` (like `*/2`). + const allDaysOfMonth = daysOfMonth === 31; + const allDaysOfWeek = daysOfWeek === 7; + const cronRunsOnEither = !allDaysOfMonth && !allDaysOfWeek; + const sentryRunsOnEither = !dayOfMonth.startsWith('*') && !dayOfWeek.startsWith('*'); + return (allDaysOfMonth && allDaysOfWeek) || cronRunsOnEither === sentryRunsOnEither; } -const FULL_DAY_OF_WEEK_RANGES = new Set(['0-6', '0-7', '1-7']); +const DAY_NAMES: Record = { sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6 }; + +/** + * How many days a day field matches, expanded the way `cron` does it, or `undefined` if `cron` can't parse it. + * Day of week `7` is Sunday, like `0`. + */ +function countCronValues(field: string, low: number, high: number): number | undefined { + let unknownName = false; + const numericField = field.replace(/[a-z]{1,3}/gi, name => { + const value = DAY_NAMES[name.toLowerCase()]; + unknownName ||= value === undefined; + return String(value); + }); + if (unknownName) { + return undefined; + } + + const values = new Set(); + for (const part of numericField.split(',')) { + const match = part.match(/^(?:(\*)|(\d+)(?:-(\d+))?)(?:\/(\d+))?$/); + if (!match) { + return undefined; + } + const [, wildcard, from, to, step] = match; + const lower = wildcard ? low : Number(from); + const upper = wildcard || (step && !to) ? high : Number(to ?? from); + const increment = Number(step ?? 1); + if (!increment || lower < low || upper > high || lower > upper) { + return undefined; + } + for (let value = lower; value <= upper; value += increment) { + values.add(high === 7 && value === 7 ? 0 : value); + } + } + return values.size; +} /** * The canonical IANA name of the time zone, or `undefined` if Sentry won't accept it (like `UTC+3`). diff --git a/packages/nestjs/src/integrations/helpers.ts b/packages/nestjs/src/integrations/helpers.ts index 5056413e911a..8c2b5755d910 100644 --- a/packages/nestjs/src/integrations/helpers.ts +++ b/packages/nestjs/src/integrations/helpers.ts @@ -31,6 +31,28 @@ export function markWrapped(fn: AnyFn): void { (fn as AnyFn & Record)[SENTRY_WRAPPED] = true; } +// `@nestjs/schedule`'s metadata key for the `@Cron()` options. +const SCHEDULE_CRON_OPTIONS = 'SCHEDULE_CRON_OPTIONS'; + +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') { + return; + } + const cronOptions = R.getMetadata(SCHEDULE_CRON_OPTIONS, method); + if (cronOptions && typeof cronOptions === 'object') { + NEST_SCHEDULE_VERSIONS.set(cronOptions, version); + } +} + +/** The `@nestjs/schedule` version that stored these `@Cron()` options, if the SDK saw the decorator run. */ +export function getNestScheduleVersion(cronOptions: object): string | undefined { + return NEST_SCHEDULE_VERSIONS.get(cronOptions); +} + /** * 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/orchestrion-subscriber.ts b/packages/nestjs/src/integrations/orchestrion-subscriber.ts index 53e21dda735f..0f207438d716 100644 --- a/packages/nestjs/src/integrations/orchestrion-subscriber.ts +++ b/packages/nestjs/src/integrations/orchestrion-subscriber.ts @@ -5,7 +5,7 @@ import { bindTracingChannelToSpan } from '@sentry/server-utils'; import { nestjsChannels as CHANNELS } from '@sentry/server-utils/orchestrion/config'; import { DEBUG_BUILD } from '../debug-build'; import type { AnyFn } from './helpers'; -import { isWrapped, markWrapped } from './helpers'; +import { isWrapped, markWrapped, setNestScheduleVersion } from './helpers'; import type { CatchTarget, InjectableTarget, MinimalNestJsExecutionContext } from './types'; import { patchCatchTarget, patchInjectableTarget } from './wrap-components'; import { @@ -184,9 +184,14 @@ 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) => { + const cronDecorator = makeMethodDecorator(decorator, handler => wrapScheduleHandler(handler, MECHANISM_CRON)); + return function (this: unknown, ...args: unknown[]): unknown { + const result = cronDecorator.apply(this, args); + setNestScheduleVersion((args[2] as PropertyDescriptor | undefined)?.value, data.moduleVersion); + return result; + }; + }); subscribeFactoryDecorator(CHANNELS.NESTJS_SCHEDULE_INTERVAL, decorator => makeMethodDecorator(decorator, handler => wrapScheduleHandler(handler, MECHANISM_INTERVAL)), ); diff --git a/packages/nestjs/test/decorators.test.ts b/packages/nestjs/test/decorators.test.ts index 405ace31e8f8..47bc07d14ab4 100644 --- a/packages/nestjs/test/decorators.test.ts +++ b/packages/nestjs/test/decorators.test.ts @@ -1,10 +1,14 @@ import 'reflect-metadata'; +import { tracingChannel } from 'node:diagnostics_channel'; import { SetMetadata } from '@nestjs/common'; import { CODE_FUNCTION_NAME, SENTRY_OP, SENTRY_ORIGIN } from '@sentry/conventions/attributes'; import * as core 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, SentryExceptionCaptured, SentryTraced } from '../src/decorators'; import * as helpers from '../src/helpers'; +import { subscribeToNestChannels } from '../src/integrations/orchestrion-subscriber'; describe('SentryTraced decorator', () => { beforeEach(() => { @@ -264,6 +268,14 @@ describe('SentryCron decorator with @Cron', () => { const Cron = (cronTime: unknown, options: Record = {}): MethodDecorator => SetMetadata('SCHEDULE_CRON_OPTIONS', { ...options, cronTime }); + // `@Cron()` as the SDK's instrumentation sees it, with the `@nestjs/schedule` version. + const VersionedCron = (cronTime: string, moduleVersion: string): MethodDecorator => { + subscribeToNestChannels(); + const data: OrchestrionChannelContext = { arguments: [cronTime], moduleVersion }; + tracingChannel(CHANNELS.NESTJS_SCHEDULE_CRON).traceSync(() => Cron(cronTime), data); + return data.result as MethodDecorator; + }; + // Mirrors the SDK's own `@Cron()` instrumentation, which swaps the method before nest stores the metadata. const WrappingCron = (cronTime: unknown): MethodDecorator => @@ -376,6 +388,14 @@ describe('SentryCron decorator with @Cron', () => { ['a full day-of-month range with a day of week', Cron('0 9 1-31 * MON')], ['a full day-of-week range with a day of month', Cron('0 9 1 * 0-6')], ['a full 1-7 day-of-week range with a day of month', Cron('0 9 15 * 1-7')], + ['a full day-of-week name range with a day of month', Cron('0 9 1 * sun-sat')], + ['a full upper-case day-of-week name range with a day of month', Cron('0 9 1 * SUN-SAT')], + ['a full mon-sun day-of-week range with a day of month', Cron('0 9 1 * mon-sun')], + ['a full day-of-week list with a day of month', Cron('0 9 1 * 0,1,2,3,4,5,6')], + ['a full day-of-month range with a step of 1 and a day of week', Cron('0 9 1-31/1 * MON')], + ['an unknown day name', Cron('0 9 1 * sunday')], + ['an out-of-range day of month', Cron('0 9 32 * *')], + ['a numeric month from @nestjs/schedule 3', VersionedCron('0 9 1 5 *', '3.0.4')], ['a fixed-offset time zone', Cron('0 * * * *', { timeZone: 'UTC+3' })], ['an unknown time zone', Cron('0 * * * *', { timeZone: 'Mars/Olympus' })], ])('sends no monitor config for %s', async (_, cronDecorator) => { @@ -389,6 +409,10 @@ describe('SentryCron decorator with @Cron', () => { it.each([ ['a month name', '0 9 1 MAY *'], ['both day fields', '0 9 1-7 * MON'], + ['a day-of-week name list with a day of month', '0 9 1 * mon,fri'], + ['a full day-of-month range with a day-of-week step', '0 9 1-31 * */2'], + ['a day-of-month step of 1 with a day of week', '0 9 */1 * MON'], + ['both day fields covering every day', '0 9 1-31 * sun-sat'], ['CronExpression.EVERY_QUARTER', '0 0 1 */3 *'], ['CronExpression.EVERY_6_MONTHS', '0 0 1 */6 *'], ])('sends a crontab with %s', async (_, crontab) => { @@ -402,6 +426,21 @@ describe('SentryCron decorator with @Cron', () => { }); }); + it.each([ + ['a numeric month', '0 9 1 5 *'], + ['a numeric month range', '0 9 1 1-6 *'], + ['CronExpression.EVERY_YEAR', '0 0 1 1 *'], + ])('sends a crontab with %s from @nestjs/schedule 4 or later', async (_, crontab) => { + const withMonitorSpy = vi.spyOn(core, 'withMonitor'); + const service = decorate(SentryCron('my-job'), VersionedCron(crontab, '4.1.2')); + + await service.job(); + expect(withMonitorSpy).toHaveBeenCalledWith('my-job', expect.any(Function), { + schedule: { type: 'crontab', value: crontab }, + timezone: 'Asia/Tokyo', + }); + }); + it('sends no monitor config when the local time zone is unknown', async () => { vi.mocked(Intl.DateTimeFormat).mockImplementation(function () { return { resolvedOptions: () => ({ timeZone: 'Etc/Unknown' }) }; From a9a89d7e7fae7203aba9de1ab4cfd2f452a3611d Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Wed, 28 Oct 2026 17:27:15 -0700 Subject: [PATCH 15/16] docs(nestjs): Add changelog entry for SentryCron monitor config --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a0d0150cd82..d6954a41f6c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +### Important Changes + +- **feat(nestjs): Derive SentryCron monitor config from @Cron ([#25013](https://github.com/getsentry/sentry-javascript/pull/25013))** + + `@SentryCron` now sends the schedule and time zone from the method's `@Cron()` decorator, so Sentry creates the monitor on the first run. If you use `@SentryCron` without a `schedule`, Sentry now creates missing monitors, which are billed, and overwrites schedules and time zones set by hand. Pass `fromCronDecorator: false` to turn this off. + ## 11.10.0 ### Important Changes From 481a0527a1c3ef1f68b4ccc84d27de077bea0a8c Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Thu, 29 Oct 2026 11:01:57 -0700 Subject: [PATCH 16/16] fix(nestjs): Skip jobs with unsupported schedules when monitoring all jobs monitorCronJobs only covers @Cron() methods whose schedule Sentry can represent. Methods with @SentryCron() keep checking in without a config. --- .../src/integrations/cron-monitoring.ts | 11 +++++++-- .../test/integrations/cron-monitoring.test.ts | 24 ++++++++++++------- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/packages/nestjs/src/integrations/cron-monitoring.ts b/packages/nestjs/src/integrations/cron-monitoring.ts index 48e3ea13d74a..450dbd61de7b 100644 --- a/packages/nestjs/src/integrations/cron-monitoring.ts +++ b/packages/nestjs/src/integrations/cron-monitoring.ts @@ -9,6 +9,7 @@ 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`. */ @@ -22,7 +23,7 @@ export interface NestCronMonitoringOptions { interface CronMonitor { slug: string; - config: MonitorConfig | undefined; + config: MonitorConfig; } const MONITORING_OPTIONS = new WeakMap(); @@ -94,5 +95,11 @@ function getCronMonitor( return null; } - return { slug, config: nestCronOptionsToMonitorConfig({ ...options, cronTime }, scheduleVersion) }; + 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/test/integrations/cron-monitoring.test.ts b/packages/nestjs/test/integrations/cron-monitoring.test.ts index fb50bd9631a6..c1931f0cdddb 100644 --- a/packages/nestjs/test/integrations/cron-monitoring.test.ts +++ b/packages/nestjs/test/integrations/cron-monitoring.test.ts @@ -134,21 +134,29 @@ describe('@Cron() monitoring', () => { expect(sentCheckIns().map(([, status]) => status)).toEqual(['in_progress', 'error']); }); - it('checks in without a config when the schedule cannot be sent', () => { + it('skips jobs whose schedule cannot be sent', () => { initClient({ monitorCronJobs: true }); decorate('Jobs', [cron('*/10 * * * * *', { name: 'every-10s' })])(); - expect(sentCheckIns()[0]).toEqual(['every-10s', 'in_progress', undefined]); + expect(captureCheckIn).not.toHaveBeenCalled(); }); - it.each([ - ['@nestjs/schedule 3', '3.0.4', undefined], - ['@nestjs/schedule 4', '4.1.2', { schedule: { type: 'crontab', value: '0 0 1 1 *' }, timezone: 'UTC' }], - ])('sends a numeric month only from %s', (_, moduleVersion, config) => { + 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' }, moduleVersion)])(); + decorate('Jobs', [cron('0 0 1 1 *', { name: 'yearly', timeZone: 'UTC' }, '4.1.2')])(); - expect(sentCheckIns()[0]).toEqual(['yearly', 'in_progress', config]); + expect(sentCheckIns()[0]).toEqual([ + 'yearly', + 'in_progress', + { schedule: { type: 'crontab', value: '0 0 1 1 *' }, timezone: 'UTC' }, + ]); }); it('does not monitor by default', () => {