Skip to content
advanced Phase 16 · Production Patterns and Interview Prep

Metrics, Logs, and Traces

Instrument services with request metrics, correlated logs, and health endpoints.

45m
0 problems
Topic Progress 0%

Is It Up, Is It Slow, and What Is This Request Doing?

Three questions arrive in this order during an incident. Is the service healthy at all — a yes/no that a monitoring system must answer continuously. Is it slower than usual, and for which endpoint — a quantitative comparison over time. What happened to this specific request — a narrative only the request itself can tell. Metrics, logs, and traces exist because no single record type answers all three cheaply: a metric answers the first two with a number you can graph, a log answers the third with narrative, and a trace answers the third with timing across services.

The first instrument a service ships is not a graph — it is the health endpoints, whose two flavors have genuinely different semantics:

var ready atomic.Bool // flipped false at shutdown start, true after init

func healthRouter() http.Handler {
	mux := http.NewServeMux()

	// Liveness: is this process functional? Restart on failure.
	mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintln(w, "ok")
	})

	// Readiness: should traffic be routed here? Fail during drain.
	mux.HandleFunc("/readyz", func(w http.ResponseWriter, r *http.Request) {
		if !ready.Load() {
			http.Error(w, "draining", http.StatusServiceUnavailable)
			return
		}
		fmt.Fprintln(w, "ok")
	})
	return mux
}

Conflating them produces one of two classic failures. Returning 503 from liveness when a dependency is slow causes restart storms: the process is fine, the restart helps nothing, and the churn makes the dependency worse. Returning 200 from readiness during shutdown keeps the load balancer sending traffic into a closing listener — exactly the dropped-request scenario Graceful Shutdown and Connection Drain exists to prevent. The rule: liveness measures self, readiness measures usefulness to the router, and readiness is the one that flips early.

With health covered, the RED framework frames instrumentation: for every endpoint, measure Rate (requests/sec), Errors (failures/sec), Duration (latency distribution). Those three numbers localize most incidents — rate says it is traffic, errors say it is correctness, duration says it is a dependency or resource. Each has a natural Go representation: a counter for rate and errors, a histogram for duration. The framework's premise is worth stating because it justifies the layout: you instrument the service boundary first, then descend to internals only when the boundary numbers demand it — the same narrowing an incident investigation performs as it moves from dashboards to a single request.

What makes instrumentation durable is wiring it into the middleware layer once, rather than scattering increments through handlers. The next chapter builds that middleware; the principle now: every measurement needs a definition someone else can interpret at 3 a.m. — p99 of what, errors by which class, over which window.

Histograms, Labels, and the Cardinality Tax

The metrics endpoint works. Then someone adds user_id as a label to the request counter, and within a day the metric holds 2 million series, the Prometheus scrape takes 40 seconds, and the query that drew a tidy latency graph now times out. This is the cardinality tax, and it is the single most common way a metrics system dies. Labels are dimensions for grouping; every distinct label combination creates a new time series that must be stored, indexed, and evaluated forever.

The RED instrumentation for an HTTP service, written once as middleware:

var (
	reqDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{
		Name:    "http_request_duration_seconds",
		Buckets: []float64{.005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5},
	}, []string{"method", "route", "code"})
	reqTotal = promauto.NewCounterVec(prometheus.CounterOpts{
		Name: "http_requests_total",
	}, []string{"method", "route", "code"})
)

func metrics(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()
		rw := &statusWriter{ResponseWriter: w, status: http.StatusOK}
		next.ServeHTTP(rw, r)
		dur := time.Since(start)

		route := routeTemplate(r) // "/users/{id}", NOT "/users/8821"
		code := strconv.Itoa(rw.status)
		reqDuration.WithLabelValues(r.Method, route, code).Observe(dur.Seconds())
		reqTotal.WithLabelValues(r.Method, route, code).Inc()
	})
}

Three decisions carry the cost and the value:

Label by route template, not raw path. /users/8821, /users/8822, … a raw path label creates a series per user id — unbounded. The label must be the pattern (/users/{id}), which requires routing middleware that knows the matched template (Go 1.22's r.PathValue makes the pattern available; otherwise derive it from your router). This one rule is the difference between 20 series and 2 million.

Buckets chosen for the service's latency shape, not defaults. Histogram buckets determine the resolution of your percentile estimates: if 99% of requests take 30 ms, buckets that jump from 100 ms to 250 ms make p99 a fiction. Start from what users feel (single-digit ms to a few seconds), and note that buckets are upper bounds — a 50 ms bucket counts anything ≤ 50 ms, so finer resolution near the alert threshold pays for itself.

Errors as a label, not a separate metric. code (or a bounded outcome of success/retry/fail) lets one query slice errors by route and status class. What must not become a label: user ids, URLs with query strings, exception messages, or any value with unbounded or high-cardinality domains. The test for a label: can you enumerate its possible values? Method, route, status, region, instance — enumerable. Request id, user id, error text — never.

This is where metrics and logs split responsibilities cleanly. The metrics answer which route got slower and by how much; they cannot answer why this one request took four seconds — the error message, the upstream host, the retry count. That narrative belongs to structured logging with correlation fields, which the next chapter ties to the same request. The discipline: put counts and distributions in metrics with bounded labels, put narrative in logs, and never try to smuggle one into the other's job.

One Request, One Thread: Correlating Logs, Metrics, and Traces

The dashboard says checkout p99 rose from 400 ms to 4 s at 14:02. Now what? Without correlation, the next hour is spent guessing which log lines belong to the slow requests — grepping timestamps and hoping the ids in the message text line up. Correlation is the mechanism that turns three separate signals into one investigation: a request id (or trace id) that appears in the log records, labels or exemplars on the metrics, and the span tree of the trace.

The mechanism in Go is context, established once at the edge:

type ctxKey struct{}

func withRequestID(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		id := r.Header.Get("X-Request-Id")
		if id == "" {
			id = newID()
		}
		w.Header().Set("X-Request-Id", id)

		l := slog.With("request_id", id, "route", routeTemplate(r))
		ctx := context.WithValue(r.Context(), ctxKey{}, l)
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

// anywhere downstream
func (s *Service) charge(ctx context.Context, amt int) error {
	loggerFrom(ctx).Info("charging", "amount_cents", amt)
	// ...and propagate the id to the next service:
	req, _ := http.NewRequestWithContext(ctx, "POST", bankURL, body)
	req.Header.Set("X-Request-Id", requestIDFrom(ctx))
	return doPost(req)
}

A logger carried in the context means every line from one request shares request_id without business code naming it — the pattern Structured Logging with log/slog established, now closed into a loop at the edge. The id must also travel outbound: forwarding X-Request-Id (or the W3C traceparent header) to downstream services is what makes one request traceable across process boundaries. An id that stops at your service documents only your half of the story.

Traces add what logs structurally cannot: parent-child timing across services. A trace is a tree of spans — checkout → payments → inventory, each with start and duration — so "slow because inventory" becomes a visual fact rather than an inference. In Go, the OpenTelemetry SDK provides the API, and instrumentation usually arrives as middleware and otelhttp wrappers rather than hand-rolled span code; the discipline that makes traces useful is manual: meaningful span names (GET /users/{id} not http handler), attributes for the fields you debug by (route, status, upstream host), and links between the trace id and the log records emitted inside the span. The payoff of a well-formed trace is exemplars — a metric point that links to the trace of a representative request — which lets you jump from "this histogram bucket got heavier" to "here is a request inside it."

Alert on symptoms, then descend. The alerting layer watches what users feel — error rate and latency SLOs from the RED metrics — because those pages a human. The investigation layer uses the trace to find the slow span, the logs inside that span for the error text, and the metrics again to scope the blast radius (one route? one instance? all regions?). This flow — metric tells you that, trace tells you where, log tells you why — is the entire architecture of the three signals, and a service that answers all three questions from its own instrumentation can be operated by someone who has never read its code.

Quiz

1. A dependency slows down. Which signal should fire the page, and why?

Question 1 options

2. The request counter's memory explodes after a teammate labels it with user_id. What is the correct fix?

Question 2 options

3. Why must the metrics middleware label with the route template rather than the raw URL path?

Question 3 options

4. A request id is generated at the edge and included only in that service's logs. What breaks in a three-service call chain?

Question 4 options

Flashcards

Question

What question does each of metrics, logs, and traces answer, and what does it not?

Answer

Metrics answer 'how much / how often / how fast, overall' — counts and distributions you graph and alert on, but no per-request detail. Logs answer 'what happened here' — narrative with context, no aggregate math. Traces answer 'where did the time go across services' — timing tree per request, but not fleet-wide trends.

Question

Why should /healthz and /readyz differ during shutdown?

Answer

Liveness measures the process itself; failing it triggers a restart, which helps nothing during a deliberate stop. Readiness measures whether to route traffic and must flip to 503 the moment shutdown begins, letting the load balancer divert requests while in-flight work drains — the two fail for different reasons and must not share an endpoint.

Question

What is cardinality, and which fields are safe as metric labels?

Answer

Cardinality is the number of distinct label-value combinations, each becoming a stored time series. Safe labels are enumerable and bounded — method, route template, status class, region, instance. Unsafe: user ids, request ids, raw URLs, error messages — any domain that grows with traffic or input.

Question

How does a request id get from the edge to every log line and downstream service?

Answer

The edge middleware creates (or adopts) the id, stores a slog.With-bound logger and the id in the request context, and sets the response header. Downstream, handlers read the logger from context for local lines and copy the id into outbound headers so the next service continues the same thread.

Question

What is the metric → trace → log investigation flow, and why does order matter?

Answer

The metric tells you that something changed (which route, when, how much) without knowing why; the trace tells you where inside the call chain the time or errors concentrated; the logs in that span carry the specific error text and values. Each step narrows the search — starting from logs means grepping blind, starting from metrics means knowing the blast radius first.

Revision Notes

Key Takeaways

  • 1. Metrics, logs, and traces answer different questions; instrumentation fails when one is forced into another's role
  • 2. Liveness and readiness have opposite correct behavior during failure and shutdown — conflating them causes restart storms or dropped traffic
  • 3. Label cardinality is a storage tax: bound every label, and keep ids in logs where high-cardinality keys are legal
  • 4. Correlation is built at the edge and propagated outbound — a request id that stops at your service cannot reconstruct the chain
  • 5. Alert on what users feel (RED/SLO), localize with traces, explain with correlated logs

Interview Tips

  • • "Walk me through observability for a Go microservice" — health endpoints, RED middleware with route-template labels, slog correlation in context, trace propagation; end with the metric→trace→log flow
  • • "How do you avoid cardinality explosions?" — the label test: can you enumerate the values? Give route template vs raw path and user_id-in-logs as the canonical examples
  • • "What's the difference between liveness and readiness?" — restart semantics vs routing semantics, with the shutdown 503 as the concrete consequence
  • • "How do you trace a request across three services?" — request id or W3C traceparent in headers, span parenting in the SDK, exemplars linking histograms to representative traces

Cheat Sheet

Metrics, Logs, and Traces

Signal → question:

Signal Answers Representation
Metric how much / how often / how fast counter, histogram
Log what happened here slog record with correlation fields
Trace where did the time go span tree across services

Health endpoints: /healthz = process functional (restart on fail); /readyz = routable (503 during init and drain, 200 otherwise).

RED middleware:

start := time.Now()
next.ServeHTTP(rw, r)
route := routeTemplate(r) // never the raw path
reqDuration.WithLabelValues(r.Method, route, code).Observe(time.Since(start).Seconds())
reqTotal.WithLabelValues(r.Method, route, code).Inc()

Label rules: enumerable only (method, route template, status, region); buckets sized near the alert threshold; errors as a code label.

Correlation: middleware sets request id → slog.With("request_id", id) into context → propagate X-Request-Id/traceparent outbound → logs, traces, and the incident timeline join on the id.

Alerting: page on symptoms (SLO burn on latency/errors), investigate with traces, read logs for cause.

Continue Learning

Before You Start

  • No prerequisites listed.

Related Reading