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
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
214 changes: 204 additions & 10 deletions packages/nestjs/src/decorators.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,228 @@
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;
// `@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(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';

// Presets of the `cron` package (which also lowercases them), as sent to Sentry. Sentry accepts
// `@yearly`/`@monthly`/`@weekly`/`@daily`/`@hourly`; the others are sent as crontabs.
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`, the job runs in the server's local time zone.
// Without a time zone Sentry would assume UTC, which may not be when the job runs.
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