Integrating Sentry with NestJS scheduled jobs
Tags: nestjs, sentry, cron, typescript
Published: 2021-05-07 09:30:50

After integrating [@ntegral/nestjs-sentry](https://github.com/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](https://stackoverflow.com/a/60608856/599391) 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:
```typescript
// 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:
```typescript
// 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](https://docs.nestjs.com/custom-decorators#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:

```typescript
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!