Files
LedgerLine/docs/roadmap/phase-5.md
T
Movses AslanyanandClaude Opus 5.5 b50f8c3b24 Mark Phase 5 done
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>
2026-10-25 02:04:54 +03:30

174 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.