Integrating Sentry with NestJS scheduled jobs

·

3 min read

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 Interceptors and ExceptionFilters 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!