It is 11:40 on a Thursday. A customer writes: "I tried to pay for my order and it gave me an error". Nothing more. With what you have in src/app.js today — that console.log at position 5 we have spent five lessons promising to replace — the investigation consists of hand-searching through thousands of lines of plain text for something resembling POST /v1/orders/ord_5001/payment → 500 (312 ms), with no idea which customer it was, no way to filter by status, no sight of the error that caused it and no way to relate it to the SQL query that failed.
Worse still: most of the time you do not even find out. Problems are discovered because a customer complains, not because the system warns you. And the decisions we have been taking since 04-01 — the limits from 04-04, the caching from 04-06, the design debt from 04-01 — depend on data you are not collecting right now.
This lesson closes module 4 with the layer that makes everything else governable. We will replace the console.log with pino and structured logs correlated by traceId, instrument the API with prom-client and expose /metrics, look at the distributed trace of a complete POST /v1/orders, distinguish liveness from readiness in /health, and define what to alert on and what to watch on launch day.
Warning. Logs are one of the easiest places for personal data and credentials to leak, with legal consequences under the GDPR. The redaction of sensitive fields and the retention policy in this lesson are a starting point; any real deployment requires a compliance and security review. All the data is fictional.
Contents
- Monitoring versus observability
- The three pillars and the question each one answers
- Structured logs: why JSON and not text
- pino and the Aroma Store logger
- What to always log and what to never log
- Automatic redaction of sensitive fields
- The
src/middleware/logging.jsmiddleware - End-to-end correlation with
Aroma-Trace-Id - 5xx errors: what gets logged versus what gets returned
- Metrics: the four golden signals and the RED method
- Metric types and why the histogram
- Instrumenting with prom-client and exposing
/metrics - Cardinality: why
ord_5001blows the system up - Distributed tracing: spans, context and
traceparent - A trace of
POST /v1/orders - Health checks: liveness and readiness
- Useful alerts, SLOs and the error budget
- The minimum launch-day dashboard
- The usual stack
- Taking stock of module 4
- Monitoring versus observability
| Monitoring | Observability | |
|---|---|---|
| Question | Is the thing I already know to look at all right? | What is going on, whatever it may be? |
| Based on | Thresholds over known metrics | Enough data to answer new questions |
| Failures it detects | The ones you anticipated | Also the ones you did not |
| Example | "CPU > 80%" | "Why have payments from customers with more than 3 items taken 2 s since Tuesday?" |
Monitoring is necessary and is not enough. Modern systems fail in ways nobody foresaw: an interaction between the rate limiting and one specific client, a query that degrades only with a certain data distribution, a webhook that jams only when SwiftShip is slow.
The operational definition that is actually useful: a system is observable if you can answer new questions about its behaviour without deploying new code. If understanding an incident means adding a console.log and waiting for it to happen again, your system is not observable.
- The three pillars and the question each one answers
| Pillar | Answers | Granularity | Cost | Example in Aroma Store |
|---|---|---|---|---|
| Logs | What exactly happened in this case? | One event | High, by volume | "The payment for ord_5001 failed: the gateway timed out" |
| Metrics | How is the system doing overall? | Aggregated | Very low | "2.3% of payments are failing; an hour ago it was 0.1%" |
| Traces | Where did the time go in this request? | One request, across services | Medium (sampling) | "Of the 312 ms, 280 were consumed by the inventory gRPC call" |
A real incident's workflow uses them in this order:
- A metric fires the alert: the error rate on
POST /v1/orders/{id}/paymenthas risen. - A trace of a failing request shows where it breaks: the call to the gateway.
- A log carrying that trace's
traceIdgives the detail: the exact error message and thecustomerId.
That is why all three have to be correlated. Three excellent systems with no shared identifier are worth far less than three mediocre ones that share the traceId. And we already have that identifier: Aroma-Trace-Id, since 03-02.
- Structured logs: why JSON and not text
What our middleware emits today:
What it should emit:
{"level":"error","time":"2026-08-15T11:40:22.318Z","traceId":"trc_9f3a2b7c","method":"POST","route":"/v1/orders/:id/payment","status":500,"durationMs":312,"customerId":"cus_842","oauthClient":"shop-spa","errorCode":"internal_error","cause":"gateway timeout"}| Plain text | Structured JSON | |
|---|---|---|
| Searching | grep and brittle regular expressions |
A query by field |
| Filtering by status | Impossible without parsing | status >= 500 |
| Aggregating | You have to write a parser | Straightforward |
| New fields | They break the existing parsers | They are ignored if not of interest |
| Correlating | By eye | By traceId |
| Human-readable | Yes | Not raw; with a formatter, yes |
The only real disadvantage — readability in development — is solved with a formatter, so there is no reason not to structure.
Two underlying rules that avoid most of the problems:
Logs are events, not sentences. "User cus_842 has created order ord_5001" forces you to extract the identifiers with a regular expression. {"event":"order_created","customerId":"cus_842","orderId":"ord_5001"} is queryable.
The route is logged as a template, not as a URI. "/v1/orders/:id/payment", never "/v1/orders/ord_5001/payment". If you log the real URI, you cannot group: every order produces a different route and per-endpoint statistics become impossible. The identifier goes in its own field, where it is genuinely useful for filtering.
- pino and the Aroma Store logger
pino is the de facto standard logger in Node: it writes JSON, it is extremely fast — it serialises off the critical path — and it ships with sensitive-field redaction and child loggers built in.
// src/config/logger.js (NEW file)
import pino from 'pino';
import { environment } from './environment.js';
const isDevelopment = environment.NODE_ENV === 'development';
export const logger = pino({
// 1. Minimum level: info in production, debug in development.
level: environment.LOG_LEVEL ?? (isDevelopment ? 'debug' : 'info'),
// 2. Field names consistent with the rest of the project.
messageKey: 'message',
errorKey: 'error',
timestamp: pino.stdTimeFunctions.isoTime, // ISO-8601 UTC, as in the contract
formatters: {
// By default pino emits level:30 (numeric). We prefer the label.
level: (label) => ({ level: label }),
},
// 3. Fixed context on EVERY line: it identifies the process that emitted them.
base: {
service: 'aromastore-api',
version: environment.APP_VERSION,
environment: environment.NODE_ENV,
instance: environment.INSTANCE_NAME ?? 'local',
},
// 4. Automatic redaction: section 6.
redact: {
paths: [
'req.headers.authorization',
'req.headers.cookie',
'req.headers["idempotency-key"]',
'req.body.password',
'req.body.currentPassword',
'req.body.token',
'req.body.refreshToken',
'res.headers["set-cookie"]',
'*.password',
'*.passwordHash',
'*.password_hash',
'*.accessToken',
'*.refreshToken',
'*.cardNumber',
'*.cvv',
],
censor: '[REDACTED]',
},
// 5. In development, readable output. In production, pure JSON to stdout.
transport: isDevelopment
? { target: 'pino-pretty', options: { colorize: true, translateTime: 'HH:MM:ss' } }
: undefined,
});Five decisions explained:
pino's levels are trace (10), debug (20), info (30), warn (40), error (50) and fatal (60). Aroma Store's criteria:
| Level | When | Example |
|---|---|---|
trace |
Never in production | Dumping a complete query |
debug |
Development and one-off debugging | "Cache miss for coffee:cof_001" |
info |
Normal business events | "Order created", a request completed |
warn |
An anomaly that breaks nothing | A slow query, a 429 emitted, Redis down |
error |
A failure that affects the request | A 500 with its stack |
fatal |
The process cannot continue | The database cannot be opened at start-up |
A frequent mistake: logging 4xxs as error. A 404 or a 400 is normal API behaviour, not a system failure. If you mark them as errors, your alerts will fill with noise and you will stop looking at them. Only 5xxs are error.
base adds service, version, environment and instance to every line. Without it, in a system with several instances you will not know which one emitted what, nor be able to tell one deployment's problem apart from another's.
Output to stdout. Not to a file. In a modern deployment, the process writes to standard output and the platform is what collects, rotates and ships the logs. Writing to a file from the application complicates rotation, permissions and containers.
- What to always log and what to never log
Always
| Field | Why |
|---|---|
traceId |
Correlates the three pillars and the response to the client |
method |
Filtering by type of operation |
route (template) |
Grouping by endpoint |
status |
Filtering errors |
durationMs |
Detecting slowness |
customerId |
Reproducing the problem of the user who complained |
oauthClient |
Telling the SPA apart from CataBox (04-03) |
ip |
Correlating abuse — it is personal data: see below |
responseSize |
Detecting anomalous responses |
errorCode |
Grouping by type of catalogue failure |
Never
| Do not log | Why |
|---|---|
| Passwords, in the clear or hashed | Obvious, and it happens constantly |
The Authorization header |
A token in the logs is a stolen session |
| Tokens of any kind | Ditto |
| Card numbers, CVV, IBAN | PCI-DSS and common sense |
| Email addresses, phone numbers, postal addresses | GDPR: minimisation |
| The complete request body | It contains all of the above |
| Cookies | Sessions |
| Configuration keys and secrets | — |
Two nuances that are not obvious:
The IP address is personal data under the GDPR. Logging it is usually justified on security grounds (legitimate interest), but it demands a short, documented retention policy. A prudent option is storing a salted hash instead of the IP, which allows correlation without identification.
The customerId is also a personal identifier, but it is pseudonymous and necessary to operate. The identifier is logged (cus_842), never the name or the email address: if you need to know who it is, you query the database with the appropriate access controls.
Retention. Logs are kept for a limited, documented period — 30, 90, 180 days depending on the type — because every extra day of retention is risk and cost. And remember that logs fall within the scope of the GDPR's right to erasure, another reason not to put identifying data in them.
- Automatic redaction of sensitive fields
Manual redaction always fails, because all it takes is somebody adding a new field or logging a whole object:
// ❌ One slip and the password ends up in a log system with 90-day retention.
logger.info({ body: req.body }, 'request received');That is why pino's redact, already configured, works at the logger level: wherever it is applied, those paths are censored.
logger.info({ req: { body: { email: '[email protected]', password: 'Fictional123' } } }, 'registration');
// → {"level":"info", ..., "req":{"body":{"email":"[email protected]","password":"[REDACTED]"}}}The wildcards (*.password) cover any depth, which catches nested objects you had not anticipated.
Two reinforcements worth adding:
A test that verifies it. Redaction is one of those things that breaks in a refactor with nobody noticing:
// tests/unit/redaction.test.js
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import pino from 'pino';
import { Writable } from 'node:stream';
describe('redaction of sensitive fields', () => {
it('censors passwords and tokens at any depth', () => {
let output = '';
const destination = new Writable({
write(chunk, _enc, cb) { output += chunk.toString(); cb(); },
});
const log = pino(
{ redact: { paths: ['*.password', '*.accessToken', 'req.headers.authorization'],
censor: '[REDACTED]' } },
destination
);
log.info({
user: { email: '[email protected]', password: 'Fictional123' },
session: { accessToken: 'eyJhbGciOi...' },
req: { headers: { authorization: 'Bearer eyJhbGciOi...' } },
});
assert.ok(!output.includes('Fictional123'), 'the password must NOT appear');
assert.ok(!output.includes('eyJhbGciOi'), 'no token must appear');
assert.equal((output.match(/\[REDACTED\]/g) ?? []).length, 3);
});
});A scanner in continuous integration that looks for secret patterns in the tests' logs. It is the same idea as the repository secret scanner from 04-02.
- The
src/middleware/logging.js middleware
src/middleware/logging.js middlewareHere we finally replace the console.log at position 5.
// src/middleware/logging.js (NEW file — replaces the console.log from 03-02)
import pinoHttp from 'pino-http';
import { logger } from '../config/logger.js';
export const logRequests = pinoHttp({
logger,
// 1. The request identifier IS our traceId, already assigned at position 2
// of the chain. That way the log, the response and the trace all match.
genReqId: (req) => req.traceId,
// 2. The name of the field where pino-http puts that identifier.
customProps: (req) => ({
traceId: req.traceId,
customerId: req.user?.id ?? null,
oauthClient: req.user?.oauthClient ?? null,
// A template, not a URI: req.route exists once the route has matched.
route: req.route?.path ? `${req.baseUrl}${req.route.path}` : req.path,
}),
// 3. Level according to the outcome: 4xxs are normal behaviour.
customLogLevel: (req, res, err) => {
if (err || res.statusCode >= 500) return 'error';
if (res.statusCode === 429) return 'warn'; // worth watching (04-04)
if (res.statusCode >= 400) return 'info'; // NOT error: it is a normal 4xx
return 'info';
},
customSuccessMessage: (req, res) => `${req.method} ${req.path} → ${res.statusCode}`,
customErrorMessage: (req, res, err) => `${req.method} ${req.path} → ${res.statusCode}: ${err.message}`,
// 4. Serialisers: what gets logged from req and res is chosen EXPLICITLY.
// Without this, pino-http would log every header, Authorization included.
serializers: {
req: (req) => ({
method: req.method,
url: req.url,
// Only harmless headers, and on an allowlist.
headers: {
'content-type': req.headers['content-type'],
'content-length': req.headers['content-length'],
'user-agent': req.headers['user-agent'],
accept: req.headers.accept,
},
ip: req.ip,
}),
res: (res) => ({
status: res.statusCode,
size: res.getHeader?.('content-length'),
}),
},
// 5. /health and /metrics are called constantly: they do not clutter the logs.
autoLogging: {
ignore: (req) => req.url === '/health' || req.url === '/metrics',
},
});Point 4 is the most important one for security: by default, pino-http logs every request header, Authorization included. The serialiser allowlist is what prevents that, and it is defence in depth alongside redact.
The per-request child logger
A child logger is a logger that automatically carries a fixed context. pino-http creates it in req.log, and from then on any line you write inside the request carries the traceId without you having to pass it around:
// src/controllers/orders.js (MODIFIED)
export async function create(req, res) {
// req.log is the child logger: it already carries traceId, customerId and route.
req.log.info({ items: req.validatedData.items.length }, 'creating order');
const order = await services.orders.create(req.validatedData, req.user, req.log);
req.log.info({ orderId: order.id, totalEuros: order.totalEuros }, 'order created');
res.status(201).location(`/v1/orders/${order.id}`).json(order);
}Passing req.log down to the service is how the inner layers log with the same context without knowing anything about HTTP. The alternative — Node's AsyncLocalStorage — avoids the explicit hand-off and is what the more advanced solutions use; for a project of this size, passing the logger is simpler and more explicit.
The position in src/app.js
// src/app.js (MODULE 4's FINAL VERSION)
import express from 'express';
import cors from 'cors';
import compression from 'compression';
import { v1Routes } from './routes/index.js';
import { assignTraceId } from './middleware/trace.js';
import { securityHeaders } from './middleware/security.js';
import { corsOptions } from './config/cors.js';
import { logRequests } from './middleware/logging.js'; // ← NEW
import { globalLimit } from './middleware/rate-limit.js';
import { conditionalEtag } from './middleware/cache.js';
import { metricsMiddleware, metricsHandler, protectMetrics } from './observability/metrics.js'; // ← NEW
import { livenessHandler, readinessHandler } from './routes/health.js'; // ← NEW
// compressionOptions was defined in 04-06, in this same file.
import { notFoundHandler } from './middleware/not-found.js';
import { errorHandler } from './middleware/errors.js';
export const app = express();
app.disable('x-powered-by'); // 1
app.set('trust proxy', 1);
app.use(assignTraceId); // 2
app.use(securityHeaders); // 3 helmet (04-02)
app.use(cors(corsOptions)); // 4 (04-05)
app.use(logRequests); // 5 ← replaces the console.log
app.use(metricsMiddleware); // 6 ← NEW (04-07)
app.use(globalLimit); // 7 (04-04)
app.use(compression(compressionOptions)); // 8 (04-06)
app.use(express.json({ limit: '100kb', type: ['application/json', 'application/merge-patch+json'] })); // 9
app.use(express.urlencoded({ extended: false, limit: '10kb' }));
app.get('/health', livenessHandler); // 10 liveness
app.get('/health/ready', readinessHandler); // 11 readiness
app.get('/metrics', protectMetrics, metricsHandler); // 12 ← NEW, outside /v1
app.use(conditionalEtag); // 13 (04-06)
app.use('/v1', v1Routes); // 14
app.use(notFoundHandler); // 15
app.use(errorHandler); // 16Why logging at 5 and metrics at 6:
- After CORS (4): if CORS rejects something, there is nothing to log as an API request.
- Before the rate limiting (7): if it came afterwards, the
429s would be neither logged nor counted, and you would be blind precisely during an attack. - Before the parser (9): so that requests with malformed JSON are logged too; they produce a
400and are a sign of a broken client. - The metrics right after the logging, so as to measure every request that gets logged, with no mismatch between the two.
- End-to-end correlation with
Aroma-Trace-Id
Aroma-Trace-IdThe traceId we have been emitting since 03-02 now comes fully into its own. Its journey:
graph LR SPA[SPA: generates or receives Aroma-Trace-Id] --> API[API: inherits or generates] API --> LOG[Logs: traceId on every line] API --> INV[Inventory gRPC: propagates the id] API --> DB[SQL query: logged with the id] API --> WH[Webhook to SwiftShip: Aroma-Trace-Id] API --> RES[Response: header + traceId on 5xx] RES --> USR[User sees trc_9f3a2b7c in the error message] USR --> SOP[Support searches for that id and sees EVERYTHING]
// src/middleware/trace.js (MODIFIED: W3C interoperability is added)
import crypto from 'node:crypto';
export function assignTraceId(req, res, next) {
// 1. The client's is inherited if it arrives and has a valid format.
// Validating the format matters: it is untrusted input and it ends up in the logs.
const inherited = req.get('Aroma-Trace-Id');
const isValid = inherited && /^trc_[0-9a-f]{8,32}$/.test(inherited);
req.traceId = isValid ? inherited : `trc_${crypto.randomBytes(4).toString('hex')}`;
// 2. If a W3C traceparent arrives (04-07 §14), the trace-id is kept
// so we can correlate with systems that do not speak our dialect.
const traceparent = req.get('traceparent');
if (traceparent) {
const parts = traceparent.split('-');
if (parts.length === 4) req.traceIdW3C = parts[1];
}
// 3. It is always returned, so the client can display it.
res.set('Aroma-Trace-Id', req.traceId);
next();
}The format validation in point 1 is not paranoia: without it, an attacker can inject line breaks into the header and forge complete log lines (log injection), or slip in payloads that exploit the log viewer.
In the SPA, the loop closes by showing the identifier to the user:
// Client: showing the trace on errors makes support ten times faster.
const response = await fetch(url, options);
if (!response.ok) {
const trace = response.headers.get('Aroma-Trace-Id'); // readable thanks to 04-05
showError(`Something went wrong. If you contact support, quote reference ${trace}.`);
}And for that to work, Aroma-Trace-Id had to be in 04-05's exposedHeaders. Every piece fits together.
- 5xx errors: what gets logged versus what gets returned
In 03-07 we promised the traceId would be the bridge between what the client sees and what the team sees. Here it materialises.
// src/middleware/errors.js (MODIFIED — fragment)
export function errorHandler(err, req, res, next) {
const isApiError = err instanceof ApiError;
const status = isApiError ? err.status : 500;
if (status >= 500) {
// TO THE TEAM: absolutely everything.
req.log.error(
{
err, // pino serialises the message, type and full stack
errorCode: isApiError ? err.code : 'internal_error',
route: req.route?.path ?? req.path,
method: req.method,
customerId: req.user?.id ?? null,
// The body is NOT logged: it may contain personal data or secrets.
},
'unhandled error'
);
} else if (status === 429) {
req.log.warn({ errorCode: err.code, key: req.rateLimit?.key }, 'limit reached');
} else {
// 4xx: info level. They are normal API behaviour.
req.log.info({ errorCode: err.code, status }, 'request rejected');
}
// TO THE CLIENT: the minimum, and the traceId only on 5xx (the 03-07 contract).
const body = {
error: {
code: isApiError ? err.code : 'internal_error',
message: isApiError ? err.message : 'An unexpected error occurred.',
details: isApiError ? (err.details ?? []) : [],
},
};
if (status >= 500) body.error.traceId = req.traceId;
res.set('Cache-Control', 'no-store'); // errors are not cached (04-06)
res.status(status).json(body);
}The asymmetry, in a table:
| Response to the client | The team's log | |
|---|---|---|
| Code | internal_error |
The real one, with its exception type |
| Message | Generic | The complete internal one |
| Stack | Never | Complete |
| SQL | Never | Yes, the template |
traceId |
Yes (5xx only) | Yes |
| Request body | — | Not that either: personal data |
Notice the last row: not even the team gets the body logged. The temptation to "save everything just in case" is exactly how passwords end up in a log system with a year's retention.
- Metrics: the four golden signals and the RED method
The four golden signals (from Google's SRE book):
| Signal | What it measures | In Aroma Store |
|---|---|---|
| Latency | How long it takes | A histogram by route and status |
| Traffic | How much demand there is | Requests per second |
| Errors | What proportion fails | The 5xx rate |
| Saturation | How full it is | Database connections, memory, event queue |
The RED method is the simplified version for request-response services, and it is the one that applies to a REST API:
- Rate: requests per second.
- Errors: failed requests per second.
- Duration: the distribution of latency.
With those three, by route and by status code, you cover 90% of what you need from an API. Saturation is added as resource metrics: process memory, the event loop, the connection pool.
One nuance that avoids a common mistake about latency: you have to measure it separating successes from errors. A 401 responds in 2 ms, so an avalanche of 401s improves your average latency while the service is broken. Splitting by status avoids that.
- Metric types and why the histogram
| Type | What it is | Example | Operations |
|---|---|---|---|
| Counter | Only goes up; resets when the process restarts | Total requests | Rate per second |
| Gauge | Goes up and down | Active connections | Current value, maximum |
| Histogram | A distribution in buckets | Latency | Percentiles, mean |
| Summary | Percentiles computed on the client | Latency | Not aggregatable across instances |
Why the histogram and not the average. As we saw in 04-06, the mean lies. But on top of that, if every instance sent its own mean, those means cannot be combined correctly: the mean of the means is not the global mean unless they all have the same number of samples.
A histogram solves both. You define buckets and count how many observations fall into each:
latency_bucket{le="0.005"} 1200 ← 1200 requests under 5 ms
latency_bucket{le="0.01"} 3400
latency_bucket{le="0.05"} 8900
latency_bucket{le="0.1"} 9500
latency_bucket{le="0.5"} 9950
latency_bucket{le="+Inf"} 10000The buckets can be summed across instances, and the percentile is computed afterwards by interpolation. In PromQL:
# p99 of latency by route, over a 5-minute window.
histogram_quantile(0.99, sum(rate(aroma_http_duration_seconds_bucket[5m])) by (le, route))The precision depends on the buckets: if the real p99 is 180 ms and your buckets jump from 100 ms to 500 ms, you will get a poor interpolation. The buckets are chosen from your latency budget (04-06), placing boundaries around the values you care about.
- Instrumenting with prom-client and exposing
/metrics
/metrics// src/observability/metrics.js (NEW file)
import client from 'prom-client';
import { environment } from '../config/environment.js';
export const registry = new client.Registry();
// Process metrics: CPU, memory, event loop, descriptors. Free and very useful.
client.collectDefaultMetrics({ register: registry, prefix: 'aroma_' });
// --- 1. Traffic and errors: one counter by route, method and status ---
const requests = new client.Counter({
name: 'aroma_http_requests_total',
help: 'HTTP requests served',
labelNames: ['method', 'route', 'status'],
registers: [registry],
});
// --- 2. Latency: a histogram with buckets aligned to the 04-06 budget ---
const duration = new client.Histogram({
name: 'aroma_http_duration_seconds',
help: 'Duration of HTTP requests',
labelNames: ['method', 'route', 'status'],
buckets: [0.005, 0.015, 0.03, 0.05, 0.08, 0.15, 0.3, 0.5, 1, 2, 5],
registers: [registry],
});
// --- 3. Business metrics: the ones that really say whether the shop works ---
export const ordersCreated = new client.Counter({
name: 'aroma_orders_created_total',
help: 'Orders created',
labelNames: ['clientSource'], // spa | mobile | catabox — low cardinality
registers: [registry],
});
export const stockDepleted = new client.Counter({
name: 'aroma_stock_depleted_total',
help: 'Purchase attempts rejected for lack of stock',
labelNames: ['roast'], // 3 possible values: safe
registers: [registry],
});
export const rateLimitsEmitted = new client.Counter({
name: 'aroma_rate_limit_total',
help: '429 responses emitted',
labelNames: ['tier'], // anonymous | customer | partner | back-office
registers: [registry],
});
export const cacheAccesses = new client.Counter({
name: 'aroma_cache_total',
help: 'Accesses to the application cache',
labelNames: ['result'], // hit | miss
registers: [registry],
});
/**
* The instrumentation middleware. Position 6 of src/app.js.
*/
export function metricsMiddleware(req, res, next) {
const end = duration.startTimer();
res.on('finish', () => {
// KEY: the route template, NEVER the URI with identifiers. See §13.
// If no route matched (a 404), it is grouped under 'unknown' so as not to
// generate a label for every non-existent URL somebody tries.
const route = req.route?.path ? `${req.baseUrl}${req.route.path}` : 'unknown';
const labels = { method: req.method, route, status: String(res.statusCode) };
requests.inc(labels);
end(labels);
});
next();
}
/** The /metrics endpoint: Prometheus's text format. */
export async function metricsHandler(req, res) {
res.set('Content-Type', registry.contentType);
res.set('Cache-Control', 'no-store');
res.end(await registry.metrics());
}
/**
* Protection for /metrics: it exposes internal routes, versions and business volume.
* It must NEVER be public.
*/
export function protectMetrics(req, res, next) {
const token = (req.get('Authorization') ?? '').replace('Bearer ', '');
if (token !== environment.METRICS_TOKEN) return res.status(404).end(); // 404, not 401
return next();
}/metrics goes outside /v1, just like /health: it is not part of the business contract and it is not versioned with it. And it must be protected: it reveals your internal routes, your versions, your order volume and your error rates, information that is valuable to a competitor and to an attacker alike. We answer 404 rather than 401 so as not even to confirm that it exists. In a real deployment it is also exposed only on the internal network or on a separate, unpublished port.
An example of business instrumentation:
// src/services/orders.js (MODIFIED — fragment)
import { ordersCreated, stockDepleted } from '../observability/metrics.js';
export async function create(data, requester, log) {
for (const item of data.items) {
const coffee = await coffeeRepository.findById(item.coffeeId);
if (coffee.stock < item.quantity) {
stockDepleted.inc({ roast: coffee.roast });
log.warn({ coffeeId: coffee.id, requested: item.quantity, available: coffee.stock },
'insufficient stock');
throw errors.conflict('insufficient_stock', `There is not enough stock of ${coffee.name}.`);
}
}
const order = await orderRepository.create(data);
ordersCreated.inc({ clientSource: requester.oauthClient ?? 'spa' });
return order;
}Business metrics are the most valuable ones and the ones almost nobody adds. "Orders per minute have fallen to zero" detects incidents no technical metric sees: the API answers 200, the CPU is fine, and yet a change in the SPA has broken the buy button.
- Cardinality: why
ord_5001 blows the system up
ord_5001 blows the system upA metric's cardinality is the number of distinct label combinations. Every combination is an independent time series, with its own memory and its own cost.
// ✅ Bounded, predictable cardinality.
// 6 methods × 24 routes × ~8 statuses = ~1,150 series. Perfectly manageable.
requests.inc({ method: 'GET', route: '/v1/orders/:id', status: '200' });
// ❌ CATASTROPHIC: one time series for EVERY order, for ever.
// With 100,000 orders: 100,000 series. The metrics system falls over.
requests.inc({ method: 'GET', route: '/v1/orders/ord_5001', status: '200' });It is called a cardinality explosion and it is the number-one way to bring down a metrics system — sometimes along with the rest of the cluster.
| Label | Possible values | Safe? |
|---|---|---|
method |
6 | Yes |
route (template) |
~24 | Yes |
status |
~8 | Yes |
roast |
3 | Yes |
role |
4 | Yes |
oauthClient |
~10 | Yes |
customerId |
Millions | No |
orderId |
Unlimited | No |
ip |
Unlimited | No |
traceId |
One per request | Never |
userAgent |
Thousands | No |
The mnemonic rule:
Identifiers go in the logs and in the traces. In metrics you only put categories with a small, known number of values.
When you need to investigate a specific case, the correct path is: the metric tells you that there is a problem and where; the log and the trace tell you which one. Never the other way round.
Beware also of the 404 for non-existent routes: if you logged req.path with no route match, anybody could blow up your cardinality by requesting random URLs. That is why the middleware in section 12 groups them under 'unknown'.
- Distributed tracing: spans, context and
traceparent
traceparentWhen a request crosses several services, each one's logs tell a fragment of the story. A trace stitches them together.
| Concept | What it is |
|---|---|
| Trace | A request's complete journey across the whole system |
| Span | A unit of work inside the trace (a query, a call) |
| Parent span | The span that originated another: it gives the tree structure |
| Trace context | What is propagated between services to join the spans |
| Sampling | Keeping only a percentage: tracing everything is very expensive |
The propagation standard is W3C Trace Context, with the traceparent header:
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
↑ ↑ ↑ ↑
version trace-id (16 bytes) span-id (8 bytes) flags- The trace-id is the same throughout the trace: it is what joins the services.
- The span-id identifies the current operation; the next service will use it as its parent.
- The flags indicate, among other things, whether this trace is sampled.
OpenTelemetry is the instrumentation standard (API, SDK and protocol) that has established itself across the industry; its main advantage is that it decouples the instrumentation from the backend: you instrument once and can ship to Jaeger, Tempo, Datadog or whatever you use later.
// src/observability/tracing.js (NEW file)
import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { environment } from '../config/environment.js';
/**
* It must be initialised BEFORE importing express and the other libraries: the
* automatic instrumentation patches them as they load. That is why src/server.js
* does `import './observability/tracing.js'` on its very first line.
*/
export const sdk = new NodeSDK({
serviceName: 'aromastore-api',
traceExporter: new OTLPTraceExporter({ url: environment.OTLP_ENDPOINT }),
instrumentations: [
getNodeAutoInstrumentations({
// Health polling would generate thousands of worthless traces.
'@opentelemetry/instrumentation-http': {
ignoreIncomingRequestHook: (req) =>
req.url === '/health' || req.url === '/health/ready' || req.url === '/metrics',
},
// The file system produces an enormous amount of noise and almost no value.
'@opentelemetry/instrumentation-fs': { enabled: false },
}),
],
});
if (environment.OTLP_ENDPOINT) sdk.start();The automatic instrumentations cover incoming and outgoing HTTP, Express, gRPC and the usual database clients, so most of the trace appears without writing any code. Manual spans are added only where there is business logic you want to see.
Sampling. Tracing 100% of the traffic is expensive in network, storage and CPU. The usual approach is low sampling (1–10%) with two exceptions: traces of failing requests and of slow ones are always kept, since they are precisely the interesting ones.
- A trace of
POST /v1/orders
POST /v1/orderssequenceDiagram participant SPA as SPA participant API as Aroma API participant DB as SQLite participant INV as Inventory service (gRPC) participant RE as SwiftShip (webhook) SPA->>API: POST /v1/orders (traceparent) Note over API: span: Zod validation (3 ms) API->>INV: ReserveStock (gRPC, propagates traceparent) INV->>API: reserved (48 ms) Note over API: span: transaction API->>DB: INSERT order (6 ms) API->>DB: INSERT items (4 ms) API->>DB: UPDATE stock (3 ms) API->>RE: POST webhook order.created (async, 120 ms) API->>SPA: 201 Created (total 68 ms)
Seen as a tree of spans, with each one's duration:
POST /v1/orders .......................... 68 ms [trace: 4bf92f35...] ├── middleware.authenticate .............. 2 ms ├── middleware.validate .................. 3 ms ├── grpc.inventory.ReserveStock .......... 48 ms ← 70% of the time │ └── inventory.warehouseQuery ......... 41 ms ├── db.transaction ....................... 13 ms │ ├── db.insert.orders .................. 6 ms │ ├── db.insert.order_items ............. 4 ms │ └── db.update.coffees.stock ........... 3 ms └── webhook.order_created (async) ........ 120 ms (off the critical path)
What you can see at a glance and that no other tool would have given you:
- The inventory gRPC call consumes 70% of the time. Optimising the SQL queries, which add up to 13 ms, would be working in the wrong place.
- The webhook is asynchronous and does not block the response. If it were on the critical path, the
201would take 188 ms. - The transaction is well bounded: three consecutive writes, with no network calls inside, which is exactly what we were after in 03-05. A gRPC call inside the transaction would hold the database lock for 48 ms.
A manual span, for when the automatic instrumentation does not reach:
// src/services/orders.js (fragment)
import { trace } from '@opentelemetry/api';
const tracer = trace.getTracer('aromastore-api');
export async function calculateTotal(items) {
return tracer.startActiveSpan('orders.calculateTotal', async (span) => {
try {
span.setAttribute('items.count', items.length); // a low-cardinality attribute
const total = /* ... calculation in cents ... */ 4190;
span.setAttribute('total.cents', total);
return total;
} catch (error) {
span.recordException(error);
span.setStatus({ code: 2 }); // ERROR
throw error;
} finally {
span.end(); // ALWAYS, or the span stays open
}
});
}
- Health checks: liveness and readiness
A single /health mixes two different questions, and confusing them causes deployment incidents.
Liveness (/health) |
Readiness (/health/ready) |
|
|---|---|---|
| Question | Is the process alive? | Can it serve requests right now? |
| If it fails | The process is restarted | It is taken out of the load balancer, without restarting |
| Checks | Nothing external | Database, migrations, Redis |
| Cost | Minimal | It may query dependencies |
| Frequency | Every 10 s | Every 5 s |
// src/routes/health.js (NEW file)
import { db } from '../config/database.js';
import { redis } from '../config/redis.js';
import { environment } from '../config/environment.js';
/**
* LIVENESS: it only says that the process responds.
* It does NOT check dependencies: if the database goes down, restarting the API does
* not fix it, and restarting every instance in a loop makes the incident worse.
*/
export function livenessHandler(req, res) {
res.set('Cache-Control', 'no-store');
res.status(200).json({ status: 'ok', version: environment.APP_VERSION });
}
/**
* READINESS: can this instance serve requests?
*/
export async function readinessHandler(req, res) {
res.set('Cache-Control', 'no-store');
const checks = {};
let ready = true;
// 1. The database: essential. A trivial query that touches no data.
try {
db.prepare('SELECT 1').get();
checks.database = 'ok';
} catch (error) {
checks.database = 'error';
ready = false;
req.log.error({ err: error }, 'readiness: database unavailable');
}
// 2. Migrations up to date: starting with the old schema produces strange errors.
try {
const { version } = db.prepare('SELECT MAX(version) AS version FROM migrations').get();
checks.migrations = version >= environment.MIN_MIGRATION ? 'ok' : 'outdated';
if (checks.migrations !== 'ok') ready = false;
} catch {
checks.migrations = 'error';
ready = false;
}
// 3. Redis: DEGRADED, not fatal. Without caching or shared rate limiting the API
// performs worse, but it works. Taking it out of the load balancer would backfire.
try {
await redis.ping();
checks.redis = 'ok';
} catch {
checks.redis = 'degraded';
}
res.status(ready ? 200 : 503).json({
status: ready ? 'ready' : 'not_ready',
checks,
});
}Three decisions that avoid classic incidents:
Liveness does not check the database. If it did and the database went down, the orchestrator would restart every instance in a loop, adding a storm of start-ups to an incident that already existed.
Redis is "degraded", not fatal. It is consistent with the decisions from 04-04 (fail-open) and 04-06 (the cache is never a hard dependency).
Readiness returns 503, not 500. It is "not now", not "I am broken", and it fits the catalogue's service_unavailable. During the graceful shutdown from 03-07, the correct approach is to start returning 503 on readiness while carrying on serving the requests in flight: that way the load balancer stops sending new traffic before the process dies, and no request is lost. That detail is what makes a deployment with no visible errors possible, and we will come back to it in 05-05.
- Useful alerts, SLOs and the error budget
The rule: alert on symptoms that affect users, not on causes that may affect nobody.
| ❌ Alerting on causes | ✅ Alerting on symptoms |
|---|---|
| "CPU > 80%" | "The p99 of /v1/coffees exceeds 500 ms" |
| "Memory > 90%" | "The 5xx rate exceeds 1%" |
| "Redis down" | "Latency has doubled" |
| "Disk at 85%" | "Orders per minute have fallen to zero" |
A CPU at 90% with latency inside the budget is not a problem: it is a well-used server. Waking somebody up for that is the fastest way to make sure alerts stop being looked at.
SLI, SLO and the error budget
- SLI (indicator): what you measure. "The proportion of requests with a status below 500".
- SLO (objective): the threshold you commit to. "99.9% over 30 days".
- Error budget: what you are allowed to fail. At 99.9%, that is 0.1% of the month: about 43 minutes.
Aroma Store's:
| SLI | SLO | Monthly budget |
|---|---|---|
Availability (< 500) |
99.9% | 43 min |
Latency GET /v1/coffees p99 < 150 ms |
99% of requests | — |
Latency POST /v1/orders p99 < 400 ms |
99% | — |
| Webhook delivery success to SwiftShip | 99.5% over 24 h | — |
The error budget turns an argument about opinions into a rule: if there is budget left, new features get deployed; if it has been used up, the time goes on reliability. There is no more negotiating "do we fix this or ship that?": the data decides.
And the correct alert is not on the instantaneous value but on the rate at which the budget is being consumed (the burn rate): "at this pace, the month's budget runs out in two hours". That is what tells an irrelevant spike apart from a real problem.
# Error rate over 5 minutes, against the total number of requests.
sum(rate(aroma_http_requests_total{status=~"5.."}[5m]))
/ sum(rate(aroma_http_requests_total[5m])) > 0.01Every alert must meet four conditions, and if it fails any of them it should be deleted: it is actionable (there is something concrete to do), it is urgent (it cannot wait until tomorrow), it is documented (a runbook with the first steps) and it is not noisy (if it fires daily, either it is a real problem that needs fixing or it is an alert that should not exist).
- The minimum launch-day dashboard
Six charts. Not one more, because a dashboard with forty charts is a dashboard nobody looks at:
| Chart | What it shows | What you are looking for |
|---|---|---|
| 1. Requests per second, by status | Traffic and errors together | Traffic rising and 5xxs not |
| 2. Latency p50 / p95 / p99 | Three lines, same chart | The p99 not shooting up |
| 3. Error rate by route | Which endpoint is failing | One specific endpoint breaking |
| 4. Orders created per minute | The business metric | That it does not fall to zero |
5. 429s emitted by tier |
The rate limiting's effect | That you are not blocking legitimate users |
| 6. Saturation: memory, event loop, DB pool | Resources | Leaks and exhaustion |
Charts 4 and 5 are what tell a useful dashboard from a decorative one. Chart 4 detects the failure no technical metric sees: everything answers 200 and yet nobody is buying. Chart 5 is the direct check that the work from 04-04 has not turned against you.
On launch day, in addition: have to hand the log query filtered by status >= 500 sorted by time, and the link to the traces of the slowest requests. That is what turns "something is wrong" into "the inventory gRPC call is taking 2 s" in under a minute.
- The usual stack
| Pillar | Tools | Note |
|---|---|---|
| Metrics | Prometheus + Grafana | The de facto standard; Prometheus scrapes /metrics |
| Logs | Loki + Grafana, or ELK (Elasticsearch, Logstash, Kibana) | Loki is cheaper: it indexes labels, not the text |
| Traces | Jaeger, Grafana Tempo | Both speak OTLP |
| All in one | Grafana Cloud, Datadog, New Relic, Honeycomb | Paid, with no operations of your own |
| Instrumentation | OpenTelemetry | The standard; it decouples you from the backend |
Two pieces of advice when choosing, without getting into installations:
Start with the managed option. Operating Prometheus, Loki and Jaeger is a part-time job. For a project like Aroma Store, a managed service costs less than the time to maintain it.
Instrument with OpenTelemetry whatever happens. It is the only thing that lets you change provider without touching the code again.
- Taking stock of module 4
When the module started you had a correct API. This is what has been hardened:
| Lesson | What it added | Files |
|---|---|---|
| 04-01 | Design judgement, antipatterns, review checklist, Spectral | .spectral.yaml, docs/decisions/ |
| 04-02 | OWASP Top 10, helmet, secrets, GDPR, uploads, SSRF | src/middleware/security.js, src/services/{downloads,images}.js |
| 04-03 | OAuth 2.0, OIDC, JWKS, scopes | src/config/oauth.js, src/middleware/oauth-authentication.js |
| 04-04 | Rate limiting, 429, Redis, backoff |
src/middleware/rate-limit.js, src/config/redis.js |
| 04-05 | CORS with an allowlist, Expose-Headers |
src/config/cors.js |
| 04-06 | HTTP caching, ETag, 304, If-Match/412, compression |
src/middleware/cache.js, src/services/cache*.js |
| 04-07 | Logs, metrics, traces, health, SLOs | src/config/logger.js, src/middleware/logging.js, src/observability/* |
The src/app.js chain has gone from 8 steps to 16, and each one answers a concrete problem you can now name. The error catalogue grew with precondition_required, and the scope catalogue appeared with OAuth. And above all, the API has stopped being a black box: when something fails, you will know before your customers do and you will be able to work out what it was.
Common Mistakes and Tips
Logging the complete URI instead of the template. It prevents grouping in the logs and it blows up cardinality in the metrics.
Logging the request body "for debugging". It is the fastest route to putting passwords and personal data into a system with long retention.
Marking 4xxs as error. It generates noise, and noise makes alerts stop being read.
Using identifiers as metric labels. customerId or orderId blow up the metrics system.
Putting the database check in liveness. It turns a database outage into a restart loop across the whole fleet.
Leaving /metrics public. It exposes internal routes, versions and business volume.
Alerting on CPU and memory. They are causes, not symptoms. Alert on latency, errors and business metrics.
Looking at the average instead of the percentiles. We saw it in 04-06 and it is still the most frequent measurement mistake.
Registering the logger without serialisers. pino-http includes every header by default, Authorization among them.
Tip: put the traceId into the SPA's error messages. It turns "it does not work for me" into a two-minute investigation.
Tip: instrument the business, not just the technology. "Orders per minute" detects incidents no infrastructure metric sees.
Tip: review your alerts every quarter. Delete the ones that have never been actionable. A noisy alerting system is worse than none at all.
Tip: write the runbook before the incident. At three in the morning nobody improvises well.
Exercises
Exercise 1: reviewing an instrumentation
This code has been proposed for instrumenting the payment endpoint. Find at least five problems and fix them.
router.post('/:id/payment', authenticate, async (req, res) => {
console.log(`Payment for ${req.params.id}, body: ${JSON.stringify(req.body)}`);
const start = Date.now();
try {
const result = await services.orders.pay(req.params.id, req.body);
requests.inc({ route: `/v1/orders/${req.params.id}/payment`, customer: req.user.id });
console.log(`OK in ${Date.now() - start} ms`);
res.json(result);
} catch (error) {
console.error(`ERROR: ${error.stack}`);
res.status(500).json({ error: error.message });
}
});Exercise 2: designing the metrics for a feature
Aroma Store is adding the monthly coffee subscription. Design the metrics for observing it: name, type, labels (with their estimated cardinality) and what question each one answers. Include at least one business metric and one useful alert expressed in words.
Exercise 3: investigating an incident
At 10:15 the alert "5xx rate > 1% on POST /v1/orders" fires. Describe, step by step, how you would use the three pillars to reach the cause, saying what you look for in each one and what conclusion you would draw from each possible result.
Solutions
Solution 1
Problems:
console.loginstead of the logger. No structure, no level, notraceId, and it never reaches the log system in the right format.- It logs the complete body. A payment's body may contain card data. It is a serious security and GDPR failure.
- Explosive cardinality in the metric.
routewith the real identifier and acustomerlabel carrying thecustomerId: one series per order and per customer. - It only counts successes. The
inc()sits inside thetryafter the operation, so errors are not counted and the error rate is always zero. - It does not measure latency as a metric, it only prints it. There is no histogram and no percentiles.
- It returns
error.messageto the client. It leaks the system's internals: it breaks what was established in 03-07 and 04-02. - It does not use the central error handler. The error's format does not meet the contract (
code,message,details,traceId). asyncHandler()is missing: an uncaught rejection would leave the request hanging. (Here thetry/catchpapers over it, but the project's convention isasyncHandler.)
The fix:
router.post(
'/:id/payment',
authenticate,
requireRole('customer', 'employee', 'administrator'),
writeLimit,
requireIdempotencyKey,
validate(paymentSchema, 'body'),
asyncHandler(async (req, res) => {
// The child logger already carries traceId, customerId and the route template.
// METADATA is logged, never the body.
req.log.info({ orderId: req.params.id, paymentMethod: req.validatedData.method }, 'starting payment');
const result = await services.orders.pay(req.params.id, req.validatedData, req.user);
// A business metric with low-cardinality labels.
paymentsCompleted.inc({ method: req.validatedData.method });
req.log.info({ orderId: req.params.id }, 'payment completed');
res.json(result);
// No try/catch: errors bubble up to the central handler, which already logs the
// 5xx with a full stack, returns internal_error + traceId and counts the metric.
// Latency is measured by metricsMiddleware for ALL routes alike.
})
);Solution 2
| Metric | Type | Labels (cardinality) | Question it answers |
|---|---|---|---|
aroma_subscriptions_active |
Gauge | frequency (2), roast (3) = 6 |
How many subscriptions are live right now? |
aroma_subscriptions_created_total |
Counter | clientSource (~4) |
How many sign-ups per day? The trend |
aroma_subscriptions_cancelled_total |
Counter | reason (~5) |
How much churn is there and why? |
aroma_subscription_charges_total |
Counter | result (ok/failure/retry) = 3 |
How many charges fail? |
aroma_subscription_shipments_generated_total |
Counter | result (2) |
Are the monthly cycle's shipments being generated? |
aroma_subscription_charge_duration_seconds |
Histogram | result (3) |
How long does the gateway take? |
The main business metric: aroma_subscriptions_active. It is the one that reflects the feature's real value; if it falls, there is a problem even with every technical indicator green.
Labels rejected on cardinality grounds: customerId (millions), subscriptionId (unlimited), coffeeId (it grows with the catalogue, and with thousands of lines it would be a problem).
A useful alert: "the proportion of subscription charges with result failure exceeds 5% over a 30-minute window". It meets the four conditions: it is actionable (check the gateway and expired payment methods), it is urgent (every hour is lost revenue and annoyed customers), it can be documented in a runbook, and it is not noisy, because a small percentage of failures is normal and the threshold sits above it.
A complementary alert: "the monthly shipment generation process has recorded no increase in aroma_subscription_shipments_generated_total during its execution window". It detects the most dangerous silent failure of a scheduled process: that it simply did not run.
Solution 3
Step 1 — Metrics: bounding the problem (2 minutes).
- The error rate by route chart: is it only
POST /v1/ordersor others too? If it is all of them, that points to something cross-cutting (the database, a deployment). If it is only that one, to its logic or to one of its dependencies. - When exactly did it start? Correlate with the deployment history. A sharp jump at an exact time is usually a deployment or a configuration change; a gradual rise points to resource exhaustion or data growth.
- Latency: if the p99 rose before the errors, they are probably timeouts. If the errors appeared with no change in latency, it is a logic failure (an exception).
aroma_orders_created_total: has it fallen? It confirms the real business impact and gives urgency.- Saturation: memory, event loop, connection pool. It rules resource exhaustion in or out.
Step 2 — Traces: locating where it breaks (3 minutes).
- Filter traces for
POST /v1/orderswith an error in the last 15 minutes. - Look at the span tree: which span does the trace end on? The possible results and how to read them:
- It cuts off at
grpc.inventory.ReserveStock→ the inventory service is failing or slow. - It cuts off at
db.transaction→ a database problem: a lock, the disk, a half-applied migration. - It finishes quickly without reaching the dependencies → an exception in the validation or in the logic.
- Every span is normal but the total is enormous → contention or garbage collector pauses.
- It cuts off at
- Compare against a correct trace from before the incident: the difference leaps out.
Step 3 — Logs: getting the detail (2 minutes).
- Take the
traceIdfrom a failing trace and search for all its lines. - Read the
error-level line: message, exception type and full stack. - Check the pattern: do all orders fail or only some? Filter by
oauthClient(only the SPA? only CataBox?), bycustomerId(one specific customer with odd data?), by the number of items in the order. - Look for
warns in the preceding minutes: slow queries, Redis down,429s. The root cause often shows up there before the error does.
Conclusion and action. With the three steps you have: what is failing (the metric), where (the trace) and why (the log). If the cause is a recent deployment, it is rolled back before investigating any further — restore the service first, understand afterwards. If it is an external dependency, the circuit breaker from 04-04 is engaged to degrade rather than fail. And the incident is closed with two things: a regression test that reproduces it (03-08) and, if the alert arrived late, a new alert or an adjusted threshold.
Conclusion
Observability is the layer that turns a system that works into a system that can be operated. You have finally replaced the console.log at position 5 with pino and structured JSON logs, with a child logger per request carrying the traceId we have been emitting since 03-02, the route logged as a template rather than a URI, levels assigned properly — 4xxs are not errors — allowlisted serialisers and automatic redaction of passwords, tokens and personal data, with its test and with the retention policy the GDPR demands. You have seen how Aroma-Trace-Id is correlated end to end, from the SPA through the inventory gRPC call and the webhooks to SwiftShip, and how the circle opened in 03-07 closes: to the team, the full stack; to the client, internal_error and a reference with which support finds everything in ten seconds. You have instrumented the API with prom-client following the four golden signals and the RED method, with histograms whose buckets come out of 04-06's latency budget, business metrics — orders created, stock depleted, 429s emitted, cache hits — and a protected /metrics outside /v1, knowing why labelling with ord_5001 blows the system up. You know about distributed tracing, W3C's traceparent and OpenTelemetry, and you can read a span tree for POST /v1/orders and discover that 70% of the time was somewhere you were not looking. And you have separated liveness from readiness, defined SLOs with an error budget, and chosen what to alert on — symptoms, never CPU — and what to watch on launch day.
That closes module 4. The Aroma Store API is no longer merely correct: it has design judgement with identified antipatterns and contract linting; it knows its threats and defends against them with helmet, secrets management and GDPR controls; it delegates third-party access with OAuth 2.0 and OpenID Connect without ever seeing a password; it protects itself from abuse with per-tier limits, 429 and Retry-After; it lets the SPA and the back office in without opening the door to anyone else; it answers 304 instead of repeating itself and closes optimistic concurrency with If-Match and the 412; and it reports what is happening to it in correlated logs, metrics and traces. It is an API ready for production.
What is missing is no longer the API: it is the tools around it and the teamwork that sustains it. In module 5, Tools and Frameworks, we will stop writing server code and start working on it: Postman for exploring, testing and sharing executable collections (05-01); Swagger and OpenAPI for turning that openapi.yaml we have been carrying since 02-08 into living documentation, client generation and validation (05-02); a comparative tour of the popular frameworks — Fastify, NestJS, Django REST, Spring Boot, ASP.NET Core — so you know what is gained and what is lost by choosing each (05-03); contracts, mocks and automated tests so that consumer and provider do not break each other (05-04); continuous integration and deployment, where Spectral, npm audit, the tests and the readiness endpoint we have just written become a pipeline that deploys with no downtime (05-05); and API gateways and developer portals, where several of this module's mechanisms — rate limiting, authentication, caching, metrics — reappear solved one layer above (05-06).
REST API Course: Principles of Designing and Developing RESTful APIs
Module 1: Introduction to RESTful APIs
- What Is an API?
- History and Evolution of APIs
- HTTP Fundamentals for APIs
- Basic Principles of REST
- The Richardson Maturity Model and HATEOAS
- REST vs. SOAP
- REST Compared with GraphQL, gRPC and Webhooks
Module 2: Designing RESTful APIs
- RESTful API Design Principles
- Resources and URIs
- HTTP Methods
- HTTP Status Codes
- Representations, Headers and Content Negotiation
- Filtering, Sorting, Pagination and Search
- API Versioning
- API Documentation
Module 3: Building RESTful APIs
- Setting Up the Development Environment
- Building a Basic Server
- Handling Requests and Responses
- Input Data Validation
- Persistence and the Data Access Layer
- Authentication and Authorisation
- Error Handling
- Testing and Validation
Module 4: Best Practices and Security
- API Design Best Practices
- Security in RESTful APIs
- OAuth 2.0 and OpenID Connect in Practice
- Rate Limiting and Throttling
- CORS and Security Policies
- HTTP Caching and Performance
- Observability: Logs, Metrics and Traces
Module 5: Tools and Frameworks
- Postman for API Testing
- Swagger and OpenAPI for Documentation
- Popular Frameworks for RESTful APIs
- Contracts, Mocks and Automated API Testing
- Continuous Integration and Deployment
- API Gateways and Developer Portals
