Skip to content
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
211 changes: 201 additions & 10 deletions packages/nestjs/src/decorators.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,225 @@
import type { MonitorConfig } from '@sentry/core';
import { CODE_FUNCTION_NAME, SENTRY_OP, SENTRY_ORIGIN } from '@sentry/conventions/attributes';
import { captureException } from '@sentry/core';
import { captureException, debug } from '@sentry/core';
import * as Sentry from '@sentry/node';
import { startSpan } from '@sentry/node';
import { DEBUG_BUILD } from './debug-build';
import { isExpectedError } from './helpers';
import type { ReflectWithMetadata } from './integrations/helpers';
import { copyReflectMetadata } from './integrations/helpers';

/**
* 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 || {};
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';

// `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);
}
}

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 === 1) {
crontab = CRON_PRESETS[(fields[0] as string).toLowerCase()];
} else if (fields.length === 5) {
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 = isSupportedCrontab(fields.slice(1)) ? 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[]): boolean {
// `cron` 2.x (`@nestjs/schedule` 3) counts months from 0, so a month number is ambiguous. Steps are not.
if (/\d/.test(month.replace(/\/\d+/g, ''))) {
return false;
}

if (dayOfMonth === '*' || dayOfWeek === '*') {
return true;
}

// With both day fields set, `cron` runs on either, but Sentry needs both when one starts with `*` (like `*/2`).
// `cron` also treats a full range like `1-31` as `*`, which Sentry may not.
return !(
dayOfMonth.startsWith('*') ||
dayOfWeek.startsWith('*') ||
dayOfMonth === '1-31' ||
FULL_DAY_OF_WEEK_RANGES.has(dayOfWeek)
);
}

const FULL_DAY_OF_WEEK_RANGES = new Set(['0-6', '0-7', '1-7']);

/**
* 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';
Loading
Loading