From 3a031694db0e9a1866d9c6f44b5b765fa30f4c40 Mon Sep 17 00:00:00 2001 From: Nic Jansma Date: Tue, 7 Oct 2025 10:32:45 -0400 Subject: [PATCH] Added prometheus /metrics endpoint (#262) * Added prometheus /metrics endpoint * Fixes per CoPilot suggestions * More Copilot fixes * Rufo formatted --- README.md | 2 + web/Gemfile | 1 + web/app.rb | 148 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 151 insertions(+) diff --git a/README.md b/README.md index 385079c..5b1cc89 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,7 @@ The web app can be configured with environment variables (defaults shown): * `MAX_NODE_DISTANCE_KM` - hide nodes farther than this distance from the center (default: `137`) * `MATRIX_ROOM` - matrix room id for a footer link (default: `#meshtastic-berlin:matrix.org`) * `PRIVATE` - set to `1` to hide the chat UI, disable message APIs, and exclude hidden clients (default: unset) +* `PROM_REPORT_IDS` - comma-separated list of Node IDs to report in prometheus metrics, `*` for all (default: unset) The application derives SEO-friendly document titles, descriptions, and social preview tags from these existing configuration values and reuses the bundled @@ -75,6 +76,7 @@ The web app contains an API: * GET `/api/messages?limit=100` - returns the latest 100 messages (disabled when `PRIVATE=1`) * GET `/api/telemetry?limit=100` - returns the latest 100 telemetry data * GET `/api/neighbors?limit=100` - returns the latest 100 neighbor tuples +* GET `/metrics`- prometheus endpoint * POST `/api/nodes` - upserts nodes provided as JSON object mapping node ids to node data (requires `Authorization: Bearer `) * POST `/api/positions` - appends positions provided as a JSON object or array (requires `Authorization: Bearer `) * POST `/api/messages` - appends messages provided as a JSON object or array (requires `Authorization: Bearer `; disabled when `PRIVATE=1`) diff --git a/web/Gemfile b/web/Gemfile index c927e96..d3a8720 100644 --- a/web/Gemfile +++ b/web/Gemfile @@ -18,6 +18,7 @@ gem "sinatra", "~> 4.0" gem "sqlite3", "~> 1.7" gem "rackup", "~> 2.2" gem "puma", "~> 7.0" +gem "prometheus-client" group :test do gem "rspec", "~> 3.12" diff --git a/web/app.rb b/web/app.rb index 8c6222c..e26803b 100644 --- a/web/app.rb +++ b/web/app.rb @@ -26,6 +26,10 @@ require "logger" require "rack/utils" require "open3" require "time" +require "prometheus/client" +require "prometheus/client/formats/text" +require "prometheus/middleware/collector" +require "prometheus/middleware/exporter" # Path to the SQLite database used by the web application. DB_PATH = ENV.fetch("MESH_DB", File.join(__dir__, "../data/mesh.db")) @@ -65,6 +69,7 @@ MAP_TILE_FILTER_DARK = ENV.fetch( "MAP_TILE_FILTER_DARK", "grayscale(1) invert(1) brightness(0.9) contrast(1.08)" ) +PROM_REPORT_IDS = ENV.fetch("PROM_REPORT_IDS", "") # Fetch a configuration string from environment variables. # @@ -131,6 +136,84 @@ MAX_NODE_DISTANCE_KM = ENV.fetch("MAX_NODE_DISTANCE_KM", "137").to_f MATRIX_ROOM = ENV.fetch("MATRIX_ROOM", "#meshtastic-berlin:matrix.org") DEBUG = ENV["DEBUG"] == "1" +# +# Prometheus metrics +# +$prom_messages_total = Prometheus::Client::Counter.new( + :meshtastic_messages_total, + docstring: "Total number of messages received", +) +Prometheus::Client.registry.register($prom_messages_total) + +$prom_nodes = Prometheus::Client::Gauge.new( + :meshtastic_nodes, + docstring: "Number of nodes seen", +) +Prometheus::Client.registry.register($prom_nodes) + +$prom_node = Prometheus::Client::Gauge.new( + :meshtastic_node, + docstring: "Node details", + labels: [:node, :short_name, :long_name, :hw_model, :role], +) +Prometheus::Client.registry.register($prom_node) + +$prom_node_battery_level = Prometheus::Client::Gauge.new( + :meshtastic_node_battery_level, + docstring: "Battery level of a node", + labels: [:node], +) +Prometheus::Client.registry.register($prom_node_battery_level) + +$prom_node_voltage = Prometheus::Client::Gauge.new( + :meshtastic_node_voltage, + docstring: "Voltage level of a node", + labels: [:node], +) +Prometheus::Client.registry.register($prom_node_voltage) + +$prom_node_uptime = Prometheus::Client::Gauge.new( + :meshtastic_node_uptime, + docstring: "Uptime of a node", + labels: [:node], +) +Prometheus::Client.registry.register($prom_node_uptime) + +$prom_node_channel_utilization = Prometheus::Client::Gauge.new( + :meshtastic_node_channel_utilization, + docstring: "Channel utilization level of a node", + labels: [:node], +) +Prometheus::Client.registry.register($prom_node_channel_utilization) + +$prom_node_transmit_air_utilization = Prometheus::Client::Gauge.new( + :meshtastic_node_transmit_air_utilization, + docstring: "Air transmit utilization level of a node", + labels: [:node], +) +Prometheus::Client.registry.register($prom_node_transmit_air_utilization) + +$prom_node_latitude = Prometheus::Client::Gauge.new( + :meshtastic_node_latitude, + docstring: "Latitude of a node", + labels: [:node], +) +Prometheus::Client.registry.register($prom_node_latitude) + +$prom_node_longitude = Prometheus::Client::Gauge.new( + :meshtastic_node_longitude, + docstring: "Longitude of a node", + labels: [:node], +) +Prometheus::Client.registry.register($prom_node_longitude) + +$prom_node_altitude = Prometheus::Client::Gauge.new( + :meshtastic_node_altitude, + docstring: "Altitude of a node", + labels: [:node], +) +Prometheus::Client.registry.register($prom_node_altitude) + # Log a debug message when the ``DEBUG`` environment variable is enabled. # # @param message [String] text written to the configured logger. @@ -394,6 +477,9 @@ Sinatra::Application.configure do app_logger = Logger.new($stdout) set :logger, app_logger use Rack::CommonLogger, app_logger + use Rack::Deflater + use Prometheus::Middleware::Collector + use Prometheus::Middleware::Exporter Sinatra::Application.apply_logger_level! end @@ -994,6 +1080,55 @@ def upsert_node(db, node_id, n) } node_num = resolve_node_num(node_id, n) + if !PROM_REPORT_IDS.empty? && node_id + report_ids = PROM_REPORT_IDS.split(",").map(&:strip).reject(&:empty?) + + if PROM_REPORT_IDS == "*" || report_ids.include?(node_id) + $prom_node.set( + 1, + labels: { + node: node_id, + short_name: user["shortName"], + long_name: user["longName"], + hw_model: user["hwModel"] || n["hwModel"], + role: role, + }, + ) + + if met["batteryLevel"] + $prom_node_battery_level.set(met["batteryLevel"], labels: { node: node_id }) + end + + if met["voltage"] + $prom_node_voltage.set(met["voltage"], labels: { node: node_id }) + end + + if met["uptimeSeconds"] + $prom_node_uptime.set(met["uptimeSeconds"], labels: { node: node_id }) + end + + if met["channelUtilization"] + $prom_node_channel_utilization.set(met["channelUtilization"], labels: { node: node_id }) + end + + if met["airUtilTx"] + $prom_node_transmit_air_utilization.set(met["airUtilTx"], labels: { node: node_id }) + end + + if pos["latitude"] + $prom_node_latitude.set(pos["latitude"], labels: { node: node_id }) + end + + if pos["longitude"] + $prom_node_longitude.set(pos["longitude"], labels: { node: node_id }) + end + + if pos["altitude"] + $prom_node_altitude.set(pos["altitude"], labels: { node: node_id }) + end + end + end + row = [ node_id, node_num, @@ -1808,6 +1943,8 @@ def insert_message(db, m) db.execute("UPDATE messages SET #{assignments} WHERE id = ?", updates.values + [msg_id]) end else + $prom_messages_total.increment + begin db.execute <<~SQL, row INSERT INTO messages(id,rx_time,rx_iso,from_id,to_id,channel,portnum,text,encrypted,snr,rssi,hop_limit) @@ -1865,6 +2002,9 @@ post "/api/nodes" do data.each do |node_id, node| upsert_node(db, node_id, node) end + + $prom_nodes.set(query_nodes(1000).length) + { status: "ok" }.to_json ensure db&.close @@ -2006,3 +2146,11 @@ get "/" do initial_theme: theme, } end + +# GET /metrics +# +# Prometheus metrics endpoint. +get "/metrics" do + content_type Prometheus::Client::Formats::Text::CONTENT_TYPE + Prometheus::Client::Formats::Text.marshal(Prometheus::Client.registry) +end