Wheelhouse docs

The telemetry stream#

GET /api/stream upgrades to a WebSocket and sends one JSON text frame every two seconds carrying the router's interface counters, read fresh each tick. It is the agent's only push surface. The web UI holds one open on every page, and the router chip in its top bar shows whether it is connected: green while it is, amber while it is not. An unreadable router arrives as an error frame rather than as silence.

RouteGET /api/stream
Roleviewer
Licencenot needed
Interval2 s
Read limit4096 bytes per inbound frame
Body limitnone; inbound frames are bounded by the read limit above

Connecting#

From a browser, same origin, with the session cookie:

javascript
const ws = new WebSocket(`wss://${location.host}/api/stream`)
ws.onmessage = (e) => console.log(JSON.parse(e.data))

From a script, with a token. This is the one route where a token may travel in the query string, because the browser WebSocket API cannot set headers:

bash
websocat "wss://192.0.2.1:8443/api/stream?token=wh_…" --insecure

On every other route a ?token= parameter is refused with a message saying so. A non-browser client that can set headers should still use Authorization: Bearer wh_….

Same-origin enforcement#

The upgrade is refused unless the Origin header's host equals the request's host, case insensitively. A request with no Origin header is allowed, because that is what a non-browser client (curl, a Go program, CLI tooling) looks like.

A cross-origin upgrade attempt gets a failed handshake, not a frame, so a page in another tab cannot open a stream with the browser's session cookie.

Frame types#

Two, and only two. Every frame carries ts, the agent's clock in Unix milliseconds.

interface_stats#

json
{
  "ts": 1788000000123,
  "type": "interface_stats",
  "data": { "…": "the router's own `show interfaces` data, undigested" }
}

data is whatever the router's API returned for show interfaces — the agent does not reshape it. Parse it the way the router prints it.

error#

json
{
  "ts": 1788000000123,
  "type": "error",
  "error": "POST /show: dial tcp 127.0.0.1:443: connect: connection refused"
}

A failed sample arrives as an error frame naming the cause, not as a frame with null data. The message distinguishes the two cases: a transport failure (the router is gone) and the router's own error text (it answered, and said no).

The stream does not close on an error. It keeps ticking, and recovers to interface_stats frames on the first sample that succeeds.

Freshness#

Each tick reads the router uncached. Every other operational read goes through a three-second read cache served stale-while-revalidating; the stream bypasses it, and so does the throughput sampler, whose rate arithmetic needs new counters each time.

Each tick costs the router a fork of cli-shell-api. Each open tab of the web UI, on any page, is one extra router read every two seconds. Close the tab when you are not watching it.

When the stream stops#

The handler exits, and the connection closes, on any of:

  • the client closing the socket, or the connection dying, which a read pump alongside the writer notices between samples;
  • the request context being cancelled;
  • a failed write.

How it is observed#

An upgraded connection has no HTTP status to record, so the stream is counted with a synthetic status of 101:

wheelhouse_http_requests_total{method="GET",route="/api/stream",status="101"}

The duration histogram for that route therefore measures the lifetime of the connection, not a request latency. A bucket full of ten-second-plus samples on /api/stream is a browser that stayed open, not a slow endpoint.

What it is not#

See also#

Updated 2026-09-18 api websocket telemetry