Integrating Sentry with NestJS scheduled jobs
After integrating @ntegral/nestjs-sentry in my NestJS project, I was surprised to find errors in my logs that weren't being reported to Sentry.
After a bit of investigation, I found that NestJS' native concepts of Interceptor
s and ExceptionFilter
s are built around the idea that the execution context will be somehow request-generated. i.e. These constructs expect errors to be triggered by an external request to the server via e.g. HTTP or GraphQL.
Unfortunately, when using the Cron
decorator from @nestjs/schedule
, my code isn't actually triggered by an external request, so errors thrown in these contexts don't seem to bubble up to the normal interceptor or exception filter pipelines.
To solve this, I took inspiration from this StackOverflow answer to create a decorator that I can use to wrap my Cron
methods in an error handler that reports any caught errors to Sentry directly.
It looks like this:
// decorators/sentry-overwatch.decorator.ts
import { Inject } from "@nestjs/common";
import { SentryService } from "@ntegral/nestjs-sentry";
export const SentryOverwatchAsync = () => {
const injectSentry = Inject(SentryService);
return (
target: any,
_propertyKey: string,
propertyDescriptor: PropertyDescriptor,
) => {
injectSentry(target, "sentry");
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const originalMethod: () => Promise<void> = propertyDescriptor.value;
propertyDescriptor.value = async function (...args: any[]) {
try {
return await originalMethod.apply(this, args);
} catch (error) {
const sentry = this.sentry as SentryService;
sentry.instance().captureException(error);
throw error;
}
};
};
};
This particular function is designed to decorate an async function. For the non-async version, you'd just need to remove the async
in the propertyDescriptor.value
definition and the await
when calling originalMethod
.
With a little more work, one could write something more generalized to detect whether or not the return value is a Promise and do the right thing, but my use case is simple.
I'm then able to wrap my original function like so:
// decorators/cron.decorator.ts
// Decorator ordering is important here. Swapping the order
// results in Nest failing to recognize this as a scheduled
// job
@Cron("*/5 * * * *")
@SentryOverwatchAsync()
async scheduledTask(): Promise<void> {
// ...
}
But now I have to add @SentryOverwatchAsync()
every time I declare a @Cron
scheduled job. A little annoying, and I'm sure I'm going to forget at some point.
So using decorator composition I decided to re-export my own version of the @Cron
decorator that packages the native Nest decorator in with my new custom decorator:
import { applyDecorators } from "@nestjs/common";
import { Cron as NestCron, CronOptions } from "@nestjs/schedule";
import { SentryOverwatchAsync } from "./sentry-overwatch.decorator";
export const Cron = (cronTime: string | Date, options?: CronOptions) => {
// Ordering is important here, too!
// The order these must appear in seems to be the reverse of
// what you'd normally expect when decorating functions
// declaratively. Likely because the order you specify here
// is the order the decorators will be applied to the
// function in.
return applyDecorators(SentryOverwatchAsync(), NestCron(cronTime, options));
};
Now all I need to do is swap all of my usages of Cron
to my internal Cron
decorator and I have complete Sentry overwatch.
Peace of mind: achieved!