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
npm install prom-client
Setting Up a Registry and Metrics
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
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
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
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
- 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.
- Add a route that sometimes returns a
404or500, send requests to it, and confirmstatuscorrectly appears as a separate label value inhttp_requests_total. - Explain, in your own words, why grouping by
req.route.path(/api/v1/notes/:id) is more useful than grouping byreq.path(/api/v1/notes/1,/api/v1/notes/2, …) for a route handling many different ids. - 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-clientprovidesCounter(only increases, a running total) andHistogram(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.