add Prometheus metrics endpoint and push script
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:
@@ -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()
|
||||
|
||||
|
||||
Executable
+9
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user