Files
duocthu/docs-legacy/17-observability.md
T

185 lines
8.7 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.
# 17 — Observability
Implementation: `rag/telemetry.py`, `rag/metrics.py`, `rag/instrumentation.py`,
`adapters/prometheus.py`, `infra/docker/{prometheus,grafana,tempo,otel}/`.
Tests: `tests/test_observability.py`.
## Signal table
| Signal | Instrumentation | Backend | Purpose |
|---|---|---|---|
| Metrics | `prometheus_client` via `adapters/prometheus.py`, exposed at `GET /metrics` | Prometheus (scrape 15 s) | Request rate/latency, decisions, abstentions, provider failures |
| Traces | OpenTelemetry SDK, OTLP/HTTP | OTel Collector → Tempo | Per-request spans with per-stage children |
| Logs | Python `logging` to stdout, uvicorn defaults | `docker logs` only | Ad-hoc debugging |
| Dashboards | Provisioned JSON | Grafana | `duocthu-observability` |
| Health | `/health`, `/ready` | Compose/K8s probes + the deploy smoke test | Liveness/readiness |
| Alerting | — | — | **Not found** |
Both metrics and tracing are optional and degrade to no-ops: a missing
`prometheus_client` yields `None` metrics rather than a service that will not
start ("observability is not a precondition for answering"), and missing
OpenTelemetry packages or `OTEL_ENABLED=false` yield a no-op tracer.
## Metrics
Names are defined in `rag/metrics.py` so, as the docstring puts it, "the numbers
on a dashboard are the numbers the domain actually decided".
| Metric | Type | Labels | Incremented in |
|---|---|---|---|
| `duocthu_requests_total` | counter | `method`, `route`, `status` (class) | `main.py` middleware |
| `duocthu_request_duration_seconds` | histogram | `method`, `route`, `status` | `main.py` middleware |
| `duocthu_stage_duration_seconds` | histogram | `stage`, `outcome` | `telemetry.stage()` |
| `duocthu_decision_total` | counter | `decision`, `reason` | `routers/rag.py` |
| `duocthu_retrieval_route_total` | counter | `route` | `InstrumentedRetrievalService` |
| `duocthu_abstention_total` | counter | `reason` | `answer.py` |
| `duocthu_generation_rejected_total` | counter | `reason` | `answer.py` |
| `duocthu_generation_served_total` | counter | — | `answer.py` |
| `duocthu_answer_extractive_total` | counter | — | `answer.py` (no-generator mode) |
| `duocthu_clarify_asked_total` | counter | `reason` | `InstrumentedRagAgent` |
| `duocthu_provider_failure_total` | counter | `provider`, `operation`, `reason` | `Instrumented{Generator,Embedder,Reranker}`, retrieval |
| `duocthu_trace_write_failed_total` | counter | — | `routers/rag.py` |
`duocthu_generation_rejected_total` is called out in the module docstring as the
one that matters: it is *the measured form of the claim that the answer layer
cannot state a figure the book does not.*
### Registered but never incremented
`duocthu_loop_retrieval_rounds_total`, `duocthu_loop_refined_total`,
`duocthu_loop_repaired_total`, `duocthu_followup_inherited_total`. Verified by
grep: their only references outside `rag/metrics.py` are the registration and
help-text tables in `adapters/prometheus.py`. They are leftovers of the ADR 0007
loop design that ADR 0008 replaced, and they will always report zero.
### Cardinality control
Every label is a bounded vocabulary. `_route_label` in `main.py` maps any
unrecognised path to the literal `"other"`, and `status` is a class
(`2xx`/`4xx`/`5xx`), not a code. `adapters/prometheus.py` normalises
stage/provider/reason values. Without this, a caller could mint unbounded time
series by varying the URL.
## Tracing
`rag/telemetry.py` configures **one** OTLP tracer provider, with
`ParentBased(TraceIdRatioBased(OTEL_SAMPLE_RATIO))` and a `BatchSpanProcessor`.
If a provider was already installed (by a host or a test) it is reused rather
than replaced.
Span structure for one request:
```
SERVER {METHOD} {route} ← main.py middleware, extracts traceparent
├── rag.stage.receive
├── rag.stage.context ← RagAgent._get_history
├── rag.stage.understanding ← InstrumentedQueryUnderstander
│ └── provider.bedrock_converse.understand
├── rag.stage.routing ← RagAgent._route
│ ├── rag.stage.retrieval
│ │ ├── rag.stage.rerank
│ │ └── rag.stage.evidence
│ ├── rag.stage.generation
│ │ └── provider.bedrock_converse.generate
│ ├── rag.stage.grounding ← @traced_stage on grounding.verify
│ └── rag.stage.entailment
│ └── provider.bedrock_converse.entailment
├── rag.stage.persistence
└── rag.stage.response
```
`InstrumentedGenerator` derives the dependency-span operation name from the
current stage (`understanding``understand`, `generation``generate`,
`entailment``entailment`), so all three Bedrock calls are distinguishable
despite going through one adapter.
`stage()` also records `duocthu_stage_duration_seconds` and marks
`duocthu.outcome` as `ok` / `error` / `cancelled` — which answers the
"trace has no per-stage timing" gap noted in the earlier pipeline audit.
### Span attributes
`duocthu.correlation_id`, `duocthu.decision`, `duocthu.reason`,
`duocthu.citation_count`, `duocthu.generated`, `duocthu.persisted_trace_id`,
`duocthu.evidence_count`, `duocthu.turn_type`, `duocthu.needs_clarify`,
`duocthu.system_error`, `duocthu.http.status_class`, `duocthu.duration_ms`,
`duocthu.stage`, `duocthu.outcome`, `duocthu.provider`, `duocthu.operation`.
`annotate_current_span` silently drops any value that is not `str`/`bool`/
`int`/`float`, so a stray object cannot break export.
## Correlation
Three identifiers, joinable:
| Id | Origin | Carried in |
|---|---|---|
| `X-Correlation-ID` | Client, or generated; validated by regex | Request header, response header, `rag_retrieval_trace.correlation_id`, span attribute |
| OTel trace id | Sampler | `X-Trace-ID` response header, `rag_retrieval_trace.otel_trace_id`, Tempo |
| `trace_id` (application) | UUID per answer | Response body, `rag_retrieval_trace.trace_id`, `rag_answer_feedback.trace_id` |
`migrations/003` adds partial indexes on the first two, so a support request
carrying either header can be looked up.
Propagation is **stored as a context var** (`ContextVar`), which works across
FastAPI's async middleware and its sync thread-pool endpoint — the module
docstring names that as the reason for the design.
## Deployed stack
```mermaid
flowchart LR
AI["ai-service<br/>OTEL_ENABLED=true"]
OC["otel-collector 0.123.0<br/>memory_limiter + batch"]
TP["tempo 2.7.2"]
PR["prometheus v3.3.0<br/>scrape ai-service:8000/metrics"]
GF["grafana 11.5.2<br/>127.0.0.1:3002"]
CD["caddy → /grafana/*"]
U[Operator]
AI -->|"OTLP/HTTP :4318"| OC -->|"OTLP/gRPC tempo:4317"| TP
PR -->|scrape 15s| AI
GF --> PR
GF --> TP
U -->|https://realvuxbaro.me/grafana/| CD --> GF
```
Datasources are provisioned with fixed UIDs `prometheus` and `tempo`
(`infra/docker/grafana/provisioning/datasources/prometheus.yml`), and the
dashboard `duocthu-observability` is provisioned from
`infra/docker/grafana/dashboards/duocthu-grounding.json`. Exemplar storage is
enabled on Prometheus (`--enable-feature=exemplar-storage`).
## What the deploy pipeline actually verifies
`.github/workflows/deploy.yml` does not just start the stack — it asserts it:
- `prometheus:9090/-/ready`, `tempo:3200/ready` (retried 12×5 s),
`grafana:3000/api/health`;
- both Grafana datasources exist by UID, authenticated as admin;
- the dashboard `duocthu-observability` exists;
- `https://realvuxbaro.me/grafana/login` is reachable;
- a real query is issued with a generated `X-Correlation-ID`, the returned
`X-Trace-ID` is asserted to match `^[0-9a-f]{32}$`, then after 20 s
`duocthu_requests_total` must be present in Prometheus **and** the exact trace
id must be retrievable from `tempo:3200/api/traces/<id>` (retried 12×5 s).
That last assertion is the strongest evidence in the repository that tracing
works end to end in production.
## Gaps
- **No alerting.** No Alertmanager, no Prometheus rule files, no Grafana alert
rules in the provisioning directory.
- **No log aggregation.** No Loki, no Promtail, no structured/JSON logging. Logs
are reachable only via `docker logs`, and the level choices are odd —
`agent.py` logs ordinary per-turn timings at `warning` because uvicorn's
default config does not wire handlers onto the root logger.
- **No SLOs, no error budget, no burn-rate rules.**
- **`web` is not instrumented at all** — no metrics, no traces, no structured
logs. It only forwards `traceparent`.
- **No Prometheus retention flag in the Compose overlay** (the Helm values set
7 d; Compose relies on the image default).
- **No RED/USE dashboard beyond the single provisioned one**; its panel set was
not audited in this pass.