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.
| Route | GET /api/stream |
| Role | viewer |
| Licence | not needed |
| Interval | 2 s |
| Read limit | 4096 bytes per inbound frame |
| Body limit | none — the handler hijacks the connection and frames its own reads |
Connecting#
From a browser, same origin, with the session cookie:
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:
websocat "wss://192.0.2.1:8443/api/stream?token=wh_…" --insecureOn 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#
{
"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#
{
"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#
- The HTTP API — body limits, timeouts and headers.
- Authenticating — why this route takes a token in the URL.
- Endpoint index
- Prometheus metrics — including the
status="101"series and the interface rate gauges, which come from the sampler rather than from this stream. - Telemetry — the page that consumes it.
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.