RED per service, the service map and exemplar click-through are verified live. Build step 3's second metrics path and step 6's TraceQL test migration are not done. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
174 lines
11 KiB
Markdown
174 lines
11 KiB
Markdown
# Phase 5 — Metrics
|
||
|
||
*Part of `docs/roadmap/` — see [`intro.md`](intro.md) for platform/background context and [`summary.md`](summary.md) for how every phase fits together. Prev: [`phase-4.md`](phase-4.md) · Next: [`phase-6.md`](phase-6.md)*
|
||
|
||
**Status:** Done
|
||
|
||
**Message:** Done 2026-10-25. All three Done-when criteria verified live: RED per
|
||
service and the service map (7 edges, including the two stream edges
|
||
gateway → notifier and gateway → audit) from Tempo's span metrics, and a Grafana
|
||
exemplar click that opens the trace.
|
||
|
||
- **Build steps done:** 1, 2, 3 (first path only), 4 and 5. Not done: 3's second
|
||
path and 6 (see below).
|
||
- **Metrics path:** each service exposes OTel metrics through
|
||
`PrometheusMetricReader` on its own `:9464` (`common/telemetry.py`), not an OTLP
|
||
exporter, because Alloy 404s OTLP metrics. Prometheus's `ledgerline` job scrapes
|
||
them through czy's Traefik at `https://ledgerline.vione.cloud/metrics/<service>`
|
||
behind basic auth.
|
||
- **Custom metrics:** the five from Build step 2, in `services/*/app.py` and
|
||
`common/metrics.py`. `ledgerline.db.pool.in_use` is labelled `pool`
|
||
(`normal`/`starved`/`default`), not `service`; the scrape adds `service`.
|
||
`currency` outside EUR/USD/GBP is recorded as `other`.
|
||
- **Cardinality experiment (Build step 4, done):** `account_id` on the transaction counter turned 75
|
||
transactions into 68 series across 60 accounts, and `account_id` entered the
|
||
TSDB Status top-10. After the revert (`d54ea2b`) the series went stale, but head
|
||
series stayed up until compaction.
|
||
- **Hand-written vs span-metrics histogram** (same 75 requests): counts equal,
|
||
`_sum` 400.85 s vs 401.39 s (the span adds ~7 ms per request of middleware and
|
||
serialization). p95 was 9.5 s vs 14.6 s. The gap comes only from Tempo's wide
|
||
8.192–16.384 s bucket.
|
||
- **Exemplar fix:** `trace_id_label_name: trace_id` in `k8s/tempo/tempo.yml`.
|
||
Tempo's code default is `traceID`; its docs wrongly say `trace_id`.
|
||
- **Not done:** Build step 3's second metrics path and its write-up (a Phase 10
|
||
artefact), and Build step 6 (migrating the call-counting tests to TraceQL).
|
||
- **Carry forward:**
|
||
- RED must count `SPAN_KIND_SERVER|SPAN_KIND_CONSUMER`. Notifier and audit only
|
||
have CONSUMER spans.
|
||
- An error-ratio query needs `(errors or total * 0) / total`, or services with
|
||
zero errors drop out.
|
||
- For gateway's latency tail, use `ledgerline_transaction_duration_seconds`.
|
||
The span-metrics p95/p99 there is an interpolation artifact.
|
||
- The Python SDK has no cardinality limit, and the Python Prometheus exporter
|
||
emits no exemplars, so only Tempo's span metrics carry them.
|
||
- Built-in HTTP histograms are most of the app's series. The body-size ones
|
||
(~⅓) can be dropped with a View or `metric_relabel_configs` if needed.
|
||
- Exemplars stored before 2026-10-24 19:10 UTC are labelled `traceID` and don't
|
||
link. Exemplar trace IDs drop leading zeros.
|
||
- Tempo stops sending idle service-graph and span-metric series after a few
|
||
minutes, so "now" queries come back empty without traffic.
|
||
|
||
**Time-box:** 2 evenings.
|
||
|
||
### Concepts
|
||
|
||
- **Instrument types.**
|
||
- **Counter** — monotonic, only increases. Requests, errors, messages consumed.
|
||
- **UpDownCounter / Gauge** — can go both ways. Queue depth, in-flight requests,
|
||
connection-pool checkouts.
|
||
- **Histogram** — a distribution. Request latency, payload size. This is what
|
||
gives you percentiles.
|
||
- Synchronous instruments are recorded inline; **observable/asynchronous** ones are
|
||
read via a callback on collection (right for things you *poll*, like pool size).
|
||
- Avoid Prometheus **Summaries**: they compute quantiles client-side and therefore
|
||
**cannot be aggregated across instances**. `avg(p99_from_summary)` is arithmetic
|
||
nonsense. Use histograms.
|
||
- **Cardinality — the one thing that will bite you.** Every unique combination of
|
||
metric name + label values is a separate time series in memory. Add `account_id`
|
||
as a label on a 800-account demo and you've made 800 series; do it in production
|
||
and you make millions and OOM Prometheus. Rule of thumb: **if a label could exceed
|
||
~10 000 distinct values in production, it does not belong on a metric.** Put it on
|
||
a span. Histograms multiply this (one series per bucket per label combination), so
|
||
don't over-bucket either.
|
||
- Bounded and fine: `service`, `http.route` (the *template*, `/accounts/{id}`, not
|
||
the concrete path), `http.status_code` class, `kind`, `currency`.
|
||
- Unbounded and forbidden: `account_id`, `transaction_id`, `merchant_name`,
|
||
raw URL, timestamp, error message.
|
||
- **RED and USE.**
|
||
- **RED** (Rate, Errors, Duration) — for *request-driven services*. This is your
|
||
per-service dashboard.
|
||
- **USE** (Utilization, Saturation, Errors) — for *resources*: CPU, memory,
|
||
connection pools, queues, disk. Saturation is the one everyone forgets and the
|
||
one that predicts collapse.
|
||
- You want both: RED tells you the service is slow, USE tells you which resource
|
||
is why.
|
||
- **Span metrics — generate metrics from traces.** Two architectures exist for this,
|
||
and knowing both is the point:
|
||
- *In the collector:* the OpenTelemetry Collector's `spanmetrics` and
|
||
`servicegraph` connectors read spans in flight and emit RED metrics and edge
|
||
metrics. This is what most blog posts (including the OneUptime article) show.
|
||
- *In the trace store:* Tempo's **metrics-generator** does the same job after
|
||
ingestion, remote-writing the result to Prometheus. **This is what your platform
|
||
does** — `processors: [service-graphs, span-metrics, local-blocks]` with
|
||
`send_exemplars: true`, remote-writing to a Prometheus started with
|
||
`--web.enable-remote-write-receiver`.
|
||
- So RED metrics, the service map and exemplars **already exist** before you write
|
||
a single metric line. Do not add the collector connectors as well — you'd
|
||
duplicate every series under a second set of names. **Go find the real metric
|
||
names in Prometheus** (`/api/v1/label/__name__/values`) before writing PromQL;
|
||
they will not match the article's.
|
||
- The trade-off worth being able to argue: collector-side generation is cheaper
|
||
and works before storage, but sees only what passes through that collector;
|
||
Tempo-side generation sees everything that landed, survives collector restarts,
|
||
and can be enabled per-tenant — at the cost of load on the trace store.
|
||
- **Exemplars.** A histogram bucket can carry a sample trace ID. That's what lets you
|
||
click a spike on a latency graph and land in the exact trace. On your platform
|
||
this is wired: Tempo's generator sets `send_exemplars: true`, Prometheus stores
|
||
them (`--enable-feature=exemplar-storage`, added in `k8s/`; the Ansible role
|
||
lacked it), and the Prometheus datasource declares `exemplarTraceIdDestinations`
|
||
with `name: trace_id`. If you emit your own histograms with exemplars, the label
|
||
must be `trace_id` to match. **But not quite end to end:** Tempo's generator
|
||
labels its exemplars `traceID` (verified live), so the span-metric exemplars
|
||
don't match that destination. Find the fix yourself and choose one: the
|
||
datasource, Tempo, or both.
|
||
- **Push vs pull.** Prometheus scrapes (`pull`); OTLP pushes. With the Collector you
|
||
can do either — receive OTLP from apps, then either expose a `/metrics` endpoint
|
||
for Prometheus to scrape, or remote-write into Prometheus/Mimir. Understand that
|
||
the pull model is why Prometheus has service discovery and why `up` exists.
|
||
|
||
### Resources
|
||
|
||
- Cloudflare — *How Cloudflare runs Prometheus at scale* (free article, ~20 min).
|
||
Read the first half. Best cardinality explanation available.
|
||
- Udemy backbone: Prometheus + PromQL sections. Do the exercises; PromQL is a skill,
|
||
not a fact.
|
||
- **The OneUptime APM article** — now it's time. Take its `spanmetrics` /
|
||
`servicegraph` connector config and its RED PromQL queries almost verbatim.
|
||
- OpenTelemetry docs → Python → *Metrics*; Semantic Conventions → Metrics.
|
||
|
||
### Build
|
||
|
||
1. Extend `common/telemetry.py` with `MeterProvider` + OTLP metric exporter.
|
||
2. Custom application metrics, deliberately few:
|
||
- `ledgerline.transactions.total` (Counter; labels: `kind`, `status`, `currency`)
|
||
- `ledgerline.transaction.duration` (Histogram; labels: `kind`)
|
||
- `ledgerline.fraud.score` (Histogram; no labels)
|
||
- `ledgerline.stream.lag` (Observable Gauge; label: `consumer_group`)
|
||
- `ledgerline.db.pool.in_use` (Observable UpDownCounter; label: `service`)
|
||
3. **Get your custom metrics somewhere they'll actually land.** Alloy rejects OTLP
|
||
metrics (404 on `/v1/metrics`), so pick a path and implement it:
|
||
- *Platform grain:* expose `/metrics` on each service and add scrape jobs to
|
||
`k8s/prometheus/prometheus.yml` (the role's `prometheus_extra_scrape_jobs`),
|
||
then `kubectl apply -k k8s/`. Prometheus scrapes your services directly.
|
||
**First decide how Prometheus on falken reaches czy**: the Traefik
|
||
override strips every service's host port (see `phase-1.md`). Note the irony and the
|
||
lesson — you're back to a pull model and a second client library, which is
|
||
exactly the fragmentation OTel exists to fix.
|
||
- *Match the diagram:* add `prometheus.scrape` + `prometheus.remote_write` to
|
||
Alloy so app metrics flow through the collector as the architecture drawing
|
||
intends. This also closes a real diagram-vs-code gap in the repo.
|
||
- *Go all-OTLP:* add a `metrics` output on `otelcol.receiver.otlp`, wire
|
||
`otelcol.exporter.prometheus` → `prometheus.remote_write`. One OTLP path for
|
||
all three signals.
|
||
Do the first to get moving; do one of the others afterwards and write up which
|
||
you'd choose for a real platform team and why. That write-up is a Phase 10
|
||
artefact.
|
||
4. Deliberately break it: add `account_id` as a label to
|
||
`ledgerline.transactions.total`, run a 200-request burst, and watch the series
|
||
count in Prometheus (`count({__name__=~"ledgerline.*"})`). Then remove it. Now
|
||
you'll never forget.
|
||
5. Compare your hand-written duration histogram against the `spanmetrics`-generated
|
||
one. Understand why they differ slightly and when you'd want each.
|
||
6. **Second migration of the call-counting assertions.** The three tests you moved
|
||
to LogQL in Phase 4 can now count **client spans** in Tempo instead — a
|
||
TraceQL query for `sanctions` client spans within a single trace answers
|
||
"how many lookups did this one transaction make?" directly, which is the
|
||
question the test was always really asking. Do it and compare all three
|
||
generations: `docker logs | grep` → LogQL → TraceQL. Keep the diff; it's the
|
||
clearest possible demonstration of what each signal is actually for, on a
|
||
problem you already understand.
|
||
|
||
**Done when:** you have a RED view per service, a working service map, and you can
|
||
click a latency spike through to a trace via an exemplar.
|
||
|