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!
Top comments (2)
Thanks for this tutorial Daniel, it works like a charm
Love the workaround, thanks a ton!