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.
| Route | GET /api/stream |
| Role | viewer |
| Licence | not needed |
| Interval | 2 s |
| Read limit | 4096 bytes per inbound frame |
| Body limit | none; inbound frames are bounded by the read limit above |
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.
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#
{
"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 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#
- The HTTP API — body limits, timeouts and headers.
- Authenticating — the token in the URL, and where it is refused.
- 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.