Skip to content

Metrics and log correlation

Moduleobs.02 · practice · ops, go · Pass 7 · 4 to 6 h
You build/metrics on the health port (9464) of your gateway (in go/cmd/gateway, recording the gateway’s instruments) and engine (L10.5 gauges, L10.7 histograms); JSON logs with trace_id and span_id (otelx.LogHandler); deploy/observability/monitors.yaml (what Prometheus scrapes) and deploy/observability/collector.yaml (the OpenTelemetry Collector)
Contractotel/metrics.yaml (names, types, buckets, labels), otel/semconv.md (log fields), helm/observability.md (the selector label, the collector Service)
Testscourse/tests/obs.02/ (check runs artifacts.py; what each test checks: section 4)
Needsobs.01 (go/otelx: LogHandler, Setup, the server span), dep.03 (the charts whose pods are scraped), L10.7 (the engine’s serve loop that records the GenAI and HTTP histograms and logs each request in its span); reading: obs.00, L10.5, and gw.01 (the other code it scrapes)
Used byobs.03 turns these series into SLOs; obs.04 draws them; ops.01 reads them during the drill
MilestoneMS-prod (contract metrics scraped on kind)
Optional depthPrometheus exposition format (free), Histograms and summaries (free), Observability Engineering, ch. 8 and 9
  • A histogram is a set of cumulative counters, one per bucket bound le. With the contract’s bounds, “fraction of requests at most 500 ms” is one exact division; with other bounds it cannot be computed at all.
  • Prometheus finds targets only through ServiceMonitor or PodMonitor objects the stack selects (release: observability), and names the series’ job from them. SLO rules and dashboards select by that job.
  • A label that copies client input (a model name) multiplies series; capped labels keep 64 values and fold the rest into _other.
  • There is no log backend: log-to-trace correlation is kubectl logs | grep <trace_id>, which works only if every line is JSON with trace_id, and never holds a prompt or a key.
  • The collector is the one door for telemetry: OTLP in on 4317 and 4318, traces to Tempo, pushed Python metrics out through its Prometheus exporter; memory_limiter first and batch in every pipeline.
Terminal window
ol start obs.02 # records the start; there are no stubs
ol tests obs.02 # read the test catalog first
# add /metrics and JSON logs to the gateway, monitors.yaml and collector.yaml, then:
OL_SMOKE=1 ol check obs.02 # static and local tiers: your services as local processes
curl -s 127.0.0.1:<health_port>/metrics | grep '^gen_ai_server_time_to_first_token_seconds_bucket'
# on kind: apply the monitors, install the collector, then the full check
kubectl apply -f deploy/observability/monitors.yaml
helm upgrade --install observability deploy/observability -n observability \
-f deploy/observability/values.yaml -f deploy/observability/collector.yaml # dep.03's umbrella chart
ol check obs.02

Traces (obs.01) explain one request. They do not say whether TTFT is getting worse for everyone, how many KV blocks are left, or which tenant is flooding the gateway: that takes metrics, counts and distributions aggregated over all requests, cheap enough to keep for every request. Your engine already serves a few gauges on its health port (L10.5); the gateway serves none, the histograms the SLOs need (obs.03) do not exist yet, and nothing in the cluster scrapes either. When ops.01 kills a decode pod, the page fires from these series, and the person paged starts from a dashboard (obs.04), then needs the log lines of one slow request. This module makes all of that possible.

SymbolMeaningType
c(t)c(t)a counter: a total that only grows (requests served)non-negative, resets to 0 on restart
g(t)g(t)a gauge: a value that goes up and down (free KV blocks)real
b1<⋯<bkb_1 < \dots < b_kthe bucket bounds of a histogram (le, “less or equal”)seconds, from metrics.yaml
Ci(t)C_i(t)requests observed so far with value ≤bi\le b_icounter; C+∞C_{+\infty} is the total
ΔCi\Delta C_ithe increase of CiC_i over a window (rate×\text{rate} \times window)non-negative

A histogram is the counters C1≤C2≤⋯≤C+∞C_1 \le C_2 \le \dots \le C_{+\infty} plus a sum. They are cumulative: an observation of 0.07 s increments every CiC_i with bi≥0.07b_i \ge 0.07. Over a window, the fraction of requests at most bib_i is

frac(bi)=ΔCiΔC+∞\text{frac}(b_i) = \frac{\Delta C_i}{\Delta C_{+\infty}}

which is exact only at a bound. That is why metrics.yaml fixes the bounds and why the check compares them exactly: an engine with its own bounds silently breaks every SLO rule that reads le="0.5".

Every service answers GET /metrics on its health port with Prometheus text:

# TYPE gen_ai_server_time_to_first_token_seconds histogram
gen_ai_server_time_to_first_token_seconds_bucket{gen_ai_operation_name="chat",gen_ai_request_model="smol-135m",tl_engine_role="gateway",le="0.5"} 3
...
gen_ai_server_time_to_first_token_seconds_count{gen_ai_operation_name="chat",gen_ai_request_model="smol-135m",tl_engine_role="gateway"} 4

OTel instrument names become Prometheus names by replacing . with _ and adding the unit (_seconds); counters end in _total. The prometheus column of metrics.yaml is the exact result; serve that name.

Every distinct combination of label values is its own series, stored and scanned separately. A histogram with 16 bounds is 19 series per combination: 16 buckets, the +Inf bucket, _sum, and _count. A label taking client input (the model field of a request) lets a client mint series: 80 random model names times 19 is 1 520 series for TTFT alone. metrics.yaml marks such labels capped: a process keeps the first 64 distinct values and records every later one as _other. Labels with values take only those.

kube-prometheus-stack runs the Prometheus Operator, which turns ServiceMonitor objects (scrape the endpoints behind a Service port) and PodMonitor objects (scrape a named container port of matching pods) into scrape config, but only the ones carrying the stack’s selector label release: observability. Each target’s series get a job label: a ServiceMonitor names it after the Service, a PodMonitor after <namespace>/<monitor name>, unless jobLabel names a label to take it from or a relabeling (targetLabel: job) sets it. The scrape interval decides the shortest window you can compute a rate over: rate() needs two samples, so the drill profile’s 25 s window needs a scrape every 10 s or faster.

Services export traces as OTLP to one address ([otel].endpoint), the OpenTelemetry Collector, which forwards them to Tempo. Python subprocesses (training, corpus stages) cannot be scraped because they live seconds to hours and run outside any Service (D9), so they push OTLP metrics to the collector, whose prometheus exporter exposes them for Prometheus to scrape. A collector pipeline is receivers -> processors -> exporters; memory_limiter goes first so an overload drops data instead of the pod, and batch before export.

Each line a service writes is one JSON object (semconv.md, Logs): ts, level, msg, service, and, inside a request, trace_id and span_id of the current span. otelx.LogHandler adds the two ids to every record logged with the request’s context. The prompt, the completion, and the API key never appear: logs are retained longer and read by more people than any request.

Four requests reach the gateway, with first-token times 0.03 s, 0.07 s, 0.30 s, and 0.60 s. The TTFT bounds of metrics.yaml around them are 0.02, 0.04, 0.06, 0.08, 0.1, 0.25, 0.5, 0.75.

lerequests ≤\le leCiC_i
0.02none0
0.040.031
0.060.031
0.080.03, 0.072
0.1, 0.250.03, 0.072
0.50.03, 0.07, 0.303
0.75 and up, +Infall four4

The fraction within the 500 ms SLO threshold is C0.5/C+∞=3/4=0.75C_{0.5} / C_{+\infty} = 3/4 = 0.75, exact. Had the engine used bounds 0.4 and 0.8, the 0.30 and the 0.60 s requests would fall in buckets that straddle 0.5, and no division of counters would give the answer.

Cardinality. After 80 requests with model names model-000 to model-079, an uncapped gateway serves 81 values of gen_ai_request_model (the 80 plus tracer): 81×19=1 53981 \times 19 = 1\,539 TTFT series. Capped, it serves 64 named values plus _other: at most 65×19=1 23565 \times 19 = 1\,235, and that ceiling holds for a million names.

Correlation. One request carries traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01. The gateway logs:

{"ts":"2026-10-09T10:00:01Z","level":"info","msg":"proxied","service":"forge-gateway","status":200,"trace_id":"4bf92f3577b34da6a3ce929d0e0e4736","span_id":"a1b2c3d4e5f60718"}

kubectl -n forge logs deploy/forge-gateway | grep 4bf92f3577b34da6a3ce929d0e0e4736 finds it; the same grep on the engine finds the engine’s lines, whose span_id is a different span of the same trace. That is the check test_logs_carry_the_trace_id runs on your local processes.

ArtifactRequirement
GET /metrics on the health port of the gateway and of every enginePrometheus text; every instrument of metrics.yaml its emitted_by names, except the later ones (tl.engine.spec_accept_rate with L10.8, tl.kv.transfer.* in disaggregated roles, tl.gateway.policy.denials with gw.08); exact names, # TYPE, bounds, label keys, enumerated values; capped labels at most 64 values plus _other
logsone JSON object per line on stdout: ts, level, msg, service, and trace_id, span_id inside a request; no prompt, no key
deploy/observability/monitors.yamla PodMonitor or ServiceMonitor per component, label release: observability, selecting every pod your charts render (every engine role release too), on the 9464 port, path /metrics, interval of 10 s or less, job = <system>-gateway / <system>-engine
deploy/observability/collector.yamlthe collector config, under otel-collector.config: (values for dep.03’s umbrella chart), config: (the chart alone), or at the top (raw): OTLP on 0.0.0.0:4317 and :4318; a traces pipeline to Tempo; a metrics pipeline to the prometheus exporter; memory_limiter first and batch in each

The reference uses PodMonitors that select pods by app.kubernetes.io/component (shared by every release of a chart, including the engine’s per-role releases <system>-engine-prefill and -decode) and set job with a relabeling, so no Service needs a metrics port.

The local tier runs your [build] steps and starts [services.engine] and [services.gateway] from system.toml as ol milestone does, sends six chat completions, one traced request whose prompt holds a canary string, and 80 requests with random model names to each service, then scrapes both health ports and reads both logs.

TestKINDChecksWhy it matters downstream
test_every_pod_is_scraped_on_its_health_portconformanceeach rendered gateway workload, and the engine rendered as unified, prefill, and decode releases, is selected by a monitor on port 9464, path /metricsno target, no series
test_monitors_are_selected_by_the_stackconformanceevery monitor carries release: observabilitythe operator ignores the rest
test_scrape_job_names_and_intervalunitjob names <system>-gateway and <system>-engine; interval 10 s or lessobs.03 rules and obs.04 panels select by job; drill windows
test_collector_pipelinesunitthe four collector requirements of the tabletraces reach Tempo; Python metrics reach Prometheus
test_services_serve_contract_metricsconformancenames, types, bounds, labels, values of both scrapesevery rule and panel downstream
test_capped_labels_stay_under_the_capfaultafter 80 model names, at most 64 values plus _other per capped labelan attacker cannot exhaust Prometheus
test_logs_carry_the_trace_idconformanceboth services log JSON with the traced request’s trace_id and a span_idthe section 3 grep
test_logs_never_contain_the_promptboundarythe canary prompt and the API key appear in no log lineuser data stays out of logs
test_targets_up_in_prometheusconformanceon kind: up is 1 for every target of both jobsthe deployed proof of the static tier
test_kubectl_logs_find_the_traceconformanceon kind: a request through the gateway NodePort, then kubectl logs finds its trace idcorrelation in the cluster, during ops.01

The last two are the cluster tier; OL_SMOKE=1 skips them with the reason.

PitfallSymptomCaught by
1. A monitor whose selector matches no pod (selecting app.kubernetes.io/name: <system>-engine misses the -decode release), or the wrong portPrometheus shows no target; graphs are empty, not zerotest_every_pod_is_scraped_on_its_health_port
2. A PodMonitor without a job relabeling (or jobLabel), or the default 30 s intervalseries under job="observability/forge-gateway", so every SLO rule is silent; drill windows hold one sampletest_scrape_job_names_and_interval
3. The model name (or a user id) as an uncapped labelPrometheus memory grows with traffic until it is OOM-killedtest_capped_labels_stay_under_the_cap
4. Logging the request body “for debugging”prompts and keys in kubectl logs, retained for weekstest_logs_never_contain_the_prompt
5. Collector OTLP bound to localhost:4317, no memory_limiter, or traces exported to the debug exporter onlyservices cannot reach it; the collector OOMs under a burst; Tempo stays emptytest_collector_pipelines
6. Your own bucket bounds (“these fit our latencies better”)SLO rules reading le="0.5" find nothingtest_services_serve_contract_metrics
DirectionModuleHow it uses this
Backobs.01LogHandler stamps the ids; Setup and the SERVER span give each log line its span
Backdep.03the charts whose pods and container ports the monitors select
BackL10.7the local tier runs your engine: its serve loop records the TTFT, TPOT, duration, and HTTP histograms this module scrapes, and writes the JSON log line with the trace id
Forwardobs.03SLIs over the TTFT and TPOT histograms and the 5xx ratio, selected by job
Forwardobs.04heatmaps of the same buckets, KV usage, per-tenant usage
Forwardops.01the drill’s detection and resolution queries read these series
Your pieceProduction equivalentWhat it addsWhere to look
hand-written expositionprometheus/client_golang or the OTel Prometheus exporterregistries, collectors, exemplarsclient_golang
classic histograms with fixed boundsnative histogramsexponential buckets, no bounds to choose, far fewer seriesNative histograms
kubectl logs | grepLoki or the OTel log pipelineindexed search, retention, log-to-trace links in GrafanaLoki
PodMonitors per componentthe collector’s Prometheus receiver or target allocatorone scraper, relabelling in the pipelineCollector prometheusreceiver
exemplars: not usedtrace exemplars on histogram bucketsjump from a slow bucket straight to a traceExemplars