Observability
A system you cannot observe is a system you cannot operate. Monitoring tells you that something is wrong; observability tells you why. The distinction matters in microservices: a request that touches eight services and three databases cannot be diagnosed from a single metric on a single dashboard. Observability is the property of a system that makes its internal state inferable from external outputs.
Picture a customer complaining that "checkout is slow" in the store. The request went through Nginx, the gateway, the auth check, the order service, the product service and Postgres, and maybe published an event to Kafka. Which one was slow? Was it slow for everyone or just for this customer? Without telemetry designed in advance — numbers, records and request paths emitted by every service — the only answer is guessing.
Learning objectives
By the end of this class you should be able to:
- Explain the difference between monitoring and observability, and which question each pillar (metrics, logs, traces) answers.
- Write PromQL queries for request rate, error ratio and latency percentiles of a Spring Boot service, and build RED / USE dashboards.
- Configure structured logs that carry the trace ID, and correlate a log line with its trace.
- Explain how trace context crosses services with the W3C
traceparentheader, and choose a sampling strategy. - Instrument a Spring Boot service with OpenTelemetry (Java agent and manual spans) and design symptom-based alerts.
Slides
Click the slides and use ← → to move through them; F goes full screen and P opens the presenter view, with the notes. Open the slides in a new tab
The Three Pillars
Observability is built on three complementary data types. Each answers a different question about system behaviour.
| Pillar | Answers | Toolchain | Retention |
|---|---|---|---|
| Metrics | Is the system healthy right now? Is it trending up or down? | Prometheus → Grafana | Weeks to months (aggregated) |
| Logs | What exactly happened, and in what sequence? | ELK / EFK / Loki | Days to weeks |
| Traces | Which service in this request was slow? Where did the error originate? | Jaeger / Tempo / Zipkin | Days |
All three describe the same events from different angles. One POST /orders that fails produces: an increment of a counter http_server_requests_seconds_count{status="500"} (metric), a JSON line {"level":"ERROR","msg":"product not found",...} (log), and a trace with one span per hop, the last one marked ERROR (trace). Metrics are cheap and tell you that something changed; logs and traces are more expensive and tell you what and where.
Observability ≠ Monitoring
Monitoring asks pre-defined questions: "Is the error rate below 1%?" Observability lets you ask questions you didn't think of at design time: "Why are exactly 7% of requests from mobile clients in São Paulo timing out on a Tuesday?" Observability requires all three pillars working together2.
Metrics
Prometheus Data Model
Every time series has a metric name and a set of key-value labels:
| Type | What it measures | Example |
|---|---|---|
| Counter | Monotonically increasing value; never decreases | http_requests_total, errors_total |
| Gauge | Current value that can go up or down | memory_bytes, active_connections |
| Histogram | Distribution of values in configurable buckets | http_request_duration_seconds{le="0.1"} |
| Summary | Pre-calculated quantiles (less flexible than histogram) | http_request_duration_seconds{quantile="0.99"} |
A label is a dimension of the series: every distinct combination of label values is a separate time series stored by Prometheus.
PromQL Basics
PromQL is Prometheus' query language. The queries below use the metric that Spring Boot (Micrometer) exports for every HTTP endpoint, http_server_requests_seconds, with the labels application, uri, method and status:
| Query | Meaning |
|---|---|
rate(http_server_requests_seconds_count[5m]) | Requests per second, averaged over the last 5 minutes |
sum by (application) (rate(http_server_requests_seconds_count{status=~"5.."}[5m])) | 5xx errors per second, per service |
sum(rate(http_server_requests_seconds_count{status=~"5.."}[5m])) / sum(rate(http_server_requests_seconds_count[5m])) | Error ratio (0–1) — the basis of an availability SLI |
histogram_quantile(0.99, sum by (le) (rate(http_server_requests_seconds_bucket[5m]))) | P99 latency across all instances |
increase(http_server_requests_seconds_count[1h]) | Total requests in the last hour |
Two rules: apply rate() or increase() to a counter before summing (they handle counter resets when a pod restarts), and keep the le label when summing histogram buckets (sum by (le)), because histogram_quantile needs it. The _bucket series exist only if histograms are enabled: management.metrics.distribution.percentiles-histogram.http.server.requests=true.
Why percentiles, not averages
If 99 requests take 50 ms and 1 takes 5 s, the average is ~100 ms — it looks fine, but one user in a hundred waits 5 seconds. P99 (the value below which 99% of requests fall) exposes that tail; with many calls per page, most users hit the tail somewhere.
RED, USE and the Golden Signals
| Method | For | Metrics |
|---|---|---|
| RED (Tom Wilkie) | Services / APIs | Rate (requests/sec), Errors (error rate), Duration (latency percentiles) |
| USE (Brendan Gregg) | Infrastructure / Resources | Utilisation (% busy), Saturation (queue depth), Errors |
| Four golden signals (Google SRE1) | Any user-facing system | Latency, Traffic, Errors, Saturation |
Which method to use?
Use RED for service-level dashboards (what users experience), USE for infrastructure dashboards (what resources experience). Both together give complete coverage.
Metrics Pipeline
flowchart LR
app["Spring Boot\n(Actuator + Micrometer)"]
prom["Prometheus"]
grafana["Grafana\n(dashboards + alerts)"]
prom -->|"scrapes /actuator/prometheus (e.g. every 15 s)"| app
prom -->|"PromQL queries"| grafana Prometheus uses a pull model: the application only exposes its current values at an HTTP endpoint, and Prometheus fetches (scrapes) them on a schedule and stores them as time series. This is what Hands-on 6 sets up for the store.
Spring Boot Actuator configuration to expose the Prometheus endpoint (it also needs the io.micrometer:micrometer-registry-prometheus dependency):
management:
endpoints:
web:
exposure:
include: health,info,prometheus
metrics:
tags:
application: ${spring.application.name}
Label cardinality
Never put unbounded values — user ID, order ID, e-mail, full URL with IDs — in a metric label. Each distinct value creates a new time series; 100,000 users × 10 endpoints × 5 status codes is 5 million series, and Prometheus runs out of memory. High-cardinality details belong in logs and traces. (This is why Spring Boot uses the URI template /orders/{id} as the uri label, not /orders/42.)
Alerting
Dashboards need someone looking at them; alerts notify a human when action is needed. Prometheus evaluates alerting rules and sends firing alerts to Alertmanager (or Grafana Alerting), which routes them to e-mail, Slack or a pager.
groups:
- name: order-service
rules:
- alert: OrderHighErrorRatio
expr: |
sum(rate(http_server_requests_seconds_count{application="order", status=~"5.."}[5m]))
/
sum(rate(http_server_requests_seconds_count{application="order"}[5m])) > 0.05
for: 5m # must hold for 5 minutes, avoids flapping
labels:
severity: page
annotations:
summary: "More than 5% of order requests are failing"
Alert on symptoms, not causes
Page people for what users feel — error ratio, latency, an SLO error budget burning fast (see SLI / SLO / SLA) — not for "CPU above 80%". High CPU with happy users is not an emergency; an alert that fires without requiring action trains people to ignore alerts.
Logs
Structured vs Unstructured
| Unstructured | Structured (JSON) | |
|---|---|---|
| Example | 2024-01-15 ERROR Order 42 failed | {"level":"ERROR","orderId":42,"msg":"failed","traceId":"abc123"} |
| Searchable | grep / regex only | Full field queries |
| Aggregatable | Hard | Easy (Kibana, Loki) |
| Machine-parseable | Brittle | Reliable |
Log Levels
| Level | Use |
|---|---|
| TRACE | Extremely detailed — every method entry/exit. Never in production. |
| DEBUG | Diagnostic — variable values, decision points. Dev/staging only. |
| INFO | Significant events — startup, config loaded, request received |
| WARN | Something unexpected but handled — retry triggered, fallback used |
| ERROR | Failure requiring attention — exception caught, downstream unreachable |
Correlation IDs and MDC
When a request touches multiple services, a shared ID in every log line — a correlation ID — allows reconstructing the full call sequence by searching for that one value. With distributed tracing, the natural correlation ID is the trace ID.
In Java, the place to put it is the MDC (Mapped Diagnostic Context) of SLF4J/Logback: a per-thread map of key-value pairs that the logging framework can print in every line with %X{key}. Who fills the MDC depends on how the service is instrumented:
With the OTel Java agent attached (see below and Hands-on 10), you write no MDC code for the trace context. The agent's Logback/Log4j instrumentation puts the active trace_id, span_id and trace_flags into the MDC whenever a span is current, and removes them afterwards. You only reference the keys in the pattern:
Without the agent, the same keys can be filled by the opentelemetry-logback-mdc-1.0 library; Spring Boot's own Micrometer Tracing fills the keys traceId and spanId instead. Use the key names of the tool you actually run.
MDC.put is for business context that no tool can know — the customer ID, the order ID — or for a correlation ID when there is no tracing at all. Always remove what you put: threads are reused by the server's pool, and a forgotten key leaks into the next request's logs.
MDC is per thread
The MDC lives in a ThreadLocal. Work handed to another thread (@Async, CompletableFuture, reactive code such as the WebFlux gateway) does not see it unless the context is propagated; the OTel agent does this for the libraries it instruments, but your own MDC.put values are not copied automatically.
Log Aggregation Pipeline
flowchart LR
svc1["Service A\n(JSON logs → stdout)"]
svc2["Service B\n(JSON logs → stdout)"]
svc3["Service C\n(JSON logs → stdout)"]
collector["Filebeat / Fluentd\n(collector)"]
elastic["Elasticsearch\n(index + store)"]
kibana["Kibana\n(search + dashboards)"]
svc1 --> collector
svc2 --> collector
svc3 --> collector
collector --> elastic
elastic --> kibana Loki as a lighter alternative
Loki (Grafana Labs) indexes only labels, not full text, making it much cheaper to operate than Elasticsearch. If you already run Prometheus and Grafana, adding Loki gives you log aggregation with zero new UI to learn — all signals live in the same Grafana dashboards.
Distributed Traces
Anatomy of a Trace
A trace represents a single end-to-end request. It is composed of spans — one per operation (service call, DB query, cache lookup, etc.).
| Field | Description | Example |
|---|---|---|
traceId | Globally unique ID for the entire request (128 bits) | 4bf92f3577b34da6a3ce929d0e0e4736 |
spanId | Unique ID for this operation | 00f067aa0ba902b7 |
parentSpanId | Span that triggered this one | a2fb4a1d1a96d312 |
name | Operation name | GET /orders/{id} |
startTime / duration | When it started and how long it took | 2024-01-15T10:30:00Z, 45ms |
status | OK / ERROR | ERROR |
attributes | Key-value tags | http.status_code=500, db.type=postgresql |
W3C traceparent Header
The traceparent header propagates trace context across service boundaries using the format version-traceId-parentSpanId-flags:
| Field | Value | Meaning |
|---|---|---|
version | 00 | W3C spec version |
traceId | 4bf92f3577b34da6a3ce929d0e0e4736 | 128-bit globally unique trace ID |
parentSpanId | 00f067aa0ba902b7 | 64-bit ID of the span that sent this request |
flags | 01 | Sampling flag (01 = sampled) |
On every downstream call, a service keeps the version, the traceId and (normally) the flags exactly as received, and replaces the parentSpanId field with the ID of its own current span. This is how trace context crosses service boundaries without any service needing to understand the full trace. The header is defined by the W3C Trace Context recommendation5; OpenTelemetry uses it by default.
Request Flow with Spans
sequenceDiagram
autonumber
actor User
participant GW as Gateway [span: root, 120ms]
participant OS as OrderService [span: child, 95ms]
participant DB as PostgreSQL [span: leaf, 40ms]
User->>+GW: GET /orders/42<br/>traceparent: 00-abc...-root-01
GW->>+OS: GET /orders/42<br/>traceparent: 00-abc...-gw_span-01
OS->>+DB: SELECT * FROM orders WHERE id=42
DB-->>-OS: row data
OS-->>-GW: 200 OK (95ms)
GW-->>-User: 200 OK (120ms) Sampling Strategies
Recording every span of every request is expensive at scale, so tracing systems keep only a sample of traces.
| Strategy | How | Tradeoff |
|---|---|---|
| Head sampling | The decision is made at the first span (e.g. keep 10% by trace ID: parentbased_traceidratio) and travels in the flags of traceparent, so every service agrees | Cheap and simple, but blind: it drops 90% of the rare error traces too |
| Tail sampling | All spans are sent to the OTel Collector, which waits until the trace is complete and then decides | Can keep every error or slow trace, but the Collector must buffer all spans in memory |
| Rate-limiting | Keep at most N traces per second | Predictable cost; under a spike most traces are dropped |
Production recommendation
Head and tail sampling are applied in sequence, so a trace dropped at the head can never be "rescued" by the tail. To keep every error trace, sample 100% at the head (or a high ratio) in the services, and let a tail-sampling policy in the OTel Collector keep all traces with an ERROR span or a high latency plus a small percentage (1–10%) of the normal ones. If you cannot run tail sampling, a head ratio of 1–10% is a reasonable default — accepting that some error traces will be lost and their logs are your fallback.
OpenTelemetry (OTel)
OpenTelemetry is the CNCF standard for generating, collecting, and exporting telemetry3. It replaces vendor-specific SDKs (Zipkin client, Jaeger client, etc.) with a single neutral API, so switching backends requires only a configuration change, not a code change.
| Component | Role |
|---|---|
| API | Language-specific interfaces — Tracer, Meter, Logger |
| SDK | Implementation of the API; includes sampling, batching, exporters |
| Collector | Standalone process that receives, processes, and exports telemetry |
| Exporter | Protocol adapter that sends the data out: OTLP (the default), Zipkin, Prometheus, console |
| OTLP | The OpenTelemetry Protocol, over gRPC (port 4317) or HTTP/protobuf (port 4318); Jaeger, Tempo and most vendors accept it natively |
flowchart LR
app["Application\n(OTel SDK)"]
collector["OTel Collector"]
prom["Prometheus\n(metrics)"]
jaeger["Jaeger\n(traces)"]
loki["Loki\n(logs)"]
app -->|"OTLP (gRPC/HTTP)"| collector
collector --> prom
collector --> jaeger
collector --> loki Java Zero-Code Instrumentation
The OTel Java agent instruments Spring Boot automatically via bytecode injection — no code changes required.
# pin a version: "latest" makes builds unreproducible
ADD https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/download/v2.4.0/opentelemetry-javaagent.jar /app/opentelemetry-javaagent.jar
ENTRYPOINT ["java", \
"-javaagent:/app/opentelemetry-javaagent.jar", \
"-jar", "/app/app.jar"]
Configure the agent via environment variables in Docker Compose:
environment:
OTEL_SERVICE_NAME: order-service
OTEL_EXPORTER_OTLP_PROTOCOL: grpc # agent 2.x defaults to http/protobuf (port 4318)
OTEL_EXPORTER_OTLP_ENDPOINT: http://jaeger:4317
OTEL_METRICS_EXPORTER: none
OTEL_LOGS_EXPORTER: none
Match the protocol to the port
Since version 2.0 the Java agent sends OTLP over HTTP/protobuf by default, which listens on port 4318. Either point it at http://jaeger:4318, or keep port 4317 and set OTEL_EXPORTER_OTLP_PROTOCOL=grpc as above. A mismatch produces no traces and only an export error in the service log.
Why disable metrics/logs exporters?
The Java agent can export all three signals via OTLP. We disable metrics and logs here because Prometheus already scrapes metrics via Actuator and Loki (or ELK) handles logs separately. Enabling all three from the agent would duplicate data and inflate storage costs.
Manual Spans
When automatic instrumentation is not granular enough — you want to see how long your price calculation took, not just the HTTP and SQL calls around it — add custom spans. Add the OTel API dependency (its version is managed by the opentelemetry-bom, or set it explicitly):
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-api</artifactId>
</dependency>
The simplest way, with the agent attached, is an annotation (dependency io.opentelemetry.instrumentation:opentelemetry-instrumentation-annotations):
@WithSpan("calculateTotal")
public BigDecimal calculateTotal(@SpanAttribute("order.items") int items, ...) { ... }
For full control, create the span yourself. The agent registers the global OpenTelemetry instance, so obtain the tracer from it (it is not a Spring bean unless you declare one):
private static final Tracer tracer =
GlobalOpenTelemetry.getTracer("store.order");
public Order processOrder(OrderRequest request) {
Span span = tracer.spanBuilder("processOrder").startSpan();
try (Scope scope = span.makeCurrent()) {
span.setAttribute("order.customerId", request.customerId());
// ... business logic
return order;
} catch (Exception e) {
span.recordException(e);
span.setStatus(StatusCode.ERROR, e.getMessage());
throw e;
} finally {
span.end();
}
}
Grafana Unified Stack (LGTM)
Loki + Grafana + Tempo + Mimir/Prometheus — the LGTM stack — puts all three observability signals into a single UI. The key advantage is signal correlation: you can jump from a metric spike to the traces that caused it to the log lines that explain it, all without leaving Grafana.
flowchart LR
app["Application"]
prom["Prometheus\n(metrics)"]
loki["Loki\n(logs)"]
tempo["Tempo\n(traces)"]
grafana["Grafana"]
app --> prom
app --> loki
app --> tempo
prom --> grafana
loki --> grafana
tempo --> grafana A typical drill-down workflow: a metric alert fires in Grafana → click "View Traces" to open the correlated trace in Tempo → click a span to see the correlated log lines from Loki. This cross-signal navigation is what separates an observability platform from three isolated monitoring tools.
DORA Metrics
DORA (DevOps Research and Assessment) identified four metrics that predict software delivery performance and organisational performance4. Two measure throughput (how fast changes flow) and two measure stability (how often changes break things and how fast you recover). Elite-performing teams score well on all four simultaneously — speed and stability are not a trade-off.
| Metric | Measures | Elite benchmark (State of DevOps 2024) |
|---|---|---|
| Deployment Frequency | How often code reaches production | On demand (multiple deploys per day) |
| Change Lead Time | Commit to running in production | Less than one day |
| Change Failure Rate | % of deployments that need a fix or rollback | ~5% |
| Failed Deployment Recovery Time (formerly MTTR / time to restore service) | How long to recover from a failed deployment | Less than one hour |
DORA and observability
DORA metrics are themselves telemetry. Deployment frequency comes from CI/CD logs (Hands-on 4). Recovery time requires precise incident start and end timestamps from your monitoring system. Change Failure Rate requires correlating deployment events with error-rate spikes. You cannot improve what you cannot measure — and you cannot measure it without observability infrastructure.
Observability Snapshot: Spike Simulation
The chart below simulates what the three observability pillars look like during a traffic spike. Panel 1 shows the request rate rising sharply at steps 10–12; Panel 2 shows error events clustering in the same window; Panel 3 shows trace span durations stretching out under load.
The three panels share a timeline: the spike visible in Panel 1 directly explains the error cluster in Panel 2, and both explain the stretched span durations in Panel 3. Without all three pillars, you would see only one piece of the picture.
Common pitfalls
- Logging everything at INFO (or DEBUG in production): log volume and cost explode, and the useful lines drown.
- Logging secrets or personal data: passwords, JWTs, card numbers and e-mails must never reach the log pipeline.
- Unbounded metric labels: user or order IDs as labels crash Prometheus — put them in logs and traces.
- Broken context propagation: one service without instrumentation (or a proxy that drops
traceparent) splits every trace in two. - Alerting on causes, not symptoms: dozens of CPU and memory alerts nobody acts on hide the one that matters.
Connections
- Hands-on 6 — Prometheus and Grafana for the store's metrics.
- Hands-on 10 — OpenTelemetry Java agent, Jaeger and
trace_idin the logs. - Reliability and SLI / SLO / SLA — SLIs are PromQL queries over these metrics; burn-rate alerts use them.
- Event-Driven Architecture — tracing across Kafka topics and monitoring consumer lag.
- Exercise: Bottlenecks — using observability to find where the platform is slow.
Key takeaways
- Monitoring answers known questions; observability lets you investigate new ones from metrics (that), logs (what) and traces (where).
- Prometheus scrapes
/actuator/prometheus; userate()on counters,histogram_quantilewithsum by (le)for percentiles, and keep labels low-cardinality. - Build dashboards with RED (services) and USE (resources); alert on symptoms users feel.
- Logs must be structured and carry the
trace_id— filled automatically by the OTel agent; useMDC.putonly for your own business keys. - A trace is a tree of spans linked by
traceparent; head sampling is cheap but blind, tail sampling keeps the interesting traces. - OpenTelemetry is the vendor-neutral standard; measure delivery performance with the four DORA metrics.
-
BEYER, B. et al. Site Reliability Engineering: How Google Runs Production Systems. O'Reilly, 2016. ↩
-
MAJORS, C.; FONG-JONES, L.; MIRANDA, G. Observability Engineering. O'Reilly, 2022. ↩
-
OPENTELEMETRY. opentelemetry.io — specification, SDKs, Collector. ↩
-
FORSGREN, N.; HUMBLE, J.; KIM, G. Accelerate: The Science of Lean Software and DevOps. IT Revolution, 2018. See also the yearly DORA State of DevOps reports. ↩
-
W3C. Trace Context. W3C Recommendation. ↩