Module 5 ended on an uncomfortable question: we have seven services deployed on Kubernetes with canary releases and GitOps, but if an order's saga gets stuck tomorrow, how will we know? In the monolith the answer was "look at the server log": one process, one file, one grep. At TechCorp there are now dozens of pods that start and die, a request crosses three or four services, and a saga stays alive minutes after the HTTP request has finished. This lesson builds the first two legs of observability —structured logs and metrics— on top of the @techcorp/common-http library and the manifests we already have, and leaves the third, traces, for 06-02.
Contents
- The three pillars of observability, and why "look at the log" is no longer enough
- Structured logging with pino: extending
createLogger - What not to log: redacting sensitive data
- Log collection in Kubernetes: Promtail/Alloy and Loki
- LogQL queries: following
ord-88213across several services - Metrics: types, the RED method and the USE method
- Instrumentation with
prom-client:GET /metricsin the library - Business metrics: orders, saga and outbox
- Prometheus on Kubernetes and basic PromQL queries
- Grafana dashboards and logs ↔ metrics correlation
- Cost and cardinality
- The three pillars of observability, and why "look at the log" is no longer enough
Monitoring is checking that the system does what we expect (does POST /v1/orders respond? how fast?). Observability is being able to answer questions we had not anticipated ("why has Ana Ruiz's order been sitting in STOCK_RESERVED for 20 minutes?") from what the system emits. It rests on three complementary signals:
| Pillar | What it is | Question it answers | Tool at TechCorp |
|---|---|---|---|
| Logs | Discrete events with context (text or JSON) | "What exactly happened to this order?" | pino → stdout → Promtail/Alloy → Loki |
| Metrics | Aggregated numeric time series | "How many, how fast, with what error rate?" | prom-client → Prometheus → Grafana |
| Traces | The path of a request through several services | "Where did the time go? Which hop failed?" | OpenTelemetry → Jaeger (06-02) |
In the monolith, log and trace were almost the same thing: a request was one thread of execution inside one process. With microservices, three things change: there are many ephemeral processes (a kubectl logs shows only one pod, and the pods of a rolling update from 05-04 disappear along with their logs: they have to be shipped out of the container and centralized); one action crosses several services (POST /v1/orders goes through the gateway, Orders, Catalog and Customers, and the saga carries on through RabbitMQ in Inventory, Payments and Notifications: nothing can be read without a common identifier, the X-Request-Id from 03-01 and the eventId from 03-02); and volume (on Black Friday the catalog multiplies ×20: reading logs by hand does not scale, and metrics summarize millions of requests in a handful of numbers).
Rule of the module: logs tell the detail, metrics tell the trend, and traces tell the path. The three are tied together by common identifiers.
- Structured logging with pino: extending
createLogger
createLoggerA "text" log (Order ord-88213 created for c-1024) is easy to read and almost impossible to query. A structured log is one JSON object per line: every field can be filtered and aggregated. In 04-02 the library already offered createLogger({service}) built on pino; now we settle its final shape:
// @techcorp/common-http/src/logger.js
const pino = require('pino');
function createLogger({ service, version = process.env.SERVICE_VERSION, level = process.env.LOG_LEVEL || 'info' }) {
return pino({
level,
base: { service, version }, // fields present on EVERY line
messageKey: 'message',
timestamp: pino.stdTimeFunctions.isoTime, // "time":"2026-08-15T10:21:04.512Z"
formatters: {
level: (label) => ({ level: label }) // "level":"info" instead of "level":30
},
redact: { // see section 3
paths: ['req.headers.authorization', 'req.headers.cookie', '*.card', '*.email', '*.password'],
censor: '[REDACTED]'
}
});
}
module.exports = { createLogger };Line by line:
level: the threshold. It comes fromLOG_LEVEL(04-03),infoin production anddebuglocally. Below the threshold pino does not even serialize the object: lowering the level costs no CPU.base: fixed fields added to every line.serviceis mandatory (orders-service);version(the image tag, 05-03) lets us compare the canary with the stable version.messageKey: 'message'andformatters.level: we replace pino's tersemsgand numeric level with full names and the level label, so the whole JSON reads consistently alongside our identifiers; an ISOtimestampis understood by Loki and Grafana without any configuration;redactis covered in section 3.
The levels and when to use them:
| Level | Use at TechCorp | Example |
|---|---|---|
fatal |
The process cannot continue | Invalid config at startup (zod, 04-03) |
error |
Unexpected failure that requires attention | Unhandled exception, message sent to the DLQ |
warn |
Tolerated anomaly | Retry against Catalog, nack with requeue |
info |
Business milestones and lifecycle | Order created, event published, server started |
debug |
Detail for development | Body of the request to Catalog, SQL executed |
The standard fields the four teams agreed on, so that Loki queries look the same in every service:
| Field | Where it appears | Source |
|---|---|---|
service, version, level, time, message |
Always | base and pino |
requestId |
Everything that happens inside an HTTP request | req.id from requestIdMiddleware() (04-02) |
orderId |
Whenever the operation touches an order | Use case / consumer |
eventId, eventType, queue |
In RabbitMQ consumers | Message headers (03-02) |
err |
In error/warn |
pino's standard serializer (message, stack, code from BusinessError) |
To avoid repeating those fields on every call we use child loggers (logger.child(...)). This is how the use case from 04-04 ends up:
// orders-service/src/use-cases/createOrder.js (excerpt)
async function execute(data, { requestId }) {
const log = logger.child({ requestId, customerId: data.customerId });
log.info({ lines: data.lines.length }, 'creating order');
const order = await repository.saveWithOutbox(/* ... */);
log.info({ orderId: order.id, total: order.total }, 'order created');
return order;
}In the saga consumer (messaging/sagaConsumer.js) the child is created from the message: logger.child({ eventId: event.id, eventType: event.type, orderId: event.data.orderId, queue: 'orders.saga' }). Real output of one line (one per row, no line breaks):
{"level":"info","time":"2026-08-15T10:21:04.512Z","service":"orders-service","version":"1.4.2","requestId":"req-01J5Q7X2N9C4M8Z0K1T3V6W8Y","customerId":"c-1024","orderId":"ord-88213","total":79.7,"message":"order created"}Access logs with pino-http. One line per HTTP request, generated by the middleware and not by hand. It goes into the library template right after requestIdMiddleware() so that it uses the same req.id:
// @techcorp/common-http/src/accessLogMiddleware.js
const pinoHttp = require('pino-http');
function accessLogMiddleware({ logger }) {
return pinoHttp({
logger,
genReqId: (req) => req.id, // reuses the req-<ulid> from requestIdMiddleware
autoLogging: { ignore: (req) => req.url.startsWith('/health') || req.url === '/metrics' },
customLogLevel: (req, res, err) => (err || res.statusCode >= 500 ? 'error' : res.statusCode >= 400 ? 'warn' : 'info'),
serializers: {
req: (req) => ({ method: req.method, route: req.url }),
res: (res) => ({ code: res.statusCode })
},
customProps: (req) => ({ requestId: req.id })
});
}genReqId: if we did not set it,pino-httpwould generate its own id and we would lose the correlation withX-Request-Id;autoLogging.ignoreavoids logging the Kubernetes probes (05-02), which call/health/*every 5-10 s.customLogLevel: a 5xx shows up aserrorwithout anyone having to remember; theserializersreducereq/resto method, route and code (pino-httpalso addsresponseTimein ms).
- What not to log: redacting sensitive data
A centralized log is read by many people and kept for weeks. Whatever goes into it must be readable without special permissions. At TechCorp it is forbidden to log: personal data beyond the identifier (customerId yes; Ana Ruiz's name, email or address, no); payment data (card number, CVV, PSP tokens); credentials (the Authorization header, cookies, passwords, the full RABBITMQ_URL, which contains user and password); and full request bodies at info (at debug only if they go through redaction).
pino's redact option does it for us: any property matching paths is replaced with [REDACTED] before serializing. *.card means "a card key in any top-level object". An example:
log.info({ payment: { card: '4111111111111111', amount: 79.7 } }, 'charge requested');
// {"...","payment":{"card":"[REDACTED]","amount":79.7},"message":"charge requested"}Redaction is a safety net, not a license: the rule is not to pass that data to the logger at all. The full treatment (encryption, secrets, auditing) belongs to 07-03; here it is enough that the logger does not leak them.
- Log collection in Kubernetes: Promtail/Alloy and Loki
Services write to stdout (decision from 05-01: twelve factors, no files inside the container). The kubelet stores that output in /var/log/containers/*.log on the node, and an agent deployed as a DaemonSet (one pod per node) reads it, adds the Kubernetes labels and ships it to the central store. At TechCorp the store is Loki (same family as Grafana and Prometheus; it indexes only labels and stores the text compressed: cheap and sufficient for our volume) and the agent is Promtail or its successor Grafana Alloy.
Excerpt of the Promtail configuration (Helm values.yaml, installed by the Platform team in the observability namespace):
config:
clients:
- url: http://loki.observability.svc.cluster.local:3100/loki/api/v1/push
snippets:
pipelineStages:
- cri: {} # parses the container log format (timestamp, stream, text)
- json: # extracts fields from pino's JSON
expressions:
level: level
service: service
- labels: # only these two become indexed labels
level:
service:
extraRelabelConfigs:
- source_labels: [__meta_kubernetes_pod_label_app] # app label of the Deployment (05-02)
target_label: app
- source_labels: [__meta_kubernetes_pod_label_version]
target_label: versionclients.url: where it pushes the lines; Loki is just anotherServicein the cluster.pipelineStages:cristrips the runtime wrapper;jsonextractslevelandservice;labelspromotes only those to labels; the rest (requestId,orderId) stays in the text and is filtered at query time.extraRelabelConfigs: turns the pod labelsappandversion(the same ones from theDeploymentin 05-02) into Loki labels. That is why we query by{app="orders-service"}.
Why not promote requestId to a label? Because every distinct value creates a new stream and Loki degrades with millions of them: labels for what has few values (app, version, level, namespace), text for what has infinitely many (requestId, orderId). The same rule will come back with Prometheus in section 11.
Equivalent alternatives, so you recognize them when they show up:
| Stack | Agent | Store | Viewer | When to choose it |
|---|---|---|---|---|
| PLG (chosen) | Promtail / Alloy | Loki | Grafana | We already use Grafana; medium volume; low cost |
| EFK / ELK | Fluent Bit / Fluentd / Logstash | Elasticsearch / OpenSearch | Kibana | Heavy full-text search; teams already operating Elastic |
| SaaS (Datadog, Cloud Logging…) | Vendor agent | Managed | Vendor's own | No Platform team; the per-GB cost is acceptable |
- LogQL queries: following
ord-88213 across several services
ord-88213 across several servicesLogQL has two parts: a label selector in braces and a pipeline of filters. The queries Luis's team uses every day:
# (1) everything Orders has written about one order
{app="orders-service"} |= "ord-88213"
# (2) one request across ALL the services in the namespace
{namespace="techcorp"} | json | requestId="req-01J5Q7X2N9C4M8Z0K1T3V6W8Y"
# (3) all errors from all services, compact
{app=~".*-service", level="error"} | json | line_format "{{.service}} {{.message}} {{.err.message}}"
# (4) did Inventory receive the event?
{app="inventory-service"} | json | eventType="order.created" | orderId="ord-88213"
# (5) errors per service in 5-minute windows (metric derived from logs)
sum by (app) (count_over_time({namespace="techcorp", level="error"}[5m]))|=means "contains the text": the creation, the event in the outbox, each step of the saga.| jsonparses the line and lets you filter by exact field; the result is the lines from the gateway, Orders, Catalog and Customers in chronological order. This is why 03-01 insisted on propagatingX-Request-Id: without it, this query does not exist.=~is a regular expression over the label;line_formatreduces each line to what matters.- If it returns nothing and Orders shows "event published", the problem lies between the outbox relay and the
inventory.ordersqueue (03-02). - LogQL aggregates too; useful for exploring, but for what is watched permanently a real metric is better, which is what comes next.
- Metrics: types, the RED method and the USE method
A metric is a number with a name and labels, sampled periodically (Prometheus scrapes every service every 15-30 s). Prometheus defines four types:
| Type | What it is | Only goes up | TechCorp example | How it is queried |
|---|---|---|---|---|
| Counter | Cumulative count | Yes | http_requests_total, orders_created_total |
With rate(): the speed matters, not the value |
| Gauge | Instantaneous value that goes up and down | No | outbox_pending, messages in a queue |
Directly, or max_over_time |
| Histogram | Distribution in cumulative buckets + sum + count | Yes | http_request_duration_seconds, saga_duration_seconds |
histogram_quantile() for percentiles; aggregates across pods |
| Summary | Percentiles computed on the client | Yes | (we do not use it) | Cannot be aggregated across pods: that is why we prefer histograms |
Two methods tell us what to measure without making it up:
- RED, for services (what the Orders team does): Rate (requests/s), Errors (proportion of failures), Duration (latency, with percentiles). With these three per service and per route you see 90% of the problems.
- USE, for resources (what the Platform team watches): Utilization (% of CPU, memory, disk), Saturation (waiting queue: messages in RabbitMQ, connections waiting in the
pgpool), Errors (errors of the resource). Kubernetes already exposes CPU and memory per pod through cAdvisor andkube-state-metrics; we contribute the RabbitMQ and pool ones.
- Instrumentation with
prom-client: GET /metrics in the library
prom-client: GET /metrics in the libraryprom-client is the official Prometheus library for Node.js. Just as we did with the logger, the HTTP instrumentation lives once in @techcorp/common-http and every service gets it:
// @techcorp/common-http/src/metrics.js
const client = require('prom-client');
function createMetrics({ service }) {
const registry = new client.Registry();
registry.setDefaultLabels({ service });
client.collectDefaultMetrics({ register: registry }); // CPU, memory, event loop lag, Node GC
const requests = new client.Counter({
name: 'http_requests_total',
help: 'HTTP requests received',
labelNames: ['method', 'route', 'code'],
registers: [registry]
});
const duration = new client.Histogram({
name: 'http_request_duration_seconds',
help: 'Duration of HTTP requests in seconds',
labelNames: ['method', 'route', 'code'],
buckets: [0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2, 5],
registers: [registry]
});
function middleware(req, res, next) {
const end = duration.startTimer();
res.on('finish', () => {
const route = req.route ? req.baseUrl + req.route.path : 'unknown'; // "/v1/orders/:id", NOT "/v1/orders/ord-88213"
const labels = { method: req.method, route, code: String(res.statusCode) };
requests.inc(labels);
end(labels);
});
next();
}
async function metricsHandler(req, res) {
res.set('Content-Type', registry.contentType);
res.end(await registry.metrics());
}
return { registry, middleware, metricsHandler };
}
module.exports = { createMetrics };Explanation:
- A dedicated
Registryper service, withserviceas a default label: every series carriesservice="orders-service"without repeating it. collectDefaultMetricsaddsprocess_cpu_seconds_total,nodejs_heap_size_used_bytes,nodejs_eventloop_lag_seconds… for free. That last one is gold in Node: if it rises, the single thread is blocked (06-04).http_requests_total{method, route, code}: the RED counter. Theroutelabel uses the Express template (/v1/orders/:id), never the real URL, or we would create one series per order (section 11). If no route matched (404), we label itunknown.http_request_duration_seconds: histogram with buckets from 10 ms to 5 s. The 2 s bucket matchesHTTP_TIMEOUT_MS=2000(03-01): that way we see how many requests brush against the limit.startTimer()returns a function that, when called with the labels, observes the elapsed seconds; it is called onfinish, once the response has been sent.metricsHandlerserves the text in Prometheus format.
In the template's createApp (04-02) the order becomes: requestIdMiddleware() → accessLogMiddleware({logger}) → metrics.middleware → routes → errorMiddleware({logger}), plus a route app.get('/metrics', metrics.metricsHandler). Output of curl localhost:3002/metrics (trimmed):
http_requests_total{method="POST",route="/v1/orders",code="202",service="orders-service"} 1287
http_requests_total{method="POST",route="/v1/orders",code="503",service="orders-service"} 4
http_request_duration_seconds_bucket{method="POST",route="/v1/orders",code="202",le="0.1",service="orders-service"} 812
http_request_duration_seconds_bucket{method="POST",route="/v1/orders",code="202",le="0.25",service="orders-service"} 1240
http_request_duration_seconds_bucket{method="POST",route="/v1/orders",code="202",le="+Inf",service="orders-service"} 1287
http_request_duration_seconds_sum{...} 142.8
http_request_duration_seconds_count{...} 1287Each _bucket{le="0.25"} counts the requests that took ≤ 0.25 s (cumulative): 1,240 out of 1,287. That is where the p95 in section 9 comes from.
- Business metrics: orders, saga and outbox
RED metrics say the service responds; business metrics say the store sells. They are declared in the service itself against the same registry:
// orders-service/src/metrics.js
const client = require('prom-client');
function createOrdersMetrics({ registry, db }) {
const created = new client.Counter({ name: 'orders_created_total', help: 'Orders created', registers: [registry] });
const cancelled = new client.Counter({
name: 'orders_cancelled_total', help: 'Orders cancelled by reason',
labelNames: ['reason'], registers: [registry] // OUT_OF_STOCK | PAYMENT_REJECTED | PAYMENT_TIMEOUT
});
const sagaDuration = new client.Histogram({
name: 'saga_duration_seconds', help: 'Seconds from order.created to order.confirmed',
buckets: [1, 2, 5, 10, 30, 60, 120, 300, 900], registers: [registry] // 900 = expires_at of the reservation
});
new client.Gauge({
name: 'outbox_pending', help: 'Unpublished outbox rows',
registers: [registry],
async collect() { // runs on every scrape
const { rows } = await db.query('SELECT count(*)::int AS n FROM outbox WHERE published_at IS NULL');
this.set(rows[0].n);
}
});
return { created, cancelled, sagaDuration };
}orders_created_totalis incremented in the use case after the transaction commits;orders_cancelled_total{reason}in the saga consumer when it processesorder.cancelled(only three values ofreason: controlled cardinality).saga_duration_seconds: when the order is closed,sagaDuration.observe((now - order.created_at) / 1000); the buckets go up to 900 s because a reservation expires after 15 minutes (02-05); in 06-05 it will be the basis of the SLI "confirmed in < 60 s".outbox_pendingwithcollect()queries the table when Prometheus scrapes: it is the metric that gives away a dead relay (orders are created but their events never leave).
RabbitMQ is measured with its rabbitmq_prometheus plugin (port 15692), which the Helm chart from 05-02 enables with metrics.enabled: true. It exposes, among others, rabbitmq_queue_messages_ready{queue="inventory.orders"} (messages waiting for a consumer) and the same for orders.saga.dlq. A DLQ with messages is always an anomaly (03-02), and in 06-05 it will be an alert.
- Prometheus on Kubernetes and basic PromQL queries
Prometheus discovers what to scrape through the Kubernetes API. With the operator (kube-prometheus-stack, installed by Platform) you declare one ServiceMonitor per service, next to the Service from 05-02:
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: orders-service
namespace: techcorp
labels:
release: kube-prometheus-stack # the operator only looks at ServiceMonitors with this label
spec:
selector:
matchLabels:
app: orders-service # matches the Service labels
endpoints:
- port: http # port name in the Service (3002)
path: /metrics
interval: 30sWithout the operator, the alternative is annotations on the Pod (prometheus.io/scrape: "true", prometheus.io/port: "3002", prometheus.io/path: "/metrics") that the classic kubernetes_sd_configs configuration interprets. The ServiceMonitor goes into each service's Kustomize base; new services inherit it from the template.
The PromQL queries that answer the RED questions (all runnable in Grafana → Explore):
# (1) Rate: requests per second per service
sum by (service) (rate(http_requests_total[5m]))
# (2) Errors: proportion of 5xx (0.002 = 0.2%)
sum by (service) (rate(http_requests_total{code=~"5.."}[5m]))
/ sum by (service) (rate(http_requests_total[5m]))
# (3) Duration: p95 per service and route
histogram_quantile(0.95, sum by (le, service, route) (rate(http_request_duration_seconds_bucket[5m])))
# (4) condition: stuck outbox
outbox_pending > 100
# (5) cancellations by reason in the last hour
sum by (reason) (increase(orders_cancelled_total[1h]))
# (6) any DLQ with messages
rabbitmq_queue_messages_ready{queue=~".*\\.dlq"} > 0ratecomputes the slope of the counter over the window; 5 m smooths the noise. A raw counter is never graphed.- The numerator filters by code regex, the denominator is the total; both aggregated by the same label so the division matches.
- The rate of each bucket is aggregated (
lemust be preserved) andhistogram_quantileinterpolates the percentile. It can be aggregated across pods because buckets are summable; with a summary we could not. - Returns only the series whose value exceeds 100: empty if all is well. That is the shape of an alert condition, which 06-05 will turn into a
PrometheusRule. increaseisrate× window: it gives units, not units/s.- A DLQ with messages is always an anomaly (03-02).
- Grafana dashboards and logs ↔ metrics correlation
Grafana reads Prometheus and Loki as datasources. We agree on two dashboards, versioned as JSON in techcorp/platform/observability/dashboards/ and loaded by a ConfigMap with the label grafana_dashboard: "1" (the chart's sidecar imports them):
Dashboard "RED per service" with a $service variable (values from label_values(http_requests_total, service)) and four panels: requests/s, % of 5xx, p50/p95/p99, and CPU/memory of the pod (USE) for correlation. The essentials of one panel's JSON:
{
"title": "p95 latency by route",
"type": "timeseries",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"targets": [{
"expr": "histogram_quantile(0.95, sum by (le, route) (rate(http_request_duration_seconds_bucket{service=\"$service\"}[5m])))",
"legendFormat": "{{route}}"
}],
"fieldConfig": { "defaults": { "unit": "s", "thresholds": { "steps": [{ "color": "green" }, { "color": "red", "value": 0.3 }] } } }
}expr is the query from section 9 with the variable; legendFormat names each line by route; thresholds paints red above 300 ms, the target that will be formalized in 06-05.
Dashboard "Orders saga" (Luis's team): orders created/h, cancelled by reason (stacked bars), p95 of saga_duration_seconds, outbox_pending, messages ready in inventory.orders/payments.stock/orders.saga and their DLQs, and a status panel with a direct SQL query against PostgreSQL (SELECT status, count(*) FROM orders WHERE created_at > now() - interval '1 hour' GROUP BY status), which shows how many orders sit in each status (PENDING, STOCK_RESERVED, PAID, CONFIRMED, CANCELLED). A growing pile in STOCK_RESERVED is the signature of "Payments is not responding".
Correlation. Metrics and logs share service/app and version (Kubernetes labels) and time. The typical flow: the errors panel rises at 10:21 → click on the point → "Explore" with {app="orders-service", level="error"} over the same window → the line with requestId and err.code=DEPENDENCY_UNAVAILABLE appears → second query by that requestId across the whole namespace → you see that Catalog returned 503. Grafana lets you configure that jump (metric → logs) as a data link on the panel; the jump from logs to traces (trace_id) is added in 06-02.
- Cost and cardinality
Every distinct combination of labels is a series that Prometheus keeps in memory. http_requests_total with 6 methods × 15 routes × 8 codes × 20 pods is 14,400 series: fine. If someone adds orderId as a label, with 3,000 orders/day that is 3,000 new series every day per pod: within a month Prometheus runs out of memory. Rules:
- Labels with few known values (
service, templateroute,code,reason,queue). Never identifiers (orderId,customerId,requestId,eventId), full URLs or bodies. - Identifiers go into the log (text, not a Loki label) and into the trace (06-02). That split is the reason the three signals exist.
- Watch the cost:
count({__name__=~".+"})in Prometheus gives the total number of series; Loki volume is kept in check with retention (30 days at TechCorp), by not logging/health, and withLOG_LEVEL=infoby default (debugin production multiplies volume by 10-50 and is only switched on briefly through aConfigMapchange).
Common Mistakes and Tips
- Concatenating into the message what should be a field (
log.info('order ' + id + ' created')). Afterwards you cannot filter byorderId. The message is constant; the context goes in the object. - Losing the
requestIdalong the way. If a service generates its own instead of propagating the one it received, the cross-service LogQL query breaks right there.requestIdMiddleware()reuses the incoming one; the HTTP clients (04-04) forward it; events carryeventIdand, in addition, the originatingrequestIdin headers. - Logging the probes and
/metrics. Thousands of useless lines per day per pod. - Real URLs as the
routelabel. Cardinality explosion. Use the Express template andunknownfor 404s. - Tip: in development,
LOG_LEVEL=debugandnode src/server.js | npx pino-prettyto read logs in a human-friendly form without changing the production format.
Exercises
Exercise 1: Inventory consumer with correlated logs
The inventory.orders consumer receives order.created and reserves stock. Write the snippet that creates the child logger with the standard fields from section 2 and emits: an info on receipt, a warn if the reservation fails for lack of stock (with productId and requested/available), and an error if the database fails. Then write the LogQL query that shows only the stock shortages for product p-501 in the last hour.
Exercise 2: detecting a dead relay
Marta asks: "if the Orders outbox relay dies but the pod stays alive, which panel would show it and which query proves it?". Answer with the metric, the PromQL query and the LogQL query that would confirm the cause.
Solutions
Exercise 1
async function processOrderCreated(event, { logger, reservations }) {
const log = logger.child({ eventId: event.id, eventType: event.type, orderId: event.data.orderId,
queue: 'inventory.orders', requestId: event.headers?.requestId });
log.info({ lines: event.data.lines.length }, 'event received');
try {
const result = await reservations.reserve(event.data);
if (!result.ok) {
log.warn({ productId: result.productId, requested: result.requested, available: result.available }, 'insufficient stock');
return publish('stock.rejected', /* ... */);
}
log.info({ reservationId: result.reservationId }, 'stock reserved');
} catch (err) {
log.error({ err }, 'error reserving stock');
throw err; // the consumer will nack (03-02)
}
}(The "last hour" time range is chosen in Grafana; it is not needed in the query.)
Exercise 2
The metric is outbox_pending (gauge with collect()): it keeps growing because POST /v1/orders keeps inserting rows and nobody publishes them. Query: outbox_pending{service="orders-service"} > 100 (or its slope: deriv(outbox_pending[10m]) > 0). Complementary: rate(orders_created_total[5m]) > 0 while at the same time rabbitmq_queue_messages_ready{queue="inventory.orders"} == 0 for minutes. On the saga dashboard you would see the outbox_pending panel climbing and the status panel with all new orders in PENDING. Cause in Loki: {app="orders-service"} | json | message=~"relay.*" would show the last "batch published" too long ago, or an error from the RabbitMQ channel; if the relay died without logging, the absence of lines is the clue. In 06-05 this will become an alert.
Conclusion
We have given TechCorp its first two observability signals. Logs are structured JSON with pino, with common fields (service, version, level, requestId, orderId, eventId), redaction of sensitive data and one access line per request via pino-http; they go out through stdout, Promtail/Alloy labels them with app/version and Loki stores them, so that {namespace="techcorp"} | json | requestId="..." reconstructs a request across several services. Metrics live in @techcorp/common-http (http_requests_total, http_request_duration_seconds on GET /metrics) and in each service (orders_created_total, orders_cancelled_total{reason}, saga_duration_seconds, outbox_pending), Prometheus scrapes them through a ServiceMonitor and Grafana shows them on the RED and saga dashboards; the price to pay is cardinality discipline: identifiers in the logs, never in the labels. What we still cannot see is the path: we know POST /v1/orders takes 250 ms at the p95, but not how much of that time went to Catalog, to Customers or to PostgreSQL, nor how to join the HTTP request with the saga that continues through RabbitMQ. That is the third signal, distributed traces with OpenTelemetry, and it is the next lesson.
Microservices Course
Module 1: Introduction to Microservices
- Basic Concepts of Microservices
- Advantages and Disadvantages of Microservices
- Comparison with the Monolithic Architecture
- When to Adopt Microservices: Decision Criteria
- The Course Case Study: TechCorp's Online Store
Module 2: Microservice Design
- Microservice Design Principles
- Decomposing Monolithic Applications
- Defining Bounded Contexts
- Data Management: One Database per Service
- Distributed Consistency: Sagas, CQRS and Event Sourcing
Module 3: Communication between Microservices
- RESTful APIs
- Asynchronous Messaging
- Communication Protocols: gRPC, GraphQL
- API Gateway and Backend for Frontend
- Service Discovery and Load Balancing
- API Contracts and Versioning
Module 4: Implementing Microservices
- Choosing Technologies and Tools
- Building a Simple Microservice
- Configuration Management
- Hands-On Integration: Consuming APIs and Publishing Events
- Testing Microservices: Unit, Integration and Contract Tests
Module 5: Deployment and Orchestration
- Containers and Docker
- Orchestration with Kubernetes
- CI/CD for Microservices
- Deployment Strategies: Rolling, Blue-Green and Canary
- Service Mesh: Istio and Linkerd
Module 6: Monitoring and Maintenance
- Monitoring and Logging
- Distributed Tracing with OpenTelemetry
- Error Handling and Recovery
- Scalability and Performance
- SLOs, Alerts and Incident Management
Module 7: Security in Microservices
- Authentication and Authorization
- Communication Security
- Security Practices
- Container and Kubernetes Security
