Skip to content

Monitoring a SaaS in Production

Aman Kumar Singh9 min read
Part 30 of 40From the Full Stack SaaS Masterclass series
Monitoring a SaaS in Production — article by Aman Kumar Singh

In the last article, I set up a pipeline that builds, tests, and deploys the monorepo automatically. That solves getting code out safely and repeatably. It does nothing for the problem that starts the moment the deploy finishes: knowing whether the thing you just shipped is actually working.

This is part of the Full Stack SaaS Masterclass series, and it sits right after CI/CD for a reason. A fast, reliable deploy pipeline raises the stakes on observability. If you can ship five times a day, you need to know within minutes when one of those releases regresses something for a customer.

Monitoring can swallow an entire team's roadmap if you let it, so I want to be upfront about scope. This article covers the version worth building for a SaaS in its first year or two: health checks, a handful of application metrics, alerting that pages a human only when it matters, and uptime checks from outside the system. Distributed tracing and log-based anomaly detection are real tools, but they earn their place later, once you have enough traffic and incidents to know what you're catching.

Why monitoring matters more once you're multi-tenant

A single-tenant app that goes down is obvious: your one set of users can't log in and someone notices fast. A multi-tenant SaaS can degrade for a subset of organizations while the rest of the system looks completely healthy. A slow query that only triggers for tenants with a large dataset, a background job that silently stops processing one organization's queue, a feature flag that misfires for a specific plan tier: none of these show up as a global outage. Without monitoring, the first signal you get is a support ticket, and by the time it reaches an engineer, the customer has already formed an opinion about your reliability.

This is also why monitoring matters earlier in a SaaS than in an internal tool. Time to detection matters as much as time to recovery. A fast rollback is worthless if nobody knows there's something to roll back. Good monitoring shortens the gap between "something broke" and "an engineer is looking at it," and that gap is usually the largest piece of downtime in a well-run system.

Start with health checks, not dashboards

Health checks come before dashboards. Build a pair of health endpoints your orchestrator (ECS, Kubernetes, or whatever you're running behind) uses to decide whether an instance should receive traffic. Liveness answers "is this process still alive and not deadlocked." Readiness answers "can this process actually serve a request right now," which usually means its database and cache connections are up.

import { Controller, Get } from '@nestjs/common';
import {
  HealthCheckService,
  HealthCheck,
  TypeOrmHealthIndicator,
  MemoryHealthIndicator,
} from '@nestjs/terminus';
import { RedisHealthIndicator } from './redis-health.indicator';

@Controller('health')
export class HealthController {
  constructor(
    private readonly health: HealthCheckService,
    private readonly db: TypeOrmHealthIndicator,
    private readonly memory: MemoryHealthIndicator,
    private readonly redis: RedisHealthIndicator,
  ) {}

  @Get('live')
  @HealthCheck()
  liveness() {
    return this.health.check([
      () => this.memory.checkHeap('memory_heap', 300 * 1024 * 1024),
    ]);
  }

  @Get('ready')
  @HealthCheck()
  readiness() {
    return this.health.check([
      () => this.db.pingCheck('database', { timeout: 1500 }),
      () => this.redis.pingCheck('redis'),
    ]);
  }
}

The RedisHealthIndicator is a small custom indicator wrapping a PING command against your Redis client; Terminus ships built-in indicators for TypeORM, Mongo, and HTTP, but you'll write your own for Redis and any third-party API you depend on directly. The distinction between the two endpoints matters operationally: a failing liveness check tells your orchestrator to restart the container, while a failing readiness check tells it to stop routing traffic, which is the right response to a database that's temporarily unreachable.

Instrument the metrics that answer "is it working" and "is it slow"

Health checks tell you whether an instance is up, not whether requests are succeeding or how long they take under load. For that you want a small set of application metrics in Prometheus format, which every major vendor (Grafana Cloud, Datadog, New Relic) can scrape or ingest without extra translation.

The metrics worth collecting from day one map closely to the RED method: rate, errors, and duration, tracked per route.

import { Injectable } from '@nestjs/common';
import { Counter, Histogram, Registry, collectDefaultMetrics } from 'prom-client';

@Injectable()
export class MetricsService {
  readonly registry = new Registry();

  readonly httpRequestDuration = new Histogram({
    name: 'http_request_duration_seconds',
    help: 'Duration of HTTP requests in seconds',
    labelNames: ['method', 'route', 'status_code'],
    buckets: [0.05, 0.1, 0.3, 0.5, 1, 2, 5],
    registers: [this.registry],
  });

  readonly httpRequestErrors = new Counter({
    name: 'http_requests_errors_total',
    help: 'Total number of HTTP requests that returned a 5xx status',
    labelNames: ['method', 'route'],
    registers: [this.registry],
  });

  constructor() {
    collectDefaultMetrics({ register: this.registry });
  }
}

An interceptor wires this into every request without touching individual controllers:

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

@Injectable()
export class MetricsInterceptor implements NestInterceptor {
  constructor(private readonly metrics: MetricsService) {}

  intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
    const request = context.switchToHttp().getRequest();
    const response = context.switchToHttp().getResponse();
    const route = request.route?.path ?? request.url;
    const stopTimer = this.metrics.httpRequestDuration.startTimer({
      method: request.method,
      route,
    });

    return next.handle().pipe(
      tap({
        next: () => stopTimer({ status_code: response.statusCode }),
        error: (err) => {
          stopTimer({ status_code: err.status ?? 500 });
          if (!err.status || err.status >= 500) {
            this.metrics.httpRequestErrors.inc({ method: request.method, route });
          }
        },
      }),
    );
  }
}

And a /metrics endpoint that Prometheus (or your hosted equivalent) scrapes on an interval:

import { Controller, Get, Res } from '@nestjs/common';
import { Response } from 'express';
import { MetricsService } from './metrics.service';

@Controller('metrics')
export class MetricsController {
  constructor(private readonly metrics: MetricsService) {}

  @Get()
  async index(@Res() res: Response) {
    res.set('Content-Type', this.metrics.registry.contentType);
    res.send(await this.metrics.registry.metrics());
  }
}

Resist the urge to label these metrics with tenant ID or user ID. Prometheus stores every unique combination of label values as a separate time series, and a tenant_id label on a growing customer base turns a handful of series into millions. That's the single most common way teams blow up their monitoring backend's storage and query cost. Per-tenant visibility belongs in structured logs or a dedicated analytics event, not a high-cardinality metric label.

Buy the platform, build the instrumentation

The decision that matters here is how much infrastructure you want to run yourself, not Prometheus versus Datadog. Self-hosting Prometheus and Grafana in Docker gives you full control and no per-host billing, but you now own that stack's availability too, storage and retention included. A hosted option (Grafana Cloud, Datadog, New Relic) removes that burden and usually ships better default dashboards, at the cost of a bill that scales with metric volume and host count.

My own bias, consistent with deferring complexity elsewhere in this series, is to start hosted. Instrument in Prometheus format regardless, so the code doesn't change if you migrate the backend later. What you're deferring is the operational cost of running Prometheus and Grafana yourself, not the decision to instrument at all. Instrumentation is cheap to add early. It gets expensive to retrofit once you have years of code with no metrics in it.

Alert on symptoms, not on every anomaly

The fastest way to make monitoring useless is to alert on everything a metric can theoretically tell you. A single slow query, one 500 response, a brief memory spike: none of these need to page a person at 2 a.m. What deserves a page is a sustained symptom a customer would notice, error rate crossing a threshold for several minutes, or latency on a critical route staying elevated.

A burn-rate style alert on error rate is a reasonable starting point:

groups:
  - name: api-availability
    rules:
      - alert: HighErrorRate
        expr: |
          sum(rate(http_requests_errors_total[5m]))
          /
          sum(rate(http_request_duration_seconds_count[5m]))
          > 0.02
        for: 10m
        labels:
          severity: page
        annotations:
          summary: "5xx error rate above 2% sustained for 10 minutes"
      - alert: ElevatedLatency
        expr: |
          histogram_quantile(0.95,
            rate(http_request_duration_seconds_bucket[5m])
          ) > 1.5
        for: 10m
        labels:
          severity: warn

Notice the for: 10m. Alerting on a single scrape interval pages someone for a transient blip that resolves before they've opened their laptop. A sustained window filters that noise without meaningfully delaying real detection. The threshold values above are illustrative; the right numbers come from watching your own traffic, not from copying a blog post.

Severity matters as much as the threshold. A distinction between page (wakes someone up) and warn (shows up on a dashboard or a low-priority channel) keeps the on-call rotation sustainable. If every alert pages, people start ignoring pages, which defeats the point of having them.

Watch the system from outside too

Everything above runs inside your infrastructure and depends on that infrastructure being reachable at all. A DNS misconfiguration, an expired TLS certificate, or a load balancer with no healthy targets can take you down in ways your internal metrics never see, because the requests never arrive. An external uptime check, hitting a small number of key endpoints from outside your network on a fixed interval, catches this class of failure that nothing inside the cluster can.

Keep these checks simple: a health endpoint, a login page, maybe one authenticated API call if you can script the auth flow reliably. Don't make synthetic checks exercise every feature; that's what internal metrics and end-to-end tests are for. The external check's only job is "can a user reach us right now," and it should stay boring and cheap to maintain.

Where monitoring setups go wrong in production

A few patterns show up often enough to call out directly. High-cardinality labels, mentioned earlier, are invisible until your monitoring bill or query latency tells you something's wrong. Alert fatigue is close behind: teams that alert on every anomaly eventually mute their pager, so the one alert that matters gets missed along with the noise.

A third is treating dashboards as documentation. A dashboard nobody looks at outside an incident is decoration, not monitoring. Deleting one to see if anyone notices within a month is a fair test of whether it earns its place.

A fourth is monitoring the application while ignoring its dependencies. A healthy API with a Postgres instance running out of connections, or a Redis instance evicting keys under memory pressure, looks fine on your dashboard right up until it doesn't. Health checks that ping these dependencies, and metrics scraped from Postgres and Redis themselves, close that gap.

Key takeaways

  • Health checks (liveness and readiness) come before dashboards; they let your orchestrator make correct routing and restart decisions without a human in the loop.
  • Instrument the RED method, rate, errors, duration, per route, and expose it in Prometheus format so any vendor can ingest it later without a rewrite.
  • Never label metrics with tenant ID or user ID; that's a cardinality explosion waiting to happen, and it belongs in logs or analytics events instead.
  • Alert on sustained symptoms a customer would notice, not on every anomaly, and separate paging alerts from informational ones to keep on-call sustainable.
  • External uptime checks catch failures your internal metrics can't see, because they run from outside your infrastructure entirely.
  • Start with a hosted monitoring platform unless you have a specific reason to self-host; the instrumentation code stays the same either way.

Frequently asked questions

What's the difference between monitoring and observability?

Monitoring is a predefined set of metrics, checks, and alerts built around known failure modes. Observability is the ability to ask new questions about a system's internal state using logs, metrics, and traces. An early-stage SaaS needs solid monitoring far more urgently than full observability.

Should I use Prometheus or a hosted APM tool like Datadog?

Instrument in Prometheus format regardless; it's a widely supported standard that keeps you from rewriting instrumentation if you switch vendors. Whether you scrape it with self-hosted Prometheus or a hosted platform is an operational decision, not one about how you write the code.

How many alerts should a small team realistically have on-call for?

Enough to cover customer-facing symptoms, availability, error rate, latency on critical paths, and nothing more in the paging tier. A handful of well-tuned alerts people trust beats dozens that get muted after the first false positive.

Do I need distributed tracing for a monorepo SaaS API?

Not immediately. Tracing earns its complexity once a request fans out across multiple services or async jobs. For a NestJS monolith calling Postgres and Redis directly, request-scoped logging with a correlation ID usually answers the same questions with far less setup.

How do I avoid alert fatigue without missing real incidents?

Alert on sustained symptoms over a window, not single data points, and match severity to urgency. Review which alerts actually fired and were useful every few weeks, and retune or delete the rest.

What should I monitor first if I only have time to build one thing?

Health checks tied into your orchestrator's readiness probe. They're cheap to build, they directly affect whether broken instances keep receiving traffic, and they're the foundation the rest of the stack builds on.

Related articles

Deploying a Full Stack SaaS — Aman Kumar Singh
A NestJS Production Checklist — Aman Kumar Singh
Deploying NestJS to Production — Aman Kumar Singh
Aman Kumar Singh

About the author

I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.