Observability
See what a Hubzoid deployment is doing through usage and cost, tool decisions, runs, alerts, health checks, OpenTelemetry traces and logs.
Observability in Hubzoid is the record a deployment keeps of itself: usage for every model call, every restricted tool decision, every access change and sign-in, every run, and every webhook event and alert. Settings and the command line read those records directly from the deployment's own databases, while alerts and traces to an outside backend are optional and off until you set them up.
When to use it
Use this page to set up monitoring for a deployment, to answer "who used what and what did it cost", and to investigate a refused tool call or a failed run.
| Question | Where to look |
|---|---|
| Is the deployment up? | GET /healthz and GET /healthz/workflows |
| Is it configured safely, and backed up? | hubzoid doctor <hub> |
| What did each agent cost this month? | Settings → Usage |
| Who called which restricted tool? | hubzoid audit, or Settings → Activity → Audit log → Tool decisions |
| Who changed access, and who signed in? | Activity → Audit log → Access changes |
| Did last night's workflow run? | Activity → Runs, or hubzoid schedule status <hub> |
The smallest real example
hubzoid doctor reads a Hub and its deployment without changing anything. These two lines are from a starter Hub:
✓ Last backup 0.0 days ago. Scheduled backups run weekly. (backup.age)
✓ Scheduled work is not held or paused (scheduler.health)Each check has a stable id in brackets. Run it with --json from a monitoring job and alert on the ids that matter to you.
How it works
Everything below lives in the deployment's own databases. Run history stays in the workflow engine's database, and Hubzoid keeps no second copy.
| Record | Contents | Read it with |
|---|---|---|
| Usage | Time, Hub, surface, user, chat, model, tokens, estimated cost, status and duration. No message content. | Usage in Settings |
| Tool decisions | Time, Hub, user, surface, tool, decision and reason for every restricted tool call | hubzoid audit, Activity → Audit log |
| Access changes | Grants and revokes, accounts added, approved or deleted, sign-ins and sign-outs, two-step sign-in changes, and workflow run, pause, resume and cancel actions | Activity → Audit log |
| Runs | Every scheduled, manual, webhook and chat-started run with its steps, result and error | Activity → Runs, each agent's Workflows tab, hubzoid schedule status |
| Webhook events and alerts | Each received webhook event and each alert delivery, with its state | hubzoid schedule deliveries |
Webhook event and alert records have no automatic retention yet.
Usage and cost
Settings → Usage shows five totals for the last 24 hours, 7 days or 30 days, and this month's use by agent with its spending limit.


| Number | Meaning |
|---|---|
| Messages | Chat messages on every chat surface, including Slack, WhatsApp and Telegram, with the number of conversations. Background title and suggestion calls are not counted. |
| Users | Sign-in accounts in the viewer's scope. |
| Tokens used | Chat turns plus background calls, scheduled work and workflow model calls, split into input and output. |
| Workflow runs | Runs started in the period, with how many failed. |
| Approx. cost | In USD, from the model's reported cost or token prices. Calls with no known price are left out and marked with *. |
A figure Hubzoid cannot know reads Unavailable or a dash, never zero. The Agents list also shows each agent's chats and spend, and agent administrators see only their own agents.
Tool decisions
Every call to a restricted tool is checked in code and written as one row, allowed or denied. A call whose row cannot be written is refused, so every call that ran is in the log.
hubzoid audit ./finance --deniedEvery audit option
| Option | Default | Effect |
|---|---|---|
--limit, -n | 50 | How many recent decisions to show |
--user | none | Only one person or service identity |
--denied | off | Only refused calls |
--json | off | Only JSON: {"entries": [{ts, user, decision, tool, surface, reason}]} |
Each line shows the time, the user, ALLOW or DENY, the tool, and the surface with a reason.
Every decision reason
| Reason | Meaning |
|---|---|
grant | Allowed by a grant |
no-grant | The caller lacks the permission |
anonymous | No verified identity reached the bridge |
surface:<name> | The surface may not reach restricted tools, for example surface:slack-channel |
blocked | The person's agent access is blocked |
store-error | The access store could not be read, so the call was denied |
Rows written by older releases can also carry group and no-group, from the group-based access those releases had.


Activity → Audit log shows the same decisions under Tool decisions, and every grant, revoke, account change and sign-in event under Access changes, with filters for agent, time, person and action. Filters live in the URL, so a filtered view can be shared. See people and activity.
Scheduled runs
Activity → Runs lists every run with its status, how it started and how long it took. Each agent's Workflows tab shows its workflows, whether each is on, who it runs as, and a link to its runs. A run's result and step outputs are shown only to the account the run acted as, and other managers see a failure summary. On the server:
hubzoid schedule status ./finance # definitions, recent runs and errors
hubzoid schedule deliveries ./finance # webhook events and alert deliverieshubzoid doctor reports scheduler.health: a hold set by a backup in progress, paused work, a failed dispatcher, or no recent dispatcher heartbeat. See operating runs.
Workflow alerts
Hubzoid can tell you when scheduled work goes wrong: failed runs (for webhook runs, only after their retries run out), failed scheduled eval suites, overdue runs, several failures in a row, a schedule that stopped firing, webhook events whose content changed under the same key, and a workflow engine that stopped responding. Repeats within an hour are combined into one alert with a count.
alerts:
to:
- webhook: ALERT_WEBHOOK_URL # the name of a variable that holds the URL
- slack: SLACK_ALERT_WEBHOOK # a Slack incoming webhook URL, also by variable name
- email: ops@example.com
cooldown: 1h
failures_in_a_row: 3
pause_after_failures: 20Every alert setting
| Setting | Effect |
|---|---|
alerts.to | The Hub's destinations. webhook and slack name an environment variable that holds the URL, and email is an address, sent through the deployment's SMTP settings. |
alert_to= on a workflow, alert_to: in a markdown task | Destinations for that one workflow or task, instead of the Hub's |
HUBZOID_ALERT_URL | The deployment's fallback webhook URL |
HUBZOID_ALERT_SECRET | Signs each webhook alert with X-Hubzoid-Signature, an HMAC-SHA256 of the timestamp, a . and the raw body |
failures_in_a_row | How many failures in a row raise an alert. Default 3. |
pause_after_failures | A schedule pauses itself after this many failed scheduled runs in a row. Default 20, and 0 turns it off. Webhook workflows are never paused. |
Delivery, signing, and what an alert carries
Delivery is durable and retried up to five times. Webhook alerts carry an Idempotency-Key and an X-Hubzoid-Timestamp header. Every alert carries only the Hub, the kind of alert, the workflow, the run id, a count and a link to the run in Settings. Outputs and exception text stay on the server. With HUBZOID_EMAIL_DELIVERY=preview, email alerts are written to private files under .hubzoid/alert-preview/ instead of being sent. A missing destination or an exhausted delivery shows in hubzoid schedule deliveries, never as a successful send.
A Hub's first start with alerts looks back 24 hours, so older failures are never alerted. An invalid workflows/settings.yaml turns workflows off for that Hub, with the error in its workflow health, while chat and other Hubs keep running. See code workflows.
Health checks
| Check | Where | Use it for |
|---|---|---|
GET /healthz | The public port, answered by the Hub's bridge (the first Hub's when several run together) | Liveness probes. Returns status, hub, agent and model. The Docker image uses it as its health check. |
GET /healthz/workflows | The public port, answered by the edge | Uptime monitors. 200 when every Hub's workflow engine is healthy, 503 otherwise. It names no Hub and no error. |
hubzoid doctor <hub> --json | The server | Configuration and state checks for scripts and monitoring |
Watch /healthz/workflows from outside the machine, and supervise the processes as well, because an edge cannot report the death of its own host. The edge also sends an alert when a Hub's workflow engine goes stale and when it recovers.
Doctor reads only. It creates no database, runs no migration and starts no engine, so it is safe against a running deployment. It exits with status 1 when any check fails.
hubzoid doctor /opt/hubzoid/agents/finance --json{
"format": 1,
"hub": "/opt/hubzoid/agents/finance",
"hubzoid": "<version>",
"ok": true,
"checks": [
{
"id": "auth.bridge_keys",
"status": "ok",
"summary": "1 bridge key(s) set",
"detail": null
},
{
"id": "backup.age",
"status": "warn",
"summary": "No backup recorded. Scheduled backups run weekly. Run `hubzoid backup` now.",
"detail": null
}
]
}Every check has a stable id and a status of ok, info, warn or fail. New checks may be added, and existing ids are never renamed.
Doctor checks worth monitoring
| Check | Watches |
|---|---|
db.operational, db.hub | Databases reachable and at the current schema |
scheduler.health | Scheduled work held, paused, failing or without a heartbeat |
backup.age | The last verified backup, a failed one, or none in 7 days |
auth.bridge_keys, auth.chat_signin, exposure.bind, exposure.local_mode | Bridge keys set, sign-in on, and how the public port is bound |
deployment.key | The deployment key, by fingerprint, and whether its file is readable by others |
access.who | An agent nobody may use yet |
model.credentials | Provider credentials present for the configured model |
deps.sqlite | SQLite new enough for the workflow engine |
The doctor reference lists every check.
OpenTelemetry traces
Hubzoid can push traces over OTLP/HTTP to any OpenTelemetry backend, such as Langfuse or an OpenTelemetry Collector. It is off until you set an endpoint in the Hub's environment:
HUBZOID_OTEL_ENDPOINT=https://langfuse.internal/api/public/otel
OTEL_EXPORTER_OTLP_HEADERS=Authorization=Basic <base64 of public_key:secret_key>OTEL_EXPORTER_OTLP_HEADERS is the standard exporter variable and passes straight through. Emission is an outbound push, and nothing is added to the public port, so keep the receiver on a private network. When several Hubs run together, set HUBZOID_OTEL_ENDPOINT in each Hub's .env, pointing at the same backend. Traces separate by hubzoid.hub.
The spans come from the model SDKs, and what they carry depends on the model path.
What each model path emits, and Langfuse cost
| Model path | What is emitted | Attribution |
|---|---|---|
| LiteLLM providers (OpenRouter, OpenAI, Azure, Anthropic and others) | LiteLLM's OpenTelemetry callback, with standard gen_ai.usage.* token attributes and gen_ai.cost.total_cost | hubzoid.hub as a resource attribute |
claude-local | Claude Code's trace spans (interaction, llm_request, tool) with token counts | hubzoid.user, hubzoid.hub, hubzoid.surface and user.id (the signed-in person) as resource attributes |
On the claude-local path only traces are exported by default. Metrics and logs stay off, because logs can carry prompt content. Set OTEL_METRICS_EXPORTER=otlp or OTEL_LOGS_EXPORTER=otlp to send them, and OTEL_EXPORTER_OTLP_PROTOCOL=grpc for a gRPC-only collector.
Claude Code puts token counts under names Langfuse does not map, so cost shows as zero there. Set HUBZOID_OTEL_NORMALIZE=true to fix this inside the bridge, with no extra process: it copies the token counts to the gen_ai.usage.* names, sets each span's user.id to the signed-in person, adds the Hub name as a Langfuse tag so cost can be charted by Hub, and forwards the batch. If you already run a shared collector, point HUBZOID_OTEL_ENDPOINT at it, leave normalization off, and use the reference docs/otel-collector.yaml (opens in a new tab). Langfuse prices tokens from its own model table, so a model it does not know shows zero cost until you add its price there.
Message content
On the claude-local path, prompt and response bodies stay out of spans
unless you set OTEL_LOG_USER_PROMPTS=1 or OTEL_LOG_ASSISTANT_RESPONSES=1.
On the LiteLLM path, spans are produced by LiteLLM's callback. Review what
your backend receives before pointing a production Hub at a shared or
third-party backend.
OpenAI tracing and eval traces
Two related switches are separate. HUBZOID_OPENAI_TRACING=true lets the OpenAI Agents SDK export its own traces, including prompts and tool data, to OpenAI. It is off by default. Eval runs push one trace per case with scores to Langfuse when LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY are set (or read back from the Basic credentials in OTEL_EXPORTER_OTLP_HEADERS), tagged hubzoid.eval so you can filter them out. See evals.
Logs
| Source | Where |
|---|---|
| A Hub, with its channels | hubzoid logs <hub> -f, and the process's standard output |
| The edge | hubzoid logs edge -f |
| The whole deployment under systemd | journalctl -u hubzoid -f |
HUB_LOG_LEVEL sets the level for the bridge and edge: info (default), debug or warning. Hubzoid removes one-time sign-in link tokens and OAuth codes from its own access logs, but a reverse proxy in front of it still records them unless you redact them there.

