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 @@ -92,6 +92,12 @@ export class AppController {
return {};
}

@Get('test-auto-monitored-cron')
async testAutoMonitoredCron() {
await this.appService.testAutoMonitoredCron();
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 @@ -120,6 +120,12 @@ export class AppService {
console.log('Test derived cron!');
}

// Monitored through `monitorCronJobs`, without `@SentryCron`.
@Cron('0 0 * * *', { name: 'test-auto-monitored-cron', timeZone: 'America/New_York', disabled: true })
async testAutoMonitoredCron() {
console.log('Test auto-monitored cron!');
}

async killTestCron(job: string) {
this.schedulerRegistry.deleteCronJob(job);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ Sentry.init({
dsn: process.env.E2E_TEST_DSN,
tunnel: `http://localhost:3031/`, // proxy server
tracesSampleRate: 1,
integrations: [Sentry.nestIntegration({ monitorCronJobs: true })],
transportOptions: {
// We expect the app to send a lot of events in a short time
bufferSize: 1000,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,29 @@ test('Sends the schedule and time zone of @Cron with the check-in', async ({ bas
);
});

test('Monitors @Cron methods without @SentryCron when monitorCronJobs is enabled', async ({ baseURL }) => {
const inProgressEnvelopePromise = waitForEnvelopeItem('nestjs-basic', envelope => {
return (
envelope[0].type === 'check_in' &&
envelope[1]['monitor_slug'] === 'test-auto-monitored-cron' &&
envelope[1]['status'] === 'in_progress'
);
});

await fetch(`${baseURL}/test-auto-monitored-cron`);

expect((await inProgressEnvelopePromise)[1]).toEqual(
expect.objectContaining({
monitor_slug: 'test-auto-monitored-cron',
status: 'in_progress',
monitor_config: {
schedule: { type: 'crontab', value: '0 0 * * *' },
timezone: 'America/New_York',
},
}),
);
});

test('Sends exceptions to Sentry on error in async cron job', async ({ baseURL }) => {
const errorEventPromise = waitForError('nestjs-basic', event => {
return (
Expand Down
6 changes: 4 additions & 2 deletions packages/nestjs/src/decorators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ 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';
import { copyReflectMetadata, markSentryCronMethod } from './integrations/helpers';

/**
* Monitor settings for `@SentryCron` whose schedule and time zone come from the `@Cron()` decorator
Expand Down Expand Up @@ -60,6 +60,8 @@ export const SentryCron = (
};

descriptor.value = wrappedMethod;
markSentryCronMethod(originalMethod);
markSentryCronMethod(wrappedMethod);

copyFunctionNameAndMetadata({ originalMethod, descriptor });

Expand Down Expand Up @@ -138,7 +140,7 @@ function getMonitorConfigFromNestCron(candidates: unknown[]): MonitorConfig | un
* 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 {
export function nestCronOptionsToMonitorConfig(cronOptions: NestCronOptions): MonitorConfig | undefined {
const { cronTime, timeZone, utcOffset } = cronOptions;

// A fixed UTC offset has no IANA time zone equivalent.
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 @@ -4,6 +4,7 @@
export * from '@sentry/node';

export { nestIntegration } from './integrations/nest';
export type { NestIntegrationOptions } from './integrations/nest';

export { getDefaultIntegrations, init } from './sdk';

Expand Down
96 changes: 96 additions & 0 deletions packages/nestjs/src/integrations/cron-monitoring.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import type { Client, MonitorConfig } from '@sentry/core';
import { debug, getClient, stringMatchesSomePattern } from '@sentry/core';
import { DEBUG_BUILD } from '../debug-build';
import { nestCronOptionsToMonitorConfig } from '../decorators';
import type { AnyFn } from './helpers';
import { isSentryCronMethod } from './helpers';

export interface NestCronMonitoringOptions {
/**
* Send check-ins for every `@Cron()` method of `@nestjs/schedule`. The monitor slug is the `name`
* option of `@Cron()`, or `ClassName.methodName`. Methods with `@SentryCron()` keep their own slug.
*
* Defaults to `false`.
*/
monitorCronJobs?: boolean;

/**
* Monitor slugs not to monitor when `monitorCronJobs` is enabled. Strings must match the slug exactly.
*/
excludeCronJobs?: Array<string | RegExp>;
}

interface CronMonitor {
slug: string;
config: MonitorConfig | undefined;
}

const MONITORING_OPTIONS = new WeakMap<Client, NestCronMonitoringOptions>();

/** Enables `@Cron()` monitoring for the client. */
export function setCronMonitoringOptions(client: Client, options: NestCronMonitoringOptions): void {
if (options.monitorCronJobs) {
MONITORING_OPTIONS.set(client, options);
}
}

/**
* Returns a function that resolves the monitor for a `@Cron()` method on each run,
* or `undefined` if the run should not be monitored.
*/
export function makeCronMonitorResolver(
cronArgs: unknown[],
target: unknown,
propertyKey: string | symbol | undefined,
): (handler: AnyFn, wrapped: AnyFn) => CronMonitor | undefined {
let monitor: CronMonitor | null | undefined;

return (handler, wrapped) => {
const client = getClient();
const options = client && MONITORING_OPTIONS.get(client);
Comment thread
wedamija marked this conversation as resolved.
if (!options || isSentryCronMethod(handler) || isSentryCronMethod(wrapped)) {
return undefined;
}

if (monitor === undefined) {
try {
monitor = getCronMonitor(cronArgs, target, propertyKey);
} catch (e) {
DEBUG_BUILD && debug.error('[Nest] Failed to set up the cron monitor:', e);
monitor = null;
}
}

if (!monitor || stringMatchesSomePattern(monitor.slug, options.excludeCronJobs, true)) {
return undefined;
}
return monitor;
};
}

function getCronMonitor(
[cronTime, cronOptions]: unknown[],
target: unknown,
propertyKey: string | symbol | undefined,
): CronMonitor | null {
// A `Date` runs once, so there is nothing to monitor.
if (typeof cronTime !== 'string') {
return null;
}

const options = (cronOptions && typeof cronOptions === 'object' ? cronOptions : {}) as Record<string, unknown>;
const className = (target as { constructor?: { name?: unknown } } | undefined)?.constructor?.name;
const slug =
typeof options.name === 'string' && options.name
? options.name
: typeof className === 'string' && className && propertyKey !== undefined
? `${className}.${String(propertyKey)}`
: undefined;

if (!slug) {
DEBUG_BUILD && debug.warn('[Nest] Not monitoring a @Cron() method without a name.');
return null;
}

return { slug, config: nestCronOptionsToMonitorConfig({ ...options, cronTime }) };
}
12 changes: 12 additions & 0 deletions packages/nestjs/src/integrations/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,18 @@ export function markWrapped(fn: AnyFn): void {
(fn as AnyFn & Record<symbol, unknown>)[SENTRY_WRAPPED] = true;
}

const SENTRY_CRON_METHODS = new WeakSet<object>();

/** Marks a method that `@SentryCron()` monitors, so `@Cron()` monitoring skips it. */
export function markSentryCronMethod(fn: object): void {
SENTRY_CRON_METHODS.add(fn);
}

/** Whether `fn` is monitored by `@SentryCron()` (see {@link markSentryCronMethod}). */
export function isSentryCronMethod(fn: object): boolean {
return SENTRY_CRON_METHODS.has(fn);
}

/**
* The subset of `reflect-metadata`'s `Reflect` augmentation that NestJS
* relies on. Methods are optional because `reflect-metadata` may not be
Expand Down
9 changes: 8 additions & 1 deletion packages/nestjs/src/integrations/nest.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,23 @@
import { defineIntegration } from '@sentry/core';
import type { NestCronMonitoringOptions } from './cron-monitoring';
import { setCronMonitoringOptions } from './cron-monitoring';
import { subscribeToNestChannels } from './orchestrion-subscriber';

const INTEGRATION_NAME = 'Nest' as const;

export type NestIntegrationOptions = NestCronMonitoringOptions;

/**
* Integration capturing tracing data for NestJS.
*/
export const nestIntegration = defineIntegration(() => {
export const nestIntegration = defineIntegration((options: NestIntegrationOptions = {}) => {
return {
name: INTEGRATION_NAME,
setupOnce() {
subscribeToNestChannels();
},
setup(client) {
setCronMonitoringOptions(client, options);
},
};
});
13 changes: 9 additions & 4 deletions packages/nestjs/src/integrations/orchestrion-subscriber.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@ import type { CatchTarget, InjectableTarget, MinimalNestJsExecutionContext } fro
import { patchCatchTarget, patchInjectableTarget } from './wrap-components';
import {
extractQueueName,
MECHANISM_CRON,
MECHANISM_INTERVAL,
MECHANISM_TIMEOUT,
patchMethodDescriptor,
patchProcessorTarget,
wrapCronHandler,
wrapEventHandler,
wrapScheduleHandler,
} from './wrap-handlers';
Expand Down Expand Up @@ -61,7 +61,10 @@ function subscribeDecoratorChannel<T>(channelName: string, patch: (target: T) =>
* `descriptor.value` with a wrapped handler before delegating to the
* original decorator.
*/
function makeMethodDecorator(original: AnyFn, wrapHandler: (handler: AnyFn) => AnyFn): AnyFn {
function makeMethodDecorator(
original: AnyFn,
wrapHandler: (handler: AnyFn, target: unknown, propertyKey: string | symbol | undefined) => AnyFn,
): AnyFn {
return function (this: unknown, ...args: unknown[]): unknown {
patchMethodDescriptor(
args[0] as { __SENTRY_INTERNAL__?: boolean } | undefined,
Expand Down Expand Up @@ -189,8 +192,10 @@ export function subscribeToNestChannels(): void {
subscribeDecoratorChannel<CatchTarget>(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) =>
makeMethodDecorator(decorator, (handler, target, propertyKey) =>
wrapCronHandler(handler, [...(data.arguments || [])], target, propertyKey),
),
);
subscribeFactoryDecorator(CHANNELS.NESTJS_SCHEDULE_INTERVAL, decorator =>
makeMethodDecorator(decorator, handler => wrapScheduleHandler(handler, MECHANISM_INTERVAL)),
Expand Down
29 changes: 26 additions & 3 deletions packages/nestjs/src/integrations/wrap-handlers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { captureException, isObjectLike, isThenable, startSpan, withIsolationScope } from '@sentry/core';
import { captureException, isObjectLike, isThenable, startSpan, withIsolationScope, withMonitor } from '@sentry/core';
import { makeCronMonitorResolver } from './cron-monitoring';
import type { AnyFn, ReflectWithMetadata } from './helpers';
import { getBullMQProcessSpanOptions, getEventSpanOptions, isWrapped, markWrapped } from './helpers';

Expand Down Expand Up @@ -56,6 +57,28 @@ export function wrapScheduleHandler(handler: AnyFn, mechanismType: string): AnyF
};
}

/**
* Wrap a `@Cron` handler like {@link wrapScheduleHandler}, sending check-ins when `monitorCronJobs` is enabled.
*/
export function wrapCronHandler(
handler: AnyFn,
cronArgs: unknown[],
target: unknown,
propertyKey: string | symbol | undefined,
): AnyFn {
const scheduleHandler = wrapScheduleHandler(handler, MECHANISM_CRON);
const resolveMonitor = makeCronMonitorResolver(cronArgs, target, propertyKey);

const wrapped = function (this: unknown, ...args: unknown[]): unknown {
const monitor = resolveMonitor(handler, wrapped);
if (!monitor) {
return scheduleHandler.apply(this, args);
}
return withMonitor(monitor.slug, () => scheduleHandler.apply(this, args), monitor.config);
};
return wrapped;
}

function eventNameFromEvent(event: unknown): string {
if (typeof event === 'string') {
return event;
Expand Down Expand Up @@ -140,11 +163,11 @@ export function patchMethodDescriptor(
target: { __SENTRY_INTERNAL__?: boolean } | undefined,
propertyKey: string | symbol | undefined,
descriptor: PropertyDescriptor | undefined,
wrapHandler: (handler: AnyFn) => AnyFn,
wrapHandler: (handler: AnyFn, target: unknown, propertyKey: string | symbol | undefined) => AnyFn,
): void {
const handler = descriptor?.value as AnyFn | undefined;
if (descriptor && handler && typeof handler === 'function' && !target?.__SENTRY_INTERNAL__ && !isWrapped(handler)) {
const wrapped = wrapHandler(handler);
const wrapped = wrapHandler(handler, target, propertyKey);
Object.defineProperty(wrapped, 'name', {
value: handler.name || String(propertyKey),
configurable: true,
Expand Down
Loading
Loading