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 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 4d5762481d2f..ead5d10338f0 100644 --- a/packages/nestjs/src/decorators.ts +++ b/packages/nestjs/src/decorators.ts @@ -1,34 +1,268 @@ 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 { copyReflectMetadata } from './integrations/helpers'; +import type { ReflectWithMetadata } from './integrations/helpers'; +import { copyReflectMetadata, getNestScheduleVersion } from './integrations/helpers'; + +/** + * 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 SentryCronMonitorSettings = Omit & { + schedule?: never; + timezone?: never; + fromCronDecorator?: boolean; +}; /** * A decorator wrapping the native nest Cron decorator, sending check-ins to Sentry. + * + * 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): MethodDecorator => { +export const SentryCron = ( + monitorSlug: string, + monitorConfig?: (MonitorConfig & { fromCronDecorator?: never }) | SentryCronMonitorSettings, +): MethodDecorator => { return (target: unknown, propertyKey, descriptor: PropertyDescriptor) => { const originalMethod = descriptor.value as (...args: unknown[]) => Promise; - descriptor.value = function (...args: unknown[]) { - return Sentry.withMonitor( - monitorSlug, - () => { - return originalMethod.apply(this, args); - }, - monitorConfig, - ); + let resolvedMonitorConfig: MonitorConfig | undefined; + let isolateTraceWithoutConfig = false; + let resolved = false; + + const wrappedMethod = function (this: unknown, ...args: unknown[]): unknown { + if (!resolved) { + resolved = true; + // 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], + ]); + isolateTraceWithoutConfig = !resolvedMonitorConfig && !!monitorConfig?.isolateTrace; + } + + 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; + copyFunctionNameAndMetadata({ originalMethod, descriptor }); return descriptor; }; }; +function resolveMonitorConfig( + monitorSlug: string, + monitorConfig: MonitorConfig | SentryCronMonitorSettings | undefined, + candidates: unknown[], +): MonitorConfig | undefined { + if (monitorConfig?.schedule) { + return monitorConfig; + } + + const { fromCronDecorator = true, ...monitorSettings } = monitorConfig || {}; + const cronConfig = fromCronDecorator ? getMonitorConfigFromNestCron(candidates) : undefined; + + if (!cronConfig) { + 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'; + debug.warn( + `[SentryCron] The monitor settings for "${monitorSlug}" are not sent, because ${reason}. A monitor config needs a schedule.`, + ); + } + return undefined; + } + + return { ...monitorSettings, ...cronConfig }; +} + +const SCHEDULE_CRON_OPTIONS = 'SCHEDULE_CRON_OPTIONS'; + +// `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', + '@weekly': '@weekly', + '@daily': '@daily', + '@hourly': '@hourly', + '@minutely': '* * * * *', + '@weekdays': '0 0 * * 1-5', + '@weekends': '0 0 * * 0,6', +}; + +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, getNestScheduleVersion(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, + scheduleVersion: string | undefined, +): 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 === 1) { + crontab = CRON_PRESETS[(fields[0] as string).toLowerCase()]; + } else if (fields.length === 5) { + 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), scheduleVersion) ? fields.slice(1).join(' ') : undefined; + } + + if (!crontab) { + return undefined; + } + + // 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; + } + + return { + schedule: { type: 'crontab', value: crontab }, + timezone, + }; +} + +/** + * Whether Sentry reads the 5 crontab fields the same way `cron` (used by `@nestjs/schedule`) runs them. + */ +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; + } + + const daysOfMonth = countCronValues(dayOfMonth, 1, 31); + const daysOfWeek = countCronValues(dayOfWeek, 0, 7); + if (daysOfMonth === undefined || daysOfWeek === undefined) { + return false; + } + + // `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 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`). + */ +function toSentryTimeZone(timezone: string | undefined): string | undefined { + if (!timezone || timezone === 'Etc/Unknown' || /^(?:utc|gmt)?[+-]/i.test(timezone)) { + return undefined; + } + try { + return new Intl.DateTimeFormat('en-US', { timeZone: timezone }).resolvedOptions().timeZone || undefined; + } catch { + return undefined; + } +} + +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 b96fb4c5390a..10a3400ee400 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 { SentryCronMonitorSettings } from './decorators'; 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 661f00b89182..47bc07d14ab4 100644 --- a/packages/nestjs/test/decorators.test.ts +++ b/packages/nestjs/test/decorators.test.ts @@ -1,9 +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 { beforeEach, describe, expect, it, vi } from 'vitest'; +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(() => { @@ -258,6 +263,291 @@ 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 }); + + // `@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 => + (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(() => { + // `@Cron()` without a `timeZone` runs in the server's local time zone. + 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(() => { + 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'), 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')); + + 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')); + + 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'), 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('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 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) => { + 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.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) => { + 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.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' }) }; + } as unknown as typeof Intl.DateTimeFormat); + 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'], + ['@WEEKLY', '@weekly'], + ['@monthly', '@monthly'], + ['@yearly', '@yearly'], + ['@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 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('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 * * * *')); + + await service.job(); + expect(withMonitorSpy).toHaveBeenCalledWith('my-job', expect.any(Function), { + schedule: { type: 'crontab', value: '0 * * * *' }, + timezone: 'Asia/Tokyo', + 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 * * * *')); + + 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', () => { beforeEach(() => { vi.clearAllMocks();