add Prometheus metrics endpoint and push script
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

New /metrics endpoint exposes WiFi signal, bitrate, gateway,
system uptime/load/memory, and ping reachability in Prometheus
exposition format. skyweave-push.sh loops every 30s pushing
metrics to the pushgateway on deltrian.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
wes
2026-04-09 07:35:51 -04:00
co-authored by Claude Opus 4.6
parent 98e04a7b31
commit d3d672c4f1
5 changed files with 258 additions and 1 deletions
+2
View File
@@ -45,6 +45,7 @@ local success, load_result = pcall(function()
local network_handlers = require("skyweave.handlers.network")
local system_handlers = require("skyweave.handlers.system")
local wifi_handlers = require("skyweave.handlers.wifi")
local metrics_handlers = require("skyweave.handlers.metrics")
local router = Router:new()
@@ -54,6 +55,7 @@ local success, load_result = pcall(function()
router:add_route("GET", "/SOS", network_handlers.set_connect_wifi_sos)
router:add_route("GET", "/wifi_scan", wifi_handlers.get_wifi_scan)
router:add_route("GET", "/system_log_parsed", system_handlers.get_system_log_parsed)
router:add_route("GET", "/metrics", metrics_handlers.get_metrics)
local request = request_context_builder.create_request_context()
+9
View File
@@ -0,0 +1,9 @@
#!/bin/sh
# Push skyweave metrics to Prometheus pushgateway every 30 seconds.
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"
while true; do
curl -sf "$METRICS_URL" | curl -sf --data-binary @- "$PUSHGW" >/dev/null 2>&1
sleep 30
done
+236
View File
@@ -0,0 +1,236 @@
-- skyweave/handlers/metrics.lua
-- Prometheus-compatible metrics endpoint for the RVLink roof unit.
-- Exposes WiFi signal quality, bitrates, active gateway, system load, memory, uptime, and ping stats.
local utils = require("skyweave.utils")
local metrics_handler = {}
-- Escape label values per Prometheus text exposition format spec (section 5).
local function escape_label(s)
if s == nil then return "" end
s = tostring(s)
s = s:gsub("\\", "\\\\")
s = s:gsub('"', '\\"')
s = s:gsub("\n", "\\n")
return s
end
-- Render one complete metric block: HELP + TYPE + sample line.
-- Skips silently if value is nil (metric unavailable).
local function emit(out, help, type_, name, labels, value)
if value == nil then return end
table.insert(out, "# HELP " .. name .. " " .. help)
table.insert(out, "# TYPE " .. name .. " " .. type_)
local label_str = ""
if labels and next(labels) then
local parts = {}
for k, v in pairs(labels) do
table.insert(parts, k .. '="' .. escape_label(v) .. '"')
end
label_str = "{" .. table.concat(parts, ",") .. "}"
end
table.insert(out, name .. label_str .. " " .. tostring(value))
end
-- Parse the leading integer dBm from strings like "-65 dBm" or "-65 [-65, -66] dBm".
local function parse_dbm(s)
if not s then return nil end
return tonumber(s:match("^%s*(-?%d+)"))
end
-- Parse the leading decimal Mbit/s from strings like "54.0 MBit/s" or "6.0 MBit/s MCS 0".
local function parse_mbps(s)
if not s then return nil end
return tonumber(s:match("^%s*(%d+%.?%d*)"))
end
-- Read /proc/uptime and return centiseconds (10ms precision).
local function uptime_cs()
local f = io.open("/proc/uptime", "r")
if not f then return nil end
local s = f:read("*l"); f:close()
local v = s and s:match("^(%d+%.%d+)")
return v and math.floor(tonumber(v) * 100) or nil
end
-- DNS lookup against an explicit server; returns {rtt_ms, loss_pct, reachable}.
-- Uses /proc/uptime for timing since busybox date lacks nanosecond support.
local function dns_check(server, hostname)
local t1 = uptime_cs()
local fh = io.popen(
"nslookup " .. hostname .. " " .. server ..
" >/dev/null 2>&1; echo $?"
)
if not fh then return nil end
local ret = fh:read("*l"); fh:close()
local t2 = uptime_cs()
local ok = (tonumber(ret) == 0)
local ms = (t1 and t2) and ((t2 - t1) * 10) or nil
return {
rtt_ms = ok and ms or nil,
loss_pct = ok and 0 or 100,
reachable = ok and 1 or 0,
}
end
-- Run ping -c 3 -W 1 against a host and return {rtt_ms, loss_pct, reachable}.
-- Returns nil only if io.popen itself fails.
local function ping_stats(host)
local fh = io.popen("ping -c 3 -W 1 " .. host .. " 2>/dev/null")
if not fh then return nil end
local output = fh:read("*a")
fh:close()
-- "round-trip min/avg/max = X/Y/Z ms" (busybox format)
local avg = output:match("min/avg/max%s*=%s*[%d%.]+/([%d%.]+)/")
-- "N packets transmitted, M packets received, P% packet loss"
local tx, rx = output:match("(%d+) packets transmitted, (%d+) packets? received")
tx = tonumber(tx) or 0
rx = tonumber(rx) or 0
local loss = (tx > 0) and math.floor((tx - rx) / tx * 100) or 100
return {
rtt_ms = avg and tonumber(avg) or nil,
loss_pct = loss,
reachable = (rx > 0) and 1 or 0,
}
end
local function read_file(path)
local f = io.open(path, "r")
if not f then return nil end
local content = f:read("*a")
f:close()
return content
end
function metrics_handler.get_metrics(request_context)
local out = {}
-- === WiFi upstream connection ===
local ssid = utils.uci_get("wireless.wwan.ssid") or ""
local station = utils.get_station_dump_info("vif-sta0")
if station then
local wifi_labels = {
ssid = ssid,
iface = "vif-sta0",
bssid = station["bssid"] or "",
}
emit(out, "WiFi signal strength of upstream connection in dBm",
"gauge", "skyweave_wifi_signal_dbm", wifi_labels, parse_dbm(station["signal"]))
emit(out, "WiFi transmit bitrate to upstream AP in Mbit/s",
"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
-- === Active default gateway (info-style gauge) ===
local active_gw, active_iface = utils.get_active_default_gateway()
if active_iface then
emit(out, "Active default gateway — value is always 1, use labels for info",
"gauge", "skyweave_active_gateway_info",
{ iface = active_iface, gateway = active_gw or "" }, 1)
end
-- === Uptime ===
local uptime_raw = read_file("/proc/uptime")
if uptime_raw then
emit(out, "System uptime in seconds",
"counter", "skyweave_system_uptime_seconds",
nil, tonumber(uptime_raw:match("^(%d+%.?%d*)")))
end
-- === Load averages ===
local loadavg_raw = read_file("/proc/loadavg")
if loadavg_raw then
local l1, l5, l15 = loadavg_raw:match("^(%S+)%s+(%S+)%s+(%S+)")
emit(out, "System 1-minute load average", "gauge", "skyweave_system_load1", nil, tonumber(l1))
emit(out, "System 5-minute load average", "gauge", "skyweave_system_load5", nil, tonumber(l5))
emit(out, "System 15-minute load average", "gauge", "skyweave_system_load15", nil, tonumber(l15))
end
-- === Memory ===
local meminfo_raw = read_file("/proc/meminfo")
if meminfo_raw then
local function memkb(key)
local v = meminfo_raw:match(key .. "%s*:%s*(%d+)%s+kB")
return v and (tonumber(v) * 1024) or nil
end
emit(out, "Total physical memory in bytes", "gauge", "skyweave_memory_total_bytes", nil, memkb("MemTotal"))
emit(out, "Free memory in bytes", "gauge", "skyweave_memory_free_bytes", nil, memkb("MemFree"))
-- MemAvailable not present on this kernel; omit to avoid phantom nil metric
end
-- === Ping connectivity ===
-- Collect results first, then emit each metric family in one block
-- (# HELP / # TYPE must appear exactly once per metric name per exposition).
local ping_targets = {
{ host = "192.168.18.1", name = "campground_gateway" },
{ host = "192.168.88.4", name = "talos_via_tunnel" },
}
local ping_results = {}
for _, t in ipairs(ping_targets) do
ping_results[#ping_results + 1] = { target = t, stats = ping_stats(t.host) }
end
-- Internet check via DNS (ICMP/HTTP are blocked by campground)
ping_results[#ping_results + 1] = {
target = { host = "8.8.8.8", name = "internet" },
stats = dns_check("8.8.8.8", "google.com"),
}
local function label_str(target, name)
return '{target="' .. escape_label(target) .. '",name="' .. escape_label(name) .. '"}'
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
if r.stats then
loss_samples[#loss_samples + 1] =
"skyweave_ping_loss_pct" .. label_str(r.target.host, r.target.name) ..
" " .. tostring(r.stats.loss_pct)
end
end
if #loss_samples > 0 then
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
return {
prometheus_metrics = true,
body = table.concat(out, "\n") .. "\n",
}
end
return metrics_handler
+8
View File
@@ -28,6 +28,14 @@ function response.json(data_table, http_status_code)
io.stdout:flush()
end
function response.metrics(body)
print("Status: 200 OK")
print("Content-Type: text/plain; version=0.0.4; charset=utf-8")
print("")
io.stdout:write(body or "")
io.stdout:flush()
end
function response.error(error_message, http_status_code, internal_error_code)
http_status_code = http_status_code or 500
internal_error_code = internal_error_code or -1
+3 -1
View File
@@ -99,7 +99,9 @@ function Router:dispatch(request_context)
response.error("Handler Error: " .. tostring(result), 500)
else
-- Handler executed successfully, 'result' is the return value
if type(result) == "table" and result.async == true and result.command and result.response then
if type(result) == "table" and result.prometheus_metrics == true then
response.metrics(result.body or "")
elseif type(result) == "table" and result.async == true and result.command and result.response then
-- Asynchronous task pattern
response.json(result.response, 202) -- 202 Accepted
-- Ensure the command is not nil or empty before executing