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.
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.
| Signal | What it is | Strong at | Weak at | Cost model |
|---|---|---|---|---|
| Metrics | Numeric samples, aggregated, with labels | Alerting, trends, capacity, SLOs, dashboards | Cannot explain a single request; detail is limited by label choices | Grows with the number of distinct label combinations (series), not with traffic |
| Logs | Timestamped event records | Exact detail, error messages, audit trail | Hard to aggregate; hard to see a request across services unless you add an id | Grows with volume and verbosity: storage, indexing, ingestion |
| Traces | A tree of timed spans for one request across services | Locating the slow or failing hop, understanding dependencies | Expensive to keep for every request; nothing if not instrumented end to end | Grows with traffic; usually reduced by sampling |
| Question | Start with | Then |
|---|---|---|
| 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 id | Logs 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.
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.
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.
| Type | What it is | Goes | Typical use | How you query it |
|---|---|---|---|---|
| Counter | A cumulative total that only increases (resets to 0 on process restart) | Up | Requests, errors, bytes, orders placed. Name ends in _total | rate() or increase(), never the raw value |
| Gauge | A value that goes up and down | Both | Queue depth, in-flight requests, memory in use, temperature | Directly, or avg_over_time, max_over_time |
| Histogram | Observations counted into cumulative buckets, plus _sum and _count | Up | Request latency, payload sizes | histogram_quantile() over rate() of the buckets; can be aggregated across instances |
| Summary | Client-side computed quantiles, plus _sum and _count | Up | Quantiles when you do not need to aggregate across instances | Read the quantile series directly; quantiles cannot be averaged or summed across instances |
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.
- A counter such as
http_requests_totalonly ever climbs, and resets when the pod restarts.http_requests_total > 1000is meaningless. Always wrap counters inrate()(per-second average over a window) orincrease()(total growth over a window).rate()handles resets for you.
- Use base units (seconds, bytes, not milliseconds or megabytes), put the unit in the name (
_seconds,_bytes), end counters in_total, usesnake_case, and never put the label names or values in the metric name. Micrometer applies these conventions when it exports: a timer calledpayment.callbecomespayment_call_seconds.
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.
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.
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.
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.
| Function | Does | Gotcha |
|---|---|---|
rate(c[5m]) | Per-second average increase of a counter over the window, handles resets | Window 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 samples | Spiky; good for zooming in, bad for alerting |
histogram_quantile(q, ...) | Estimates a quantile from le buckets | Aggregate with sum by (le) before the function; never average percentiles |
sum by (x) | Keeps label x, adds up the rest | Dropping le too early breaks histogram_quantile |
avg_over_time(g[10m]) | Averages a gauge over time | Not for counters |
predict_linear(g[1h], 4*3600) | Linear projection, e.g. disk full in 4 hours | Linear only; fine for disks, poor for bursty values |
sum by (le) first?rate() inside histogram_quantile?rate() you would get the quantile of all history. With a 5 minute rate() you get the quantile of the last five minutesorders:error_ratio:rate5m. Dashboards and alerts then read a cheap seriesIn 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.
- When there is no traffic the denominator is 0 and the ratio is
NaN. Comparisons withNaNare 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 orup == 0alert.
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.
| Label | Safe? | Why |
|---|---|---|
method, status, outcome | Yes | Small fixed sets |
uri as a route template (/orders/{id}) | Yes | Bounded by the number of routes. Spring MVC and Micrometer use the template, not the raw path |
uri as the raw path (/orders/8841) | No | One series per order id |
user_id, email, session_id, request_id, trace_id | No | Unbounded: one series per entity. Put these in logs and traces instead |
pod / instance | Yes, with care | Bounded, but multiplies by every other label and churns on every deploy |
error_message (free text) | No | Unbounded and noisy; use a small reason enum |
- 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_scrapedandprometheus_tsdb_head_seriesclimb. - 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 boundedtenantlabel 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).
| Method | For | Signals | Answers |
|---|---|---|---|
| RED (Tom Wilkie) | Request-driven services | Rate (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 pools | Utilisation (busy fraction), Saturation (queued work), Errors | Is this resource the bottleneck? |
| Four golden signals (Google SRE book) | Any user-facing system | Latency (separate successful from failed requests), Traffic, Errors, Saturation | RED plus saturation in one list |
| Signal | Spring Boot / Micrometer series to start with |
|---|---|
| Rate | rate(http_server_requests_seconds_count[5m]) |
| Errors | Same counter filtered with status=~"5.." (or outcome="SERVER_ERROR") |
| Duration | http_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 |
- 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.
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.
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.
- 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.
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.
| Meter | In code | Exported as (Prometheus) | Note |
|---|---|---|---|
| Counter | Counter.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 |
| Gauge | registry.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 |
| Timer | Timer.builder("payment.call")...publishPercentileHistogram() | payment_call_seconds_bucket/_count/_sum and _max | Without publishPercentileHistogram() you get only count, sum and max |
- What you see: a custom gauge works for a while, then the series shows
NaNor 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 (asinFlightabove) or useGauge.builder(..., obj, fn).strongReference(true).
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.
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.
- 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@Observedannotation (needs anObservedAspectbean), and get a metric and a span from one line.
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).
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.
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.
- 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 (SpringTaskDecorator, or Micrometer context-propagation) or pass the id explicitly. Spring Boot with Micrometer Tracing configures propagation for its own executors and the auto-configuredRestClient/RestTemplate/WebClientbuilders.
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.
| Grafana Loki | Elasticsearch / OpenSearch (ELK/EFK stack) | |
|---|---|---|
| Indexing approach | Indexes only a small set of labels (service, namespace, level); log text is stored compressed and scanned at query time | Full-text inverted index over the content of every field |
| Cost profile | Cheaper to store and run, especially on object storage (S3); queries over a lot of text are slower | More expensive (index size, memory, disk); fast arbitrary full-text search and rich aggregations |
| Query language | LogQL (label selectors, then filters, then metrics from logs) | Lucene/KQL, Query DSL, aggregations; Kibana or OpenSearch Dashboards |
| Fits when | Logs are mainly for correlation with metrics and traces in Grafana, and you can filter by service and time first | You need ad hoc full-text search, analytics on log fields, or security/audit search at scale |
| Careful about | Putting high-cardinality values (user id) in labels: same problem as Prometheus. Keep them in the log line | Mapping explosions (too many distinct field names), shard sizing, cluster operations |
| Shipping | Grafana Alloy, Promtail (deprecated in favour of Alloy), OTel Collector | Filebeat/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.
| Level | Use for | In production |
|---|---|---|
ERROR | The operation failed and someone may need to act | Always on. Alert on rate, not on every line |
WARN | Unexpected but handled: retry, fallback, declined payment | On |
INFO | Business milestones: order placed, job finished, startup | On, but one line per request is already a lot at scale |
DEBUG | Developer detail | Off; switch on per logger at runtime (/actuator/loggers) for a short window |
TRACE | Very fine detail | Off |
- 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.
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.
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.
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.
| Field | Size | Meaning |
|---|---|---|
| version | 2 hex chars | 00 is the current version |
| trace-id | 32 hex chars (16 bytes) | Identifies the whole trace. All zeros is invalid |
| parent-id | 16 hex chars (8 bytes) | The span id of the caller; the callee's new span records it as its parent |
| trace-flags | 2 hex chars | Bit 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.
- 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-builtWebClient) instead of the auto-configured builder, so notraceparentis 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, allowtraceparent/tracestatethrough proxies, and enable observation or propagation for the messaging library.
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 sampling | Tail sampling | |
|---|---|---|
| Decided | At 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 on | Only a probability or a rule, because nothing about the outcome is known yet | The whole trace: keep all errors, keep slow traces, keep a small share of the rest |
| Propagated | The decision travels in the sampled flag of traceparent, so all services agree | Not applicable: the collector decides |
| Cost | Cheap and simple; no buffering | Needs a collector that buffers spans per trace, memory, and all spans of a trace routed to the same collector instance |
| Weakness | Can discard the one interesting error trace | More moving parts to run and scale |
| Spring Boot | management.tracing.sampling.probability (default 0.1, that is 10 percent) | Done in the OpenTelemetry Collector (tail_sampling processor, contrib distribution) |
- 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.
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.
| Piece | Role | Notes |
|---|---|---|
| API | Interfaces that libraries and your code use to create spans and metrics | Stable and safe for libraries to depend on; no-op without an SDK |
| SDK | Implements the API: samplers, processors, exporters | Configured in the application or by the Java agent |
| OTLP | Protocol for traces, metrics, logs | gRPC on port 4317, HTTP on port 4318 by default (OTLP specification) |
| Collector | Vendor-neutral pipeline: receivers, processors, exporters, wired in service.pipelines | Run as a sidecar, a DaemonSet (agent) and/or a central gateway |
| Instrumentation | Code that creates telemetry for libraries | Auto (Java agent) or manual (API) or framework-native (Spring Observation) |
| Approach | How | Pros | Cons |
|---|---|---|---|
| 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 apps | Bytecode instrumentation (startup cost, possible version conflicts); less control; agent version is a separate thing to manage |
| Spring Boot + Micrometer Tracing + OTel bridge | Boot dependencies and management.* properties; one Observation produces metrics and spans | Native to Spring, one configuration model, trace ids in logs and exemplars out of the box | Covers what Spring instruments, plus what you instrument with the Observation API |
| Manual OTel SDK/API | Create tracer and spans in code | Full control | Most 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.
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:
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.
- 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.
- 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.
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.
| Term | Meaning | Example |
|---|---|---|
| SLI (service level indicator) | A measurement of one aspect of service quality, expressed as a ratio of good events to valid events | Fraction 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 window | 99.9 percent of requests good, over a rolling 30 days |
| SLA (service level agreement) | A contract with consequences (credits, penalties) if missed | Usually looser than the internal SLO |
| Error budget | 1 - SLO: the amount of unreliability you are allowed to spend | 0.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.
| SLO (30-day window) | Allowed error ratio | Allowed 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.
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:
| Severity | Long window | Short window | Burn rate | Budget consumed when it fires |
|---|---|---|---|---|
| Page | 1 hour | 5 minutes | 14.4 | 2% |
| Page | 6 hours | 30 minutes | 6 | 5% |
| Ticket | 3 days | 6 hours | 1 | 10% |
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.
- 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.
- 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.
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).
| Setting | Meaning | Default (Alertmanager docs) |
|---|---|---|
group_by | Alerts with the same values of these labels are sent in one notification | none: set it, or every alert is its own notification |
group_wait | Wait before the first notification of a new group, so related alerts arrive together | 30s |
group_interval | Wait before notifying about new alerts added to an existing group | 5m |
repeat_interval | How often to re-send a notification that is still firing | 4h |
routes + matchers | Child routes, first match wins unless continue: true | Unmatched alerts use the root receiver |
inhibit_rules | Suppress alerts while another, more fundamental alert fires | e.g. mute all "high latency" pages while ServiceDown fires |
| Silences | Time-boxed mutes created in the UI or API | Use for planned maintenance |
| Symptom alert | Cause alert | |
|---|---|---|
| Describes | What users experience: errors, latency, failed checkouts | A suspected reason: CPU high, a pod restarting, disk at 85 percent, a queue growing |
| Fires when | The SLO is threatened | A resource crosses a threshold, whether or not users are hurt |
| Rate of false alarms | Low | High |
| Use for | Paging | Dashboards, 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) |
- 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 arunbook_urlannotation; review alert volume in each retro and delete or downgrade any alert nobody acted on; usegroup_byand inhibition to turn one incident into one page.
| Alert quality checklist | |
|---|---|
| Actionable | The recipient knows the first thing to do (runbook link) |
| Urgent | If it can wait until morning, it is a ticket, not a page |
| Specific | Labels name the service and environment, summary says what users feel |
| Tested | Rules checked with promtool check rules and unit-tested with promtool test rules in CI |
| Owned | Routes to the team that can fix it |
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_intervalrather 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.
| Anti-pattern | Why it hurts | Instead |
|---|---|---|
| A wall of 60 panels | Nobody knows where to look at 3 a.m. | One overview, then drill-down |
| Averages only | Hide tail latency | p50/p95/p99 and error ratio |
| Dashboards edited in the UI only | Lost on rebuild, no review | Dashboards as code |
| No unit, no description | Unreadable to others | Always label |
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.
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.
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.
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.
| 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:rate1m | about 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).
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.
| Common mistake | What you see | Fix |
|---|---|---|
| Endpoint not exposed | 404 on /actuator/prometheus | Add prometheus to management.endpoints.web.exposure.include |
| Registry dependency missing | Endpoint exists but returns 404 or no Prometheus format | Add micrometer-registry-prometheus |
| Target "down", connection refused | Prometheus UI shows the error text | Use a hostname the container can reach, not localhost |
| Counter graph is a staircase | Raw counter plotted | Use rate() or increase() |
| Percentile panel is empty | No _bucket series | Enable percentiles-histogram or publishPercentileHistogram() |
| Rate shows nothing after start | Fewer than two samples in the window | Wait, or use a window of at least four scrape intervals |
| High-cardinality label | Memory climbs, queries time out | Remove it; chapter 4 |
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 (userate). 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 byhistogram_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 likeuser_idorrequest_idcreates 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?
Addspring-boot-starter-actuatorandmicrometer-registry-prometheus, includeprometheusinmanagement.endpoints.web.exposure.include(onlyhealthis 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
traceparentheader?
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, addfor: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.
Cheat Sheet and Recap
| Topic | Remember | Detail |
|---|---|---|
| Pillars | Metrics detect, traces locate, logs explain | Link them with trace ids and labels |
| Metric types | Counter, gauge, histogram, summary | Counters: rate(). Histograms: sum by (le) then histogram_quantile |
| Pull model | Prometheus scrapes /metrics | up is 0 when a target fails |
| Cardinality | Series = product of label value counts | No user, request or trace ids in labels |
| Method | RED for services, USE for resources | Golden signals add saturation |
| Spring Boot | actuator + micrometer-registry-prometheus | Expose prometheus; endpoint /actuator/prometheus |
| Logs | JSON, MDC id, one error log per failure | Boot 3.4+: logging.structured.format.console |
| Tracing | Spans share a trace id via traceparent | Version-traceid-parentid-flags |
| Sampling | Head: cheap, blind. Tail: smart, needs a collector | Boot default probability 0.1 |
| OTLP | gRPC 4317, HTTP 4318 | Collector: receivers, processors, exporters, pipelines |
| SLO math | Budget = 1 - SLO; 99.9 percent over 30 d = 43.2 min | Burn rate = error ratio / (1 - SLO) |
| Burn-rate alerts | 14.4 (1h/5m) page, 6 (6h/30m) page, 1 (3d/6h) ticket | 2 percent, 5 percent, 10 percent of budget |
| Alertmanager | Group, route, inhibit, silence | Defaults: group_wait 30s, group_interval 5m, repeat_interval 4h |
- 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.
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.
- 08 Deployment Strategies - use these metrics to gate canary and blue/green releases.
- 04 Kubernetes Operations - probes, resources and the metrics Kubernetes exposes.
- 09 Interview Q&A - practice scenarios including a p99 latency spike.
- Performance Engineering - what to do once the trace points at the slow span.