Add dashboards and alerts as code, mark Phase 6 done

Dashboards: observability/dashboards/generate.py (Grafana Foundation SDK)
writes k8s/grafana/dashboards/*.json -- a RED dashboard per HTTP service
and a system overview -- shipped through a file provider and
configMapGenerators, so `kubectl apply -k k8s/` rebuilds them. Verified by
deleting Grafana and its volume and re-applying. Adds tracesToMetrics to
the Tempo datasource.

Alerts: Prometheus rule files delivering to Alertmanager, under a
labels/annotations contract enforced by observability/alerts/check_contract.py,
with runbooks in docs/runbooks/.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Movses Aslanyan
2026-10-25 20:01:52 +03:30
co-authored by Claude Sonnet 5.5
parent b50f8c3b24
commit fef735c922
19 changed files with 1314 additions and 7 deletions
+53 -3
View File
@@ -2,10 +2,60 @@
*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-5.md`](phase-5.md) · Next: [`phase-7.md`](phase-7.md)* *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-5.md`](phase-5.md) · Next: [`phase-7.md`](phase-7.md)*
**Status:** Not started **Status:** Done
**Message:** Depends on Phases 3–5 producing traces, logs and metrics worth **Message:** Done 2026-10-25. Both Done-when criteria verified live: Grafana's
dashboarding. resources and its volume were deleted and `kubectl apply -k k8s/` rebuilt all
six dashboards and four datasources from the files (fresh PVC, every dashboard
`provisioned: true`), and every dashboard's version history shows only the
provisioner as author.
- **Build steps done:** 1, 2, 3, 4 (`tracesToMetrics` added; the
`tracesToLogsV2` question was not answered) and 5.
- **Dashboards:** `observability/dashboards/generate.py` (Foundation SDK)
writes `k8s/grafana/dashboards/*.json`: `red-<service>` for the five HTTP
services (gateway, ledger, fraud, sanctions, insights; audit and notifier
serve no HTTP, so have none) and `overview` (service graph, stream lag, pool
in use). No error-budget panel yet (Phase 8). The app exports no pool *size*,
so the pool panel shows "in use", not saturation. The generator refuses to
write while a `FIXME` remains and checks the total size against the
ConfigMap limit.
- **Alerts:** Prometheus rule files, not Grafana alerting, delivering to
Alertmanager (`default` receiver is still a no-op). `LedgerlineTargetDown`
and `LedgerlineHighErrorRatio` in `k8s/prometheus/rules/ledgerline.rules.yml`,
under an alerting contract (labels `severity`/`team`/`service`; annotations
`summary`/`description`/`runbook_url`/`dashboard`) enforced by
`observability/alerts/check_contract.py`, with runbooks in `docs/runbooks/`.
A scratch alert was seen in Alertmanager; `LedgerlineHighErrorRatio` was seen
`pending` on real traffic, but its firing and rendered annotations were not
verified. The runbook URLs are a guessed Gitea-style path and returned 404
before the files were pushed.
- **Not done:** answering the `tracesToLogsV2` / `filterByTraceID` question
(the `QUESTION` comment is still in `k8s/grafana/datasources.yml`); clicking
"Related metrics" on a span to confirm `tracesToMetrics` end to end; a real
firing of either `ledgerline` alert; Grafana-managed alerting (Phase 8
decides).
- **Carry forward:**
- The SDK's PyPI versions are `<epoch>!<grafana-version>`, and pip's default
picks the *oldest* schema (10.1). Pin `grafana-foundation-sdk==1769699379!11.6.0`
(newest published is the 11.6 schema; Grafana 12.1.1 loads it).
- Provisioned dashboards can't be deleted from the UI or API in Grafana
12.1.1 whatever `disableDeletion` says (the error is "provisioned dashboard
cannot be deleted"). Removing the *file* removes the dashboard. The destroy
test is `kubectl kustomize k8s/grafana | ssh falken kubectl delete -n
ledgerline-observability -f -` then re-apply. **Do not run `kubectl delete
-k k8s/`**: the root kustomization includes the Prometheus, Loki and Tempo
PVCs and would delete all telemetry.
- The configMap hash suffix rolls the pod on every change and leaves old
hashed ConfigMaps behind (plain `apply` doesn't prune them).
- Tempo's service map is built in the browser from Prometheus queries; the
`serviceMap` query type returns "unsupported query type" through
`/api/ds/query`, so that panel can only be checked by eye.
- An unlabelled series renders as "Value" in a legend. The gateway's 404s have
no `http_route`, so the rate panel shows one.
- `team` on an alert is static per rule (who answers the class of alert), not
per service. Phase 8's burn-rate rules belong in `k8s/prometheus/rules/`
under the same contract.
**Time-box:** 2 evenings. This is an explicit requirement from Babak: *destroy and **Time-box:** 2 evenings. This is an explicit requirement from Babak: *destroy and
recreate in seconds.* recreate in seconds.*
+4 -2
View File
@@ -21,7 +21,7 @@ warnings), **Time-box**, **Concepts**, **Resources**, **Build**, **Done when**.
| 3 | [`phase-3.md`](phase-3.md) | Done | Distributed tracing | 2–3 evenings | Connected trace across 7 services incl. the async hop | | 3 | [`phase-3.md`](phase-3.md) | Done | Distributed tracing | 2–3 evenings | Connected trace across 7 services incl. the async hop |
| 4 | [`phase-4.md`](phase-4.md) | Done | Structured logs | 1–2 evenings | Two-way trace↔log correlation | | 4 | [`phase-4.md`](phase-4.md) | Done | Structured logs | 1–2 evenings | Two-way trace↔log correlation |
| 5 | [`phase-5.md`](phase-5.md) | Done | Metrics | 2 evenings | RED per service, service map, exemplars | | 5 | [`phase-5.md`](phase-5.md) | Done | Metrics | 2 evenings | RED per service, service map, exemplars |
| 6 | [`phase-6.md`](phase-6.md) | Not started | Dashboards as code | 2 evenings | `kubectl apply -k k8s/` restores all dashboards | | 6 | [`phase-6.md`](phase-6.md) | Done | Dashboards as code | 2 evenings | `kubectl apply -k k8s/` restores all dashboards |
| 7 | [`phase-7.md`](phase-7.md) | Not started | k6 + percentiles | 1–2 evenings | Baseline vs degraded load profiles | | 7 | [`phase-7.md`](phase-7.md) | Not started | k6 + percentiles | 1–2 evenings | Baseline vs degraded load profiles |
| 8 | [`phase-8.md`](phase-8.md) | Not started | SLO / error budget / RTO / RPO | 1–2 evenings | `docs/SLO.md` + burn-rate alerts | | 8 | [`phase-8.md`](phase-8.md) | Not started | SLO / error budget / RTO / RPO | 1–2 evenings | `docs/SLO.md` + burn-rate alerts |
| 9 | [`phase-9.md`](phase-9.md) | Not started | Bottleneck hunting | 2 evenings | `docs/INVESTIGATION.md` | | 9 | [`phase-9.md`](phase-9.md) | Not started | Bottleneck hunting | 2 evenings | `docs/INVESTIGATION.md` |
@@ -56,7 +56,9 @@ warnings), **Time-box**, **Concepts**, **Resources**, **Build**, **Done when**.
`phase-5.md` (second metrics path and the TraceQL test migration not done). `phase-5.md` (second metrics path and the TraceQL test migration not done).
- **6 — Dashboards and alerts as code.** Generate dashboard JSON with the - **6 — Dashboards and alerts as code.** Generate dashboard JSON with the
Grafana Foundation SDK and ship it with the platform (`k8s/grafana/`), so Grafana Foundation SDK and ship it with the platform (`k8s/grafana/`), so
"destroy and recreate in seconds" is a real, repeatable command. "destroy and recreate in seconds" is a real, repeatable command. **Done** —
see `phase-6.md` (alerts as Prometheus rules under a contract; the
`tracesToLogsV2` question and a real alert firing not done).
- **7 — Load testing with k6, and percentiles.** Learn why percentiles can't - **7 — Load testing with k6, and percentiles.** Learn why percentiles can't
be averaged, what coordinated omission is, and drive Ledgerline with be averaged, what coordinated omission is, and drive Ledgerline with
arrival-rate executors to get a clean before/after load profile. arrival-rate executors to get a clean before/after load profile.
@@ -0,0 +1,27 @@
# LedgerlineHighErrorRatio
**Fires when:** a service's 5xx ratio is above 5% for 5 minutes, with more than
0.05 requests/s (so it is not one failed request on an idle service).
**Severity:** warning · **Team:** payments
## What it means
A meaningful share of the requests this service serves are failing with a server
error. 4xx responses (for example a 404) are deliberately not counted.
## First five minutes
1. **Look at the `RED: <service>` dashboard** (linked in the alert). Is the error
ratio still elevated, or already back to normal? Did the request rate or the
latency change at the same time?
2. **Find a failing request.** In Tempo, search for the service with an error
status, or in Loki: `{service_name="<service>"} | json | level="ERROR"`.
3. **Follow one trace.** The `trace_id` in the log line opens the whole request
across services. Look for the first span in the trace with an error status.
4. **Is it only this service?** The System overview's service graph shows whether
its callers or callees are failing as well.
## Escalate when
The ratio is still rising after ten minutes, or the failing requests are on the
critical path (transactions), not a side route.
+29
View File
@@ -0,0 +1,29 @@
# LedgerlineTargetDown
**Fires when:** Prometheus has failed to scrape a Ledgerline service for 2 minutes.
**Severity:** critical · **Team:** platform
## What it means
Prometheus scrapes all seven services through one address,
`https://ledgerline.vione.cloud/metrics/<service>` (czy's Traefik, basic auth).
"Down" means that scrape failed. It does **not** by itself prove the service is
down: the service, the proxy, or the credentials can each cause it.
## First five minutes
1. **How many targets?** Open the alert in Alertmanager. One service alerting is
likely that service. All seven at once points at the shared path (Traefik on
czy, DNS, the scrape credentials), not at the services.
2. **Ask Prometheus why.** In Prometheus → Status → Targets, find the target and
read its `Last error`.
3. **Is the container running?** `ssh czy docker compose ps` for the service.
4. **What did it last say?** `ssh czy docker logs ledgerline-<service>-1`, or in
Loki: `{service_name="<service>"}`.
5. **Is traffic still flowing?** Open the `RED: <service>` dashboard. Traces and
logs can still be arriving while only the metrics scrape is broken.
## Escalate when
The shared path is down (all seven targets), or the service is down and not
recovering after a restart.
+13
View File
@@ -0,0 +1,13 @@
apiVersion: 1
providers:
- name: ledgerline
orgId: 1
type: file
folder: Ledgerline
options:
path: /etc/grafana/dashboards/
allowUiUpdates: false
disableDeletion: false
updateIntervalSeconds: 15
foldersFromFilesStructure: false
+115
View File
@@ -0,0 +1,115 @@
{
"annotations": {},
"editable": false,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"panels": [
{
"datasource": {
"type": "tempo",
"uid": "tempo"
},
"gridPos": {
"h": 12,
"w": 24,
"x": 0,
"y": 0
},
"repeatDirection": "h",
"targets": [
{
"datasource": {
"type": "tempo",
"uid": "tempo"
},
"filters": [],
"queryType": "serviceMap",
"refId": "graph"
}
],
"title": "Service graph",
"transparent": false,
"type": "nodeGraph"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "none"
},
"overrides": []
},
"gridPos": {
"h": 9,
"w": 12,
"x": 0,
"y": 12
},
"repeatDirection": "h",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"expr": "ledgerline_stream_lag",
"legendFormat": "{{consumer_group}}",
"refId": "lag"
}
],
"title": "Stream consumer lag",
"transparent": false,
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "none"
},
"overrides": []
},
"gridPos": {
"h": 9,
"w": 12,
"x": 12,
"y": 12
},
"repeatDirection": "h",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"expr": "sum by (service, pool) (ledgerline_db_pool_in_use)",
"legendFormat": "{{service}} / {{pool}}",
"refId": "pool"
}
],
"title": "DB pool connections in use",
"transparent": false,
"type": "timeseries"
}
],
"refresh": "15s",
"schemaVersion": 41,
"tags": [
"generated",
"overview"
],
"templating": {},
"time": {
"from": "now-1h",
"to": "now"
},
"timezone": "browser",
"title": "System overview",
"uid": "overview"
}
+140
View File
@@ -0,0 +1,140 @@
{
"annotations": {},
"editable": false,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"panels": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "reqps"
},
"overrides": []
},
"gridPos": {
"h": 9,
"w": 12,
"x": 0,
"y": 0
},
"repeatDirection": "h",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"expr": "sum by (http_route) (rate(http_server_request_duration_seconds_count{job=\"ledgerline\",service=\"fraud\"}[5m]))",
"legendFormat": "{{http_route}}",
"refId": "rate"
}
],
"title": "Request rate",
"transparent": false,
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"min": 0,
"unit": "percentunit"
},
"overrides": []
},
"gridPos": {
"h": 9,
"w": 12,
"x": 12,
"y": 0
},
"repeatDirection": "h",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"expr": "(sum(rate(http_server_request_duration_seconds_count{job=\"ledgerline\",service=\"fraud\",http_response_status_code=~\"5..\"}[5m])) or vector(0)) / sum(rate(http_server_request_duration_seconds_count{job=\"ledgerline\",service=\"fraud\"}[5m]))",
"legendFormat": "5xx ratio",
"refId": "errors"
}
],
"title": "Error ratio",
"transparent": false,
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "s"
},
"overrides": []
},
"gridPos": {
"h": 9,
"w": 12,
"x": 0,
"y": 9
},
"repeatDirection": "h",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"expr": "histogram_quantile(0.5, sum by (le) (rate(http_server_request_duration_seconds_bucket{job=\"ledgerline\",service=\"fraud\"}[5m])))",
"legendFormat": "p50",
"refId": "p50"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"expr": "histogram_quantile(0.95, sum by (le) (rate(http_server_request_duration_seconds_bucket{job=\"ledgerline\",service=\"fraud\"}[5m])))",
"legendFormat": "p95",
"refId": "p95"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"expr": "histogram_quantile(0.99, sum by (le) (rate(http_server_request_duration_seconds_bucket{job=\"ledgerline\",service=\"fraud\"}[5m])))",
"legendFormat": "p99",
"refId": "p99"
}
],
"title": "Request duration",
"transparent": false,
"type": "timeseries"
}
],
"refresh": "15s",
"schemaVersion": 41,
"tags": [
"generated",
"red"
],
"templating": {},
"time": {
"from": "now-1h",
"to": "now"
},
"timezone": "browser",
"title": "RED: fraud",
"uid": "red-fraud"
}
+140
View File
@@ -0,0 +1,140 @@
{
"annotations": {},
"editable": false,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"panels": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "reqps"
},
"overrides": []
},
"gridPos": {
"h": 9,
"w": 12,
"x": 0,
"y": 0
},
"repeatDirection": "h",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"expr": "sum by (http_route) (rate(http_server_request_duration_seconds_count{job=\"ledgerline\",service=\"gateway\"}[5m]))",
"legendFormat": "{{http_route}}",
"refId": "rate"
}
],
"title": "Request rate",
"transparent": false,
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"min": 0,
"unit": "percentunit"
},
"overrides": []
},
"gridPos": {
"h": 9,
"w": 12,
"x": 12,
"y": 0
},
"repeatDirection": "h",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"expr": "(sum(rate(http_server_request_duration_seconds_count{job=\"ledgerline\",service=\"gateway\",http_response_status_code=~\"5..\"}[5m])) or vector(0)) / sum(rate(http_server_request_duration_seconds_count{job=\"ledgerline\",service=\"gateway\"}[5m]))",
"legendFormat": "5xx ratio",
"refId": "errors"
}
],
"title": "Error ratio",
"transparent": false,
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "s"
},
"overrides": []
},
"gridPos": {
"h": 9,
"w": 12,
"x": 0,
"y": 9
},
"repeatDirection": "h",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"expr": "histogram_quantile(0.5, sum by (le) (rate(http_server_request_duration_seconds_bucket{job=\"ledgerline\",service=\"gateway\"}[5m])))",
"legendFormat": "p50",
"refId": "p50"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"expr": "histogram_quantile(0.95, sum by (le) (rate(http_server_request_duration_seconds_bucket{job=\"ledgerline\",service=\"gateway\"}[5m])))",
"legendFormat": "p95",
"refId": "p95"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"expr": "histogram_quantile(0.99, sum by (le) (rate(http_server_request_duration_seconds_bucket{job=\"ledgerline\",service=\"gateway\"}[5m])))",
"legendFormat": "p99",
"refId": "p99"
}
],
"title": "Request duration",
"transparent": false,
"type": "timeseries"
}
],
"refresh": "15s",
"schemaVersion": 41,
"tags": [
"generated",
"red"
],
"templating": {},
"time": {
"from": "now-1h",
"to": "now"
},
"timezone": "browser",
"title": "RED: gateway",
"uid": "red-gateway"
}
+140
View File
@@ -0,0 +1,140 @@
{
"annotations": {},
"editable": false,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"panels": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "reqps"
},
"overrides": []
},
"gridPos": {
"h": 9,
"w": 12,
"x": 0,
"y": 0
},
"repeatDirection": "h",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"expr": "sum by (http_route) (rate(http_server_request_duration_seconds_count{job=\"ledgerline\",service=\"insights\"}[5m]))",
"legendFormat": "{{http_route}}",
"refId": "rate"
}
],
"title": "Request rate",
"transparent": false,
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"min": 0,
"unit": "percentunit"
},
"overrides": []
},
"gridPos": {
"h": 9,
"w": 12,
"x": 12,
"y": 0
},
"repeatDirection": "h",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"expr": "(sum(rate(http_server_request_duration_seconds_count{job=\"ledgerline\",service=\"insights\",http_response_status_code=~\"5..\"}[5m])) or vector(0)) / sum(rate(http_server_request_duration_seconds_count{job=\"ledgerline\",service=\"insights\"}[5m]))",
"legendFormat": "5xx ratio",
"refId": "errors"
}
],
"title": "Error ratio",
"transparent": false,
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "s"
},
"overrides": []
},
"gridPos": {
"h": 9,
"w": 12,
"x": 0,
"y": 9
},
"repeatDirection": "h",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"expr": "histogram_quantile(0.5, sum by (le) (rate(http_server_request_duration_seconds_bucket{job=\"ledgerline\",service=\"insights\"}[5m])))",
"legendFormat": "p50",
"refId": "p50"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"expr": "histogram_quantile(0.95, sum by (le) (rate(http_server_request_duration_seconds_bucket{job=\"ledgerline\",service=\"insights\"}[5m])))",
"legendFormat": "p95",
"refId": "p95"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"expr": "histogram_quantile(0.99, sum by (le) (rate(http_server_request_duration_seconds_bucket{job=\"ledgerline\",service=\"insights\"}[5m])))",
"legendFormat": "p99",
"refId": "p99"
}
],
"title": "Request duration",
"transparent": false,
"type": "timeseries"
}
],
"refresh": "15s",
"schemaVersion": 41,
"tags": [
"generated",
"red"
],
"templating": {},
"time": {
"from": "now-1h",
"to": "now"
},
"timezone": "browser",
"title": "RED: insights",
"uid": "red-insights"
}
+140
View File
@@ -0,0 +1,140 @@
{
"annotations": {},
"editable": false,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"panels": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "reqps"
},
"overrides": []
},
"gridPos": {
"h": 9,
"w": 12,
"x": 0,
"y": 0
},
"repeatDirection": "h",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"expr": "sum by (http_route) (rate(http_server_request_duration_seconds_count{job=\"ledgerline\",service=\"ledger\"}[5m]))",
"legendFormat": "{{http_route}}",
"refId": "rate"
}
],
"title": "Request rate",
"transparent": false,
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"min": 0,
"unit": "percentunit"
},
"overrides": []
},
"gridPos": {
"h": 9,
"w": 12,
"x": 12,
"y": 0
},
"repeatDirection": "h",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"expr": "(sum(rate(http_server_request_duration_seconds_count{job=\"ledgerline\",service=\"ledger\",http_response_status_code=~\"5..\"}[5m])) or vector(0)) / sum(rate(http_server_request_duration_seconds_count{job=\"ledgerline\",service=\"ledger\"}[5m]))",
"legendFormat": "5xx ratio",
"refId": "errors"
}
],
"title": "Error ratio",
"transparent": false,
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "s"
},
"overrides": []
},
"gridPos": {
"h": 9,
"w": 12,
"x": 0,
"y": 9
},
"repeatDirection": "h",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"expr": "histogram_quantile(0.5, sum by (le) (rate(http_server_request_duration_seconds_bucket{job=\"ledgerline\",service=\"ledger\"}[5m])))",
"legendFormat": "p50",
"refId": "p50"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"expr": "histogram_quantile(0.95, sum by (le) (rate(http_server_request_duration_seconds_bucket{job=\"ledgerline\",service=\"ledger\"}[5m])))",
"legendFormat": "p95",
"refId": "p95"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"expr": "histogram_quantile(0.99, sum by (le) (rate(http_server_request_duration_seconds_bucket{job=\"ledgerline\",service=\"ledger\"}[5m])))",
"legendFormat": "p99",
"refId": "p99"
}
],
"title": "Request duration",
"transparent": false,
"type": "timeseries"
}
],
"refresh": "15s",
"schemaVersion": 41,
"tags": [
"generated",
"red"
],
"templating": {},
"time": {
"from": "now-1h",
"to": "now"
},
"timezone": "browser",
"title": "RED: ledger",
"uid": "red-ledger"
}
+140
View File
@@ -0,0 +1,140 @@
{
"annotations": {},
"editable": false,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"panels": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "reqps"
},
"overrides": []
},
"gridPos": {
"h": 9,
"w": 12,
"x": 0,
"y": 0
},
"repeatDirection": "h",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"expr": "sum by (http_route) (rate(http_server_request_duration_seconds_count{job=\"ledgerline\",service=\"sanctions\"}[5m]))",
"legendFormat": "{{http_route}}",
"refId": "rate"
}
],
"title": "Request rate",
"transparent": false,
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"min": 0,
"unit": "percentunit"
},
"overrides": []
},
"gridPos": {
"h": 9,
"w": 12,
"x": 12,
"y": 0
},
"repeatDirection": "h",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"expr": "(sum(rate(http_server_request_duration_seconds_count{job=\"ledgerline\",service=\"sanctions\",http_response_status_code=~\"5..\"}[5m])) or vector(0)) / sum(rate(http_server_request_duration_seconds_count{job=\"ledgerline\",service=\"sanctions\"}[5m]))",
"legendFormat": "5xx ratio",
"refId": "errors"
}
],
"title": "Error ratio",
"transparent": false,
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "s"
},
"overrides": []
},
"gridPos": {
"h": 9,
"w": 12,
"x": 0,
"y": 9
},
"repeatDirection": "h",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"expr": "histogram_quantile(0.5, sum by (le) (rate(http_server_request_duration_seconds_bucket{job=\"ledgerline\",service=\"sanctions\"}[5m])))",
"legendFormat": "p50",
"refId": "p50"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"expr": "histogram_quantile(0.95, sum by (le) (rate(http_server_request_duration_seconds_bucket{job=\"ledgerline\",service=\"sanctions\"}[5m])))",
"legendFormat": "p95",
"refId": "p95"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"expr": "histogram_quantile(0.99, sum by (le) (rate(http_server_request_duration_seconds_bucket{job=\"ledgerline\",service=\"sanctions\"}[5m])))",
"legendFormat": "p99",
"refId": "p99"
}
],
"title": "Request duration",
"transparent": false,
"type": "timeseries"
}
],
"refresh": "15s",
"schemaVersion": 41,
"tags": [
"generated",
"red"
],
"templating": {},
"time": {
"from": "now-1h",
"to": "now"
},
"timezone": "browser",
"title": "RED: sanctions",
"uid": "red-sanctions"
}
+16
View File
@@ -38,6 +38,22 @@ datasources:
filterByTraceID: true filterByTraceID: true
spanStartTimeShift: -1m spanStartTimeShift: -1m
spanEndTimeShift: 1m spanEndTimeShift: 1m
# QUESTION (answer in your own words; verify by reading the LogQL that
# "Logs for this span" generates): why is `filterByTraceID: true` enough
# with no `tags` mapping here, and when would it not be?
tracesToMetrics:
datasourceUid: prometheus
# Wider than the span: rate() needs a few scrape intervals of data.
spanStartTimeShift: -2m
spanEndTimeShift: 2m
# span attribute -> Prometheus label on traces_spanmetrics_*
tags:
- key: service.name
value: service
queries:
# `$$` is the provisioning-file escape for a literal `$`.
- name: Request rate
query: sum(rate(traces_spanmetrics_calls_total{$$__tags}[5m]))
serviceMap: serviceMap:
datasourceUid: prometheus datasourceUid: prometheus
nodeGraph: nodeGraph:
+10
View File
@@ -51,6 +51,10 @@ spec:
mountPath: /var/lib/grafana mountPath: /var/lib/grafana
- name: datasources - name: datasources
mountPath: /etc/grafana/provisioning/datasources mountPath: /etc/grafana/provisioning/datasources
- name: dashboards-provider
mountPath: /etc/grafana/provisioning/dashboards
- name: dashboards
mountPath: /etc/grafana/dashboards/
volumes: volumes:
- name: data - name: data
persistentVolumeClaim: persistentVolumeClaim:
@@ -58,3 +62,9 @@ spec:
- name: datasources - name: datasources
configMap: configMap:
name: grafana-datasources name: grafana-datasources
- name: dashboards-provider
configMap:
name: grafana-dashboards-provider
- name: dashboards
configMap:
name: grafana-dashboards
+11 -2
View File
@@ -7,9 +7,18 @@ resources:
- service.yaml - service.yaml
- ingress.yaml - ingress.yaml
# Datasources only, as in the Ansible role. Dashboard provisioning is the
# Phase 6 deliverable and belongs in this directory.
configMapGenerator: configMapGenerator:
- name: grafana-datasources - name: grafana-datasources
files: files:
- datasources.yml - datasources.yml
- name: grafana-dashboards-provider
files:
- dashboards-provider.yml
- name: grafana-dashboards
files:
- dashboards/overview.json
- dashboards/red-fraud.json
- dashboards/red-gateway.json
- dashboards/red-ledger.json
- dashboards/red-sanctions.json
- dashboards/red-insights.json
+1
View File
@@ -14,3 +14,4 @@ configMapGenerator:
- name: prometheus-rules - name: prometheus-rules
files: files:
- rules/host.rules.yml - rules/host.rules.yml
- rules/ledgerline.rules.yml
+61
View File
@@ -0,0 +1,61 @@
# Ledgerline alerting rules.
#
# ALERTING CONTRACT -- every alert in this directory (except scratch.*) must carry:
#
# labels (machine-readable; Alertmanager routes and groups on these)
# severity critical | warning | info
# critical = a human is needed now; warning = look today.
# team who owns responding: platform | payments | risk | data
# service which Ledgerline service, taken from the series' own label
# (aggregations must keep it: `by (service)`)
#
# annotations (text for humans; never used for routing)
# summary one line, no templating beyond the service name
# description what is wrong, with the measured value and the threshold
# runbook_url what to do about it (docs/runbooks/<kebab-alert-name>.md)
# dashboard where to look first (the dashboard that explains the alert)
#
# observability/alerts/check_contract.py enforces this; run it before applying.
#
# `team` is static per alert rule, not per service: it says who answers this
# *class* of alert. Per-service ownership would need a join against an
# ownership metric or one rule per team.
groups:
- name: ledgerline.rules
rules:
# All seven targets are scraped through one Traefik address, so a proxy
# failure takes every target down at once and fires seven alerts.
- alert: LedgerlineTargetDown
expr: up{job="ledgerline"} == 0
for: 2m
labels:
severity: critical
team: platform
annotations:
summary: "{{ $labels.service }} is not being scraped"
description: "Prometheus has failed to scrape {{ $labels.service }} for more than two minutes."
runbook_url: "https://registry.mohsen-hassani.com/mohsen_hassani/LedgerLine/src/branch/main/docs/runbooks/ledgerline-target-down.md"
dashboard: "https://grafana.vione.cloud/d/overview"
# 5xx only (a 404 is the client's mistake). The second condition keeps a
# service with almost no traffic from alerting on one failed request:
# below 0.05 req/s (about 15 requests per 5m) the ratio is noise. When
# there is no traffic at all the ratio is NaN and nothing fires.
- alert: LedgerlineHighErrorRatio
expr: |
(
sum by (service) (rate(http_server_request_duration_seconds_count{job="ledgerline",http_response_status_code=~"5.."}[5m]))
/
sum by (service) (rate(http_server_request_duration_seconds_count{job="ledgerline"}[5m]))
) > 0.05
and
sum by (service) (rate(http_server_request_duration_seconds_count{job="ledgerline"}[5m])) > 0.05
for: 5m
labels:
severity: warning
team: payments
annotations:
summary: "{{ $labels.service }} is returning 5xx errors"
description: "{{ $labels.service }} has had a 5xx ratio of {{ $value | humanizePercentage }} for five minutes (threshold 5%)."
runbook_url: "https://registry.mohsen-hassani.com/mohsen_hassani/LedgerLine/src/branch/main/docs/runbooks/ledgerline-high-error-ratio.md"
dashboard: "https://grafana.vione.cloud/d/red-{{ $labels.service }}"
+58
View File
@@ -0,0 +1,58 @@
"""Check every alert rule against the alerting contract (see the header of
k8s/prometheus/rules/ledgerline.rules.yml).
python observability/alerts/check_contract.py
Needs PyYAML. Skips scratch.* and host.* rule files.
"""
import sys
from pathlib import Path
import yaml
ROOT = Path(__file__).resolve().parents[2]
RULES = ROOT / "k8s" / "prometheus" / "rules"
SEVERITIES = {"critical", "warning", "info"}
TEAMS = {"platform", "payments", "risk", "data"} # placeholders: edit to taste
ANNOTATIONS = ("summary", "description", "runbook_url", "dashboard")
RUNBOOK_MARKER = "/docs/runbooks/"
def check(path: Path) -> list[str]:
problems = []
for group in yaml.safe_load(path.read_text())["groups"]:
for rule in group["rules"]:
name = rule.get("alert")
if name is None: # recording rule
continue
where = f"{path.name}:{name}"
labels, ann = rule.get("labels", {}), rule.get("annotations", {})
if labels.get("severity") not in SEVERITIES:
problems.append(f"{where}: severity must be one of {sorted(SEVERITIES)}")
if labels.get("team") not in TEAMS:
problems.append(f"{where}: team must be one of {sorted(TEAMS)}")
for key in ANNOTATIONS:
if not ann.get(key):
problems.append(f"{where}: missing annotation {key}")
url = ann.get("runbook_url", "")
if RUNBOOK_MARKER in url:
local = ROOT / "docs" / "runbooks" / url.rsplit("/", 1)[-1]
if not local.exists():
problems.append(f"{where}: runbook {local.relative_to(ROOT)} does not exist")
elif url:
problems.append(f"{where}: runbook_url should point under {RUNBOOK_MARKER}")
return problems
def main() -> int:
files = [p for p in sorted(RULES.glob("*.yml")) if p.name.split(".")[0] not in ("scratch", "host")]
problems = [msg for p in files for msg in check(p)]
for msg in problems:
print(msg)
print(f"{len(files)} file(s) checked, {len(problems)} problem(s)")
return 1 if problems else 0
if __name__ == "__main__":
sys.exit(main())
+213
View File
@@ -0,0 +1,213 @@
"""Generate Grafana dashboard JSON into k8s/grafana/dashboards/.
python -m venv .venv && .venv/bin/pip install -r requirements.txt
.venv/bin/python generate.py
"""
import json
from pathlib import Path
from grafana_foundation_sdk.builders.dashboard import Dashboard
from grafana_foundation_sdk.builders.prometheus import Dataquery as PromQuery
from grafana_foundation_sdk.builders.nodegraph import Panel as NodeGraph
from grafana_foundation_sdk.builders.tempo import TempoQuery
from grafana_foundation_sdk.builders.timeseries import Panel as Timeseries
from grafana_foundation_sdk.cog.encoder import JSONEncoder
from grafana_foundation_sdk.models.dashboard import DataSourceRef
OUT_DIR = Path(__file__).resolve().parents[2] / "k8s" / "grafana" / "dashboards"
PROMETHEUS = DataSourceRef(type_val="prometheus", uid="prometheus")
# Audit and Notifier are consumers and does not have HTTP, so commented them out for now
SERVICES = [
"gateway",
"ledger",
"fraud",
"sanctions",
"insights",
# "audit",
# "notifier",
]
def prom(expr: str, ref_id: str, legend: str) -> PromQuery:
return (
PromQuery()
.expr(expr)
.ref_id(ref_id)
.legend_format(legend)
.datasource(PROMETHEUS)
)
def red_dashboard(service: str) -> Dashboard:
"""Rate, Errors, Duration for one service, in the order you'd ask them
during an incident: is it serving, is it failing, is it slow."""
# `service` and `instance` carry the same value on these series.
sel = f'job="ledgerline",service="{service}"'
count = f"http_server_request_duration_seconds_count{{{sel}}}"
bucket = f"http_server_request_duration_seconds_bucket{{{sel}}}"
def quantile(q: float) -> str:
return f"histogram_quantile({q}, sum by (le) (rate({bucket}[5m])))"
rate = (
Timeseries()
.title("Request rate")
.datasource(PROMETHEUS)
.unit("reqps")
.with_target(
prom(
f"sum by (http_route) (rate({count}[5m]))",
"rate",
"{{http_route}}",
)
)
)
errors = (
Timeseries()
.title("Error ratio")
.datasource(PROMETHEUS)
.unit("percentunit")
.min(0)
.with_target(
prom(
# 5xx only: a 404 is the client's mistake, not the service failing.
# `or vector(0)` keeps the line at 0 when no 5xx series exists yet;
# with no traffic the denominator is 0 and the result is NaN (a gap).
f'(sum(rate({count[:-1]},http_response_status_code=~"5.."}}[5m])) or vector(0))'
f" / sum(rate({count}[5m]))",
"errors",
"5xx ratio",
)
)
)
duration = (
Timeseries()
.title("Request duration")
.datasource(PROMETHEUS)
.unit("s")
# One query per quantile. Aggregate the histogram *buckets* first and
# compute the quantile after; never average per-instance quantiles.
.with_target(prom(quantile(0.50), "p50", "p50"))
.with_target(prom(quantile(0.95), "p95", "p95"))
.with_target(prom(quantile(0.99), "p99", "p99"))
)
return (
Dashboard(f"RED: {service}")
.uid(f"red-{service}")
.tags(["generated", "red"])
.time("now-1h", "now")
.refresh("15s")
.readonly()
.with_panel(rate)
.with_panel(errors)
.with_panel(duration)
)
TEMPO = DataSourceRef(type_val="tempo", uid="tempo")
def overview_dashboard() -> Dashboard:
"""System-wide: who calls whom, is the async hop keeping up, is a pool
under pressure. Not per service, so no `service` variable."""
# Not a backend query: Grafana's browser builds the node graph from the
# Prometheus span metrics that Tempo's metrics-generator writes (wired by
# `serviceMap.datasourceUid` in datasources.yml). Calling it through
# /api/ds/query fails with "unsupported query type", so this panel can only
# be checked by looking at it in the browser.
service_graph = (
NodeGraph()
.title("Service graph")
.datasource(TEMPO)
.span(24)
.height(12)
.with_target(
TempoQuery()
.datasource(TEMPO)
.ref_id("graph")
.query_type("serviceMap")
)
)
lag = (
Timeseries()
.title("Stream consumer lag")
.datasource(PROMETHEUS)
.span(12)
.unit("none")
.with_target(
prom(
"ledgerline_stream_lag",
"lag",
"{{consumer_group}}",
)
)
)
pool = (
Timeseries()
.title("DB pool connections in use")
.datasource(PROMETHEUS)
.span(12)
.unit("none")
.with_target(
prom(
# "In use", not a percentage: the app exports no pool *size*.
# What would you need to turn this into saturation, and where
# could it come from? (Also look at the labels of
# `db_client_connections_usage_connections{state=...}`.)
"sum by (service, pool) (ledgerline_db_pool_in_use)",
"pool",
"{{service}} / {{pool}}",
)
)
)
return (
Dashboard("System overview")
.uid("overview")
.tags(["generated", "overview"])
.time("now-1h", "now")
.refresh("15s")
.readonly()
.with_panel(service_graph)
.with_panel(lag)
.with_panel(pool)
)
DASHBOARDS = {
"overview": overview_dashboard,
**{f"red-{svc}": (lambda s=svc: red_dashboard(s)) for svc in SERVICES},
}
# A ConfigMap is capped at ~1 MiB, and client-side `kubectl apply` stores a
# second copy in an annotation, so keep well under half of it.
MAX_TOTAL_BYTES = 400_000
def main() -> None:
OUT_DIR.mkdir(parents=True, exist_ok=True)
rendered = {
name: json.dumps(fn().build(), cls=JSONEncoder, indent=2, sort_keys=True) + "\n"
for name, fn in DASHBOARDS.items()
}
for name, text in rendered.items():
assert "FIXME" not in text, f"{name}: unfilled FIXME in generated JSON"
total = sum(len(t) for t in rendered.values())
assert total < MAX_TOTAL_BYTES, f"{total} bytes of dashboards; ConfigMap limit"
for name, text in rendered.items():
(OUT_DIR / f"{name}.json").write_text(text)
print(f"wrote {OUT_DIR / f'{name}.json'}")
print(f"{total} bytes total")
if __name__ == "__main__":
main()
@@ -0,0 +1,3 @@
# Epoch!grafana-version. The unpinned default is the Grafana 10.1 schema (highest
# epoch = oldest schema); 11.6.0 is the newest published, loads fine in 12.1.1.
grafana-foundation-sdk==1769699379!11.6.0