AP-role metrics: client telemetry replaces dead STA/campground metrics
Skyweave Test, Lint, and Deploy Ritual / Test and Lint Code (push) Has been cancelled
Skyweave Test, Lint, and Deploy Ritual / Deploy to Roof Unit (push) Has been cancelled

- metrics.lua: drop vif-sta0 station + campground-gateway sections (roof
  is a dumb AP since 2026-06-07); add skyweave_ap_* families — client
  count, per-client signal/SNR/bitrates/idle/association-time from
  iwinfo assoclist, noise floor, txpower, channel. Ping targets now
  chateau_gateway + talos_via_tunnel + internet (DNS).
- skyweave-push.sh: PUT instead of POST so departed-client series and
  retired families don't linger on the pushgateway; skip push when the
  local metrics fetch fails.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wes
2026-06-09 19:40:11 -04:00
co-authored by Claude Fable 5
parent d3d672c4f1
commit 6d0a8227a4
2 changed files with 138 additions and 77 deletions
+7 -1
View File
@@ -1,9 +1,15 @@
#!/bin/sh #!/bin/sh
# Push skyweave metrics to Prometheus pushgateway every 30 seconds. # Push skyweave metrics to Prometheus pushgateway every 30 seconds.
# PUT (not POST) so the whole group is replaced each cycle — per-client
# series for departed stations don't linger. Skips the push if the local
# metrics fetch fails, so a CGI hiccup never blanks the group.
PUSHGW="http://192.168.88.44:9091/metrics/job/rvlink/instance/rvlink" PUSHGW="http://192.168.88.44:9091/metrics/job/rvlink/instance/rvlink"
METRICS_URL="http://127.0.0.1:42069/cgi-bin/skyweave.lua/metrics" METRICS_URL="http://127.0.0.1:42069/cgi-bin/skyweave.lua/metrics"
while true; do while true; do
curl -sf "$METRICS_URL" | curl -sf --data-binary @- "$PUSHGW" >/dev/null 2>&1 BODY=$(curl -sf "$METRICS_URL")
if [ -n "$BODY" ]; then
printf '%s\n' "$BODY" | curl -sf -X PUT --data-binary @- "$PUSHGW" >/dev/null 2>&1
fi
sleep 30 sleep 30
done done
+126 -71
View File
@@ -1,6 +1,9 @@
-- skyweave/handlers/metrics.lua -- skyweave/handlers/metrics.lua
-- Prometheus-compatible metrics endpoint for the RVLink roof unit. -- Prometheus-compatible metrics endpoint for the RVLink roof unit.
-- Exposes WiFi signal quality, bitrates, active gateway, system load, memory, uptime, and ping stats. -- AP-role edition (since 2026-06-07 the roof is a dumb outdoor AP, not a
-- campground-WiFi station): exposes AP client telemetry (count, per-client
-- signal/bitrate), radio noise floor, active gateway, system load, memory,
-- uptime, and connectivity checks toward the Chateau / home tunnel / internet.
local utils = require("skyweave.utils") local utils = require("skyweave.utils")
@@ -33,16 +36,28 @@ local function emit(out, help, type_, name, labels, value)
table.insert(out, name .. label_str .. " " .. tostring(value)) table.insert(out, name .. label_str .. " " .. tostring(value))
end end
-- Parse the leading integer dBm from strings like "-65 dBm" or "-65 [-65, -66] dBm". -- Render a metric family with multiple samples: HELP/TYPE once, then all
local function parse_dbm(s) -- sample lines. samples is a list of {labels=..., value=...}; skips entries
if not s then return nil end -- with nil value, skips the family entirely if no samples survive.
return tonumber(s:match("^%s*(-?%d+)")) local function emit_family(out, help, type_, name, samples)
local lines = {}
for _, s in ipairs(samples) do
if s.value ~= nil then
local label_str = ""
if s.labels and next(s.labels) then
local parts = {}
for k, v in pairs(s.labels) do
table.insert(parts, k .. '="' .. escape_label(v) .. '"')
end end
label_str = "{" .. table.concat(parts, ",") .. "}"
-- Parse the leading decimal Mbit/s from strings like "54.0 MBit/s" or "6.0 MBit/s MCS 0". end
local function parse_mbps(s) lines[#lines + 1] = name .. label_str .. " " .. tostring(s.value)
if not s then return nil end end
return tonumber(s:match("^%s*(%d+%.?%d*)")) end
if #lines == 0 then return end
table.insert(out, "# HELP " .. name .. " " .. help)
table.insert(out, "# TYPE " .. name .. " " .. type_)
for _, l in ipairs(lines) do table.insert(out, l) end
end end
-- Read /proc/uptime and return centiseconds (10ms precision). -- Read /proc/uptime and return centiseconds (10ms precision).
@@ -103,27 +118,97 @@ local function read_file(path)
return content return content
end end
-- Parse `iwinfo <iface> info` for AP-side radio facts.
local function ap_radio_info(iface)
local fh = io.popen("iwinfo " .. iface .. " info 2>/dev/null")
if not fh then return nil end
local out = fh:read("*a"); fh:close()
if not out or out == "" then return nil end
return {
ssid = out:match('ESSID:%s*"([^"]*)"'),
channel = tonumber(out:match("Channel:%s*(%d+)")),
txpower = tonumber(out:match("Tx%-Power:%s*(%d+)%s*dBm")),
noise = tonumber(out:match("Noise:%s*(-?%d+)%s*dBm")),
}
end
-- Parse `iwinfo <iface> assoclist` into a list of client tables.
-- Block format (Chaos Calmer iwinfo):
-- 28:05:A5:6F:83:30 -83 dBm / -95 dBm (SNR 12) 1420 ms ago
-- RX: 6.0 MBit/s, MCS 0, 20MHz 317 Pkts 22997 Bytes.
-- TX: 19.5 MBit/s, MCS 2, 20MHz 84 Pkts 8594 Bytes.
-- Connected Time: 00:01:54
local function ap_clients(iface)
local fh = io.popen("iwinfo " .. iface .. " assoclist 2>/dev/null")
if not fh then return {} end
local out = fh:read("*a"); fh:close()
local clients = {}
local cur = nil
for line in out:gmatch("[^\n]+") do
local mac, sig, inact = line:match(
"^(%x%x:%x%x:%x%x:%x%x:%x%x:%x%x)%s+(-?%d+) dBm.-(%d+) ms ago")
if mac then
cur = {
mac = mac,
signal = tonumber(sig),
snr = tonumber(line:match("SNR (%d+)")),
inactive_ms = tonumber(inact),
}
clients[#clients + 1] = cur
elseif cur then
local rx = line:match("RX:%s*([%d%.]+) MBit/s")
if rx then cur.rx_mbps = tonumber(rx) end
local tx = line:match("TX:%s*([%d%.]+) MBit/s")
if tx then cur.tx_mbps = tonumber(tx) end
local h, m, s = line:match("Connected Time:%s*(%d+):(%d+):(%d+)")
if h then
cur.connected_s = tonumber(h) * 3600 + tonumber(m) * 60 + tonumber(s)
end
end
end
return clients
end
function metrics_handler.get_metrics(request_context) function metrics_handler.get_metrics(request_context)
local out = {} local out = {}
local iface = "vif-ap0"
-- === WiFi upstream connection === -- === AP radio + clients ===
local ssid = utils.uci_get("wireless.wwan.ssid") or "" local radio = ap_radio_info(iface)
local station = utils.get_station_dump_info("vif-sta0") local clients = ap_clients(iface)
local ap_labels = { iface = iface, ssid = (radio and radio.ssid) or "" }
if station then emit(out, "Number of stations associated to the roof AP",
local wifi_labels = { "gauge", "skyweave_ap_clients", ap_labels, #clients)
ssid = ssid, if radio then
iface = "vif-sta0", emit(out, "AP radio noise floor in dBm",
bssid = station["bssid"] or "", "gauge", "skyweave_ap_noise_dbm", ap_labels, radio.noise)
} emit(out, "AP transmit power in dBm",
emit(out, "WiFi signal strength of upstream connection in dBm", "gauge", "skyweave_ap_txpower_dbm", ap_labels, radio.txpower)
"gauge", "skyweave_wifi_signal_dbm", wifi_labels, parse_dbm(station["signal"])) emit(out, "AP operating channel",
emit(out, "WiFi transmit bitrate to upstream AP in Mbit/s", "gauge", "skyweave_ap_channel", ap_labels, radio.channel)
"gauge", "skyweave_wifi_tx_bitrate_mbps", wifi_labels, parse_mbps(station["tx bitrate"]))
emit(out, "WiFi receive bitrate from upstream AP in Mbit/s",
"gauge", "skyweave_wifi_rx_bitrate_mbps", wifi_labels, parse_mbps(station["rx bitrate"]))
end end
local function client_samples(field)
local s = {}
for _, c in ipairs(clients) do
s[#s + 1] = { labels = { mac = c.mac }, value = c[field] }
end
return s
end
emit_family(out, "Per-client signal strength at the AP in dBm",
"gauge", "skyweave_ap_client_signal_dbm", client_samples("signal"))
emit_family(out, "Per-client signal-to-noise ratio in dB",
"gauge", "skyweave_ap_client_snr_db", client_samples("snr"))
emit_family(out, "Per-client RX bitrate (client to AP) in Mbit/s",
"gauge", "skyweave_ap_client_rx_bitrate_mbps", client_samples("rx_mbps"))
emit_family(out, "Per-client TX bitrate (AP to client) in Mbit/s",
"gauge", "skyweave_ap_client_tx_bitrate_mbps", client_samples("tx_mbps"))
emit_family(out, "Per-client idle time in milliseconds",
"gauge", "skyweave_ap_client_inactive_ms", client_samples("inactive_ms"))
emit_family(out, "Per-client association duration in seconds",
"gauge", "skyweave_ap_client_connected_seconds", client_samples("connected_s"))
-- === Active default gateway (info-style gauge) === -- === Active default gateway (info-style gauge) ===
local active_gw, active_iface = utils.get_active_default_gateway() local active_gw, active_iface = utils.get_active_default_gateway()
if active_iface then if active_iface then
@@ -161,71 +246,41 @@ function metrics_handler.get_metrics(request_context)
-- MemAvailable not present on this kernel; omit to avoid phantom nil metric -- MemAvailable not present on this kernel; omit to avoid phantom nil metric
end end
-- === Ping connectivity === -- === Connectivity checks ===
-- Collect results first, then emit each metric family in one block -- Collect results first, then emit each metric family in one block
-- (# HELP / # TYPE must appear exactly once per metric name per exposition). -- (# HELP / # TYPE must appear exactly once per metric name per exposition).
local ping_targets = { local ping_targets = {
{ host = "192.168.18.1", name = "campground_gateway" }, { host = "192.168.69.1", name = "chateau_gateway" },
{ host = "192.168.88.4", name = "talos_via_tunnel" }, { host = "192.168.88.4", name = "talos_via_tunnel" },
} }
local ping_results = {} local ping_results = {}
for _, t in ipairs(ping_targets) do for _, t in ipairs(ping_targets) do
ping_results[#ping_results + 1] = { target = t, stats = ping_stats(t.host) } ping_results[#ping_results + 1] = { target = t, stats = ping_stats(t.host) }
end end
-- Internet check via DNS (ICMP/HTTP are blocked by campground) -- Internet check via DNS (also exercises the resolver path, not just ICMP)
ping_results[#ping_results + 1] = { ping_results[#ping_results + 1] = {
target = { host = "8.8.8.8", name = "internet" }, target = { host = "8.8.8.8", name = "internet" },
stats = dns_check("8.8.8.8", "google.com"), stats = dns_check("8.8.8.8", "google.com"),
} }
local function label_str(target, name) local function target_samples(field)
return '{target="' .. escape_label(target) .. '",name="' .. escape_label(name) .. '"}' local s = {}
end
-- rtt (only emit samples where rtt is available)
local rtt_samples = {}
for _, r in ipairs(ping_results) do
if r.stats and r.stats.rtt_ms then
rtt_samples[#rtt_samples + 1] =
"skyweave_ping_rtt_ms" .. label_str(r.target.host, r.target.name) ..
" " .. tostring(r.stats.rtt_ms)
end
end
if #rtt_samples > 0 then
table.insert(out, "# HELP skyweave_ping_rtt_ms ICMP round-trip time in milliseconds")
table.insert(out, "# TYPE skyweave_ping_rtt_ms gauge")
for _, s in ipairs(rtt_samples) do table.insert(out, s) end
end
-- loss
local loss_samples = {}
for _, r in ipairs(ping_results) do for _, r in ipairs(ping_results) do
if r.stats then if r.stats then
loss_samples[#loss_samples + 1] = s[#s + 1] = {
"skyweave_ping_loss_pct" .. label_str(r.target.host, r.target.name) .. labels = { target = r.target.host, name = r.target.name },
" " .. tostring(r.stats.loss_pct) value = r.stats[field],
}
end end
end end
if #loss_samples > 0 then return s
table.insert(out, "# HELP skyweave_ping_loss_pct ICMP packet loss percentage (0-100)")
table.insert(out, "# TYPE skyweave_ping_loss_pct gauge")
for _, s in ipairs(loss_samples) do table.insert(out, s) end
end
-- reachable
local reach_samples = {}
for _, r in ipairs(ping_results) do
if r.stats then
reach_samples[#reach_samples + 1] =
"skyweave_ping_reachable" .. label_str(r.target.host, r.target.name) ..
" " .. tostring(r.stats.reachable)
end
end
if #reach_samples > 0 then
table.insert(out, "# HELP skyweave_ping_reachable Host reachability via ICMP (1=up, 0=down)")
table.insert(out, "# TYPE skyweave_ping_reachable gauge")
for _, s in ipairs(reach_samples) do table.insert(out, s) end
end end
emit_family(out, "ICMP round-trip time in milliseconds",
"gauge", "skyweave_ping_rtt_ms", target_samples("rtt_ms"))
emit_family(out, "ICMP packet loss percentage (0-100)",
"gauge", "skyweave_ping_loss_pct", target_samples("loss_pct"))
emit_family(out, "Host reachability via ICMP (1=up, 0=down)",
"gauge", "skyweave_ping_reachable", target_samples("reachable"))
return { return {
prometheus_metrics = true, prometheus_metrics = true,