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
187 changes: 183 additions & 4 deletions packages/nestjs/src/decorators.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,213 @@
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';

/**
* 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[]) {
let resolvedMonitorConfig: MonitorConfig | undefined;
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],
]);
}

return Sentry.withMonitor(
monitorSlug,
() => {
return originalMethod.apply(this, args);
},
monitorConfig,
resolvedMonitorConfig,
);
};

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) {
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] 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';

// 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<string, string | undefined> = {
'@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;
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.
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,
};
}

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