Monitoring
The health, stats and status endpoints, the relay's metrics, canaries, and what to alert on.
Four places say how the service is doing: the control API's endpoints, the relay's metrics port, the relay's admin port, and the logs. On the managed service you use the first only; on your own hosts you have all four.
The control API
| Call | Key | Answers |
|---|---|---|
GET /v1/health | none | 200 {"ok":true} while the process is up. It checks nothing else: not the relay, not the gateway |
GET /v1/status | none | the fleet: overall state, regions, relays, recent incidents (Fleet). Without a fleet file: {"state":"up","regions":[],"incidents":[]} |
GET /status | none | the same as a page |
GET /v1/streams/{id}/stats | sk_ | a stream's seats now |
GET /v1/tenant | sk_ | your account: limits, cap, this month's totals |
GET /v1/usage | sk_ | viewer-hours and bytes by day and stream (Usage) |
GET /v1/fleet | ok_ | every node in detail, viewer_relays, the recent alerts |
Stream stats
GET /v1/streams/{id}/stats
is for a dashboard polling every few seconds. The player never calls it.
curl -s $API/v1/streams/$ID/stats -H "Authorization: Bearer $SK"Illustrative example. The values below are made up to show the shape; they are not a measurement.
{ "viewers": { "seats": 1, "queued": 0, "cap": 100, "queue_max": 200, "peak": 1, "hls": 0 },
"seats_with_tokens": 1, "turns": 0, "ingest_token_at": "2026-11-01T12:00:03Z",
"ingest": { "live": true, "since": "2026-11-01T12:00:02Z", "width": 1920, "height": 1080,
"fps": 60, "kbps": 5980, "reconnects": 0 } }| Field | Means |
|---|---|
viewers.seats | seats taken now |
viewers.queued | people in the waiting line |
viewers.cap, viewers.queue_max | the stream's limits |
viewers.peak | the most seats at once |
viewers.hls | seats reading the LL-HLS fallback |
seats_with_tokens | seats that hold a live token |
turns | live controller turns |
ingest_token_at | when the gateway last took a publisher token for this stream (UTC), or null. It stays set after the encoder stops: use ingest.live |
ingest.live | video is arriving: an encoder session is open on the gateway and a frame came in within the last 5 s. null: the API cannot tell (an SDK-only stream, or no gateway report for 15 s) |
ingest.since | while live, when this encoder session began; otherwise when the last frame came in |
ingest.width, height, fps | from the encoder's SPS (fps only when the encoder sends a fixed rate) |
ingest.kbps | while live, the encoder's media bitrate over the last 3 s, without container overhead |
ingest.reconnects | encoder sessions after the first since the gateway started |
Reading it also frees seats whose heartbeat stopped, so a stale count corrects itself.
The relay's metrics
Start the relay with --metrics-addr 127.0.0.1:9090 (the package's unit
does). It then serves Prometheus text at /metrics (on any path, with no
key; keep it on loopback or a private network and scrape it from there).
Counters appear once they have fired.
| Metric | Type | Means |
|---|---|---|
moq_relay_active_connections | gauge | client connections now |
moq_relay_active_publishers | gauge | publishers now |
moq_relay_active_subscriptions, moq_relay_active_tracks | gauge | subscriptions and tracks being served |
moq_relay_announced_namespaces | gauge | registered namespaces |
moq_relay_seat_sessions | gauge | live token sessions |
moq_relay_connections_total, _connections_closed_total | counter | connections accepted, closed |
moq_relay_publishers_total, _subscribers_total | counter | publish and subscribe requests received |
moq_relay_subscribe_latency_seconds | histogram | time to resolve a subscription, by source |
moq_relay_clock_synchronized | gauge | 1 when the kernel says the clock is synchronized. At 0 every token is refused |
moq_relay_clock_unsynced_refusals_total | counter | requests refused for that |
moq_relay_token_issued_in_future_total | counter | tokens refused because their issuer's clock is ahead of the relay's by more than the skew |
moq_relay_auth_errors_total{stage} | counter | authorization failures |
moq_relay_seat_refused_total{reason} | counter | sessions refused by the seat gate: scope_full (the account's session limit), token_id_in_use (a second session on one token), token_closed |
moq_relay_seat_expired_total | counter | sessions closed at their token's deadline |
moq_relay_seat_admin_closed_total | counter | sessions closed through the admin port (kicks, lost seats) |
moq_relay_admin_denied_total | counter | admin port calls without the right key |
moq_relay_seat_log_errors_total | counter | session-log writes that failed: usage is being lost |
moq_relay_setup_refused_total{limit} | counter | connections refused before their handshake for the per-address or global rate (per_ip, global) |
moq_relay_admission_busy | gauge | busy share (0 to 1) of the runtime serving viewers |
moq_relay_admission_delay_ms | gauge | timer lateness, ms |
moq_relay_admission_saturated | gauge | 1 while the relay refuses new viewers |
moq_relay_admission_sessions | gauge | sessions admitted under the admission rule |
moq_relay_admission_admitted_total, _refused_total{why} | counter | admitted, and refused (saturated, paced) |
Watch moq_relay_admission_busy to see an edge approach its limit: it is
the relay's own headroom, and 0.85 is where it starts to refuse.
The relay's admin port
--admin-addr 127.0.0.1:4480 --admin-key-file ... (loopback only). With the
key as a bearer, GET /usage:
Illustrative example. The values below are made up to show the shape; they are not a measurement.
{ "scopes": { "/tn_...": { "sessions": 1, "publishers": 1, "egress_bps": 1702,
"bytes_out_closed": 0, "sessions_closed": 0, "bytes_out_live": 7055 } },
"admission": { "mode": "on", "saturated": false, "busy": 0.003, "delay_ms": 1.9,
"sessions": 0, "budget": 50, "admitted": 0, "returned": 0, "places": 0,
"refused_saturated": 0, "refused_paced": 0, "episodes": 0, "changed_at_s": 0,
"enter_busy": 0.85, "leave_busy": 0.7, "retry_ms": 2000 } }Per scope: live sessions and publishers, the send rate, and the bytes sent by
closed and by live sessions since the relay started. It is the number to
check usage against: bytes_out_closed on each relay should add up to the
bytes the API's usage shows for the same period.
Without the key the port answers 401. It also serves the node agent's
heartbeat (Fleet).
The session log (--session-log) holds one JSON line per closed token
session: scope, namespace, token_id, sub, role, start_ms,
end_ms, bytes_out, token_exp and close (closed, expired,
admin).
Logs
Services log to stdout, so systemd keeps them (journalctl -u nearglass-relay). The control API logs at info; set RUST_LOG (for example
nearglass_control_api=debug) to change it. Lines worth searching for:
| Line | Service | Means |
|---|---|---|
session authorization failed, closing code=2 reason="unauthorized" | relay | a token was refused; the line does not say why (Troubleshooting) |
system clock is not synchronized: token scopes refuse every token until it is | relay | fix time sync |
refusing a token issued in the future | relay | the relay's clock is behind its issuer's |
session log write failed | relay | usage records are being lost |
admin port failed; exiting | relay | the admin address is taken |
alert event=... | API | an alert was raised; alert delivered or alert webhook ... attempt=N follows |
no relay admin port: a lost seat's open sessions run until the relay's watch grace ends | API | NEARGLASS_API_RELAY_ADMIN_* is not set |
control API unreachable for 120 s: every stream key dropped | gateway | encoders are being refused until the API answers |
shipped records=N | nearglass-meter | usage reached the API |
Canaries
A canary is a viewer that watches a stream all the time, so you know it works before a customer does. The repository has two, and both are our own tooling for our demo, not part of the packages:
tools/canaryruns a headless browser against a page with the player for a window every hour, and records frames on screen, how far behind live the picture is (median and 99th percentile), reconnects and held pictures every second. It raises an alarm when a window's median is under 50 frames a second or it reconnects more than twice in 10 minutes.tools/audio-canaryis a native listener that subscribes to the audio track the way the player does, plays it into a null device, and reports every dropout and its cause every 10 s.
To canary your own deployment, publish a looping file to a stream of its
own (nearglass publish --file ... --loop, with its own key), and watch it with a
real browser on another network, on a schedule. Read the player's
statechange events and stats()
(Embed the player), and alert on: no live within
a few seconds of load, a mode of ll-hls where you expect moq, or
reconnects.
What to alert on
| Alert on | Why | Where it shows |
|---|---|---|
GET /v1/health not 200 | the API is down: nothing can be seated or tokened | an external probe, every 30 s |
GET /v1/status state not up, or a relay.down event | a relay stopped answering | status, the operator channel |
moq_relay_clock_synchronized is 0 | every token is refused; viewers see "Cannot play" | relay metrics |
moq_relay_admission_saturated is 1 for more than a few minutes, or edge.saturated | the edge is full; new viewers go elsewhere or wait | relay metrics, operator channel |
moq_relay_seat_log_errors_total rises | usage and spend are undercounted | relay metrics |
shipped records stops appearing in the agent's log | the same, from the other side | agent log |
publisher.gone (or moq_relay_active_publishers is 0 while a stream should be live) | the encoder dropped | webhook, relay metrics |
viewers.queued above 0 for long, or seats near cap | the cap is too low for the audience | /stats |
percent_of_cap at 80 or more, or spend.threshold | new viewers will be refused at 100 % | GET /v1/tenant, webhook |
moq_relay_setup_refused_total rises | a connection flood, or the rate limit is too low for your audience behind one address | relay metrics |
moq_relay_auth_errors_total jumps | a deploy changed a key, issuer or audience, or clocks drifted | relay metrics |
the gateway logs every stream key dropped | the gateway cannot reach the API | gateway log |
What was run, and what was not
Run on one host from the packages: GET /v1/health, /v1/status, /status,
/v1/fleet, /v1/tenant, /v1/usage and the stream stats (including
seats, a full stream and the line), the relay's /metrics with its metric
names and the admin GET /usage, with and without the key, and the session
log.