Signals
The first question during an incident is never "why?" β it is "is something wrong, and where should I look?". The second is "what exactly happened to this request?". The third is "which line of code is burning the time?". Each question needs a different kind of data, with a different cost. Emitting every detail of every request answers all of them and bankrupts you; emitting only CPU graphs is cheap and answers none. This page is about choosing the right signal for each question.
Four signals, one spectrum
| Signal | Unit of data | Answers | Volume / cost | Typical retention | Backends |
|---|---|---|---|---|---|
| Metrics | a number per time series per interval | is something wrong? how much, since when? | low and independent of traffic | months (downsampled: years) | Prometheus, Mimir, Thanos, CloudWatch |
| Logs | one record per event | what happened in this event? | grows linearly with traffic | days to weeks hot, months cold | Loki, Elasticsearch/OpenSearch, Cloud Logging |
| Traces | a tree of spans per request | where did the time / error go across services? | grows with traffic Γ hops (sampled) | days to weeks | Tempo, Jaeger, X-Ray, Honeycomb |
| Profiles | stack samples per process over time | which code uses CPU / memory / locks? | roughly constant per process | days to weeks | Pyroscope, Parca, cloud profilers |
Think of it as a spectrum from aggregated and cheap to detailed and expensive. Metrics compress a million requests into a few numbers per interval β you can keep them for months, but you cannot ask about one request. Logs and traces keep each request β you can ask anything about it, but you must sample, filter or expire them.
flowchart LR
M["Metrics<br/>aggregate Β· cheap Β· long retention"] --> T["Traces<br/>per request Β· sampled"]
T --> L["Logs<br/>per event Β· verbose"]
L --> P["Profiles<br/>per code path"] Names used in this class
BoxOffice is instrumented with the OpenTelemetry Java agent (Hands-on 8), which follows the OpenTelemetry semantic conventions1. Its HTTP server metric http.server.request.duration (a histogram, in seconds) arrives in Prometheus as http_server_request_duration_seconds_bucket, _count and _sum, with the labels http_route, http_request_method and http_response_status_code. The resource attribute service.name arrives as the label service_name (the one lab 8's dashboard and alerts filter on), and Prometheus builds job from service.namespace and service.name β boxoffice/order in lab 8; a classic scrape configuration names job after the scrape target instead. The queries on this page use job="order" for brevity. Spring Boot's own Micrometer metrics use other names (http_server_requests_seconds with uri, status, application). The queries are the same idea β check which names your stack emits before copying a query.
Metrics
The data model
A metric is a time series: a name, a set of key-value labels (also called attributes or dimensions), and a sequence of (timestamp, value) samples.
http_server_request_duration_seconds_count{job="order", http_route="/orders", http_request_method="POST", http_response_status_code="201"} 48213
Every distinct combination of label values is a separate time series that the database must index and keep in memory. This single fact explains most of the operational problems with metrics (see cardinality).
| Type | Behaviour | Use for | BoxOffice example |
|---|---|---|---|
| Counter | only goes up (resets to 0 on restart) | events: requests, errors, bytes | tickets sold, 5xx responses |
| Gauge | goes up and down | current state | seats left in a hold pool, queue depth, open connections |
| Histogram | counts observations in buckets (le = less or equal) plus _sum and _count | latency and size distributions, percentiles | checkout duration |
| Summary | quantiles computed in the process | legacy; a single instance's percentiles | β avoid: cannot be aggregated |
Histograms are the important one. Each bucket is a counter "how many observations were β€ this bound": with bounds 0.1, 0.25, 0.5, 1, 2.5 s, a 0.3 s checkout increments the buckets 0.5, 1, 2.5 and +Inf. Because buckets are counters, histograms from 20 pods can be summed and a percentile estimated from the total. Newer formats β Prometheus native histograms and OpenTelemetry exponential histograms β choose bucket bounds automatically with bounded relative error, which removes the most common mistake: buckets that do not cover your SLO threshold.
Pull vs push
| Pull (scrape) | Push | |
|---|---|---|
| How | Prometheus calls GET /metrics on every target every 15β60 s | the process sends metrics (OTLP, StatsD) to a collector |
| Discovers dead targets | yes β a failed scrape is itself a signal (up == 0) | only by absence of data |
| Short-lived jobs, serverless | awkward (needs a push gateway) | natural |
| Behind NAT / many networks | the scraper must reach every target | targets only need to reach the collector |
| Typical stack | Prometheus + Kubernetes service discovery | OpenTelemetry SDK β Collector β Prometheus/Mimir (OTLP) |
Both are fine; modern stacks mix them. With OpenTelemetry the default is push over OTLP to a Collector, which can also scrape existing Prometheus endpoints (OpenTelemetry).
RED, USE and the four golden signals
You do not need hundreds of metrics per service. Three well-known checklists tell you which few matter:
| Method | Applies to | Signals | Origin |
|---|---|---|---|
| RED | request-driven services (APIs) | Rate, Errors, Duration | Tom Wilkie (Weaveworks/Grafana)2 |
| USE | resources (CPU, disk, pools, queues) | Utilization, Saturation, Errors | Brendan Gregg3 |
| Four golden signals | any user-facing system | latency, traffic, errors, saturation | Google SRE book4 |
The golden signals are essentially RED + saturation. Saturation is the subtle one: how full a resource is, usually visible as waiting β a queue, a pool with pending requests, threads blocked. Utilization near 100% is not a problem by itself; work waiting in line is.
BoxOffice β RED for services, USE for resources
| Component | Method | What to plot |
|---|---|---|
order (POST /checkout) | RED | checkouts/s; fraction of 5xx; p50/p95/p99 duration |
payment β external provider | RED (client side) | calls/s; fraction failed or timed out; p99 of the provider call |
PostgreSQL connection pool of inventory | USE | active / max connections; pending acquisitions and wait time; acquisition timeouts |
Kafka topic order-events | USE | produce rate vs consume rate; consumer lag (messages and seconds); failed deliveries / DLQ size |
order JVM | USE | CPU; heap after GC; GC pause time; thread pool queue |
During the flash sale, the payment pool's pending acquisitions (saturation) rose two minutes before the checkout p99 alert fired. Saturation is the leading indicator; RED is what users feel.
RED in PromQL
PromQL is the query language of Prometheus (and of Mimir, Thanos and most compatible backends). Two rules prevent most wrong queries: apply rate() to a counter before summing it (it handles counter resets when a pod restarts), and keep the le label when summing histogram buckets.
# Rate: requests per second, per route, of the order service
sum by (http_route) (
rate(http_server_request_duration_seconds_count{job="order"}[5m])
)
# Errors: fraction (0..1) of 5xx responses
sum(rate(http_server_request_duration_seconds_count{job="order", http_response_status_code=~"5.."}[5m]))
/
sum(rate(http_server_request_duration_seconds_count{job="order"}[5m]))
# Duration: p99 latency per route, aggregated across all pods
histogram_quantile(0.99,
sum by (le, http_route) (
rate(http_server_request_duration_seconds_bucket{job="order"}[5m])
)
)
# SLI style: fraction of checkouts faster than 2 s (needs a bucket at le="2")
sum(rate(http_server_request_duration_seconds_bucket{job="order", http_route="/checkout", le="2"}[5m]))
/
sum(rate(http_server_request_duration_seconds_count{job="order", http_route="/checkout"}[5m]))
In the BoxOffice code: the RED dashboard (lab 8)
Hands-on 8 β RED dashboard provisions one dashboard for every service from a JSON file in the repository, reviewed and versioned like any other code. Its top row is the three queries above, one line per service_name: the rate is sum by (service_name) of the counter's rate(); the errors are a share of 5xx, not a count (50 errors/s is a disaster at 60 req/s and noise at 60,000); the p95 comes from histogram_quantile over buckets summed by le β never an average of per-replica percentiles, which matters because catalog runs three replicas as one service. The bottom row is USE: JVMs and connection pools.
"title": "Rate (requests/s)",
"expr": "sum by (service_name) (rate(http_server_request_duration_seconds_count{service_name=~\"$service\"}[$__rate_interval]))",
"title": "Errors (% of requests answered 5xx)",
"expr": "100 * sum by (service_name) (rate(http_server_request_duration_seconds_count{service_name=~\"$service\", http_response_status_code=~\"5..\"}[$__rate_interval])) / sum by (service_name) (rate(http_server_request_duration_seconds_count{service_name=~\"$service\"}[$__rate_interval]))",
"title": "Duration p95 (s)",
"expr": "histogram_quantile(0.95, sum by (service_name, le) (rate(http_server_request_duration_seconds_bucket{service_name=~\"$service\"}[$__rate_interval])))",
Percentiles, not averages β and never average percentiles
An average latency hides the tail: if 99 checkouts take 100 ms and one takes 10 s, the average is ~200 ms and looks fine, but one fan in a hundred waits ten seconds β and a page that makes 20 calls hits that tail on most loads (see Percentiles).
A subtler mistake is to compute p99 per pod and then average across pods. Percentiles do not compose: the p99 of the whole fleet is not the mean of the pods' p99s. The simulation below has four order pods; one of them shares a node with a noisy neighbour and is slow, and it receives 10% of the traffic.
| Pod | Requests | p99 (ms) |
|---|---|---|
| pod-a | 30,000 | 179 |
| pod-b | 30,000 | 180 |
| pod-c | 30,000 | 181 |
| pod-d (noisy neighbour) | 10,000 | 1,260 |
| average of the four p99s | 450 | |
| true fleet p99 (from all requests) | 100,000 | 756 |
Averaging the pods' percentiles understates the tail users actually experience. Histograms avoid the problem because their buckets are counters that can be summed before the percentile is estimated β which is exactly what sum by (le) does in the query above.
Cardinality
Cardinality is the number of distinct time series a metric produces: the product of the number of values of each label. It is the main driver of the memory, cost and query speed of a metrics backend β and the most common way teams accidentally take down their monitoring.
The table below grows the checkout latency histogram label by label. Each histogram series is really 17 series (15 buckets, _sum, _count); the memory column assumes, for illustration, about 4 KB per active series in the time-series database.
| Label added | Γ values | Series | Memory |
|---|---|---|---|
| one histogram (15 buckets + _sum + _count) | 17 | 17 | 68.0 KB |
| http_request_method (4 values) | 4 | 68 | 272.0 KB |
| http_route (25 route templates) | 25 | 1,700 | 6.6 MB |
| http_response_status_code (6 values seen) | 6 | 10,200 | 39.8 MB |
| pod (20 replicas) | 20 | 204,000 | 796.9 MB |
| customer_id (1,000,000 fans) | 1,000,000 | 204,000,000,000 | 760.0 TB |
The first five labels are bounded: their number of values is small and known (routes are templates such as /orders/{id}, not /orders/42). Together they produce at most about 200 thousand series (an upper bound: not every route returns every status code) β a normal load for one Prometheus server. The last label is unbounded: it grows with the number of users, and it multiplies everything before it. No metrics system survives that.
| Label | Bounded? | Where it belongs |
|---|---|---|
http_route (template), method, status_code, region, pod | yes | metric labels |
event_id (β thousands of events on sale) | borderline β only if small and you need per-event SLOs | usually traces |
customer_id, order_id, email, full URL, trace_id | no | traces and logs (high-cardinality data is their job) |
| error message text | no (free text) | logs; use an error class as the label |
High cardinality is not bad β it is in the wrong place
"Which fans had slow checkouts?" is a great question. It is answered by traces and wide events with customer_id as an attribute, queried on demand β not by a metric label that pre-aggregates every possible value forever. Metrics answer "how many / how fast"; traces and logs answer "which ones".
Logs
A log is a timestamped record of a discrete event. Logs are the most familiar signal and the easiest to abuse: they are free to write and expensive to keep.
Structured logs
Write logs as structured records (JSON, or the OpenTelemetry log data model) with fixed field names, not as free text.
| Unstructured | Structured | |
|---|---|---|
| Example | 10:04:12 WARN payment slow for order 9912 (8120ms) | see below |
| Query | grep and regular expressions | field filters and aggregations |
| Correlation | by timestamp, by hand | by trace_id / span_id fields |
| Parsing | brittle; breaks when a message changes | stable |
{
"timestamp": "2027-03-13T10:04:12.481Z",
"level": "WARN",
"service.name": "payment",
"message": "provider call slow",
"trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
"span_id": "00f067aa0ba902b7",
"order_id": "ord_9912",
"provider": "acme-pay",
"duration_ms": 8120,
"pool_wait_ms": 7900
}
The trace_id and span_id fields are what turn a log line into part of an investigation: from a slow span you jump to exactly its log lines, and from an error line you jump to the full trace. With the OpenTelemetry Java agent, they are added to the logging context automatically (OpenTelemetry).
What (not) to log
| Level | Use | Production? |
|---|---|---|
ERROR | a failure that needs attention: exception, dependency unreachable | yes |
WARN | unexpected but handled: retry, fallback, slow call | yes |
INFO | significant business or lifecycle events: order confirmed, service started | yes, but not one per inner step |
DEBUG / TRACE | diagnostic detail | off by default; enable per service or per request when investigating |
- Log events, not steps. One line "checkout completed" with ten fields beats ten lines "entering method X".
- Never log secrets or personal data: passwords, tokens, card numbers, full e-mails. Mask at the source and, as a second barrier, redact in the Collector.
- Log the decision, not just the outcome: "fallback to provider B because breaker open" explains the next hour of graphs.
- Errors once: log an exception where it is handled, not at every layer it passes through.
Querying logs
With Loki (the "L" of the Grafana LGTM stack), labels select streams and the rest of the line is parsed at query time, which keeps indexing cheap:
| Loki | Elasticsearch / OpenSearch | |
|---|---|---|
| Indexes | only labels (stream selectors) | the full text of every field |
| Storage | compressed chunks in object storage β cheap | inverted index on disks β expensive |
| Fast for | "logs of service X around time T", trace-ID lookups | arbitrary full-text search across everything |
| Cost driver | volume scanned per query | index size |
Traces
A trace records the path of one request through the system as a tree of spans. A span is one timed operation β an HTTP call received or sent, a database query, a message produced or consumed β with a start time, a duration, a status and attributes.
| Field | Meaning | Example |
|---|---|---|
trace_id | same for every span of the request (128 bits) | 4bf92f3577b34da6a3ce929d0e0e4736 |
span_id / parent_span_id | build the tree | 00f067aa0ba902b7 / a2fb4a1d1a96d312 |
name | the operation, low cardinality | POST /checkout, SELECT seat_holds |
kind | SERVER, CLIENT, PRODUCER, CONSUMER, INTERNAL | CLIENT for the call to the provider |
status | OK, ERROR (unset by default) | ERROR |
attributes | key-value details β high cardinality is fine here | order.id=ord_9912, http.response.status_code=504 |
events | timestamped annotations inside the span | exception with stack trace, retry |
links | references to spans of other traces | a batch consumer linking to each producer's trace |
Reading a waterfall
The trace of one slow checkout during the flash sale, drawn as the waterfall that Tempo or Jaeger shows:
trace 4bf92f35β¦ POST /checkout total 9.12 s
0s 3s 6s 9s
gateway SERVER POST /checkout |ββββββββββββββββββββββββββββββ| 9.12 s
order SERVER POST /orders | βββββββββββββββββββββββββββββ| 9.08 s
inventory CLIENT PUT /holds/{id} | β | 0.09 s
postgres CLIENT UPDATE seat_holds | β | 0.02 s
payment SERVER POST /charges | ββββββββββββββββββββββββββββ| 8.95 s
payment INTERNAL pool.acquire | ββββββββββββββββββββββββββ | 7.90 s
payment CLIENT POST provider/charges | ββββ| 1.02 s
order PRODUCER order-events publish | β| 0.01 s
Read it top-down and look for the critical path β the chain of spans that determines the total time. Here it is not the provider call (1 s, slow but not dramatic): it is 7.9 s waiting for a connection from the payment adapter's pool. The provider got slower, every call held its connection longer, and by Little's law (concurrency = throughput Γ time) the pool of 20 connections saturated β so most of the time was spent queueing inside BoxOffice. Without the pool.acquire span you would blame the provider for nine seconds; with it, you know a bounded pool plus a timeout is the fix (Resilience patterns).
Spans for waiting, not just for doing
Automatic instrumentation creates spans for I/O (HTTP, SQL, Kafka). Queueing inside a service β a connection pool, a semaphore, a thread pool queue, a rate limiter β is invisible unless you add a span or at least an attribute for the wait time. Those are exactly the places where saturation hides.
Profiles
A profile samples, many times per second, what each thread of a process is executing β the full call stack β and aggregates the samples. Continuous profiling does this all the time in production with low overhead (the tools typically target around 1β2% CPU), so you can compare "10:00 today" with "10:00 last week".
The usual visualization is the flame graph5: each box is a function, its width is the fraction of samples in which it was on the stack, and children sit on top of their callers. Wide plateaus at the top are where the CPU actually goes.
| Profile type | Shows | BoxOffice question |
|---|---|---|
| CPU | where CPU time is spent | why does search use 4 cores for autocomplete? |
| Allocation / heap | who allocates memory | what triggers the GC pauses in order during the on-sale? |
| Lock / mutex | where threads wait for locks | is the seat-map cache lock contended? |
| Wall-clock | where time passes, including waiting | where do checkout threads sleep? |
Profiling became the fourth OpenTelemetry signal (still maturing in 2026), and eBPF-based profilers (Parca, Pyroscope, cloud profilers) can profile every process on a node without changing the application. Traces tell you which service and operation is slow; profiles tell you which function inside it.
Dashboards
A dashboard is a set of saved queries arranged to answer a question quickly. Most dashboards fail because they try to show everything; the useful ones are designed top-down, from what users feel to what resources do.
flowchart TB
L1["1 Β· User journeys and SLOs<br/>search, seat hold, checkout: SLI, budget left"]
L2["2 Β· Services β RED<br/>per service and route: rate, errors, p50/p99"]
L3["3 Β· Dependencies<br/>provider latency, DB, cache hit ratio, Kafka lag"]
L4["4 Β· Resources β USE<br/>CPU, memory, pools, queues per pod/node"]
L1 --> L2 --> L3 --> L4 - One dashboard, one purpose. The on-call "is checkout healthy?" view fits on one screen; deep dives live elsewhere.
- Same time axis, same units: seconds for latency, ratios (0β1) or percentages for errors, never mixed on one panel.
- Show percentiles and the SLO threshold as a line on the latency panel, so the eye compares at once.
- Annotate deploys and feature flags: most incidents start at a change.
- Dashboards as code (Grafana JSON in Git, Jsonnet/Grafonnet, Terraform): reviewed, versioned and reproducible, instead of clicked by hand and lost.
Trade-offs
| Decision | Option A | Option B | Rule of thumb |
|---|---|---|---|
| Latency metric | summary (quantiles in-process) | histogram (buckets) | histogram β it aggregates across pods |
| Per-user detail | metric label | trace / log attribute | attribute; labels must be bounded |
| Logs | free text | structured with trace_id | structured, always |
| Coverage | log every step | log events + traces | events + traces; turn on DEBUG when needed |
| Collection | pull (scrape) | push (OTLP) | either; OTLP is the portable default |
| Retention | keep everything long | tiers: hot (days) β cold object storage | tiers; metrics long, raw logs/traces short |
In the interview
When you add a component to a design, name its one key metric in the same sentence β it shows you have operated systems: cache β hit ratio; queue β consumer lag / age of oldest message; database replica β replication lag; connection pool β pending acquisitions; API β RED. If asked "how do you debug a slow request across services?", the answer is distributed tracing with propagated context, then the logs of the slow span. And if you propose a metric with user_id as a label, expect the follow-up question about cardinality.
Best practices
- Give every service the same RED row, from the same three queries β rate, error share and p95 per
service_name, so anyone on call can read any service in ten seconds. In BoxOffice:boxoffice-red.json(Hands-on 8 β RED dashboard). - Plot errors as a ratio, and draw the SLO threshold on the latency panel β 50 errors/s is a disaster at 60 req/s and noise at 60,000; a line at the threshold turns "is p99 bad?" into a glance.
- Expose saturation for every pool and queue you own β pending connection acquisitions, queue depth, consumer lag: in the flash sale they rose two minutes before the checkout alert. In BoxOffice: the DB connections panel (
db_client_connections_pending_requests) of the lab 8 dashboard. - Set a series budget per service and check it after every release β count the series each service creates (
count by (service_name) ({service_namespace="boxoffice"})); one new label can multiply them by 1,000. In BoxOffice: experiment C of Hands-on 8 β Measure it. - Emit one structured log event per outcome, with
trace_idand the decision taken β "fallback to provider B, breaker open" explains the next hour of graphs; ten "entering method" lines explain nothing. In BoxOffice:LOGGING_PATTERN_CORRELATIONin thex-otelanchor puts the trace id on every line.
Common pitfalls
- Unbounded labels (
user_id,order_id, raw URLs): the metrics backend runs out of memory or the bill explodes. - Averages and averaged percentiles: use histograms, aggregate buckets, then compute the percentile.
- Histogram buckets that miss the SLO: if the SLO is "under 2 s" and no bucket ends at 2 s, the SLI cannot be computed exactly.
- Logging personal data and secrets: logs are copied, indexed and retained β a leak multiplier.
- Only automatic spans: waiting inside the service (pools, locks, queues) stays invisible.
- Dashboards nobody owns: a hundred stale panels slow down every investigation.
How it connects
flowchart LR
REQ["one checkout:<br/>gateway β order β payment"] --> MET["metrics: RED per route,<br/>USE per pool and topic"]
REQ --> TR["trace: spans,<br/>critical path"]
REQ --> LOG["structured log<br/>with trace_id"]
MET --> HIST["histograms β p99<br/>β Estimation: percentiles"]
MET --> CARD["bounded labels<br/>(cardinality)"]
CARD -->|"high-cardinality detail"| TR
TR --> WAIT["pool.acquire wait<br/>β Little's law"]
WAIT --> RES["timeout + bulkhead<br/>β Reliability patterns"]
TR --> PROF["profiles: which function<br/>β Performance"]
MET --> SLI["SLI per journey<br/>β Alerting, SLOs"] Palette components: metrics Β· logs Β· tracing Β· app (RED per service) Β· sql (USE of the connection pool) Β· kafka (consumer lag) Β· payment (client-side RED of the provider call). Scenarios: observability-stack, event-fanout (consumer lag), cascading-failure (a slow provider saturating a pool). Continues in: OpenTelemetry, Alerting, Estimation β percentiles, Estimation β queueing, Reliability β patterns, Performance β bottlenecks (flame graphs), Hands-on 8 β RED dashboard.
Key takeaways
- Four signals on a costβdetail spectrum: metrics (cheap, aggregate), traces (per request, sampled), logs (per event), profiles (per code path).
- RED for services, USE for resources, the golden signals for anything user-facing; saturation (waiting) is the leading indicator.
- Use histograms, aggregate buckets with
sum by (le), then compute percentiles; never average percentiles. - Cardinality is the product of label values; keep metric labels bounded and put high-cardinality detail in traces and logs.
- Logs must be structured and carry the
trace_id; traces show the critical path, including time spent waiting; profiles show the function. - Design dashboards top-down: journeys/SLOs β RED β dependencies β USE.
-
OPENTELEMETRY. Semantic conventions for HTTP metrics.Β β©
-
WILKIE, T. The RED Method: key metrics for microservices architecture. Grafana Labs, 2018.Β β©
-
GREGG, B. The USE Method. See also Systems Performance, 2nd ed. Addison-Wesley, 2020.Β β©
-
BEYER, B. et al. Site Reliability Engineering. O'Reilly, 2016. Chapter 6 β Monitoring Distributed Systems.Β β©
-
GREGG, B. Flame Graphs.Β β©