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 only push surface the agent has, it is what makes the Dashboard's counters move, and it is the Dashboard's liveness indicator — which is why 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 — the handler hijacks the connection and frames its own reads

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.

That check is what stops a malicious page in another tab from opening a telemetry stream with the browser's session cookie. A cross-origin upgrade attempt gets a failed handshake, not a frame.

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 frame with null data would look like an idle router, which is the opposite of the truth. So a failed sample arrives as an error frame naming the cause, and the panel wearing it can say the router is unreachable. The message distinguishes the two cases that matter: 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 in the agent goes through a three-second read cache served stale-while-revalidating; the stream bypasses it, because a telemetry stream that replays a cached answer is not live and the panel wearing it says "live". The throughput sampler bypasses the cache for the same reason — rate arithmetic needs genuinely new counters.

That is a real cost on the router: each tick forks cli-shell-api. One open Dashboard 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 — a read pump runs alongside the writer specifically so a peer that left is noticed between samples rather than when the kernel eventually complains;
  • the request context being cancelled;
  • a failed write.

Without that read pump, a closed laptop lid meant the agent kept asking the router for statistics nobody was watching, every two seconds, indefinitely.

How it is observed#

The stream bypasses the status recorder — the WebSocket handler needs the raw ResponseWriter to hijack the connection — so it 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 healthy dashboard, not a slow endpoint.

What it is not#

See also#

Checked against#

agent/main.go (handleStream, sameOriginWS, streamErrorText, upgrader), agent/vyos.go (ShowUncached), agent/security.go (observe, limitBodies), agent/authhttp.go (bearerToken), agent/cache.go, agent/stream_test.go, ui/src/lib/stream.tsx.

Updated 2026-09-02 api websocket telemetry