Observability: traces, metrics, cost¶
Operators of a software factory need to see where time and money go, and to notice when something breaks. hangar gives you three things:
- OpenTelemetry traces from the control plane: one trace per job run, session turn, MCP call or REST request, down to each sandbox start, checkpoint and agent turn. Claude Code's and Codex's own spans join the same trace.
- Prometheus metrics at
GET /metrics: fleet, sessions and jobs by state, latencies, tokens and dollars, budget stops, gateway pushes, MCP calls and control-plane health. - A cost and usage view:
GET /api/usageand the dashboard's Usage page. - An operator console:
hangar status,GET /api/statusand the MCP resourcehangar://status, for admins: power, loops, versions, builds, the day's failures by cause, egress denies, waiting requests, SLOs, credential health and recent warnings (the operator console). - Flight records of every failed, timed-out or slow job and turn, with the end of its run log, sealed (flight records). The Cloud Doctor reads them every night.
This builds on the events, usage and budgets in activity.md, and covers the observability parts of spec 0005 and spec 0008 §5.7. It does not include 0005's in-process OTLP collector, traces.db or decision model.
Metrics¶
GET /metrics returns the Prometheus text format.
- Who can read it. An admin token, or a token that can read metrics and nothing else:
hangar token create prometheus --scope metrics. - Turning it off.
HANGAR_METRICS_ENABLED=falseturns/metricsoff (404). hangar then never importsprometheus_client.
| Metric | Type | Labels |
|---|---|---|
hangar_sandboxes |
gauge | state, provider |
hangar_sandbox_create_seconds |
histogram | provider, environment, result (ok, error). Time to a running sandbox, with image build, start, setup and clone. |
hangar_warm_pool_claims_total |
counter | provider, environment, result (hit, miss). Only for environments with a warm pool. |
hangar_sessions |
gauge | state |
hangar_jobs |
gauge | state, reason (the outcome: over_budget, verify_failed, agent_failed…) |
hangar_job_retries_total |
counter | mode: same (the deck's Retry, a canned follow-up) or fresh (Start over, a new job) (working with the agent) |
hangar_turn_seconds |
histogram | harness, outcome. The agent's time, start to finish. |
hangar_tokens_total |
counter | kind (input, output, cache_read, cache_write), model, harness, environment, requester |
hangar_cost_usd_total |
counter | model, harness, environment, requester |
hangar_unpriced_turns_total |
counter | model, harness. Turns whose model has no price; their cost is unknown, not 0. |
hangar_budget_stops_total |
counter | harness, environment |
hangar_cost_uncached_usd_total |
counter | model, harness, environment. What priced turns would have cost with no prompt cache; minus hangar_cost_usd_total, what caching saved (sessions.md). |
hangar_cache_write_1h_tokens_total |
counter | model, harness. The part of cache_write tokens written with the 1-hour TTL (priced at 2x input). |
hangar_cache_ttl_turns_total |
counter | ttl (5m, 1h, off, none: the harness has no setting), harness |
hangar_checkpoint_seconds |
histogram | op (capture, restore), result (stored, unchanged, ok, error) |
hangar_checkpoint_bytes |
histogram | reason (turn, interval, suspend, destroy, manual) |
hangar_gateway_pushes_total |
counter | result (ok, failed) |
hangar_gateway_denials_total |
counter | reason (repo_not_granted, ref_policy, too_large, protocol) |
hangar_egress_connections_total |
counter | decision (allow, deny), reason (for a denial: not_allowed, sni_mismatch, private_address, too_many…; for an allow, what matched: harness, preset, environment, repo, approved), mode |
hangar_egress_bytes_total |
counter | direction (out to the internet, in) |
hangar_egress_open_connections |
gauge | tunnels open through the egress proxy |
hangar_sandbox_exec_seconds |
histogram | transport: envd (hangar-envd ran it), exec (the provider's own exec: docker exec, pods/exec, E2B), error. A falling envd share means daemons can't be reached or started. |
hangar_mcp_calls_total, hangar_mcp_call_seconds |
counter, histogram | tool, status (ok, tool_error, error) |
hangar_http_request_seconds |
histogram | method (a method outside HTTP's standard ones is _OTHER), route (the template), status |
hangar_db_backups_total, hangar_db_backup_seconds, hangar_db_backup_last_success_timestamp_seconds |
counter, histogram, gauge | result (ok, error). Alert when time() - hangar_db_backup_last_success_timestamp_seconds passes two backup intervals; the gauge starts empty after a restart. |
hangar_event_loop_lag_seconds, hangar_event_loop_lag_last_seconds |
histogram, gauge | How late a 0.5 s sleep on the event loop wakes up. Sustained lag means something blocks the loop. |
hangar_loop_last_tick_timestamp, hangar_loop_failures_total |
gauge, counter | loop (reaper, reconciler, sessions, schedules, backups, ops, egress, cloud-tokens). When each background loop last finished a pass, and passes that raised. Alert when time() - hangar_loop_last_tick_timestamp passes three of the loop's intervals (reaper 60 s, reconciler 30 s, sessions 60 s, schedules HANGAR_SCHEDULE_POLL_SECONDS, backups HANGAR_BACKUP_HOURS); the gauge starts empty after a restart. |
hangar_job_starts_total, hangar_job_queue_seconds, hangar_jobs_stuck |
counter, histogram, gauge | result (ok, error) on starts. The job-start SLO signals and the stuck-job alert (SLOs, a stuck job) |
hangar_jobs_awaiting_capacity |
gauge | Queued jobs held for review capacity; no person label |
hangar_eval_observed_tpm, hangar_eval_tpm_budget, hangar_eval_jobs_waiting, hangar_eval_window_open |
gauge | provider on the first two, reason on waiting jobs (the eval queue) |
hangar_governance_fail_closed_total, hangar_governance_disagreements_total |
counter | kind, plus failure on fail-closed. Tower's answers hangar had to make itself, and shadow disagreements (Tower) |
hangar_deploy_lag_commits, hangar_deploy_lag_working_days, hangar_deploy_lag_over |
gauge | How far production is behind main; -1 while unknown (a deploy waiting too long) |
hangar_outbox_pending |
gauge | Run capture objects not uploaded yet (run capture) |
hangar_io_pool_queue_depth, hangar_io_pool_threads, hangar_io_pool_max_threads |
gauge | Blocking provider calls waiting for a worker thread (HANGAR_IO_THREADS, see runtime.py) |
hangar_streams_open, hangar_streams_total, hangar_stream_bytes_total, hangar_stream_first_output_seconds |
gauge, counter, counter, histogram | kind (exec, process_attach, events, watch, process, turn_log, ...); the counter also reason (done, client, shutdown, destroyed, paused, revoked, lifetime, error). Streams to clients and the logs and processes hangar follows (streaming.md). |
Labels never carry a job, session, sandbox or run id.
- Capped labels. Requesters, models, environments, tools, routes and reasons are capped at 200 distinct values each; anything past the cap is
other. - Counters reset on restart. Use
rate()andincrease(). - When usage is counted. Tokens and cost are added when a turn ends, so a running turn shows up in
/api/usage(live) before it does here. - Gauges are read live.
hangar_sandboxes,hangar_sessionsandhangar_jobsare computed from the store at scrape time.
Some useful queries:
sum by (requester) (increase(hangar_cost_usd_total[1d])) # spend per requester, last day
histogram_quantile(0.95, sum by (le, provider) (rate(hangar_sandbox_create_seconds_bucket[1h])))
sum(rate(hangar_warm_pool_claims_total{result="hit"}[1h])) / sum(rate(hangar_warm_pool_claims_total[1h]))
increase(hangar_budget_stops_total[1d]) > 0
histogram_quantile(0.99, rate(hangar_event_loop_lag_seconds_bucket[5m])) > 0.25 # the control plane is stalling
hangar_io_pool_queue_depth > 0 # provider calls are queueing
# prompt-cache hit ratio per harness: the share of prompt tokens read from the cache
sum by (harness) (rate(hangar_tokens_total{kind="cache_read"}[1d]))
/ sum by (harness) (rate(hangar_tokens_total{kind=~"input|cache_read|cache_write"}[1d]))
sum(increase(hangar_cost_uncached_usd_total[1d])) - sum(increase(hangar_cost_usd_total[1d])) # saved by caching
Cost and usage¶
Weekly adoption numbers (yield, agent share, review wait, cost per merged PR, ...) and the morning standup line:
metrics.md, hangar metrics weekly.
GET /api/usage?group_by=day|model|environment|requester|repo|harness|session|job|cache_ttl&since=7d&limit=50 (admin)
hangar usage --by session --cache prints it as a table; --cache adds the cache columns (activity.md).
since takes:
- a relative span:
7d,12hor30m; - an ISO 8601 date or time, such as
2026-10-01; - epoch seconds.
The default is 30 days ago. Days are UTC. limit caps the rows; for day it keeps the most recent rows.
{"group_by": "repo", "since": 1789313019.0,
"rows": [{"key": "acme/widgets", "turns": 32, "input_tokens": 1732139, "output_tokens": 347633,
"cache_read_tokens": 6228892, "cache_write_tokens": 0, "tokens": 2079772, "cost_usd": 67.74,
"unpriced_turns": 0, "budget_stops": 4, "cache_write_1h_tokens": 0, "cache_hit_ratio": 0.7824,
"saved_usd": 22.43}],
"total": {"key": "total", ...},
"budget_stops": [{"ts": 1791866712.4, "session": "job-3fa91c20", "job": "job-3fa91c20", "repo": "acme/widgets",
"requester": "github:sean", "reason": "spent $5.12; the budget is $5.00", "cost_usd": 5.12}]}
The numbers come from agent turns, each counted once:
- Session turns. A job's turns are the turns of the sessions that carry it, so best-of-N attempts, repair runs and the judge are all included. A fork's copy of its parent's history is not counted again.
- Older jobs. Jobs from before sessions carried them use their own turn records.
- Deleted sessions. A deleted session that carried a job keeps its total on the job, so its spend stays in the view. It counts on the day the job was created, with no turns.
- Running turns. A running turn counts with its live usage, on the day it started.
- Costs.
cost_usdadds the known costs.unpriced_turnscounts turns whose model has no price (activity.md). - Repos. Repos are shown as
owner/namefor GitHub, and never with credentials from a URL.
The dashboard's Usage page (#usage) shows, for the last 7, 30 or 90 days:
- spend, tokens, turns and budget stops;
- spend per day, with each day's numbers on hover;
- the top repos and requesters;
- the latest budget stops.
The nightly pass¶
The ops loop runs once a night at HANGAR_DOCTOR_SCHEDULE (cron, default 30 0 * * *) in HANGAR_DOCTOR_TIME_ZONE (default: the power schedule's zone, else UTC): half an hour before the sandbox pools power down. It checks every credential, then runs the Cloud Doctor. The first look after a start only remembers the latest slot, so a deploy never runs it on the spot; a slot missed while hangar was down runs once when it is back. hangar status shows the last pass under nightly.
Deploying¶
Prometheus¶
| Prometheus | File | |
|---|---|---|
| Prometheus Operator (kube-prometheus-stack) | deploy/k8s/observability/prometheus-operator.yaml |
A hangar-metrics Service and a ServiceMonitor that sends the token from Secret hangar-metrics-token |
| Google Cloud Managed Service for Prometheus | deploy/k8s/observability/gke-managed-prometheus.yaml |
A PodMonitoring with authorization.credentials.secret, and a Role that lets Managed Prometheus's collectors (gmp-system/collector) read that one Secret |
Both read the token from a Secret:
hangar token create prometheus --scope metrics # prints hgr_…
kubectl -n hangar create secret generic hangar-metrics-token --from-literal=token=hgr_...
kubectl apply -f deploy/k8s/observability/gke-managed-prometheus.yaml # or prometheus-operator.yaml
Plain prometheus.io/scrape annotations can't send a token, so there is no annotation-only variant. Managed Prometheus's PodMonitoring fields were checked against the prometheus-engine API reference. Its collector service account (collector in gmp-system) was checked against the project's manifests/operator.yaml.
Traces in Google Cloud Trace¶
Cloud Trace takes OTLP at the Telemetry API, https://telemetry.googleapis.com (/v1/traces). That endpoint needs Google OAuth with the trace.append or cloud-platform scope, which a plain OTLP exporter can't add. So route traces through Google's collector:
- Install the Google-Built OpenTelemetry Collector:
export GOOGLE_CLOUD_PROJECT=<project> PROJECT_NUMBER=<number>
kubectl kustomize https://github.com/GoogleCloudPlatform/otlp-k8s-ingest.git/k8s/base | envsubst | kubectl apply -f -
Give its Kubernetes service account (opentelemetry/opentelemetry-collector, through Workload Identity) the roles Google's page lists: roles/cloudtrace.agent, roles/monitoring.metricWriter and roles/logging.logWriter. The collector exports traces with otlphttp to https://telemetry.googleapis.com, with the googleclientauth extension.
- Point hangar at it. Add these to the
hangarConfigMap and restart:
OTEL_EXPORTER_OTLP_ENDPOINT: http://opentelemetry-collector.opentelemetry.svc.cluster.local:4318
OTEL_SERVICE_NAME: hangar
HANGAR_OTEL_SANDBOX_ENDPOINT: http://opentelemetry-collector.opentelemetry.svc.cluster.local:4318 # optional
- Optional: let sandboxes reach the collector. If you set
HANGAR_OTEL_SANDBOX_ENDPOINT, applydeploy/k8s/observability/otel.yaml. It lets sandboxes reach the collector's port 4318 and nothing else in the cluster. The collector also forwards the harnesses' metrics and logs to Cloud Monitoring and Cloud Logging.
The endpoint, scopes, collector install and roles were checked against Google's docs on 2026-10-13: the Telemetry API v1.traces and the collector page above. hangar's spans can also go to any other OTLP backend (Tempo, Jaeger, Honeycomb, Grafana Cloud…) through OTEL_EXPORTER_OTLP_ENDPOINT and OTEL_EXPORTER_OTLP_HEADERS. None of those has been tested.
Settings¶
| Variable | Default | |
|---|---|---|
OTEL_EXPORTER_OTLP_ENDPOINT / _TRACES_ENDPOINT / OTEL_TRACES_EXPORTER |
unset | Turn tracing on (see above). Every other standard OTEL_* variable applies. |
HANGAR_OTEL_SANDBOX_ENDPOINT |
unset | OTLP/HTTP base URL that sandboxes export Claude Code and Codex telemetry to. A plain http(s) URL without credentials. |
HANGAR_METRICS_ENABLED |
true |
GET /metrics |
HANGAR_TRACE_LIVE_TOOLS |
false |
With tracing on, also send each turn's tool, subagent and approval spans (traces) |
HANGAR_DOCTOR_SCHEDULE |
30 0 * * * |
When the nightly pass runs (credential checks, the Cloud Doctor); off turns it off |
HANGAR_DOCTOR_TIME_ZONE |
the power schedule's | The schedule's IANA time zone (UTC when there's no power schedule) |
HANGAR_OUTBOX_URL |
unset | Run capture: gs://bucket[/prefix] or a local directory (outbox.md) |
HANGAR_OUTBOX_SESSIONS |
all |
jobs_only leaves out sessions not attached to a job |
HANGAR_EXPORT_GRACE_DAYS |
30 |
How long a deleted session's event log and manifests wait for their export |
HANGAR_DETERMINISM_SCHEDULE |
0 21 * * * |
When the determinism report's replays start, in HANGAR_EVAL_TZ; off turns it off |
HANGAR_DETERMINISM_RUNS_PER_REPO |
50 |
Previous-day turns replayed per repo (at most 200) |
HANGAR_DETERMINISM_MAX_REPOS |
10 |
Repos replayed per night, the busiest first |
HANGAR_DETERMINISM_REPLAY_BUDGET_USD |
1 |
Each replay's job budget (sandbox time only) |
Moved from this page¶
| Section | Now on |
|---|---|
| Traces | Traces |
| What is traced | Traces |
| Into the sandbox: the agents' own telemetry | Traces |
| Your MCP client's trace | Traces |
| Each tool call, and traces as files | Traces |
The operator console: hangar status |
The operator console, flight records and run capture |
| SLOs | The operator console, flight records and run capture |
| Flight records | The operator console, flight records and run capture |
| Run capture | The operator console, flight records and run capture |
| The determinism report | The operator console, flight records and run capture |
| Credential health | The operator console, flight records and run capture |