## What & why
`verify-tracing` flaked on `verify-stack` run 722 — `FAIL — no single trace spanned ['bff', 'projection-api']` — and went green on a plain re-run of the same commit. **The trace chain was not broken; Tempo could not ingest:**
```
removing distributor_pool failing healthcheck addr=127.0.0.1:9095
reason="rpc error: code = DeadlineExceeded"
pusher failed to consume trace data err="context canceled" (x18)
```
The root cause is the *mechanism* of the data loss, not whatever caused the stall. Tempo runs **single-binary**, so the distributor and the ingester are the same process and the distributor's ingester pool holds exactly one, in-process, member. dskit nevertheless health-checks that member over loopback gRPC with a **1 s** deadline (`checkinterval: 15s`, confirmed from the running image's `/status/config`). On the shared runner a transient stall blows the deadline, the only ingester is evicted from the pool, and every subsequent push fails until the next check interval — spans silently dropped.
With one in-process ingester the health check can **never** route around a failure. Its only possible effect is to discard data. So it is off:
```yaml
ingester_client:
pool_config:
healthcheckenabled: false
```
This lands at the point where *both* candidate triggers named in #156 (GC pressure near `mem_limit`, CPU contention from the grown stack) turn into lost spans, so **`mem_limit: 400m` is untouched** — raising it on a memory-tight runner risks reintroducing the `verify-e2e` OOM of #144. It also does not paper over anything the way a longer `TRACING_TIMEOUT` would (#156's own note).
Second change: `infra/tracing-check.py` prints `tempo_distributor_ingester_clients` on its failure path. From the check's side, Tempo-dropped-spans and missing instrumentation look identical — that ambiguity is what cost a container-log dive on run 722. A recurrence now names itself.
Closes #156
## Definition of Done
- [x] Linked Gitea issue (#156).
- [ ] **Failing test committed before the implementation — N/A, and deliberately so.** The trigger is runner load, so no deterministic red exists; the "red" is run 722's observed `verify-tracing` failure plus its Tempo logs. Same precedent as d5e5fa2 (#115, Playwright OOM) and 4aafd32 (#147, uWSGI caps). A test asserting the config says what the config says would add no gate: Tempo hard-fails on an unknown key (verified — `field health_check_enabled not found in type client.PoolConfig`), so a typo or a config rename on a Tempo bump already turns `verify-up` red.
- [x] Conventional Commits referencing the issue (`refs #156`).
- [ ] CI green — the point of the change.
- [x] `docker compose up` health unaffected (Tempo is not in `WAIT_SVCS`; config-only change, same image).
- [x] Docs updated — ADR-0023 Consequences.
- [x] ADR — amended **ADR-0023** rather than adding a new one: this is a consequence of that ADR's single-binary Tempo choice, not a new decision (one decision per ADR, §12).
- [x] Demo note — N/A, not user-visible.
## Notes for reviewers
Verified locally against the built image (the flake itself is not locally reproducible — see the runner-load point above):
1. `docker run --rm register-referentie/tempo:dev -config.file=/etc/tempo.yaml -config.verify=true` → parses.
2. `GET /status/config` on the running container → `healthcheckenabled: false` (was `true`).
3. The new diagnostic reads `tempo_distributor_ingester_clients` off a live Tempo.
Worth knowing: that metric is legitimately `0` on an idle Tempo — the pool is populated lazily on first push. It only prints on the failure path of a check that has already generated traffic, so the reading is meaningful there, but don't read a bare `0` on a quiet stack as an eviction.
Follow-up left undone: if `verify-tracing` still flakes after this, the next suspect is the .NET OTLP exporter timeout (#156's last note), not Tempo's memory cap.Reviewed-on: #157
97 lines
3.3 KiB
Python
Executable File
97 lines
3.3 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""S-16b (#123): prove distributed tracing works end to end.
|
|
|
|
Generate anonymous BFF traffic (GET /openbaar/register, which the BFF serves by
|
|
calling projection-api — no auth, no OpenZaak egress), then query Tempo and assert
|
|
that ONE trace contains spans from both `bff` and `projection-api`. That proves the
|
|
services export OTLP to Tempo AND that the W3C traceparent propagates across the
|
|
HttpClient hop, stitching the request into a single connected trace.
|
|
|
|
Stdlib only (urllib/json) so it runs in a bare python:3-slim container in-network.
|
|
"""
|
|
import json
|
|
import os
|
|
import sys
|
|
import time
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
|
|
BFF = os.environ["BFF"] # http://<bff-ip>:8080
|
|
TEMPO = os.environ["TEMPO"] # http://<tempo-ip>:3200
|
|
TIMEOUT = int(os.environ.get("TRACING_TIMEOUT", "90"))
|
|
WANT = {"bff", "projection-api"} # the two services that must share one trace
|
|
|
|
|
|
def _get(url):
|
|
with urllib.request.urlopen(url, timeout=10) as r:
|
|
return r.read()
|
|
|
|
|
|
def generate_traffic():
|
|
# A non-2xx still produces spans; only total unreachability of the BFF is fatal.
|
|
for _ in range(3):
|
|
try:
|
|
_get(f"{BFF}/openbaar/register")
|
|
except urllib.error.HTTPError:
|
|
pass
|
|
|
|
|
|
def search_trace_ids():
|
|
q = urllib.parse.quote('{ resource.service.name = "bff" }')
|
|
try:
|
|
data = json.loads(_get(f"{TEMPO}/api/search?q={q}&limit=50"))
|
|
except Exception:
|
|
return []
|
|
return [t["traceID"] for t in data.get("traces", [])]
|
|
|
|
|
|
def services_in_trace(trace_id):
|
|
try:
|
|
data = json.loads(_get(f"{TEMPO}/api/traces/{trace_id}"))
|
|
except Exception:
|
|
return set()
|
|
names = set()
|
|
for batch in data.get("batches", []):
|
|
for attr in batch.get("resource", {}).get("attributes", []):
|
|
if attr.get("key") == "service.name":
|
|
names.add(attr.get("value", {}).get("stringValue"))
|
|
return names
|
|
|
|
|
|
def tempo_ingest_state():
|
|
"""#156: distinguish a broken trace chain from Tempo dropping spans. `ingester_clients` is 0
|
|
when the distributor has evicted its (single, in-process) ingester over a failed loopback
|
|
health check — pushes fail and spans are lost, which looks identical to missing instrumentation
|
|
from here. Diagnostics only; never fails the check."""
|
|
try:
|
|
for line in _get(f"{TEMPO}/metrics").decode().splitlines():
|
|
if line.startswith("tempo_distributor_ingester_clients "):
|
|
return f"tempo {line.strip()} (0 = no ingester in the pool — evicted, so pushes\n are failing and spans are being dropped; see #156)"
|
|
except Exception as e:
|
|
return f"tempo /metrics unreadable: {e}"
|
|
return "tempo_distributor_ingester_clients not reported"
|
|
|
|
|
|
def main():
|
|
deadline = time.time() + TIMEOUT
|
|
generate_traffic()
|
|
seen = set()
|
|
while time.time() < deadline:
|
|
for tid in search_trace_ids():
|
|
names = services_in_trace(tid)
|
|
seen |= names
|
|
if WANT.issubset(names):
|
|
print(f"OK — trace {tid} spans {sorted(names)}")
|
|
return 0
|
|
time.sleep(3)
|
|
generate_traffic()
|
|
print(f"FAIL — no single trace spanned {sorted(WANT)}; services seen: {sorted(seen)}",
|
|
file=sys.stderr)
|
|
print(f" {tempo_ingest_state()}", file=sys.stderr)
|
|
return 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|