From 2f71ee72272bd3a2be297e0d8a987e47206b5095 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Fri, 2 Oct 2026 12:42:58 -0700 Subject: [PATCH 1/5] 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 e4690c988d7a..e52289af918d 100644 --- a/packages/nestjs/src/decorators.ts +++ b/packages/nestjs/src/decorators.ts @@ -4,31 +4,108 @@ import { captureException, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENT 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 9640244de437..5124640d3483 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 } from '@sentry/conventions/attributes'; import * as core from '@sentry/core'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; @@ -259,6 +260,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 47acc00779b6dec9645547a81dc2de3d7d7c66b3 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Fri, 2 Oct 2026 12:57:17 -0700 Subject: [PATCH 2/5] 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 e52289af918d..431a0fa711bd 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 5124640d3483..8d0ad2f0b166 100644 --- a/packages/nestjs/test/decorators.test.ts +++ b/packages/nestjs/test/decorators.test.ts @@ -298,7 +298,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), { @@ -308,7 +308,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), { @@ -318,7 +318,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), { @@ -328,7 +328,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), { @@ -344,13 +347,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 47881404c4cef6e22a982496057aa5cb0a1ea1f6 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Fri, 2 Oct 2026 13:08:38 -0700 Subject: [PATCH 3/5] 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 431a0fa711bd..0c0ca8935402 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 8d0ad2f0b166..01df5e3c2f6f 100644 --- a/packages/nestjs/test/decorators.test.ts +++ b/packages/nestjs/test/decorators.test.ts @@ -3,7 +3,7 @@ import { SetMetadata } from '@nestjs/common'; import { CODE_FUNCTION_NAME } from '@sentry/conventions/attributes'; import * as core from '@sentry/core'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } 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'; @@ -293,45 +293,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), { @@ -347,15 +354,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); @@ -363,14 +370,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 d7a6341e49c78416e0d157d8966dad65f5559e32 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Fri, 2 Oct 2026 13:20:47 -0700 Subject: [PATCH 4/5] 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 0c0ca8935402..4b383c375fe6 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 } from '@sentry/conventions/attributes'; -import { captureException, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; +import { captureException, debug, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } 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 01df5e3c2f6f..aae54874712f 100644 --- a/packages/nestjs/test/decorators.test.ts +++ b/packages/nestjs/test/decorators.test.ts @@ -350,7 +350,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'); @@ -360,6 +360,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 * * * *')); @@ -389,6 +430,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 afde1dc66cc81e538888fbf36c292dde83453f2b Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Fri, 2 Oct 2026 15:01:49 -0700 Subject: [PATCH 5/5] 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 4b383c375fe6..f50b5edf1545 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 aae54874712f..80bd76ba57e0 100644 --- a/packages/nestjs/test/decorators.test.ts +++ b/packages/nestjs/test/decorators.test.ts @@ -352,6 +352,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); @@ -360,6 +364,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'], @@ -402,11 +431,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 () => {