From d3d672c4f15d773db6061504bde4f8173639392e Mon Sep 17 00:00:00 2001 From: Wesley Ray Date: Thu, 9 Apr 2026 07:35:51 -0400 Subject: [PATCH] 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 --- cgi_scripts/skyweave.lua | 2 + skyweave-push.sh | 9 ++ skyweave/handlers/metrics.lua | 236 ++++++++++++++++++++++++++++++++++ skyweave/response.lua | 8 ++ skyweave/router.lua | 4 +- 5 files changed, 258 insertions(+), 1 deletion(-) create mode 100755 skyweave-push.sh create mode 100644 skyweave/handlers/metrics.lua diff --git a/cgi_scripts/skyweave.lua b/cgi_scripts/skyweave.lua index 4c5913a..83c0478 100755 --- a/cgi_scripts/skyweave.lua +++ b/cgi_scripts/skyweave.lua @@ -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() diff --git a/skyweave-push.sh b/skyweave-push.sh new file mode 100755 index 0000000..d6e0169 --- /dev/null +++ b/skyweave-push.sh @@ -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 diff --git a/skyweave/handlers/metrics.lua b/skyweave/handlers/metrics.lua new file mode 100644 index 0000000..04999eb --- /dev/null +++ b/skyweave/handlers/metrics.lua @@ -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 diff --git a/skyweave/response.lua b/skyweave/response.lua index d983158..3dcd67a 100755 --- a/skyweave/response.lua +++ b/skyweave/response.lua @@ -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 diff --git a/skyweave/router.lua b/skyweave/router.lua index ddb7d36..4f3e3d5 100755 --- a/skyweave/router.lua +++ b/skyweave/router.lua @@ -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