Skip to content
System Architecture

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 0

Packages

PackageResponsibilityDepends on
cmd/hangoFlags (-version, -healthcheck), signal.NotifyContext, exit codeinternal/app
internal/appWiring and lifecycleall internal packages
internal/configReads and validates environment variablesinternal/telemetry (its Config type)
internal/serverRouter, middleware, JSON helpers, health endpoints, http.Server lifecycleinternal/config, internal/telemetry
internal/telemetryOpenTelemetry providers, the default slog logger, attribute keys, metric names, content attribute listOpenTelemetry 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:

LayerJob
otelhttpStarts 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.
recordStatusWraps the response writer so later code can read the status. Keeps http.ResponseController working through Unwrap.
requestIDAccepts 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.
logRequestsWrites one request log line with method, route, status and duration. Skips /healthz.
recoverPanicsTurns a handler panic into a logged, traced 500.
http.ServeMuxMatches 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.

EndpointMeaning
GET /healthzLiveness: the process is up. Always 200. Not traced or logged.
GET /readyzReadiness: 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 TracerProvider with a batch exporter and a parent-based ratio sampler (HANGO_OTEL_SAMPLE_RATIO). OTEL_TRACES_SAMPLER is rejected at startup rather than silently ignored; OTEL_SDK_DISABLED=true disables telemetry like HANGO_OTEL_ENABLED=false.
  • Metrics: one MeterProvider with 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 in internal/telemetry/metrics_names.go, and a test fails on a literal name anywhere else.
  • Logs: the default slog logger writes to stdout (text in dev, JSON in prod) and to the otelslog bridge. 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 in attrs.go. GenAI semantic convention keys are copied into genai.go and Langfuse keys into langfuse.go, so an upstream rename cannot change them silently.
  • Content redaction: content_attrs.go lists 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’s transform processors 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 carries hango.tenant.trace_llm_content as the boolean true (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.version and deployment.environment.name, overridable with OTEL_SERVICE_NAME and OTEL_RESOURCE_ATTRIBUTES.
  • Shutdown: HANGO_SHUTDOWN_TIMEOUT is 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.