Skip to main content

Multi-machine aggregation

OpenUsage runs per-machine by default — the dashboard sees only what the local daemon collected. When you want a single pane that shows spend across multiple workstations (e.g. work laptop, home desktop, dedicated build host), the Hub + Exporter pair gives you that without changing how local data is collected.

Architecture

machine A (openusage telemetry)
└─ Exporter ──POST /v1/push──▶ Hub server ◀── openusage hub-view <url>
machine B (openusage telemetry)
└─ Exporter ──POST /v1/push──▶ (in-memory Store)

Every worker still runs the normal openusage telemetry daemon — collection logic, providers, and the SQLite store are unchanged. The new piece is an exporter inside the daemon that, when export.target is set, periodically POSTs the latest UsageSnapshot batch to a remote hub. The hub holds the latest snapshot per machine in memory and exposes:

EndpointMethodAuth required
/v1/pushPOSTBearer (if a token is configured)
/v1/snapshotsGETBearer (if a token is configured)
/healthzGETnever (liveness probe)

openusage hub provides a built-in TUI for the aggregated view. openusage hub-view <url> is a read-only client suitable for a laptop that doesn't need its own daemon.

Step 1: choose where the hub runs

Pick one host that all workers can reach. Common picks:

  • A home-lab box or always-on workstation on the same LAN
  • A small VPS, exposed via Tailscale / WireGuard / Cloudflare Tunnel
  • A docker host running the hub container

Step 2: pick an auth posture

Scenarioauth tokenbind
Trusted home LAN, just for personal visibilitynone127.0.0.1:9190 (loopback only)
Trusted LAN, accessed from another LAN machinenone, --allow-public:9190
Reachable over Tailscale / WireGuard / VPNOPENUSAGE_HUB_TOKEN set:9190
Public internetOPENUSAGE_HUB_TOKEN set:9190 + TLS terminator in front

Recommendation: always set a token if anything other than this machine can reach the port. Once set, every push or snapshot fetch must include Authorization: Bearer <token>.

:::warning Tokens are never written to settings.json The token lives in your shell environment (OPENUSAGE_HUB_TOKEN), not in ~/.config/openusage/settings.json. This matches the accounts[].api_key_env convention: configs reference secrets by env-var name; secrets themselves never sit on disk. :::

Step 3: start the hub

As a normal process

# Trusted LAN, no auth
openusage hub --listen :9190 --allow-public

# With Bearer auth, headless (for systemd / launchd / containers)
export OPENUSAGE_HUB_TOKEN=$(openssl rand -hex 32)
openusage hub --headless

The unsafe-default guard refuses to bind a non-loopback interface when no auth is configured. You'll see one of three remediations in the startup error:

hub: refusing to listen on ":9190" without auth_token.
Choose one:
1. export OPENUSAGE_HUB_TOKEN=<secret> to enable Bearer auth, OR
2. bind to loopback only: --listen 127.0.0.1:9190, OR
3. pass --allow-public if you have a network-level firewall in place

In Docker

A Dockerfile.hub is included at the repo root. Build it locally:

docker build -f Dockerfile.hub -t openusage-hub:dev .
docker run --rm \
-e OPENUSAGE_HUB_TOKEN=$(openssl rand -hex 32) \
-p 9190:9190 \
openusage-hub:dev

The container is for the hub server only — it does not run the TUI dashboard. Expect a published image and a versioned release tag to follow in a separate PR.

Key properties of the image:

  • Non-root user (USER 65534:65534 / nobody)
  • HEALTHCHECK against /healthz
  • EXPOSE 9190
  • OCI labels (source, version, revision, created, licenses)

Step 4: enable the exporter on each worker

On every machine you want feeding the hub, edit ~/.config/openusage/settings.json:

{
"export": {
"target": "http://hub.lan:9190",
"interval_seconds": 60,
"machine_name": "work-laptop"
}
}

If the hub requires auth, OPENUSAGE_HUB_TOKEN needs to live in the daemon's environment — not just your interactive shell. How you set it depends on how you run the daemon.

# Foreground daemon
OPENUSAGE_HUB_TOKEN=<secret> openusage telemetry daemon

For a user-managed service, put the token in the service environment:

  • Linux/systemd user service — add OPENUSAGE_HUB_TOKEN="<secret>" to the unit's EnvironmentFile= or add Environment=OPENUSAGE_HUB_TOKEN=<secret> under [Service], then run systemctl --user daemon-reload && systemctl --user restart openusage-telemetry.
  • macOS/launchd — add OPENUSAGE_HUB_TOKEN to the plist's EnvironmentVariables dictionary, then reload with launchctl unload ~/Library/LaunchAgents/com.openusage.telemetryd.plist && launchctl load ~/Library/LaunchAgents/com.openusage.telemetryd.plist.

The exporter pushes immediately on startup and then every interval_seconds. Best-effort: errors are logged and swallowed; the daemon never stops over an exporter failure.

Step 5: view the aggregated dashboard

From a machine with a daemon

openusage hub --listen 127.0.0.1:9190

Same TUI as the local dashboard. Each provider tile is keyed by machine:providerID:accountID so two machines running the same provider don't collide.

From a laptop with no daemon

openusage hub-view http://hub.lan:9190
OPENUSAGE_HUB_TOKEN=s3cret openusage hub-view https://openusage.example.com

hub-view polls GET /v1/snapshots on the ui.refresh_interval_seconds cadence (override with --interval). The status line shows hub <url> · N machine snapshots and flips to an error if the hub becomes unreachable.

Operational notes

  • Stale eviction. A machine entry is pruned after hub.stale_timeout_seconds (default 300s). Stop a worker and within 5 min its tiles disappear from the aggregated view.
  • Snapshot is the latest, not a stream. The hub holds only the newest batch per machine. If you want historical aggregates, query each daemon's SQLite separately.
  • /healthz is unauthenticated by design. Liveness probes work without secrets; the response only lists machine names — no snapshot data. On a trusted LAN this is fine, but on an internet-facing hub the machine list leaks topology. If you care, bind the hub loopback-only and reverse-proxy /v1/push and /v1/snapshots via your existing TLS terminator, or firewall /healthz at the network layer.
  • Bind address matters. 127.0.0.1:9190 is loopback-only and safe even without auth; :9190 or 0.0.0.0:9190 is all-interfaces and requires either auth or --allow-public.

See also