Prometheus metrics#
GET /metrics serves a hand-rolled Prometheus text exposition — no client library, so
the agent stays one dependency-light binary. It needs the viewer role unless the agent
was started with --metrics-public. The content type is
text/plain; version=0.0.4; charset=utf-8, every series carries HELP and TYPE, and
the output is sorted so a diff between two scrapes is readable.
curl -sk "$R/metrics" -H "Authorization: Bearer $T"Re-derive the list of series names for the version you are running:
grep -o 'wheelhouse_[a-z_]*' agent/metrics.go | sort -uEvery series#
Agent identity#
| Series | Type | Labels | Notes |
|---|---|---|---|
wheelhouse_up | gauge | — | Always 1 when the scrape succeeded. Its value is not the interesting part; its absence is. |
wheelhouse_build_info | gauge | version | Always 1. The version is the agent's compiled-in version string. |
wheelhouse_uptime_seconds | gauge | — | Seconds since the metrics registry was created, which is process start. |
HTTP#
| Series | Type | Labels |
|---|---|---|
wheelhouse_http_requests_total | counter | method, route, status |
wheelhouse_http_request_duration_seconds | histogram | method, route |
Both are recorded before the request is authenticated, so an anonymous 401 is
counted. status="101" is the telemetry WebSocket, and for that route the duration is
the lifetime of the connection rather than a request latency.
The router#
| Series | Type | Labels | Notes |
|---|---|---|---|
wheelhouse_vyos_calls_total | counter | — | Every call the agent made to the router's API. |
wheelhouse_vyos_failures_total | counter | — | Of those, the ones that errored — either a transport failure or an error field in the router's answer. |
wheelhouse_vyos_request_duration_seconds | histogram | endpoint | Round-trip time by router API endpoint: /retrieve, /show, /configure, /config-file, /container-image, /image, /traceroute, /reboot, /poweroff. |
wheelhouse_router_reachable | gauge | — | 1 when the most recent call to the router succeeded. Not "a call was tried recently" — which is why the next series comes with it. |
wheelhouse_router_last_success_seconds | gauge | — | Seconds since the last successful router call. Absent until the agent has succeeded once. |
A monotonic failure counter cannot tell an alert that the router is down now rather
than having had a blip an hour ago. wheelhouse_router_reachable and
wheelhouse_router_last_success_seconds can.
The read cache#
| Series | Type | Labels | Notes |
|---|---|---|---|
wheelhouse_cache_reads_total | counter | result = hit | stale | miss | A stale read was served immediately from the cache and triggered one background refresh. |
wheelhouse_cache_primes_total | counter | — | Warm-up passes run by the primer. |
wheelhouse_cache_prime_reads_total | counter | — | Reads a warm-up pass asked for. The per-interface and per-app fan-out at the end of a full pass is not counted. |
wheelhouse_cache_prime_seconds_total | counter | — | Time spent in warm-up passes. |
A cache that has quietly stopped hitting is the difference between a fast appliance and
a slow one, and there was no way to see it before these existed. Every router read the
cache does not serve forks cli-shell-api on the router.
Configuration state#
| Series | Type | Labels | Notes |
|---|---|---|---|
wheelhouse_staged_operations | gauge | — | Operations in the working set, waiting for a commit. |
wheelhouse_desired_managed | gauge | — | Always 1 when present. Absent unless --reconcile-file is set — absence means "not managing a file", which is the default. |
wheelhouse_desired_drift | gauge | — | 1 when the router differs from its desired-state file. Same absence rule. |
wheelhouse_desired_pending_ops | gauge | — | How many operations the loop wants applied. Same absence rule. |
Accounts#
| Series | Type | Notes |
|---|---|---|
wheelhouse_users | gauge | Configured accounts. |
wheelhouse_sessions_active | gauge | Live browser sessions, after expired ones are pruned. |
wheelhouse_api_tokens | gauge | Issued API tokens, expired ones included until they are deleted. |
Single sign-on#
| Series | Type | Labels | Notes |
|---|---|---|---|
wheelhouse_oidc_assertions_total | counter | outcome = ok | failure | refused | failure is the protocol breaking; refused is an identity being told no. They are separate because the second one means somebody is being turned away. |
wheelhouse_oidc_roles_total | counter | action, role | The role handed out — or refused — at each successful sign-on. |
Both series are present but empty on an agent with no identity provider configured.
Interface throughput#
| Series | Type | Labels |
|---|---|---|
wheelhouse_interface_rx_bytes_per_second | gauge | interface |
wheelhouse_interface_tx_bytes_per_second | gauge | interface |
These come from the sampler, not from the telemetry stream: one counter read every five
seconds for every interface at once, with the rate derived from the difference between
successive reads. A counter that goes backwards — an interface reset — reports 0 for
that step rather than a negative rate. An interface the sampler has seen only once has a
rate of 0 until its second sample.
Histogram buckets#
Both histograms share one bucket layout, in seconds:
0.005 0.01 0.025 0.05 0.1 0.25 0.5 1 2.5 5 10 +InfEach exports _bucket (cumulative), _sum and _count. There is no configuration for
the buckets; changing them means changing durationBuckets in
agent/metrics.go.
How the route label is built#
Request paths carry object names, and a label built from a client-supplied string is an
unbounded exposition — a fresh time series per request. routeLabel collapses them:
| Path | Label |
|---|---|
/health, /metrics | themselves |
Anything else outside /api/ | /ui |
/api/<area>/… where <area> is not one the mux registers | /api/{unknown} |
/api/fleet/<id>/… | /api/fleet/{id}/… |
/api/admin/users/<name>, /api/admin/tokens/<id>, /api/auth/sessions/<id>, /api/apps/logs/<name>, /api/apps/hints/<page> | fourth segment becomes {id} |
Any remaining segment from the third onwards containing . or :, or longer than 20 characters | {id} |
The /api/{unknown} rule matters because requests are observed before they are
authenticated: without it, an anonymous GET /api/<anything> minted a series.
Label escaping#
Label values escape backslash, double quote and newline, and nothing else; other control
characters become ?. Go's %q would have written \t, \x01 and \u00a0 too, which
the reference parser rejects — and since every request is observed before
authentication, one GET of a path holding a tab character broke the whole scrape for
good.
A scrape config#
scrape_configs:
- job_name: wheelhouse
scheme: https
tls_config: { insecure_skip_verify: true } # self-signed by default
authorization:
type: Bearer
credentials: wh_…
static_configs:
- targets: ['192.0.2.1:8443']Issue a viewer token for this. Prometheus needs nothing else, and a viewer token in
a scrape config is the smallest credential that works. --metrics-public removes the
need for a credential entirely; it also removes the authentication from a route that
reports your account count, your session count and your interface names, so bind
accordingly.
What is not here#
Log lines, not metrics#
Some conditions are only ever a log line — a secret file with wrong permissions, a state file that would not parse, a reconcile pass that could not read its file. Those are on Log lines worth alerting on.
See also#
- The HTTP API
- Endpoint index
- The telemetry stream — where the
status="101"series comes from. - Log lines worth alerting on
- Every flag —
--metrics-public. - Telemetry
Checked against#
agent/metrics.go (handleMetrics, durationBuckets,
promLabel, History),
agent/security.go (observe, routeLabel, apiAreas),
agent/cache.go (counts),
agent/vyos.go (done, Health, clientFor),
agent/primer.go,
agent/main.go (routes, newHistory),
agent/metrics_test.go,
docs/deploy.md "Observability".