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

11 KiB
Raw Blame History

Phase 5 — Metrics

Part of docs/roadmap/ — see intro.md for platform/background context and summary.md for how every phase fits together. Prev: phase-4.md · Next: 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.