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

  1. Monitoring versus observability
  2. The three pillars and the question each one answers
  3. Structured logs: why JSON and not text
  4. pino and the Aroma Store logger
  5. What to always log and what to never log
  6. Automatic redaction of sensitive fields
  7. The src/middleware/logging.js middleware
  8. End-to-end correlation with Aroma-Trace-Id
  9. 5xx errors: what gets logged versus what gets returned
  10. Metrics: the four golden signals and the RED method
  11. Metric types and why the histogram
  12. Instrumenting with prom-client and exposing /metrics
  13. Cardinality: why ord_5001 blows the system up
  14. Distributed tracing: spans, context and traceparent
  15. A trace of POST /v1/orders
  16. Health checks: liveness and readiness
  17. Useful alerts, SLOs and the error budget
  18. The minimum launch-day dashboard
  19. The usual stack
  20. Taking stock of module 4

  1. 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.

  1. 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:

  1. A metric fires the alert: the error rate on POST /v1/orders/{id}/payment has risen.
  2. A trace of a failing request shows where it breaks: the call to the gateway.
  3. A log carrying that trace's traceId gives the detail: the exact error message and the customerId.

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.

  1. Structured logs: why JSON and not text

What our middleware emits today:

POST /v1/orders/ord_5001/payment → 500 (312 ms)

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.

  1. 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.

npm install pino pino-http
npm install --save-dev pino-pretty
// 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.

  1. 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.

  1. 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.

  1. The src/middleware/logging.js middleware

Here 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);                                            // 16

Why 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 400 and 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.

  1. End-to-end correlation with Aroma-Trace-Id

The 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.

  1. 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.

  1. 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.

  1. 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"}  10000

The 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.

  1. Instrumenting with prom-client and exposing /metrics

npm install prom-client
// 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.

  1. Cardinality: why ord_5001 blows the system up

A 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'.

  1. Distributed tracing: spans, context and traceparent

When 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.

  1. A trace of POST /v1/orders

sequenceDiagram
  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 201 would 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
    }
  });
}

  1. 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.

  1. 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.01

Every 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).

  1. 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.

  1. 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.

  1. 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:

  1. console.log instead of the logger. No structure, no level, no traceId, and it never reaches the log system in the right format.
  2. It logs the complete body. A payment's body may contain card data. It is a serious security and GDPR failure.
  3. Explosive cardinality in the metric. route with the real identifier and a customer label carrying the customerId: one series per order and per customer.
  4. It only counts successes. The inc() sits inside the try after the operation, so errors are not counted and the error rate is always zero.
  5. It does not measure latency as a metric, it only prints it. There is no histogram and no percentiles.
  6. It returns error.message to the client. It leaks the system's internals: it breaks what was established in 03-07 and 04-02.
  7. It does not use the central error handler. The error's format does not meet the contract (code, message, details, traceId).
  8. asyncHandler() is missing: an uncaught rejection would leave the request hanging. (Here the try/catch papers over it, but the project's convention is asyncHandler.)

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/orders or 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/orders with 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.
  • Compare against a correct trace from before the incident: the difference leaps out.

Step 3 — Logs: getting the detail (2 minutes).

  • Take the traceId from 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?), by customerId (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

Module 2: Designing RESTful APIs

Module 3: Building RESTful APIs

Module 4: Best Practices and Security

Module 5: Tools and Frameworks

Module 6: Case Studies and Projects

© Copyright 2026. All rights reserved