DEV Community

Cover image for How to monitor a NestJS app
Kamil Mysliwiec for NestJS

Posted on

How to monitor a NestJS app

How to monitor a NestJS app

When something breaks in production, the first question is usually why it happened. Monitoring gives you a much better answer than a collection of isolated logs, so here's a practical way to think about the signals that help.

Many applications start with logs and a health check, while a broader monitoring plan waits for a quieter week. That's a reasonable place to begin. Logs are much more useful when you already know what to look for, though, and during an incident that context is often the first thing you need.

So let's go through what's actually worth having, roughly in the order I'd add it.

Health checks

This is a useful first layer, and Nest has @nestjs/terminus for it:

@Controller("health")
export class HealthController {
  constructor(
    private health: HealthCheckService,
    private db: TypeOrmHealthIndicator,
  ) {}

  @Get()
  @HealthCheck()
  check() {
    return this.health.check([() => this.db.pingCheck("database")]);
  }
}
Enter fullscreen mode Exit fullscreen mode

Point your load balancer at /health and it knows when an instance is dead. Keep in mind that's all it knows. An app can answer /health in 2 ms while every real request takes 9 seconds, and as far as the load balancer is concerned everything is fine.

It is worth excluding this endpoint from the other instrumentation below. A probe every few seconds creates thousands of identical requests a day and can pull latency numbers toward a route that does very little real work.

Logs

The built-in Logger is often enough during development. In production, a few ConsoleLogger options make a real difference:

const levels = (process.env.LOG_LEVELS ?? "fatal,error,warn,log").split(
  ",",
) as LogLevel[];

const logger = new ConsoleLogger({
  json: true,
  flattenParams: true,
  logLevels: levels,
});

const app = await NestFactory.create(AppModule, { logger });
Enter fullscreen mode Exit fullscreen mode

json: true gives your log system one object per line instead of coloured text it has to parse back apart.

Levels are a contract

logLevels decides what gets written at all, but the levels only help if everyone on the team uses them the same way. The split I'd use:

  • fatal: the process can't continue. Bad config at startup, a lost connection that won't come back.
  • error: an operation failed and somebody should look at it. If nobody would act on it, it isn't an error.
  • warn: something went wrong and was handled. A retry, a fallback, a degraded response. One of these is noise; a hundred in a minute is an early warning.
  • log: business events you'll want during an incident. Order placed, payment captured, job finished.
  • debug and verbose: off in production. They bury the lines you're looking for, and you pay to ship and store every one of them.

Structured params

Since Nest 12, plain objects passed after the message are treated as data attached to the entry, not printed as extra messages. With flattenParams, their keys land at the top level of the JSON line:

this.logger.warn("Payment declined", { orderId, provider: "stripe", attempt });
// {"level":"warn",...,"message":"Payment declined","context":"PaymentsService","orderId":"ord_91f2","provider":"stripe","attempt":2}
Enter fullscreen mode Exit fullscreen mode

Now your log system can filter on orderId and group by provider instead of you grepping text. A few rules make this pay off:

  • Constant message, variables in params. "Payment declined" written 400 times is a pattern you can count and alert on. 400 different strings with the order id baked into each one are not.
  • Objects, never extra strings. this.logger.log('Order shipped', order.id) doesn't do what it looks like. A trailing string is how Nest passes a context, so on a context-less logger the id replaces the context, and on a new Logger(OrdersService.name) it's printed as a second, separate log line. { orderId: order.id } is always safe.
  • Errors keep their stack. Params go before the stack: this.logger.error('Charge failed', { orderId }, err.stack) produces one line with orderId and a stack field.
  • Framework fields win. A param called message, level or timestamp is dropped silently rather than overwriting the real one. Name it reason or status.
  • Ids, not objects. { user } ships the whole entity, five levels deep, including whatever PII is on it. { userId: user.id } is what you'll actually filter on.

Trace ids

What the built-in logger can't give you is a trace id on every line. Without one, finding the dozen entries for a failed request among thousands of lines written that minute is guesswork, and filtering by orderId only helps if every line in the request happened to include it.

A hand-rolled request id (a middleware plus AsyncLocalStorage) gets you part of the way, but it only covers HTTP. Queue jobs, cron runs and microservice handlers never pass through that middleware, and it isn't the id of anything else you can look at: not a trace, not an error report.

ConsoleLogger has no idea which operation it's logging for, so something that does has to add the id. That's one of the things @nestjs/observe does. It hooks into ConsoleLogger, and every line written during a traced operation (a request, a queue job, a cron run, a microservice message) gets the id of that trace:

{
  "level": "warn",
  "pid": 4242,
  "timestamp": 1789992138000,
  "message": "Payment declined",
  "context": "PaymentsService",
  "orderId": "ord_91f2",
  "provider": "stripe",
  "attempt": 2,
  "traceId": "4bf92f3577b34da6a3ce929d0e0e4736"
}
Enter fullscreen mode Exit fullscreen mode

The id is read when the line is written, so it's the right one even when a hundred requests interleave across awaits. It's the same id as the trace in the dashboard, so even if your logs stay in your own pipeline, you can go from a slow request to its log lines and back. If you log through pino or winston instead, the hook doesn't apply; TracerService.currentTraceId() gives you the id to add yourself, for example from pino's mixin.

Metrics

Requests per second, error rate, latency, memory, event loop delay. Cheap to collect, and they're what you put alerts on.

Two choices matter here. First, average latency can hide one request in twenty taking four seconds, so p95 is usually a more useful view of the slower experience. Second, break the data down by route. A global number changes when your traffic mix changes, even if the code has not become slower.

Metrics tell you that something changed. They never tell you why.

Traces

Traces are often what turns a broad symptom into a short list of likely causes, although they do take more setup than logs and metrics.

A trace is a single request broken into the things that happened during it: the guard, the controller method, the service it called, the three queries that service ran, the call to Stripe. Each one with a start time and a duration. Instead of "it's probably the database" you get a picture, and the picture is usually surprising.

The traditional setup can be substantial: the OpenTelemetry SDK, auto-instrumentation packages, an exporter, a collector, and a backend to store the data. Depending on the existing infrastructure, it can take a few days before the first useful trace is available, which is difficult to prioritize alongside product work.

If you do go shopping for a tracing tool, here's what I'd look for in a Nest app specifically. Spans named after your code (OrdersService.create, not middleware - <anonymous>). Self time and not only total time, because a controller that awaits a slow repository didn't do anything wrong and you don't want it at the top of your list. Database queries and outbound HTTP calls as their own spans, with the statement visible. And traces that survive a queue, so the request that enqueues a job and the job itself are one story.

SQL query

Error tracking

Logs contain errors, but they do not automatically tell you that this TypeError has happened 3,140 times since the last deploy, that those occurrences represent one bug, or that the bug is new.

That's what error tracking is for: group occurrences into actual defects, remember when each one first showed up and in which release, and tell you when a new one appears. It should also know the difference between errors you threw on purpose (a NotFoundException is your API doing its job) and the ones you didn't see coming. If those two end up in the same bucket, your error rate is permanently at 4% and everybody learns to ignore it.

Queues

If you use BullMQ or Bull, half your app runs somewhere no HTTP dashboard can see. For each queue I want to know how long jobs wait before a worker picks them up, how often they fail and on which attempt, and which request enqueued them. I wrote a separate post about this because it deserves one.

Doing all of this without losing a week

You can assemble everything above from separate tools. Terminus, a log shipper, Prometheus and Grafana, an OpenTelemetry pipeline, an error tracker, something for queues. Lots of teams run exactly that and it works. It's also five or six systems to keep alive.

This is the reason we built NestJS Observe. We're the framework team, so we could do something nobody else can: instrument the app from the inside, using Nest's own hooks, instead of wrapping a generic Node.js agent around it. The whole setup is this:

npm install @nestjs/observe
Enter fullscreen mode Exit fullscreen mode

and with this package installed, the rest is configuration:

// app.module.ts
import { createObserveModule } from "@nestjs/observe";

export const { ObserveModule, ObserveInstrument } = createObserveModule();

@Module({
  imports: [
    ObserveModule.forRoot({
      appKey: process.env.OBSERVE_APP_KEY,
      appSecret: process.env.OBSERVE_APP_SECRET,
      serviceId: "orders-api",
      http: {
        // keep the load balancer probe out of your percentiles
        ignore: [/^\/health(?:\?|$)/],
      },
    }),
  ],
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode
// main.ts
const app = await NestFactory.create(AppModule, {
  instrument: ObserveInstrument,
});
Enter fullscreen mode Exit fullscreen mode

And that's it. No spans to write, nothing to preload, no collector to run. From that point you get requests, GraphQL operations, microservice messages, WebSocket messages, queue jobs and cron jobs, per route, with throughput, error rate and p95. Every request has a trace where the spans are your own classes and methods. SQL queries show up under the method that ran them (through pg, mysql2 and mongodb, so TypeORM, Drizzle, MikroORM and Mongoose just work), and so do outbound HTTP calls. We only record the shape of a statement, never the values. Unhandled errors come with the failing source line, get grouped into defects, and you get an email when a new one shows up. Jobs carry their wait time and attempts, and they share a trace with the request that enqueued them. And every line your ConsoleLogger writes during any of that carries its trace id.

NestJS Observe Dashboard

It needs @nestjs/core 11.1 or newer and works with both Express and Fastify. The free tier is 300,000 events a month, which is plenty to find out if it's useful to you. Trace ids in your logs are on every plan. Log forwarding (the lines themselves shipped to Observe and placed on the trace's timeline, with redaction on by default) and alert rules are on the paid plans.

Two caveats. Keep your Terminus health check, because your orchestrator still needs an endpoint to hit. And if your company runs five languages and has standardised on OpenTelemetry, stick with that. A shared standard across your whole stack is worth more than a shortcut for one framework.

If I were starting from zero today

Health check first, excluded from everything else. Then JSON logs with production log levels and structured params. Then tracing and error tracking together, since they come from the same instrumentation and an error without its trace is half a story, and the same instrumentation puts a trace id on every log line. Alerts on error rate and p95 once you have a week of data to set thresholds against. Queue monitoring the day you add a queue.

The goal is not a collection of attractive dashboards. It is a shorter path from a production symptom to the line of code or dependency that needs attention.

You can click around a live project without signing up here: the demo dashboard.

Top comments (1)

Collapse
 
malikidrees profile image
Malik Idrees

Looks great :)