CodingNic

Observability: Metrics and Monitoring

Instrumenting Express with Metrics

Observability: Metrics and Monitoring 20 min read

Instrumenting Express with Metrics

Objectives

By the end of this lesson, you should be able to:

  • Add a request counter and a duration histogram to an Express app
  • Explain what a counter and a histogram each track, and why they’re different metric types
  • Confirm real metrics reflect real traffic

💡 Why this matters: Lesson 1 explained what metrics are for, this lesson actually adds them, using Prometheus-style metrics, the most common format for application metrics, readable by nearly every modern monitoring system.

⚠️ A note on verification: every snippet and every output in this lesson was actually run.

Installing prom-client

bash
npm install prom-client

Setting Up a Registry and Metrics

javascript
const client = require('prom-client');

const register = new client.Registry();
client.collectDefaultMetrics({ register });

const httpRequestsTotal = new client.Counter({
  name: 'http_requests_total',
  help: 'Total number of HTTP requests',
  labelNames: ['method', 'route', 'status'],
  registers: [register]
});

const httpRequestDuration = new client.Histogram({
  name: 'http_request_duration_seconds',
  help: 'HTTP request duration in seconds',
  labelNames: ['method', 'route', 'status'],
  buckets: [0.01, 0.05, 0.1, 0.3, 0.5, 1],
  registers: [register]
});

A Registry holds every metric an application exposes, client.collectDefaultMetrics({ register }) adds Node.js’s own process metrics, memory usage, event loop lag, automatically, on top of whatever custom metrics an application defines. A Counter only ever goes up, a running total, exactly right for “how many requests so far.” A Histogram tracks a distribution, bucketing observed values (here, request durations) into ranges, letting later analysis answer “what fraction of requests took longer than 300ms,” not just an average that could hide a real problem.

Recording a Metric on Every Request

javascript
app.use((req, res, next) => {
  const start = process.hrtime.bigint();
  res.on('finish', () => {
    const durationNs = process.hrtime.bigint() - start;
    const durationSeconds = Number(durationNs) / 1e9;
    const route = req.route ? req.route.path : req.path;
    httpRequestsTotal.inc({ method: req.method, route, status: res.statusCode });
    httpRequestDuration.observe({ method: req.method, route, status: res.statusCode }, durationSeconds);
  });
  next();
});

A single middleware, applied globally, res.on('finish', ...) fires once the response has actually been sent, at which point res.statusCode is final, req.route.path gives the parameterized route (/api/v1/notes/:id), not the literal URL (/api/v1/notes/1), grouping metrics by endpoint shape rather than by every individual id ever requested, exactly the granularity that’s actually useful.

Sending Real Traffic and Checking the Numbers

javascript
await request(app).get('/api/v1/notes/1');
await request(app).get('/api/v1/notes/1');
await request(app).get('/api/v1/notes/2');
await request(app).get('/api/v1/slow'); // an artificially slow route, ~120ms
text
http_requests_total{method="GET",route="/api/v1/notes/:id",status="200"} 3
http_requests_total{method="GET",route="/api/v1/slow",status="200"} 1
http_request_duration_seconds_sum{method="GET",route="/api/v1/notes/:id",status="200"} 0.00484512
http_request_duration_seconds_count{method="GET",route="/api/v1/notes/:id",status="200"} 3
http_request_duration_seconds_sum{method="GET",route="/api/v1/slow",status="200"} 0.120322222
http_request_duration_seconds_count{method="GET",route="/api/v1/slow",status="200"} 1

Three requests to /api/v1/notes/:id (two for id 1, one for id 2), correctly counted together as 3, since they’re the same route shape, the slow route’s duration sum, 0.120... seconds, matches its real, artificial 120ms delay almost exactly, these numbers are genuinely measured, not illustrative, from real requests actually handled by a real Express app.

Try It

  1. Build this middleware and both metrics, send a handful of requests to a couple of different routes, and confirm the counter and histogram values match what you actually sent.
  2. Add a route that sometimes returns a 404 or 500, send requests to it, and confirm status correctly appears as a separate label value in http_requests_total.
  3. Explain, in your own words, why grouping by req.route.path (/api/v1/notes/:id) is more useful than grouping by req.path (/api/v1/notes/1, /api/v1/notes/2, …) for a route handling many different ids.
  4. Explain, in one or two sentences, why a histogram’s bucket counts are more useful than a single average duration for spotting a slow tail of requests.

Recap

  • prom-client provides Counter (only increases, a running total) and Histogram (a distribution, bucketed by value) as the core metric types for tracking request volume and latency.
  • A single middleware, recording on res.on('finish', ...), is enough to instrument every route in an application at once.
  • Real requests produced real, correctly aggregated numbers here, confirming the metrics genuinely reflect actual traffic, not just configured correctly in theory.

Next lesson: exposing these metrics on a /metrics endpoint, in the format a monitoring system actually expects to scrape.