DevOps · 07

Observ ability

You cannot operate what you cannot see. This page covers the three pillars and how to connect them: Prometheus metrics and PromQL, why cardinality matters, Spring Boot with Micrometer, structured logs with correlation ids, traces and the W3C traceparent header, OpenTelemetry and the Collector, then SLOs, error budgets and multi-window burn-rate alerts, with a Prometheus and Grafana lab you can run in minutes.

13
Chapters
3
Pillars
99.9%
Worked SLO
1
Docker lab
01
Chapter 1 - The Idea

Monitoring vs Observability, and the Three Pillars

Monitoring answers questions you decided to ask in advance: is the CPU above 90 percent, is the error rate above 1 percent, is the health check failing. You build dashboards and alerts for known failure modes. Observability is the property of a system that lets you ask new questions about its behaviour from the outside, without shipping new code: why are only requests from tenant 42 slow, why did latency double right after the 14:05 deploy. In practice the difference is whether your telemetry is rich enough (high-detail events, consistent identifiers, traces) to debug failures you did not predict.

Telemetry comes in three signal types, usually called the three pillars. They are complementary, not competing. Each is good at one question and poor at the others.

Three pillars, one investigation: alert, dashboard, trace, log
Metrics: numbers over time Is something wrong? How bad? Since when? cheap, aggregated, fixed cost per series Traces: one request, many hops Where in the call chain is the time or error? per request, sampled Logs: discrete events What exactly happened in this one case? per event, cost grows with volume A typical investigation moves left to right: 1 Alert fires (symptom, metric) 2 Dashboard which service, which route 3 Trace which hop is slow 4 Logs of that trace id The glue is shared identifiers: labels on metrics, a trace id on spans and on every log line.
SignalWhat it isStrong atWeak atCost model
MetricsNumeric samples, aggregated, with labelsAlerting, trends, capacity, SLOs, dashboardsCannot explain a single request; detail is limited by label choicesGrows with the number of distinct label combinations (series), not with traffic
LogsTimestamped event recordsExact detail, error messages, audit trailHard to aggregate; hard to see a request across services unless you add an idGrows with volume and verbosity: storage, indexing, ingestion
TracesA tree of timed spans for one request across servicesLocating the slow or failing hop, understanding dependenciesExpensive to keep for every request; nothing if not instrumented end to endGrows with traffic; usually reduced by sampling
Key insight: Metrics are the dashboard lights of a car, traces are the route you took, logs are the diary of what happened at each stop. The warning light tells you something is wrong, the route shows where, the diary says why. You need a way to jump from one to the next, and that way is a shared identifier.
The questions each pillar answers
QuestionStart withThen
Is the service healthy right now?Metrics (golden signals dashboard)
Is the error rate above the SLO?Metrics (burn-rate alert)Dashboard to find the route
Why was this one checkout slow?Trace by trace idLogs filtered by the same trace id
Which downstream call dominates latency?Traces (span durations)Metrics on that dependency
What did user X do at 14:07?Logs (search by user/request id)Trace if the log carries a trace id
Do we need more pods next quarter?Metrics over weeks (USE method)

Two further signals appear in the OpenTelemetry world: profiles (continuous profiling, still maturing) and events. This page covers the three that every team needs and how to wire them together, using Prometheus, Grafana, Loki, Tempo and OpenTelemetry, with Spring Boot as the example application. Page 05 CI/CD Pipelines shows where these checks run after a deploy; page 08 Deployment Strategies uses the metrics from this page to gate canary releases.

02
Chapter 2 - Metrics

Prometheus: Data Model and the Pull Model

Prometheus is a time-series database plus a scraper plus a query language. Its data model is small. A time series is identified by a metric name and a set of labels (key/value pairs); each series stores (timestamp, float64) samples. http_server_requests_seconds_count{method="POST",status="200",uri="/orders"} is one series; changing any label value gives a different series.

Pull model: Prometheus scrapes /metrics from every target
Service discovery (Kubernetes, EC2, static, DNS) Prometheus server Scraper (every 15 s) TSDB (local disk) Rule engine + PromQL recording and alerting rules targets app pod 1 /metrics app pod 2 /metrics node exporter /metrics HTTP GET, text format Grafana (queries) PromQL Alertmanager A target that stops answering is itself a signal: the synthetic series up becomes 0.

Prometheus pulls: every scrape interval it sends GET /metrics (or whatever metrics_path says) to every target and stores what comes back. The text exposition format is plain lines of name{labels} value, plus # HELP and # TYPE comments. Pull has practical benefits: a dead target is detected immediately (the scrape fails, up becomes 0), you can curl any target by hand to see exactly what Prometheus sees, and targets need no knowledge of where Prometheus lives. Push is available for short-lived batch jobs through the Pushgateway, but it is the exception.

The four metric types
TypeWhat it isGoesTypical useHow you query it
CounterA cumulative total that only increases (resets to 0 on process restart)UpRequests, errors, bytes, orders placed. Name ends in _totalrate() or increase(), never the raw value
GaugeA value that goes up and downBothQueue depth, in-flight requests, memory in use, temperatureDirectly, or avg_over_time, max_over_time
HistogramObservations counted into cumulative buckets, plus _sum and _countUpRequest latency, payload sizeshistogram_quantile() over rate() of the buckets; can be aggregated across instances
SummaryClient-side computed quantiles, plus _sum and _countUpQuantiles when you do not need to aggregate across instancesRead the quantile series directly; quantiles cannot be averaged or summed across instances
Text exposition format (abridged, illustrative values; the real series from the lab in chapter 11 look like this)
# HELP orders_placed_total Orders accepted # TYPE orders_placed_total counter orders_placed_total{application="shop"} 12.0 # HELP orders_in_flight # TYPE orders_in_flight gauge orders_in_flight{application="shop"} 0.0 # HELP payment_call_seconds Latency of the payment provider call # TYPE payment_call_seconds histogram payment_call_seconds_bucket{application="shop",le="0.05"} 1 payment_call_seconds_bucket{application="shop",le="0.1"} 5 payment_call_seconds_bucket{application="shop",le="0.25"} 12 payment_call_seconds_bucket{application="shop",le="+Inf"} 12 payment_call_seconds_count{application="shop"} 12 payment_call_seconds_sum{application="shop"} 0.936224511

Histogram buckets are cumulative: the le="0.1" bucket counts every observation less than or equal to 0.1 seconds, so it includes the 0.05 bucket. _count equals the +Inf bucket. Average latency is _sum / _count; percentiles are estimated from the buckets by interpolation, so their accuracy depends on how well the bucket boundaries fit your real latencies.

⚠ Alerting on a raw counter value
  • A counter such as http_requests_total only ever climbs, and resets when the pod restarts. http_requests_total > 1000 is meaningless. Always wrap counters in rate() (per-second average over a window) or increase() (total growth over a window). rate() handles resets for you.
Naming conventions that Prometheus documents
  • Use base units (seconds, bytes, not milliseconds or megabytes), put the unit in the name (_seconds, _bytes), end counters in _total, use snake_case, and never put the label names or values in the metric name. Micrometer applies these conventions when it exports: a timer called payment.call becomes payment_call_seconds.
03
Chapter 3 - Metrics

Scrape Configuration and PromQL by Example

A minimal prometheus.yml has global timing, optional rule files and a list of scrape jobs. scrape_interval controls resolution and cost; the default is 1 minute, and 15 to 30 seconds is common. The job label is set from the job_name, and instance from the target address.

prometheus.yml (validated with promtool check config, Prometheus 3.5.0)
global: scrape_interval: 15s evaluation_interval: 15s rule_files: - /etc/prometheus/rules.yml scrape_configs: - job_name: shop metrics_path: /actuator/prometheus static_configs: - targets: ["shop-1:8080", "shop-2:8080"]

In Kubernetes you rarely list targets by hand. kubernetes_sd_configs discovers pods, and relabel_configs decides which to scrape and which labels to attach (usually through an annotation convention or, with the Prometheus Operator, ServiceMonitor and PodMonitor objects). The pipeline is always: discover, relabel (filter and add labels), scrape, optionally metric_relabel_configs to drop series before storing.

PromQL: the six patterns you will use daily

A selector like http_server_requests_seconds_count{uri="/orders"} returns an instant vector (one sample per matching series). Adding [5m] makes a range vector (the last five minutes of samples per series), which functions such as rate() consume. Aggregations (sum, avg, max, topk) collapse series, and by (...) or without (...) chooses which labels survive.

PromQL - the patterns (example queries against the lab application of chapter 11)
# 1. Request rate: per-second average over 5 minutes, summed over all pods sum(rate(http_server_requests_seconds_count{uri="/orders"}[5m])) # 2. Error ratio: failing requests divided by all requests sum(rate(http_server_requests_seconds_count{uri="/orders",status=~"5.."}[5m])) / sum(rate(http_server_requests_seconds_count{uri="/orders"}[5m])) # 3. Breakdown by label: requests per second per status code sum by (status) (rate(http_server_requests_seconds_count{uri="/orders"}[5m])) # 4. 95th percentile latency from a histogram, aggregated across all pods histogram_quantile(0.95, sum by (le) (rate(payment_call_seconds_bucket[5m]))) # 5. Average latency: sum of durations divided by number of calls rate(payment_call_seconds_sum[5m]) / rate(payment_call_seconds_count[5m]) # 6. Is anything down? up is 1 when the last scrape worked, 0 otherwise count(up{job="shop"} == 0)

I ran queries 2, 3, 4 and 5 against the sample application of chapter 11 with Prometheus 3.5.0. With about 10 percent of orders failing by design, the error ratio settled near 0.11, the 95th percentile of the payment call came out at about 0.2 seconds (the simulated call sleeps between 20 and 200 ms), and the status breakdown showed roughly one 502 for every eight 200 responses. Your numbers will differ; the point is that every query is a rate over a window, aggregated, and only then converted to a ratio or quantile.

FunctionDoesGotcha
rate(c[5m])Per-second average increase of a counter over the window, handles resetsWindow must contain at least 2 samples: use at least 4x the scrape interval
increase(c[1h])Total increase over the window (rate times seconds)Extrapolates at the edges, so it can return non-integers for integer counters
irate(c[5m])Rate between the last two samplesSpiky; good for zooming in, bad for alerting
histogram_quantile(q, ...)Estimates a quantile from le bucketsAggregate with sum by (le) before the function; never average percentiles
sum by (x)Keeps label x, adds up the restDropping le too early breaks histogram_quantile
avg_over_time(g[10m])Averages a gauge over timeNot for counters
predict_linear(g[1h], 4*3600)Linear projection, e.g. disk full in 4 hoursLinear only; fine for disks, poor for bursty values
Why sum by (le) first?
▶
A percentile of percentiles is meaningless. Buckets, however, are plain counters: adding them across pods gives the bucket counts of the combined traffic, and then interpolating gives a valid fleet-wide estimate
Why rate() inside histogram_quantile?
▶
Buckets are cumulative since process start. Without rate() you would get the quantile of all history. With a 5 minute rate() you get the quantile of the last five minutes
Why recording rules?
▶
Expensive queries (long windows, many series) are precomputed by the rule engine every interval and stored as new series such as orders:error_ratio:rate5m. Dashboards and alerts then read a cheap series
rules.yml - a recording rule and an alert (validated with promtool check rules)
groups: - name: shop-slo rules: - record: job:orders_error_ratio:rate1m expr: | sum(rate(http_server_requests_seconds_count{job="shop",uri="/orders",status=~"5.."}[1m])) / sum(rate(http_server_requests_seconds_count{job="shop",uri="/orders"}[1m])) - alert: OrdersHighErrorRatio expr: job:orders_error_ratio:rate1m > 0.05 for: 1m labels: severity: page annotations: summary: "More than 5% of POST /orders return 5xx"

In the lab this alert went from inactive to pending to firing as the error ratio stayed above 5 percent: for: 1m means the condition must hold continuously for a minute before the alert fires, which filters out single noisy evaluations.

Division by zero returns NaN, not zero
  • When there is no traffic the denominator is 0 and the ratio is NaN. Comparisons with NaN are false, so the alert stays quiet. That is usually what you want, but it means a total outage that also stops all traffic hides itself. Pair ratio alerts with a traffic-drop or up == 0 alert.
04
Chapter 4 - Metrics

Cardinality, RED, USE and the Four Golden Signals

Cardinality is the number of distinct time series. Prometheus keeps recent series in memory and indexes every label value, so its cost scales with the number of series, not with request volume. A busy service with a handful of label values is cheap; a quiet service with an unbounded label is not.

Cardinality: every label value multiplies the number of series
metric: http_requests_total x method 5 values x status 8 values x uri template 50 values x pod 20 values 5 x 8 x 50 x 20 = 40,000 series for ONE metric. Still fine. Add one label: user_id with 1,000,000 distinct users 40,000 x 1,000,000 = 40 billion possible series Every series costs memory in the Prometheus head block, index entries and disk, and every scrape has to carry it. The server slows down, then runs out of memory. Only series that actually receive samples count, but unbounded labels grow without limit.
LabelSafe?Why
method, status, outcomeYesSmall fixed sets
uri as a route template (/orders/{id})YesBounded by the number of routes. Spring MVC and Micrometer use the template, not the raw path
uri as the raw path (/orders/8841)NoOne series per order id
user_id, email, session_id, request_id, trace_idNoUnbounded: one series per entity. Put these in logs and traces instead
pod / instanceYes, with careBounded, but multiplies by every other label and churns on every deploy
error_message (free text)NoUnbounded and noisy; use a small reason enum
⚠ Putting a user id or order id in a metric label
  • What you see: Prometheus memory grows steadily after a release, scrapes get slow, queries time out, eventually the server is OOM-killed and restarts; scrape_samples_scraped and prometheus_tsdb_head_series climb.
  • Fix: remove the label, drop it at scrape time with metric_relabel_configs (action: labeldrop), and look the entity up in logs or traces. If you need "which tenant", use a bounded tenant label for your top tenants or a tiered bucket label.
  • Find the culprit: topk(10, count by (__name__) ({__name__=~".+"})) shows the metrics with the most series.

Cardinality is why exemplars exist: a histogram bucket can carry a single sample trace id, so you can jump from a latency spike to one concrete trace without ever adding a trace-id label. Spring Boot supports Prometheus exemplars when a Micrometer Tracing span context is available (Spring Boot reference, Actuator metrics section).

What to measure: three checklists
MethodForSignalsAnswers
RED (Tom Wilkie)Request-driven servicesRate (requests/s), Errors (failed requests/s or ratio), Duration (latency distribution)Is this service serving users well?
USE (Brendan Gregg)Resources: CPU, memory, disk, network, connection pools, thread poolsUtilisation (busy fraction), Saturation (queued work), ErrorsIs this resource the bottleneck?
Four golden signals (Google SRE book)Any user-facing systemLatency (separate successful from failed requests), Traffic, Errors, SaturationRED plus saturation in one list
SignalSpring Boot / Micrometer series to start with
Raterate(http_server_requests_seconds_count[5m])
ErrorsSame counter filtered with status=~"5.." (or outcome="SERVER_ERROR")
Durationhttp_server_requests_seconds_* (count, sum, max by default; enable a histogram to get percentiles)
Saturation (JVM)jvm_memory_used_bytes / jvm_memory_max_bytes, jvm_gc_pause_seconds, jvm_threads_live_threads, tomcat_threads_busy_threads (Tomcat metrics need server.tomcat.mbeanregistry.enabled=true)
Saturation (pool)hikaricp_connections_active, hikaricp_connections_pending
Saturation (container)container_cpu_cfs_throttled_seconds_total (cAdvisor), memory working set vs limit
Percentiles are not averages
  • Latency is skewed: a mean of 120 ms can hide a p99 of 3 seconds. Alert and set SLOs on percentiles (p95, p99) or on the fraction of requests faster than a threshold, which is what an SLI should be. The mean is for capacity planning only.
05
Chapter 5 - Spring Boot

Micrometer and Actuator: Metrics from a Spring Boot Service

Micrometer is the metrics facade of the Spring ecosystem, the SLF4J of metrics: your code talks to a MeterRegistry, and a registry implementation translates to a backend (Prometheus, OTLP, CloudWatch, Datadog and others). Spring Boot Actuator auto-configures Micrometer, instruments the JVM, the HTTP server, the HTTP clients, the datasource pool and more, and exposes management endpoints over HTTP.

To expose metrics to Prometheus you need two things: the Actuator starter, and the Prometheus registry. The endpoint is /actuator/prometheus; by default it is not exposed, so you must list it in management.endpoints.web.exposure.include (the default there is only health). The Spring Boot reference documents exactly this endpoint and the matching scrape_config.

pom.xml - dependencies (Spring Boot 3.5; the version is managed by the Boot parent)
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-actuator</artifactId> </dependency> <dependency> <groupId>io.micrometer</groupId> <artifactId>micrometer-registry-prometheus</artifactId> <scope>runtime</scope> </dependency>
application.yml
spring: application: name: shop management: endpoints: web: exposure: include: health,info,prometheus # default is only "health" metrics: tags: application: ${spring.application.name} # common tag on every meter

Start the application and curl localhost:8080/actuator/prometheus. You immediately get JVM, process, Tomcat-independent HTTP and logging metrics without writing code. I built and ran the sample of this chapter (Spring Boot 3.5.16, Java 21) and the endpoint returned the series shown in chapter 2, including http_server_requests_seconds_count labelled by method, status, uri and outcome.

Securing the management endpoints
  • Do not leave /actuator/** open to the internet. Common options: run management on a separate port (management.server.port=9090) that only the cluster network reaches, or secure the endpoints with Spring Security and give Prometheus credentials. Expose the smallest set of endpoints you need.
Custom metrics: counters, gauges, timers

Inject the MeterRegistry and register meters once (in the constructor), then update them on the hot path. Register a meter once and keep the reference; building the same meter again on every request works (the registry returns the existing one) but wastes time on a hot path.

OrderController.java - custom metrics (compiled and run in the lab)
package demo; import io.micrometer.core.instrument.Counter; import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.Timer; import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.atomic.AtomicInteger; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RestController; @RestController public class OrderController { private static final Logger log = LoggerFactory.getLogger(OrderController.class); private final Counter placed; private final Counter failed; private final Timer payment; private final AtomicInteger inFlight; public OrderController(MeterRegistry registry) { this.placed = Counter.builder("orders.placed").description("Orders accepted").register(registry); this.failed = Counter.builder("orders.failed").tag("reason", "payment").register(registry); this.payment = Timer.builder("payment.call") .description("Latency of the payment provider call") .publishPercentileHistogram() .register(registry); this.inFlight = registry.gauge("orders.in.flight", new AtomicInteger()); } @PostMapping("/orders") public ResponseEntity<String> place() throws InterruptedException { inFlight.incrementAndGet(); try { payment.record(() -> sleep(ThreadLocalRandom.current().nextInt(20, 200))); if (ThreadLocalRandom.current().nextInt(10) == 0) { failed.increment(); log.warn("payment declined"); return ResponseEntity.status(502).body("payment failed"); } placed.increment(); log.info("order placed"); return ResponseEntity.ok("ok"); } finally { inFlight.decrementAndGet(); } } private static void sleep(long ms) { try { Thread.sleep(ms); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } }
MeterIn codeExported as (Prometheus)Note
CounterCounter.builder("orders.placed")orders_placed_total (counter)Micrometer adds _total for counters
Counter with tag.tag("reason", "payment")orders_failed_total{reason="payment"}Tag values must be a small set
Gaugeregistry.gauge("orders.in.flight", new AtomicInteger())orders_in_flight (gauge)The registry holds only a weak reference to the gauged object: keep your own strong reference (a field) or the gauge reads NaN after GC
TimerTimer.builder("payment.call")...publishPercentileHistogram()payment_call_seconds_bucket/_count/_sum and _maxWithout publishPercentileHistogram() you get only count, sum and max
⚠ Gauge that reads NaN
  • What you see: a custom gauge works for a while, then the series shows NaN or disappears. Cause: Micrometer keeps only a weak reference to the object you passed, and it was garbage collected. Fix: store the object in a field of a long-lived bean (as inFlight above) or use Gauge.builder(..., obj, fn).strongReference(true).
Histograms for HTTP latency

By default the http.server.requests timer exports a summary-style count, sum and max: enough for average latency and rate but not for percentiles across pods. Turn on a histogram for that meter (or for a prefix) in configuration, then use histogram_quantile as in chapter 3.

application.yml - percentile histogram for the HTTP server timer
management: metrics: distribution: percentiles-histogram: http.server.requests: true slo: http.server.requests: 100ms,300ms,1s # extra buckets at your SLO thresholds

The slo boundaries add buckets exactly at the thresholds you care about, which makes "fraction of requests under 300 ms" an exact count rather than an interpolation. Each added bucket multiplies series by the number of tag combinations, so enable histograms where you need them, not everywhere.

Observation API
  • Spring Boot 3 instruments HTTP, JDBC pools, Kafka and others through the Micrometer Observation API: one instrumentation point produces both a timer metric and (when tracing is configured) a span. For your own code you can use Observation.createNotStarted("payment.call", observationRegistry).observe(() -> ...) or the @Observed annotation (needs an ObservedAspect bean), and get a metric and a span from one line.
06
Chapter 6 - Logs

Structured Logging, Correlation Ids and Log Backends

A log line like Order 8841 failed for user 17: payment declined is readable by humans and nearly useless to machines: to count failures per user you need a regex. Structured logging writes each event as a JSON object with named fields, so a log backend can filter and aggregate on fields without parsing prose.

Spring Boot 3.4 and later has built-in structured logging. One property switches the console output to JSON in a chosen format; the formats it ships are Elastic Common Schema (ecs), Logstash (logstash) and Graylog Extended Log Format (gelf).

application.yml - structured JSON logs (Spring Boot 3.4+)
logging: structured: format: console: ecs # or: logstash, gelf
One real log line from the lab (formatted over several lines for reading; the application prints it on one line)
{ "@timestamp": "2026-10-31T17:45:17.398538853Z", "log": {"level": "INFO", "logger": "demo.OrderController"}, "process": {"pid": 2360825, "thread": {"name": "http-nio-8080-exec-2"}}, "service": {"name": "shop", "version": "0.0.1", "node": {}}, "message": "order placed", "requestId": "req-1", "ecs": {"version": "8.11"} }
Correlation ids with MDC

A request in a busy service interleaves with hundreds of others in the same log stream. The fix is an identifier on every line of that request. SLF4J provides the MDC (Mapped Diagnostic Context), a thread-local map whose entries the logging framework adds to each event. Put the id in at the edge (a servlet filter), and it appears on every log line produced by that thread without passing it around.

CorrelationFilter.java - request id into the MDC (compiled and run in the lab)
package demo; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; import java.util.UUID; import org.slf4j.MDC; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; @Component public class CorrelationFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain) throws ServletException, IOException { String id = req.getHeader("X-Request-Id"); if (id == null || id.isBlank()) id = UUID.randomUUID().toString(); MDC.put("requestId", id); res.setHeader("X-Request-Id", id); try { chain.doFilter(req, res); } finally { MDC.remove("requestId"); // threads are pooled: always clean up } } }

Note how the structured format picked up requestId from the MDC automatically: that is the reason to use a structured encoder instead of a hand-written pattern. Also note MDC.remove in finally: server threads are pooled, and an id left behind would be attributed to the next, unrelated request.

⚠ MDC across threads
  • The MDC is thread-local. When work moves to another thread (@Async, an executor, CompletableFuture, reactive pipelines), the id does not follow, and log lines lose their correlation. Wrap the executor with a context-propagating decorator (Spring TaskDecorator, or Micrometer context-propagation) or pass the id explicitly. Spring Boot with Micrometer Tracing configures propagation for its own executors and the auto-configured RestClient/RestTemplate/WebClient builders.

A request id is useful inside one service. A trace id is the same idea across services, generated by the tracing library and propagated in headers (chapter 7). When Micrometer Tracing is on the classpath, Spring Boot puts traceId and spanId in the MDC for you and includes them in the default log pattern; the chapter 8 lab shows them in the JSON output. Prefer the trace id as your correlation id once tracing exists, because it also links the logs to the trace view in Grafana.

Where the logs go: Loki and Elasticsearch/OpenSearch
Grafana LokiElasticsearch / OpenSearch (ELK/EFK stack)
Indexing approachIndexes only a small set of labels (service, namespace, level); log text is stored compressed and scanned at query timeFull-text inverted index over the content of every field
Cost profileCheaper to store and run, especially on object storage (S3); queries over a lot of text are slowerMore expensive (index size, memory, disk); fast arbitrary full-text search and rich aggregations
Query languageLogQL (label selectors, then filters, then metrics from logs)Lucene/KQL, Query DSL, aggregations; Kibana or OpenSearch Dashboards
Fits whenLogs are mainly for correlation with metrics and traces in Grafana, and you can filter by service and time firstYou need ad hoc full-text search, analytics on log fields, or security/audit search at scale
Careful aboutPutting high-cardinality values (user id) in labels: same problem as Prometheus. Keep them in the log lineMapping explosions (too many distinct field names), shard sizing, cluster operations
ShippingGrafana Alloy, Promtail (deprecated in favour of Alloy), OTel CollectorFilebeat/Elastic Agent, Fluent Bit, Logstash, OTel Collector

Both are valid choices. The qualitative difference is the trade between index everything, search anything (Elasticsearch) and index little, store cheaply, filter by label first (Loki). Teams that already live in Grafana for metrics and traces often pick Loki; teams that need heavy ad hoc log analytics pick Elasticsearch or OpenSearch. In Kubernetes the usual pattern is: the application writes JSON to stdout, the container runtime stores it on the node, and a DaemonSet agent tails and ships it.

LogQL examples (Loki)
# All error lines of the shop service in the prod namespace {namespace="prod", app="shop"} |= "ERROR" # Parse JSON, filter on a field, show only the message {app="shop"} | json | requestId="req-77" | line_format "{{.message}}" # Metric from logs: errors per second per service sum by (app) (rate({namespace="prod"} | json | log_level="ERROR" [5m]))
Log levels and what logging costs
LevelUse forIn production
ERRORThe operation failed and someone may need to actAlways on. Alert on rate, not on every line
WARNUnexpected but handled: retry, fallback, declined paymentOn
INFOBusiness milestones: order placed, job finished, startupOn, but one line per request is already a lot at scale
DEBUGDeveloper detailOff; switch on per logger at runtime (/actuator/loggers) for a short window
TRACEVery fine detailOff
  • Log cost is roughly events x average size x retention, plus indexing. A debug line inside a hot loop can double a bill.
  • Never log secrets, tokens, full card numbers or personal data. Redact at the source; downstream scrubbing is unreliable.
  • Log an error once, at the place that handles it, with the stack trace. Logging and rethrowing at every layer produces duplicates that bury the real signal.
  • Sample or rate-limit repeated identical lines (a retry loop that logs every 10 ms for an hour).
  • Retention is a policy decision: keep detailed logs for days, aggregates and audit data longer.
07
Chapter 7 - Traces

Distributed Tracing: Spans, Context Propagation, Sampling

A trace is the record of one request as it crosses services. It is a tree of spans. A span is one timed unit of work: an incoming HTTP request, an outgoing call, a database query, a function you chose to wrap. Each span has a name, start time, duration, key/value attributes (http.request.method, db.system), a status, a span id, and the id of its parent span. All spans of a trace share one trace id.

One trace: a tree of spans, linked by the traceparent header
trace id 0af7651916cd43dd8448eb211c80319c (same for every span below) GET /checkout (api-gateway) POST /orders (order-service) SELECT inventory (db) POST /charge (payment-service) call card provider Each span: name, start, duration, attributes, status, parent span id. The widest child (the card provider call) is where the time went. Header sent on each outgoing call: traceparent: 00-<trace-id>-<parent-span-id>-<flags> The receiver creates its span as a child of that parent id, keeping the same trace id.

A waterfall like this is why tracing exists. Metrics told you p99 latency of /checkout doubled; the trace shows that the time sits in one span, the payment service calling an external card provider. No amount of staring at service-level averages shows that.

Context propagation and the W3C traceparent header

For spans from different processes to join one trace, the caller must pass the trace context to the callee, and the callee must pick it up. This is context propagation. The standard is W3C Trace Context, which defines the traceparent and tracestate HTTP headers. It replaced vendor formats such as Zipkin B3 as the default in OpenTelemetry.

traceparent header (the example from the W3C Trace Context specification)
traceparent: 00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01 | | | | | trace-id (32 hex = 16 bytes) parent-id trace-flags version (00) (16 hex = 8 bytes, the span id of the caller)
FieldSizeMeaning
version2 hex chars00 is the current version
trace-id32 hex chars (16 bytes)Identifies the whole trace. All zeros is invalid
parent-id16 hex chars (8 bytes)The span id of the caller; the callee's new span records it as its parent
trace-flags2 hex charsBit 0 (01) is the sampled flag: the caller recorded this trace

tracestate is a second header carrying vendor-specific key/value pairs alongside traceparent. Applications rarely touch either by hand: the tracing library injects them into outgoing HTTP, gRPC and messaging calls and extracts them from incoming ones. In the lab of chapter 8 I sent a request with the specification's example header, and the application's log lines for that request carried "traceId":"0af7651916cd43dd8448eb211c80319c": it continued the caller's trace instead of starting a new one.

⚠ Broken traces: every service shows a separate one-span trace
  • What you see: instead of one trace across services, the backend shows several short, disconnected traces. Causes: an HTTP client created with new RestTemplate() (or a hand-built WebClient) instead of the auto-configured builder, so no traceparent is injected (the Spring Boot reference warns about exactly this); a proxy or gateway that strips unknown headers; messaging without propagation configured; async hand-offs that lose the context. Fix: use the injected builders, allow traceparent/tracestate through proxies, and enable observation or propagation for the messaging library.
Sampling: you cannot keep everything

Recording and storing every span of every request is expensive at scale, so tracing systems sample. The decision can be made at two moments.

Head samplingTail sampling
DecidedAt the start of the trace, in the first service (or SDK)After the trace is complete, in a collector that sees all of its spans
Based onOnly a probability or a rule, because nothing about the outcome is known yetThe whole trace: keep all errors, keep slow traces, keep a small share of the rest
PropagatedThe decision travels in the sampled flag of traceparent, so all services agreeNot applicable: the collector decides
CostCheap and simple; no bufferingNeeds a collector that buffers spans per trace, memory, and all spans of a trace routed to the same collector instance
WeaknessCan discard the one interesting error traceMore moving parts to run and scale
Spring Bootmanagement.tracing.sampling.probability (default 0.1, that is 10 percent)Done in the OpenTelemetry Collector (tail_sampling processor, contrib distribution)
A practical combination
  • Head-sample generously (for example 10 percent) in the services so telemetry volume is bounded, and add tail sampling in the collector when you have the capacity, keeping 100 percent of traces with errors or high latency. Metrics are never sampled, which is why alerts live on metrics and traces are for investigation.
08
Chapter 8 - OpenTelemetry

OpenTelemetry: API, SDK, Collector, OTLP and Spring Boot

OpenTelemetry (OTel) is a CNCF project that standardises how telemetry is produced and shipped, independent of the backend. It defines an API (what instrumentation calls), an SDK (the implementation that samples, batches and exports), semantic conventions (standard attribute names), the OTLP wire protocol, and the Collector, a standalone service that receives, processes and exports telemetry.

Telemetry pipeline: apps, OpenTelemetry Collector, backends, Grafana
Applications Spring Boot app Micrometer + OTel Java app with OTel agent node/pod exporters OTel Collector receivers (otlp, prom) processors batch, memory_limiter, tail_sampling exporters OTLP Prometheus metrics Loki logs Tempo traces Grafana dashboards, Explore queries all three Alertmanager Prometheus may also scrape the apps directly (the simplest setup, no Collector needed). The Collector adds a single place for batching, filtering, sampling and re-routing telemetry. Tempo, Loki and Prometheus are examples; OTLP lets you swap any backend.
PieceRoleNotes
APIInterfaces that libraries and your code use to create spans and metricsStable and safe for libraries to depend on; no-op without an SDK
SDKImplements the API: samplers, processors, exportersConfigured in the application or by the Java agent
OTLPProtocol for traces, metrics, logsgRPC on port 4317, HTTP on port 4318 by default (OTLP specification)
CollectorVendor-neutral pipeline: receivers, processors, exporters, wired in service.pipelinesRun as a sidecar, a DaemonSet (agent) and/or a central gateway
InstrumentationCode that creates telemetry for librariesAuto (Java agent) or manual (API) or framework-native (Spring Observation)
Three ways to get traces out of a Java service
ApproachHowProsCons
OTel Java agent (zero-code)java -javaagent:path/to/opentelemetry-javaagent.jar -jar app.jar, config by OTEL_* env vars or -Dotel.*No code or dependency changes; broad library coverage; works for non-Spring appsBytecode instrumentation (startup cost, possible version conflicts); less control; agent version is a separate thing to manage
Spring Boot + Micrometer Tracing + OTel bridgeBoot dependencies and management.* properties; one Observation produces metrics and spansNative to Spring, one configuration model, trace ids in logs and exemplars out of the boxCovers what Spring instruments, plus what you instrument with the Observation API
Manual OTel SDK/APICreate tracer and spans in codeFull controlMost work; easy to leave gaps

For Spring Boot 3.x the supported way is Micrometer Tracing with the OpenTelemetry bridge. The Boot 3.5 reference lists two dependencies for tracing with OTLP: io.micrometer:micrometer-tracing-bridge-otel (bridges the Observation API to OpenTelemetry) and io.opentelemetry:opentelemetry-exporter-otlp (reports to an OTLP endpoint), configured with management.otlp.tracing.*. In Spring Boot 4 the reference instead names a single starter, org.springframework.boot:spring-boot-starter-opentelemetry, with management.opentelemetry.tracing.export.otlp.* properties. The concepts are identical; only the packaging and property prefix changed, so check the reference for your Boot version.

Spring Boot 3.5 - dependencies for tracing over OTLP
<dependency> <groupId>io.micrometer</groupId> <artifactId>micrometer-tracing-bridge-otel</artifactId> </dependency> <dependency> <groupId>io.opentelemetry</groupId> <artifactId>opentelemetry-exporter-otlp</artifactId> </dependency>
application.yml - Spring Boot 3.5 (run in the lab below)
management: otlp: tracing: endpoint: http://localhost:4318/v1/traces # OTLP over HTTP tracing: sampling: probability: 1.0 # default is 0.1; 1.0 samples everything (lab/dev only)

The lab run: I started an OpenTelemetry Collector (image otel/opentelemetry-collector, version 0.164.0) with an OTLP receiver and the debug exporter, started the sample application with these two dependencies added, and sent two requests. The collector logged "resource spans": 2, "spans": 2, and each application log line carried traceId and spanId fields. The second request sent the traceparent header of chapter 7, and its log line carried that same trace id:

Log line from the lab, abridged (message, traceId, spanId, requestId fields)
"message":"order placed","traceId":"0af7651916cd43dd8448eb211c80319c","spanId":"87aaf85f29319c55","requestId":"18a4c6e2-9abb-4639-86b8-de81dfeb3c1f"
otel-collector.yml - receive OTLP, batch, print (validated with the collector validate command, version 0.164.0)
receivers: otlp: protocols: grpc: endpoint: 0.0.0.0:4317 http: endpoint: 0.0.0.0:4318 processors: batch: {} exporters: debug: verbosity: basic service: pipelines: traces: receivers: [otlp] processors: [batch] exporters: [debug]

A realistic production pipeline replaces the debug exporter with exporters for your backends (for example otlp to Tempo, prometheusremotewrite (contrib distribution) or a Prometheus scrape of the collector for metrics, otlphttp to Loki), and adds memory_limiter first and batch last in the processor list. The collector configuration has receivers, processors, exporters, connectors, extensions and a service section whose pipelines decide what is wired together; a component that is defined but not listed in a pipeline does nothing.

⚠ Leaving sampling at 1.0 in production
  • A probability of 1.0 is right for a lab. In production it multiplies telemetry cost with traffic. Start with the default 0.1 or lower, send the rest through tail sampling if errors must always be kept, and never judge service health by traces, which are a sample.
Agent or starter, which one?
  • Choose the Spring Boot route for a Spring service you control: it is simpler and ties metrics, traces and logs together. Choose the agent for services you cannot or will not modify, mixed-language estates that want one approach, or to get instrumentation of libraries Spring does not observe. Do not run both on the same service, or you will get duplicate spans.
09
Chapter 9 - Reliability

SLIs, SLOs, Error Budgets and Burn-Rate Alerts

Alerting on raw thresholds (CPU above 80 percent, error rate above 1 percent for 5 minutes) asks the wrong question. The right question is: are users getting the reliability we promised, and are we on course to break that promise? Site Reliability Engineering turns this into three terms.

TermMeaningExample
SLI (service level indicator)A measurement of one aspect of service quality, expressed as a ratio of good events to valid eventsFraction of POST /orders requests that return non-5xx and complete in under 300 ms
SLO (service level objective)The target for the SLI over a window99.9 percent of requests good, over a rolling 30 days
SLA (service level agreement)A contract with consequences (credits, penalties) if missedUsually looser than the internal SLO
Error budget1 - SLO: the amount of unreliability you are allowed to spend0.1 percent of requests in 30 days

The error budget turns reliability into a shared, measurable resource. While budget remains, the team ships features and takes risks (canary releases, chaos experiments). When it is spent, the team shifts effort to reliability work. It also ends the argument "is 100 percent the goal": it is not, because each extra nine costs far more and users cannot tell the difference beyond their own network's reliability.

Worked example: a 99.9 percent SLO, in numbers
SLO (30-day window)Allowed error ratioAllowed downtime if all-or-nothing (30 d = 43,200 min)
99%1 in 100 (0.01)432 min (7.2 hours)
99.9%1 in 1,000 (0.001)43.2 min
99.95%1 in 2,000 (0.0005)21.6 min
99.99%1 in 10,000 (0.0001)4.32 min
  • Example service: 10,000,000 requests in 30 days, SLO 99.9 percent. Error budget = 0.001 x 10,000,000 = 10,000 failed requests.
  • A bad deploy fails 2 percent of requests (burn rate = 0.02 / 0.001 = 20). At 200 requests per second that is 4 failures per second, so the 10,000-request budget lasts 10,000 / 4 = 2,500 seconds, about 42 minutes, even though the service is "98 percent up".
  • The same budget at a constant 0.1 percent error ratio (burn rate 1) lasts exactly the 30 days.
  • These are worked examples with made-up traffic; use your own request counts.
How fast does a burn rate eat a 30-day error budget?
Burn rate 1 = errors arrive exactly at the pace the SLO allows (budget lasts the whole 30 days) burn 1 720 h = 30 days burn 6 120 h = 5 days burn 14.4 50 h (2% gone in 1 h) burn 100 7.2 h Burn rate = observed error ratio / allowed error ratio (1 - SLO). Time to exhaust = 30 days / burn rate. Bar lengths are proportional to hours; the 7.2 h bar is a sliver by design.
Multi-window, multi-burn-rate alerts

A simple alert "error ratio above X for 5 minutes" fires too often on harmless blips and too late on slow burns. The Google SRE Workbook (chapter "Alerting on SLOs") compares several approaches and recommends multiple burn-rate alerts with a long and a short window: the long window proves the burn is significant (it already consumed a meaningful share of the budget), the short window proves it is still happening, so the alert resets quickly after the fix. For a 99.9 percent SLO it recommends:

SeverityLong windowShort windowBurn rateBudget consumed when it fires
Page1 hour5 minutes14.42%
Page6 hours30 minutes65%
Ticket3 days6 hours110%

Check the arithmetic: budget consumed = burn rate x window / SLO period. For the first row 14.4 x 1 h / 720 h = 0.02, so 2 percent; the second 6 x 6 / 720 = 0.05; the third 1 x 72 / 720 = 0.10. The threshold on the error ratio is burn rate x (1 - SLO), for example 14.4 x 0.001 = 1.44 percent. The short window is one twelfth of the long window.

slo-rules.yml - burn-rate alerts for a 99.9 percent SLO (validated with promtool check rules; the 1h and 6h recording rules are defined the same way)
groups: - name: orders-slo rules: - record: orders:error_ratio:rate5m expr: | sum(rate(http_server_requests_seconds_count{job="shop",uri="/orders",status=~"5.."}[5m])) / sum(rate(http_server_requests_seconds_count{job="shop",uri="/orders"}[5m])) # ... same shape for rate30m, rate1h, rate6h ... - alert: OrdersErrorBudgetFastBurn expr: orders:error_ratio:rate1h > (14.4 * 0.001) and orders:error_ratio:rate5m > (14.4 * 0.001) labels: severity: page annotations: summary: "Orders SLO: burning the 30-day budget 14.4x too fast (2% of budget in 1 hour)" - alert: OrdersErrorBudgetSlowBurn expr: orders:error_ratio:rate6h > (6 * 0.001) and orders:error_ratio:rate30m > (6 * 0.001) labels: severity: page annotations: summary: "Orders SLO: burning the 30-day budget 6x too fast (5% of budget in 6 hours)"
⚠ SLO on the wrong denominator
  • Measuring availability from the server's own metrics misses requests that never reached it (DNS, load balancer, TLS failures). Where possible measure at the edge (load balancer or gateway metrics, or synthetic probes), and exclude requests that are the client's fault (4xx) from the bad count but keep them in the valid count only when that reflects user experience.
Low traffic breaks ratio alerts
  • At 1 request per minute a single failure is a 100 percent error ratio. For low-traffic services use longer windows, a minimum-traffic guard in the expression, or synthetic probes that generate steady traffic.
10
Chapter 10 - Operating

Alertmanager, Alert Design and Grafana Dashboards

Prometheus evaluates alert rules; it does not send notifications. Firing alerts go to Alertmanager, which deduplicates, groups, silences, inhibits and routes them to receivers (a pager, chat, email, a webhook).

Alertmanager routing
alertmanager.yml (validated with amtool check-config, Alertmanager docker image)
route: receiver: team-chat group_by: [alertname, service] group_wait: 30s group_interval: 5m repeat_interval: 4h routes: - matchers: [severity="page"] receiver: oncall-pager - matchers: [severity="ticket"] receiver: team-tickets repeat_interval: 24h receivers: - name: oncall-pager webhook_configs: - url: http://pager-bridge.example.internal/alert - name: team-tickets webhook_configs: - url: http://ticket-bridge.example.internal/alert - name: team-chat webhook_configs: - url: http://chat-bridge.example.internal/alert inhibit_rules: - source_matchers: [alertname="ServiceDown"] target_matchers: [severity="page", alertname!="ServiceDown"] equal: [service]
SettingMeaningDefault (Alertmanager docs)
group_byAlerts with the same values of these labels are sent in one notificationnone: set it, or every alert is its own notification
group_waitWait before the first notification of a new group, so related alerts arrive together30s
group_intervalWait before notifying about new alerts added to an existing group5m
repeat_intervalHow often to re-send a notification that is still firing4h
routes + matchersChild routes, first match wins unless continue: trueUnmatched alerts use the root receiver
inhibit_rulesSuppress alerts while another, more fundamental alert firese.g. mute all "high latency" pages while ServiceDown fires
SilencesTime-boxed mutes created in the UI or APIUse for planned maintenance
What deserves a page?
Symptom alertCause alert
DescribesWhat users experience: errors, latency, failed checkoutsA suspected reason: CPU high, a pod restarting, disk at 85 percent, a queue growing
Fires whenThe SLO is threatenedA resource crosses a threshold, whether or not users are hurt
Rate of false alarmsLowHigh
Use forPagingDashboards, tickets, and diagnosis after a symptom alert fires; page only when the cause is near-certain to become a symptom soon (disk will be full in 4 hours)
⚠ Alert fatigue
  • What you see: the on-call channel is full, alerts get muted "temporarily", real pages are missed. Causes: cause-based paging, thresholds with no for: duration, flapping, duplicates, no owner. Fixes: page only on symptoms tied to an SLO; every page must be urgent, actionable and need a human; every alert needs a runbook_url annotation; review alert volume in each retro and delete or downgrade any alert nobody acted on; use group_by and inhibition to turn one incident into one page.
Alert quality checklist
ActionableThe recipient knows the first thing to do (runbook link)
UrgentIf it can wait until morning, it is a ticket, not a page
SpecificLabels name the service and environment, summary says what users feel
TestedRules checked with promtool check rules and unit-tested with promtool test rules in CI
OwnedRoutes to the team that can fix it
Grafana dashboards

Grafana queries many data sources (Prometheus, Loki, Tempo, CloudWatch, SQL) and draws them. Good dashboards are designed top-down: one overview with the golden signals and SLO status for the service, then drill-down rows (per route, per dependency, JVM, database pool), with template variables ($service, $env) so one dashboard serves every deployment.

  • Put the question in the panel title ("Are we serving errors?") and the unit on the axis.
  • Show the SLO line and the current burn rate; users should see "healthy or not" in two seconds.
  • Use rate() windows tied to $__rate_interval rather than hard-coded [5m], so panels stay correct when you zoom.
  • Provision data sources and dashboards as code (the lab mounts a provisioning YAML for the data source) so a rebuilt Grafana is not empty; keep dashboard JSON in Git.
  • Link panels to logs and traces: with a trace id in the log line and Loki and Tempo configured, Grafana can jump from a log line to its trace and back, and exemplars jump from a latency graph to one trace.
grafana-datasource.yml - provisioning a Prometheus data source (used in the lab)
apiVersion: 1 datasources: - name: Prometheus type: prometheus access: proxy url: http://prometheus:9090 isDefault: true
Anti-patternWhy it hurtsInstead
A wall of 60 panelsNobody knows where to look at 3 a.m.One overview, then drill-down
Averages onlyHide tail latencyp50/p95/p99 and error ratio
Dashboards edited in the UI onlyLost on rebuild, no reviewDashboards as code
No unit, no descriptionUnreadable to othersAlways label
11
Chapter 11 - Hands-On

Lab: Prometheus and Grafana Scraping a Spring Boot App

This lab was run on Linux with Docker Compose, Prometheus 3.5.0 and Grafana 12.1.0 (the image tags used), and a Spring Boot 3.5.16 application on Java 21. The application runs on the host and the containers reach it through host.docker.internal (mapped with extra_hosts and host-gateway, which works on Linux). Docker Desktop provides that name automatically.

Step 1 - the application

Create the project from chapter 5 and chapter 6 (pom.xml with web, actuator and micrometer-registry-prometheus; ShopApplication, OrderController, CorrelationFilter; the application.yml shown there plus logging.structured.format.console: ecs). POST /orders sleeps 20 to 200 ms to simulate a payment call and fails with 502 about one time in ten. Build and run it.

Shell - build and start
$ mvn -q -DskipTests package $ java -jar target/shop-0.0.1.jar & $ curl -s localhost:8080/actuator/health {"status":"UP"}

The first command prints nothing on success. The health output is the real response from the lab; startup took about ten seconds on this machine.

Step 2 - Prometheus and Grafana
docker-compose.yml
services: prometheus: image: prom/prometheus:v3.5.0 ports: ["9090:9090"] volumes: - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro - ./rules.yml:/etc/prometheus/rules.yml:ro extra_hosts: - "host.docker.internal:host-gateway" grafana: image: grafana/grafana:12.1.0 ports: ["3000:3000"] environment: GF_SECURITY_ADMIN_PASSWORD: admin volumes: - ./grafana-datasource.yml:/etc/grafana/provisioning/datasources/ds.yml:ro
prometheus.yml
global: scrape_interval: 5s # lab value; 15s-60s is typical in production evaluation_interval: 5s rule_files: - /etc/prometheus/rules.yml scrape_configs: - job_name: shop metrics_path: /actuator/prometheus static_configs: - targets: ["host.docker.internal:8080"]
rules.yml
groups: - name: shop-slo rules: - record: job:orders_error_ratio:rate1m expr: | sum(rate(http_server_requests_seconds_count{job="shop",uri="/orders",status=~"5.."}[1m])) / sum(rate(http_server_requests_seconds_count{job="shop",uri="/orders"}[1m])) - alert: OrdersHighErrorRatio expr: job:orders_error_ratio:rate1m > 0.05 for: 1m labels: severity: page annotations: summary: "More than 5% of POST /orders return 5xx"
Shell - start the stack and check the target
$ docker compose up -d $ curl -s localhost:9090/api/v1/targets # abridged: the shop target reports health "up"

Open http://localhost:9090/targets to see the target, http://localhost:9090/alerts for the rule, and http://localhost:3000 for Grafana (login admin with the password set in the compose file; the data source is pre-provisioned). Prometheus took a few scrape intervals before rates were non-zero: rate() needs at least two samples after the traffic starts.

Step 3 - generate load and query
Shell - 40 parallel requests every 4 seconds, four rounds
$ for r in 1 2 3 4; do > for i in $(seq 1 40); do curl -s -o /dev/null -X POST localhost:8080/orders & done > wait; sleep 4 > done
Query (run in the Prometheus UI or Grafana Explore)Result in the lab run
sum(rate(http_server_requests_seconds_count{uri="/orders"}[1m]))about 2.9 requests per second
sum by (status) (increase(http_server_requests_seconds_count{uri="/orders"}[2m]))502: about 20, 200: about 157
job:orders_error_ratio:rate1mabout 0.11
histogram_quantile(0.95, sum by (le) (rate(payment_call_seconds_bucket[1m])))about 0.197 (seconds)
rate(payment_call_seconds_sum[1m]) / rate(payment_call_seconds_count[1m])about 0.115 (seconds)

Both the p95 and the mean are consistent with the code: the simulated call is uniform between 20 and 200 ms, so the mean should be near 110 ms and the 95th percentile near 190 ms. The alert OrdersHighErrorRatio moved to pending while the ratio stayed above 5 percent (the check ran moments before the minute elapsed, so I did not see firing; the state machine is exactly what the for: clause describes).

Step 4 - add tracing and a Collector (optional)

Add the two dependencies and the management.otlp.tracing and management.tracing.sampling properties of chapter 8, start the Collector with otel-collector.yml (for example docker run --network host -v $PWD/otel-collector.yml:/c.yml:ro otel/opentelemetry-collector:latest --config=/c.yml), and send requests. With verbosity: basic the debug exporter prints a count of spans per batch; raise it to detailed to see every span.

Shell - clean up
$ docker compose down $ kill %1 # stops the java process started above
Common mistakeWhat you seeFix
Endpoint not exposed404 on /actuator/prometheusAdd prometheus to management.endpoints.web.exposure.include
Registry dependency missingEndpoint exists but returns 404 or no Prometheus formatAdd micrometer-registry-prometheus
Target "down", connection refusedPrometheus UI shows the error textUse a hostname the container can reach, not localhost
Counter graph is a staircaseRaw counter plottedUse rate() or increase()
Percentile panel is emptyNo _bucket seriesEnable percentiles-histogram or publishPercentileHistogram()
Rate shows nothing after startFewer than two samples in the windowWait, or use a window of at least four scrape intervals
High-cardinality labelMemory climbs, queries time outRemove it; chapter 4
12
Chapter 12 - Interview

Questions You Should Be Able to Answer

Crisp answers for the observability questions that come up in DevOps and backend interviews.

  • Q: What is the difference between monitoring and observability?
    Monitoring checks known conditions with predefined dashboards and alerts. Observability means the system emits enough rich, correlated data (metrics, logs, traces) that you can investigate failures you did not anticipate by asking new questions, without deploying new code.
  • Q: Name the three pillars and when you use each.
    Metrics for detection, trends and SLOs (cheap, aggregated). Traces for locating the slow or failing hop in a distributed request. Logs for the detailed reason in one case. They are linked by shared identifiers: labels, trace ids.
  • Q: Counter vs gauge vs histogram vs summary?
    A counter only goes up (use rate). A gauge goes up and down (read it directly). A histogram counts observations into cumulative buckets and can be aggregated across instances, with quantiles estimated by histogram_quantile. A summary computes quantiles in the client, cannot be aggregated across instances.
  • Q: Why does Prometheus pull instead of push?
    Pull makes target health visible (up), lets you inspect any target with curl, centralises scrape config and avoids targets needing to know the server. Short-lived batch jobs that cannot be scraped can push to a Pushgateway.
  • Q: Write a PromQL query for the 99th percentile latency across all pods.
    histogram_quantile(0.99, sum by (le) (rate(http_server_requests_seconds_bucket[5m]))), with histogram buckets enabled for that timer. Sum the buckets across pods first, then take the quantile; never average per-pod percentiles.
  • Q: What is cardinality and why is it dangerous?
    The number of distinct time series (unique metric name plus label set). Cost in memory and index grows with it. A label like user_id or request_id creates a series per value and can take Prometheus down; keep such values in logs and traces.
  • Q: RED vs USE vs golden signals?
    RED (rate, errors, duration) describes request-driven services. USE (utilisation, saturation, errors) describes resources. The four golden signals are latency, traffic, errors, saturation: RED plus saturation.
  • Q: How do you expose Spring Boot metrics to Prometheus?
    Add spring-boot-starter-actuator and micrometer-registry-prometheus, include prometheus in management.endpoints.web.exposure.include (only health is exposed by default), and scrape /actuator/prometheus.
  • Q: How do you correlate logs across services?
    Log JSON, put a correlation id in the MDC on every request, propagate it (preferably as the W3C trace id via Micrometer Tracing or OpenTelemetry) and include it in every log line, so a backend can show all lines of one request.
  • Q: What is in a W3C traceparent header?
    Four dash-separated fields: version (00), 32-hex trace id, 16-hex parent span id, and trace flags (bit 0 is the sampled flag). Example: 00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01.
  • Q: Head vs tail sampling?
    Head sampling decides at the start of a trace (cheap, but blind to outcome). Tail sampling decides after the trace completes in a collector, so it can keep all errors and slow traces, at the cost of buffering and routing all spans of a trace to one instance.
  • Q: OTel Java agent or Spring Boot Micrometer Tracing?
    The agent needs no code changes and covers many libraries and any JVM app. The Boot route is native, unifies metrics, traces and log correlation through the Observation API. Do not use both on one service.
  • Q: What is an error budget?
    One minus the SLO: the unreliability you may spend. A 99.9 percent SLO over 30 days allows 0.1 percent bad events (43.2 minutes of full outage). Budget left permits risky changes; budget spent shifts work to reliability.
  • Q: Explain multi-window burn-rate alerting.
    Burn rate is observed error ratio divided by the allowed one. Page when a long window (for example 1 h at burn 14.4, 2 percent of a 30-day budget) and a short window (5 min) both exceed the threshold: the long one proves significance, the short one makes the alert reset quickly after the fix.
  • Q: Symptom vs cause alerts?
    Symptom alerts describe user-visible impact and should page. Cause alerts (CPU, disk, restarts) are noisy; use them for dashboards and tickets, and page only when they predict an imminent symptom.
  • Q: How do you reduce alert fatigue?
    Page only on SLO-tied symptoms, add for: durations, group and inhibit in Alertmanager, require a runbook for every alert, and review and delete alerts nobody acts on.
  • Q: Loki vs Elasticsearch?
    Loki indexes only labels and stores log text cheaply (query scans text after label filtering); Elasticsearch indexes all content for fast full-text search and analytics at higher cost and operational effort.
13
Chapter 13 - Summary

Cheat Sheet and Recap

Cheat sheet
TopicRememberDetail
PillarsMetrics detect, traces locate, logs explainLink them with trace ids and labels
Metric typesCounter, gauge, histogram, summaryCounters: rate(). Histograms: sum by (le) then histogram_quantile
Pull modelPrometheus scrapes /metricsup is 0 when a target fails
CardinalitySeries = product of label value countsNo user, request or trace ids in labels
MethodRED for services, USE for resourcesGolden signals add saturation
Spring Bootactuator + micrometer-registry-prometheusExpose prometheus; endpoint /actuator/prometheus
LogsJSON, MDC id, one error log per failureBoot 3.4+: logging.structured.format.console
TracingSpans share a trace id via traceparentVersion-traceid-parentid-flags
SamplingHead: cheap, blind. Tail: smart, needs a collectorBoot default probability 0.1
OTLPgRPC 4317, HTTP 4318Collector: receivers, processors, exporters, pipelines
SLO mathBudget = 1 - SLO; 99.9 percent over 30 d = 43.2 minBurn rate = error ratio / (1 - SLO)
Burn-rate alerts14.4 (1h/5m) page, 6 (6h/30m) page, 1 (3d/6h) ticket2 percent, 5 percent, 10 percent of budget
AlertmanagerGroup, route, inhibit, silenceDefaults: group_wait 30s, group_interval 5m, repeat_interval 4h
∑ Observability - what to remember
  • Observability is the ability to ask new questions of a running system; the three pillars are its raw material.
  • Metrics are cheap and aggregated, which makes them the basis of alerting; traces and logs are for investigation.
  • Watch cardinality. One unbounded label can take down the metrics server.
  • Use histograms for latency, aggregate buckets before taking quantiles, and alert on percentiles or SLO ratios, not means.
  • A correlation id in every log line (ideally the trace id) is the cheapest big win.
  • Spring Boot 3 gives you Micrometer metrics and OpenTelemetry traces with a few dependencies; expose the endpoint deliberately and secure it.
  • Alert on symptoms tied to an SLO, with multi-window burn rates, and treat alert volume as something to design.
Version note

Checked on 2026-10-31: Spring Boot reference (Actuator metrics and tracing, endpoints exposure default, structured logging; Boot 3.5 and the current 4.x documentation, including the Boot 4 change to spring-boot-starter-opentelemetry); Google SRE Workbook, Alerting on SLOs (burn-rate table); W3C Trace Context specification (traceparent example); Alertmanager configuration reference (defaults); OTLP specification (ports 4317 and 4318); Prometheus 3.5.0, Grafana 12.1.0 and Spring Boot 3.5.16 run in a local lab; OpenTelemetry Collector 0.164.0 validated and run. Not verified by running: Loki, Tempo and Elasticsearch behaviour (described qualitatively), the Java agent, tail sampling, and the Boot 4 starter.

Next in this section