AOP in NestJS: Using Interceptors for Advanced Logging and Monitoring

AOP in NestJS uses interceptors for cleaner code. They transform results, change execution flow, and enable advanced logging and monitoring across the application, improving maintainability and debugging.

AOP in NestJS: Using Interceptors for Advanced Logging and Monitoring

Aspect-Oriented Programming (AOP) in NestJS is like having a secret superpower for your code. It’s all about separating cross-cutting concerns from your main business logic, making your codebase cleaner and more maintainable. And when it comes to AOP in NestJS, interceptors are the real MVPs.

Interceptors in NestJS are incredibly versatile. They can transform the result of a function, change the execution flow, or even replace a method entirely. But today, we’re going to focus on how we can use them for advanced logging and monitoring. Trust me, once you start using interceptors for this, you’ll wonder how you ever lived without them.

Let’s start with a simple example. Say you want to log the execution time of each request in your application. Without interceptors, you’d have to add timing logic to every single route handler. Yikes! But with an interceptor, you can do it in one place and apply it across your entire app. Here’s what that might look like:

import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';

@Injectable()
export class LoggingInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const now = Date.now();
    return next
      .handle()
      .pipe(
        tap(() => console.log(`Request took ${Date.now() - now}ms`)),
      );
  }
}

This interceptor logs the execution time of each request. To use it, you can either apply it to a specific controller or make it global. For a global interceptor, you’d add this to your main.ts:

app.useGlobalInterceptors(new LoggingInterceptor());

But that’s just scratching the surface. Let’s take it up a notch and create an interceptor that logs detailed information about each request and response. This could be super helpful for debugging and monitoring your application:

import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';

@Injectable()
export class DetailedLoggingInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const request = context.switchToHttp().getRequest();
    const method = request.method;
    const url = request.url;
    const now = Date.now();

    console.log(`[${method}] ${url} - Started at ${new Date(now).toISOString()}`);

    return next
      .handle()
      .pipe(
        tap(response => {
          const responseTime = Date.now() - now;
          console.log(`[${method}] ${url} - Completed in ${responseTime}ms with status ${response.statusCode}`);
        }),
      );
  }
}

This interceptor logs the start time, method, and URL of each request, and then logs the completion time and status code when the request is finished. It’s like having a built-in stopwatch for each request!

But why stop there? We can use interceptors to add all sorts of useful monitoring features. For example, let’s create an interceptor that tracks the number of active requests:

import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';

@Injectable()
export class ActiveRequestsInterceptor implements NestInterceptor {
  private static activeRequests = 0;

  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    ActiveRequestsInterceptor.activeRequests++;
    console.log(`Active requests: ${ActiveRequestsInterceptor.activeRequests}`);

    return next
      .handle()
      .pipe(
        tap(() => {
          ActiveRequestsInterceptor.activeRequests--;
          console.log(`Active requests: ${ActiveRequestsInterceptor.activeRequests}`);
        }),
      );
  }
}

This interceptor increments a counter when a request starts and decrements it when the request finishes. It’s a simple way to keep track of how many requests your server is handling at any given time.

Now, let’s talk about error handling. Interceptors are great for catching and logging errors across your entire application. Here’s an example of an error logging interceptor:

import { Injectable, NestInterceptor, ExecutionContext, CallHandler, HttpException, HttpStatus } from '@nestjs/common';
import { Observable, throwError } from 'rxjs';
import { catchError } from 'rxjs/operators';

@Injectable()
export class ErrorLoggingInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    return next
      .handle()
      .pipe(
        catchError(error => {
          if (error instanceof HttpException) {
            console.error(`HTTP Exception: ${error.message}`);
          } else {
            console.error(`Unexpected error: ${error.message}`);
          }
          return throwError(() => new HttpException('Internal server error', HttpStatus.INTERNAL_SERVER_ERROR));
        }),
      );
  }
}

This interceptor catches any errors that occur during request processing, logs them, and then throws a generic 500 Internal Server Error. In a real-world scenario, you might want to log more details or handle different types of errors differently.

One of the coolest things about interceptors is that you can chain them together. For example, you could use all of the interceptors we’ve created so far:

app.useGlobalInterceptors(
  new LoggingInterceptor(),
  new DetailedLoggingInterceptor(),
  new ActiveRequestsInterceptor(),
  new ErrorLoggingInterceptor()
);

This would give you a pretty comprehensive logging and monitoring setup right out of the box!

But wait, there’s more! Interceptors can also be used to transform the response data. For example, let’s say you want to wrap all of your responses in a standard format:

import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';

@Injectable()
export class TransformInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    return next.handle().pipe(
      map(data => ({
        statusCode: context.switchToHttp().getResponse().statusCode,
        timestamp: new Date().toISOString(),
        path: context.switchToHttp().getRequest().url,
        data,
      })),
    );
  }
}

This interceptor wraps the response data in an object that includes the status code, timestamp, and request path. It’s a great way to standardize your API responses.

Now, I know what you’re thinking. “This is all great, but what about performance?” Well, I’ve got good news for you. While interceptors do add some overhead, it’s generally negligible, especially when compared to the benefits they provide in terms of code organization and maintainability.

That being said, if you’re working on a high-performance application, you’ll want to be mindful of how many interceptors you’re using and what they’re doing. As with all things in programming, it’s about finding the right balance.

In my experience, the benefits of using interceptors for logging and monitoring far outweigh any potential performance concerns. They’ve saved me countless hours of debugging and made it much easier to understand what’s happening in my applications.

One last tip: while interceptors are great for application-wide concerns, don’t forget about custom decorators and middleware. They each have their strengths, and using them together can create a really powerful and flexible architecture.

So there you have it – a deep dive into using interceptors for advanced logging and monitoring in NestJS. It’s a powerful technique that can really level up your NestJS applications. Give it a try in your next project and see how it can simplify your code and improve your debugging process. Happy coding!