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 network_handlers = require("skyweave.handlers.network")
|
||||||
local system_handlers = require("skyweave.handlers.system")
|
local system_handlers = require("skyweave.handlers.system")
|
||||||
local wifi_handlers = require("skyweave.handlers.wifi")
|
local wifi_handlers = require("skyweave.handlers.wifi")
|
||||||
|
local metrics_handlers = require("skyweave.handlers.metrics")
|
||||||
|
|
||||||
local router = Router:new()
|
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", "/SOS", network_handlers.set_connect_wifi_sos)
|
||||||
router:add_route("GET", "/wifi_scan", wifi_handlers.get_wifi_scan)
|
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", "/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()
|
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()
|
io.stdout:flush()
|
||||||
end
|
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)
|
function response.error(error_message, http_status_code, internal_error_code)
|
||||||
http_status_code = http_status_code or 500
|
http_status_code = http_status_code or 500
|
||||||
internal_error_code = internal_error_code or -1
|
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)
|
response.error("Handler Error: " .. tostring(result), 500)
|
||||||
else
|
else
|
||||||
-- Handler executed successfully, 'result' is the return value
|
-- 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
|
-- Asynchronous task pattern
|
||||||
response.json(result.response, 202) -- 202 Accepted
|
response.json(result.response, 202) -- 202 Accepted
|
||||||
-- Ensure the command is not nil or empty before executing
|
-- Ensure the command is not nil or empty before executing
|
||||||
|
|||||||
Reference in New Issue
Block a user