Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
256 changes: 245 additions & 11 deletions packages/nestjs/src/decorators.ts
Original file line number Diff line number Diff line change
@@ -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<MonitorConfig, 'schedule' | 'timezone'> & {
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<unknown>;

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<PropertyKey, unknown> | 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 || {};

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When this lands, every current @SentryCron user who passes no schedule starts to upsert monitors on the next deploy. That has three effects:

  1. Monitors that did not exist get created, and they are billed.
  2. Monitors that users created by hand get their schedule and time zone overwritten on each check-in. If a user picked a different time zone in the UI on purpose (for example, the one they think in), the SDK now silently replaces it.
  3. If the org is over its monitor quota, creation can fail, and the check-ins are dropped as before. That is not worse, but users get no signal.

The PR description says this, but users of the SDK are more likely to notice a changelog entry. This repo writes the changelog at release time, so this is mainly a request to make sure the release notes put it under "Important Changes".

Can you confirm that default-on is the intended product choice?

If so, then this is what this is doing, so great! Let's just add a clear note for the release changelog that names the billing effect and the fromCronDecorator: false opt-out.

@wedamija wedamija Oct 29, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah default-on is intended. Generally, we expect that people want to just use their cron's built in schedule, and that it's most likely a misconfiguration if they didn't do so.

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;
Comment thread
wedamija marked this conversation as resolved.
}

return { ...monitorSettings, ...cronConfig };
}

const SCHEDULE_CRON_OPTIONS = 'SCHEDULE_CRON_OPTIONS';
Comment thread
wedamija marked this conversation as resolved.

// `cron` presets (case-insensitive). Sentry accepts only the first five, so the others are sent as crontabs.
const CRON_PRESETS: Record<string, string | undefined> = {
'@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());

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Without timeZone on @Cron(), this'll send the host's local time zone. That matches what cron does on that host, so each single check-in is correct.

But a monitor config is shared by all environments that use the same slug. If one host runs in UTC (eg, a container) and another in America/New_York (a developer laptop, a staging box, etc), each check-in overwrites the monitor's time zone. The monitor then expects runs at the wrong hour, and Sentry can report missed check-ins for runs that happened on time.

Before this PR, the user had to pick one time zone by hand, so that failure mode did not exist.

Suggestion: probably just need to document that jobs without an explicit timeZone take the host's time zone (if it's not already in the linked docs PR), and that hosts with different zones must set timeZone on @Cron(). I can't think of a correct automatic fix, because the job really does run in the host's zone.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, updated the docs pr getsentry/sentry-docs#19782

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<string, number | undefined> = { 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<number>();
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.
*/
Expand Down
1 change: 1 addition & 0 deletions packages/nestjs/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
22 changes: 22 additions & 0 deletions packages/nestjs/src/integrations/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,28 @@ export function markWrapped(fn: AnyFn): void {
(fn as AnyFn & Record<symbol, unknown>)[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<object, string>();

/** 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
Expand Down
Loading
Loading