Skip to content
Phase 1: foundation and observability spine

Phase 1: foundation and observability spine

October 15, 2026

What happened

Built phase 1 of hango: module, env config, composition root, stdlib http.ServeMux with a small router, health endpoints, graceful drain, and the telemetry package (one tracer, meter and logger provider, otelslog as default logger, Go runtime metrics). Added the collector config, compose stack (hango, postgres, otelcol, lgtm), Dockerfile, Makefile and golangci-lint v2 config.

Verified live: /readyz returned 200 with X-Request-Id and X-Trace-Id; the trace was in Tempo, its log line in Loki by trace_id, go_* and http_server_request_duration_seconds_* in Prometheus; SIGTERM drained and exited 0 in about 1s.

Problems and fixes

  • Spans were named GET and the duration metric lacked http.route. Cause: requestID passes r.WithContext(ctx), a copy, so the mux set Pattern on the copy while otelhttp reads it from the original. Fix: copy inner.Pattern back after next.ServeHTTP. Caught by server_test.go.
  • Collector 0.161 warned "otlp" alias is deprecated; use "otlp_grpc" and the same for otlphttp. Renamed exporters.
  • Collector logged connection refused to lgtm:4317 at startup. Compose now waits for lgtm to be healthy.
  • A successful request logged nothing, so Loki had no line to find by trace id. Added logRequests middleware (one line per request, /healthz skipped) with a test.
  • Langfuse export returned 401 with keys unset. Changed after review, see below.

Review fixes

A code review found nothing blocking. Fixed: incoming traceparent now a link only (WithPublicEndpointFn), so clients cannot pick trace ids or force sampling; /readyz hides check error text; a check ignoring its context no longer hangs readiness; telemetry shutdown splits its budget so logs still flush; compose stop_grace_period: 35s; second Ctrl-C force-quits; *Error with no status becomes a 500; ports bind to 127.0.0.1.

Langfuse gating: with no keys the collector was still sending spans to Langfuse Cloud (rejected with 401), and from phase 3 those spans would carry prompt text. Decision: the Langfuse leg runs only when LANGFUSE_PUBLIC_KEY is set. filter/langfuse_disabled drops every span on that leg when the key is empty. Verified: no key, no export; fake key, 401.

Decisions

  • lgtm publishes only 3000/3200/9090 on the host; its 4317/4318 would clash with otelcol and only otelcol sends to it.
  • -healthcheck flag on the binary because the distroless image has no shell or curl.
  • G101 suppressed inline on gen_ai.request.max_tokens (attribute name, not a credential).

Next steps

  • Add Langfuse keys to .env and confirm the same trace appears in Langfuse (last open criterion).
  • Phase 2: persistence and tenancy core; register the Postgres readiness check.

Historical work record — not durable authority. Prefer docs/specs/ADRs for current decisions.