Monitoring
This page covers what to watch on a running server: the numbers the System page shows and what each of them actually means, the HTTP endpoints an external monitoring system should poll, and a table of what is worth waking someone for.
ETTIX measures the machine itself by reading /proc,
/sys and the filesystems directly. There is no Prometheus
exporter, no agent to install and no third-party metrics library in the
binary. Everything below is available from the administration interface and
from two JSON endpoints, which is enough to drive a dashboard or an alerting
system of your own.
Unavailable is never reported as zero. A container without
/proc/diskstats, a kernel without an interface, a path that is not
mounted: each is reported as not measurable, with the file that could
not be read and the reason. Zero is a measurement. An operator who cannot tell
the two apart will eventually act on a number that was never taken, so ETTIX
refuses to make that confusion possible.
The System page
Open the administration interface and choose System in the
navigation. On a default installation that is
http://127.0.0.1:8081/#/system. The page is built from two
endpoints, GET /api/v1/system for the machine and this process,
and GET /api/v1/disks for the volumes ETTIX writes to.
The collector samples every two seconds. Percentages and per-second figures
are the difference between two samples, so for the first interval after
start-up there is nothing to divide and the page says so rather than drawing a
row of zeroes. In the JSON that state is rates_available: false,
and interval_seconds is the period the rates were averaged
over.
CPU
The first meter is total CPU across all cores over the last interval, and underneath it is the split that explains where the time went.
| Field | What it is |
|---|---|
user_percent | Time running application code — ETTIX and everything else on the machine. On a healthy media server this is most of the busy time. |
system_percent | Time in the kernel on this process's behalf, including hardware and software interrupt handling. Network-heavy servers spend real time here; it is not waste. |
iowait_percent | Time the cores were idle with at least one disk request outstanding — the CPU had nothing to do because storage had not answered yet. |
steal_percent | Time the hypervisor gave to another guest on the same physical host: this virtual machine was ready to run and was not allowed to. |
Both of the last two are routinely misread, and in opposite directions. High iowait is not CPU load — the processor is idle. It is a storage symptom, and on a recording server it usually means the DVR volume, not the encoder. Steal is not your fault and you cannot tune it away — the machine is slow because a neighbour is busy or the instance is oversubscribed, and the fix is a different instance or a different host, not a configuration change here. Sustained steal above a few per cent on a VM is the first thing to look at when the server feels slow while every other number says idle.
The per-core panel below the CPU meter shows each logical CPU separately. It is worth a glance, because a single thread pinned at 100 % on one core of sixteen is a 6 % average — a bottleneck that is invisible in the total.
Load average is the 1, 5 and 15 minute figures from the
kernel, and the bar beside them is the 1-minute load as a percentage of the
core count. That comparison is the whole point: a load average of 8 is
comfortable on a 16-core machine and severe on a 4-core one, so the number is
meaningless until it is divided by cores. One unit of load per
core means the machine is fully committed with nothing queued; above that,
work is waiting.
Inside a container with a cgroup v2 CPU quota, the page adds a line naming
the limit in cores, the raw cpu.max quota and period, and how much
of the quota is being used. A process approaching 100 % of its quota is
being throttled even though the host's CPU meter looks calm.
Memory
The Used meter is deliberately not "total minus free". It is
MemTotal minus MemAvailable, which means
reclaimable page cache is excluded from used. Linux fills
otherwise idle memory with cached file contents; on a server writing DVR
segments that cache can be most of the machine's RAM. Counting it as used would
show 95 % memory pressure on a machine under no pressure at all, and would
train you to ignore the number that matters. The cached_bytes
figure is shown separately, as information rather than as consumption.
Swap is shown only when the machine has any. On a live media server, swap in use is a warning rather than a statistic: a page fault that has to reach the disk in the middle of a segment write is a delay measured in milliseconds, which viewers see. The interface starts colouring swap at 10 % used for that reason, far earlier than it colours anything else.
In a container, the totals are the cgroup's, not the host's, and
source reads cgroup rather than meminfo.
Reporting the host's 256 GB inside a 2 GB container would look healthy
right up to the moment the kernel kills the process, so the limit wins and the
host's figures are shown beside it as a separate line. Used inside a cgroup is
memory.current minus the cgroup's own file cache, for the same
reclaimability reason.
Storage and disk I/O
This panel has one row per volume ETTIX writes to — every configured DVR destination path, merged so that two streams recording to the same path are one volume with two destinations rather than two volumes each claiming the whole disk. Each row shows free space against capacity, a fill bar, the block device behind the path, what that device is reading and writing per second, its I/O busy percentage, and the rate this server's own recorders are writing there.
Two details are worth knowing:
- Capacity for a DVR destination comes from that destination's own storage backend — the same measurement the retention rule uses when it decides whether to delete old recordings. It is not a second, independent check. A disagreement between "the API says 40 GB free" and "retention thinks it is out of space" is not a thing that can happen here.
recorded_bytesis what this server's recordings occupy, as the DVR index counts them. It is not capacity minus free space: other things live on the disk too, and the gap between the two is a useful sanity check when a volume fills up unexpectedly.
For an extra path that is not a DVR destination, free space is the unprivileged figure — the space an ordinary process can actually use. The blocks a filesystem holds back as a root-only reserve are excluded, because a recording cannot be written into them.
Block devices
The Block devices table is the raw per-device view from
/proc/diskstats: bytes read and written per second, completed
reads and writes per second, requests currently in flight, and I/O busy.
Partitions are listed and marked as such; the totals count whole disks only,
since adding a partition to the disk that contains it would count every byte
twice. RAM disks and loop devices are excluded because they are noise on every
dashboard.
busy_percent is the fraction of wall-clock time the
device had at least one request in flight. It is exactly the figure
iostat prints as %util, and it is the number that
tells you a DVR disk is saturated before writes start failing. A single spinning
disk sitting near 100 % is doing all it can, and any further recording load
will queue.
On a device that serves several requests at once — an NVMe drive, a RAID
volume, a SAN LUN — 100 % busy means "never idle", not "at capacity". Read
it alongside the throughput columns and the DVR destinations'
max_write_latency_ms rather than on its own.
Network
One row per interface, in and out, in bits per second rather than bytes, because that is the unit a link is sold in and the unit an encoder is configured in. Errors and dropped counters are the interface's own lifetime totals; what matters is whether they move, not what they are. The totals at the top exclude loopback: traffic from this process to itself would otherwise be counted twice and would make a quiet server look busy.
The ETTIX process
The last panel is this process rather than the machine.
| Row | What it tells you |
|---|---|
| Resident memory | rss_bytes: the memory the kernel's OOM killer and any cgroup limit actually count. This is the figure to compare against a container limit, not heap. |
| CPU | cpu_percent, where 100 means one core fully used — the convention top uses. On an 8-core machine, 400 is half the machine. |
| Goroutines | Concurrent tasks inside the server. It rises and falls with viewers and sources. A count that climbs steadily and never comes back down after streams end is a leak worth reporting. |
| OS threads, Open files | Thread count and open descriptors. Descriptors climbing towards the process limit will eventually refuse connections. |
| Heap in use | Live Go heap. Always smaller than resident memory; a large gap between the two is normal. |
| GC cycles | Lifetime garbage collections. Only the rate of change is interesting. |
| GC pause p50 / p99 / max | Stop-the-world pauses during the last sampling interval only, not since start-up. A percentile computed over the whole life of the process would be an hour old and would say nothing about a glitch a viewer saw a moment ago. These are normally well under a millisecond. |
| Scheduler latency p99 | How long a task waited for a turn on the CPU. This rises before anything else does when the machine is oversubscribed, and it is the most useful early warning on the page. |
Below these, when anything could not be measured, the page lists the source,
the file and the reason. The same condition is written to the log once, as
SYSSTAT_SOURCE_UNAVAILABLE, when it first appears and once again
when it clears — not every two seconds for the life of the process.
Endpoints for external monitoring
/health and /ready, on the playback listener
These two live on the playback listener — port 8080 by default — and neither needs a credential, so a load balancer can reach them without being given one. The difference between them is the part that matters.
| Endpoint | Answers | Meaning |
|---|---|---|
GET /health | always 200 while the process is running | The process is alive. It stays 200 even when the licence is restricted, because an operator has to be able to reach the administration interface of a server that has stopped serving. A server that failed its own liveness check when its licence lapsed would be restarted or destroyed by the platform, and the person trying to fix the licence would be chasing a moving target. |
GET /ready | 200 when ready, 503 with a JSON error when not | This server should receive viewers. It goes not-ready when the licence is restricted, so a pool drains this node instead of sending viewers to a server that will refuse every playlist. |
Health-check on /health; route on /ready.
Using /health as the load balancer's pool check is the common
mistake, and it sends viewers to a server that answers 503 to every playlist
request. Using /ready as the liveness or restart check is the
opposite mistake: it will restart a perfectly healthy process whose licence
needs attention.
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/health
curl -s http://127.0.0.1:8080/ready
Both endpoints are also present on the administration listener, which is convenient for a local check but is not where a load balancer should look.
The administration API
Everything else is on the administration listener, which
defaults to 127.0.0.1:8081 and is loopback-only unless
http.admin_allow_remote is set. All of these require a credential
from http.admin_auth once one is configured — on the loopback
default, where a fresh installation has none, they are open — presented either
as an API key in an Authorization header or as a browser session
cookie. All are read-only and all answer GET.
| Route | Carries |
|---|---|
/api/v1/status | Version, uptime, hostname, stream counts by state, total viewers, HLS cache statistics, per-listener HTTP counters (requests, bytes out, 4xx, 5xx, open and rejected connections) and a short Go runtime summary. |
/api/v1/streams | Per stream: state, bitrate, video and audio codecs, HLS output counters, and each configured source with its SRT statistics and MPEG-TS demultiplexer counters. |
/api/v1/system | The whole System page as JSON: CPU, memory, network, block devices, this process, and the list of anything not measurable. |
/api/v1/disks | Per volume: capacity, free space, used percentage, backing device and its throughput, and every DVR destination writing there with its queue depth, segments written and dropped, write errors and write latency. |
/api/v1/license | state, restricted, offline_seconds_remaining, lease_expires, failures, clock_rollback and the installation id. |
/api/v1/certificates | Issuer, subject, hostnames, not_after, days_remaining, expiring_soon, last_error, and the ACME renewal state when ACME is in use. |
/api/v1/events | The operational event ring, filterable by level, stream, code, component and since, with limit up to 1000 (200 by default). |
/api/v1/events/stream | The same events as they happen, as Server-Sent Events. A new subscriber receives a snapshot first, then deltas; a client that falls too far behind is disconnected and told to resubscribe. |
curl -s -H "Authorization: Bearer <id>.<secret>" \
http://127.0.0.1:8081/api/v1/system
curl -s -H "Authorization: Bearer <id>.<secret>" \
"http://127.0.0.1:8081/api/v1/events?level=warn&since=-15m&limit=100"
/api/v1/license and the routes beside it are
never refused because of the licence they report on. An
installation whose lease has lapsed has to stay diagnosable remotely, or fixing
it would require someone standing next to the machine.
Wiring up an external check
Point the load balancer at /ready
Pool membership follows /ready on the playback listener. Every
non-200 answer should drain the node, not restart it.
Point the platform's liveness check at /health
Under systemd, Kubernetes or a process supervisor, restart decisions belong
to /health alone.
Give the monitoring system its own credential
Create a separate administration API key for polling rather than sharing an
operator's. It can then be revoked without locking anyone out. If the
monitoring system is not on this machine, the administration listener has to be
reachable from it, which means binding it off loopback and accepting the
exposure with http.admin_allow_remote — configure
http.admin_auth first, and see the
Security page before you do.
Poll, and compare against the previous poll
Most of the interesting values are counters, not gauges. Alert on their rate
of change rather than their absolute value: connect_failures at
40 means nothing, connect_failures rising by 40 in five minutes
means a source is flapping.
What to alert on
The thresholds below are the ones the administration interface itself colours at, which makes an alert and the screen an operator opens next agree with each other. Where a sensible universal threshold does not exist, the column says what to look for instead.
The machine
| Signal | Where | Alert when |
|---|---|---|
cpu.percent | /api/v1/system | Sustained above 85 % (the interface warns at 70 %). Check the per-core figures before acting: one saturated core is a different problem from sixteen busy ones. |
cpu.iowait_percent | same | Sustained above roughly 10 % on a recording server. Treat it as a storage alert and go straight to the block devices. |
cpu.steal_percent | same | Sustained above 5 %. The remedy is a different host or instance size, not a change here. |
cpu.load_average[0] ÷ cpu.cores | same | Above 1.0 sustained. Below 0.7 is comfortable. |
memory.used_percent | same | Above 92 % (warn at 80 %). Remember that cache is already excluded, so this figure is real pressure. |
memory.swap_used_bytes | same | Above 10 % of swap total. On a live server any sustained swapping deserves attention. |
disk.devices[].busy_percent | same | Above 90 % sustained on a device carrying DVR writes (warn at 70 %). |
| interface errors and dropped | same | Any increase. On a clean link these do not move at all. |
unavailable is non-empty | same | Anything appears that was measurable before — a metric source has gone away and the dashboard is now blind to it. |
process.goroutines | same | Rising monotonically over hours while viewer and stream counts are flat. |
process.sched_latency_p99_ms | same | Rising while CPU is not — the machine is oversubscribed. No fixed threshold; alert on the trend against its own baseline. |
process.gc_pause_p99_ms | same | Above a few milliseconds sustained. These are last-interval figures, so a spike is current, not historical. |
Streams and storage
| Signal | Where | Alert when |
|---|---|---|
state | /api/v1/streams | Not live on a stream that should be. |
bitrate_bps | same | Far below what the encoder is configured to send. That is usually loss on the path, not ETTIX. |
srt.packets_dropped | same | Rising at all. Retransmissions are arriving too late for the configured latency budget; raise latency on the source. |
srt.loss_rate_percent | same | Sustained above a few per cent. |
srt.packets_undecrypted | same | Any non-zero value: the passphrase matches on one side only. |
demux.cc_errors, demux.discontinuities | same | Anything but zero on a healthy link. The source is producing a broken transport stream, and nothing on this server will repair it. |
connect_failures, activations | same, per source | Climbing: a source is flapping between connected and disconnected. |
used_percent | /api/v1/disks | Above 92 % (warn at 80 %). Retention deletes old recordings to stay under its own free-space rule, so a volume that keeps climbing means retention cannot keep up with what is being written. |
destination state | same | Anything other than active or idle. degraded, full and failed each mean recording is impaired; the live stream and other destinations are unaffected. |
segments_dropped, write_errors | same | Any increase. A dropped segment is a hole in the recording that will not be filled in later. |
max_write_latency_ms | same | Growing over time on a destination that used to be fast — the disk is degrading before it fails. |
hls_cache.over_budget | /api/v1/status | True: live playlist windows alone exceed the configured cache budget. |
http.playback.errors_5xx | same | Rising. Compare against requests to get a rate rather than a count. |
Licence and certificates
| Signal | Where | Alert when |
|---|---|---|
restricted | /api/v1/license | True. Ingest and playback are being refused. Recorded media is untouched. |
offline_seconds_remaining | same | Below 86400 (one day). At zero the server stops serving. |
failures | same | Climbing: the licence authority cannot be reached. This is a network alert long before it is a licensing one. |
clock_rollback | same | True: the system clock moved backwards and licence timing can no longer be trusted. Fix NTP. Nothing is deleted and nothing stops because of it. |
days_remaining | /api/v1/certificates | Below 14. expiring_soon turns true earlier — as soon as the certificate enters its renewal window, 30 days before expiry by default — which means renewal should be under way, not that anything has failed yet. |
last_error | same | Non-empty, at either the top level or inside acme. |
Events worth paging on
The event ring carries a code on the entries that matter, which
makes /api/v1/events?code=… a precise alert source. These mean a
customer is already affected or is about to be:
| Code | Meaning |
|---|---|
LICENSE_EXPIRED, LICENSE_RESTRICTED, LICENSE_REVOKED | Ingest and playback are refused. Recorded media is untouched. |
LICENSE_OFFLINE | The authority is unreachable. Still serving, on a clock. |
CLOCK_ROLLBACK | The system clock moved backwards. |
HLS_ENCRYPTION_UNLICENSED | A stream configured to encrypt is being served in the clear. |
PLAYBACK_UNAUTHENTICATED | Playlists, segments, content keys and DVR are being served to anyone who can reach the listener. |
INGEST_UNAUTHENTICATED | Any publisher reaching the RTMP listener is accepted. |
ADMIN_API_REMOTE | The administration listener is not on loopback. |
EXTERNAL_AUTH_BREAKER_OPEN, VIEWER_AUTH_UNAVAILABLE, PUBLISHER_AUTH_UNAVAILABLE | An authorization decision could not be made. |
HLS_CACHE_OVER_BUDGET | Live playlist windows alone exceed the cache budget. |
SYSSTAT_SOURCE_UNAVAILABLE | A system metric can no longer be measured. Logged once when it starts, once when it clears. |
TELEMETRY_OFFLINE | Usage is not reaching Ettix.com. It is buffered, not lost. |
Logs
The event ring is bounded and lives in memory, so it is the right place to look during an incident and the wrong place to look for last Tuesday. For that, read the service log.
journalctl -u ettix-stream -f
journalctl -u ettix-stream -p warning --since -1h
Setting log.format to "json" produces one object
per line, which is what a log shipper wants. The log formatter and the event
ring share the same redaction function rather than a copy of it, so stream keys,
SRT passphrases and administration passwords cannot reach one sink by escaping
the other.
The configuration file is not redacted, because the server
has to read those secrets in the clear. API responses and logs are safe to send
to support; /etc/ettix-stream/config.json is not.
What is not here
There is no Prometheus /metrics endpoint, no OpenTelemetry
exporter and no StatsD output in this build. An external monitoring system
consumes the JSON endpoints above, or subscribes to
/api/v1/events/stream. Historical retention is likewise outside the
server: the System page shows the present, and the event ring holds a bounded
recent window. If you need trends over weeks, poll /api/v1/system
into your own time-series store.
When something is wrong rather than merely worth watching, continue to Troubleshooting.