System Architecture
This document describes hango as it is built now. Each phase fills in or updates its sections. Sections marked “Not built yet” name the phase that adds them.
Overview
hango is one Go binary. cmd/hango/main.go handles flags and signals and calls app.Run. internal/app is the composition root: it is the only code that constructs dependencies, and it does so in a fixed order.
config.Load -> telemetry.Init -> server.New -> errgroup(server.Start) -> wait
|
shutdown, in reverse: server drains and stops -> telemetry flushes -> exit 0Packages
| Package | Responsibility | Depends on |
|---|---|---|
cmd/hango | Flags (-version, -healthcheck), signal.NotifyContext, exit code | internal/app |
internal/app | Wiring and lifecycle | all internal packages |
internal/config | Reads and validates environment variables | internal/telemetry (its Config type) |
internal/server | Router, middleware, JSON helpers, health endpoints, http.Server lifecycle | internal/config, internal/telemetry |
internal/telemetry | OpenTelemetry providers, the default slog logger, attribute keys, metric names, content attribute list | OpenTelemetry SDK |
Data model
Not built yet. Phase 2 adds Postgres, migrations and the tenant tables. The postgres compose service already runs so the stack shape does not change.
Request flows
Every request passes through the same middleware, outermost first:
| Layer | Job |
|---|---|
otelhttp | Starts the server span, names it {method} {route}, records http.server.request.duration with http.route. Skips /healthz. An incoming traceparent is kept only as a link. |
recordStatus | Wraps the response writer so later code can read the status. Keeps http.ResponseController working through Unwrap. |
requestID | Accepts a safe client X-Request-Id or makes a UUIDv7. Sets it on the span, in the log context and on the response, with X-Trace-Id. |
logRequests | Writes one request log line with method, route, status and duration. Skips /healthz. |
recoverPanics | Turns a handler panic into a logged, traced 500. |
http.ServeMux | Matches the route pattern and calls the handler. |
Handlers return an error. The router renders *server.Error values with their status and message, and turns any other error into a 500 whose body never contains the error text.
| Endpoint | Meaning |
|---|---|
GET /healthz | Liveness: the process is up. Always 200. Not traced or logged. |
GET /readyz | Readiness: every registered check passes and the server is not draining. 200 or 503. Failing checks show as "fail"; error text goes to the log. |
Run lifecycle
Not built yet. Phase 4 adds runs and their state machine.
Tenancy
Not built yet. Phase 2 adds tenants and API keys.
Observability
hango sends all three signals over OTLP/HTTP to one OpenTelemetry Collector, and the collector fans out.
hango --OTLP/HTTP--> otelcol --+-- traces --> Langfuse (only when LANGFUSE_PUBLIC_KEY is set)
+-- traces --> lgtm: Tempo (content attributes stripped)
+-- metrics --> lgtm: Prometheus
+-- logs --> lgtm: Loki (content attributes stripped)
+-- all three --> Grafana Cloud (only when GRAFANA_CLOUD_INSTANCE_ID is set, content stripped)- Traces: one
TracerProviderwith a batch exporter and a parent-based ratio sampler (HANGO_OTEL_SAMPLE_RATIO).OTEL_TRACES_SAMPLERis rejected at startup rather than silently ignored;OTEL_SDK_DISABLED=truedisables telemetry likeHANGO_OTEL_ENABLED=false. - Metrics: one
MeterProviderwith a periodic reader. Go runtime metrics (go_memory_used_bytes,go_goroutine_count, and more) start at init. Every metric name the code registers is a constant ininternal/telemetry/metrics_names.go, and a test fails on a literal name anywhere else. - Logs: the default
sloglogger writes to stdout (text in dev, JSON in prod) and to theotelslogbridge. Both outputs carry the trace and span ids, so a log line found on stdout or in Loki leads to its trace. - Attribute keys: hango’s own keys (
hango.*) are inattrs.go. GenAI semantic convention keys are copied intogenai.goand Langfuse keys intolangfuse.go, so an upstream rename cannot change them silently. - Content redaction:
content_attrs.golists every attribute that can hold prompt, completion or tool content, and it is applied twice. The logger drops those keys from every log record before it reaches stdout or the collector, at any depth inside groups. The collector’stransformprocessors then delete exactly those keys from span attributes, span event attributes, log record attributes and map log bodies, on the lgtm and Grafana Cloud legs always; this is the backstop for spans and for anything that bypasses the logger. The Langfuse leg is fail-closed: it strips unless the span carrieshango.tenant.trace_llm_contentas the booleantrue(span events follow their span), so every span that may carry content must also carry the flag. A string log body is not inspected, so never log content as the message. Span links and resource and scope attributes are not stripped; hango never puts content there. The collector statements are generated from the Go list, and a test keeps them identical to it. - Resource:
service.name,service.versionanddeployment.environment.name, overridable withOTEL_SERVICE_NAMEandOTEL_RESOURCE_ATTRIBUTES. - Shutdown:
HANGO_SHUTDOWN_TIMEOUTis one budget that starts when the signal arrives. The server drains within it and leaves up to 2s for telemetry, which flushes after the server stops so the last requests’ spans and logs are exported. The process exits within the budget.
The lgtm container (grafana/otel-lgtm) bundles Grafana, Tempo, Loki and Prometheus for development only. Langfuse defaults to Langfuse Cloud; deploy/langfuse/compose.langfuse.yaml documents the self-hosted stack. Setting up both clouds is covered in observability-cloud-setup.md.
Deployment
Development runs on compose.yaml: hango, postgres, otelcol, lgtm. The image is a static binary on gcr.io/distroless/static-debian12:nonroot, and the compose health check runs /hango -healthcheck because the image has no shell. Production deployment is phase 16.