mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-10-30 19:41:58 +01:00
290 lines
13 KiB
Ruby
290 lines
13 KiB
Ruby
# Copyright © 2025-26 l5yth & contributors
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
# frozen_string_literal: true
|
|
|
|
require "spec_helper"
|
|
require "prometheus/client"
|
|
require_relative "support/metrics_spec_helpers"
|
|
|
|
# What +/metrics+ prints, driven through the API with the app's full
|
|
# middleware stack (SPEC PG1-PG4, ACCEPTANCE PG-A1-PG-A4; review items M1,
|
|
# L-PROMLABELS, IB-G1 and M5). Every example reads only what an unfixed tree
|
|
# also has, so the file loads and fails there on its assertions.
|
|
RSpec.describe "/metrics request labels, node series and node count" do
|
|
include MetricsSpecHelpers
|
|
|
|
let(:app) { Sinatra::Application }
|
|
let(:api_token) { "metrics-routes-token" }
|
|
let(:auth_headers) do
|
|
{ "CONTENT_TYPE" => "application/json", "HTTP_AUTHORIZATION" => "Bearer #{api_token}" }
|
|
end
|
|
let(:now) { Time.now.to_i }
|
|
let(:marker) { PotatoMesh::Config.node_opt_out_marker }
|
|
|
|
around do |example|
|
|
saved = ENV.to_h.slice("API_TOKEN", "PROM_REPORT_IDS", "PRIVATE")
|
|
ENV["API_TOKEN"] = api_token
|
|
ENV["PROM_REPORT_IDS"] = "*"
|
|
ENV.delete("PRIVATE")
|
|
Dir.mktmpdir("metrics-routes-spec-") do |dir|
|
|
RSpec::Mocks.with_temporary_scope do
|
|
allow(PotatoMesh::Config).to receive(:db_path).and_return(File.join(dir, "mesh.db"))
|
|
db_helper = Object.new.extend(PotatoMesh::App::Database)
|
|
db_helper.init_db
|
|
db_helper.ensure_schema_upgrades
|
|
PotatoMesh::App::ApiCache.invalidate_all
|
|
example.run
|
|
end
|
|
end
|
|
ensure
|
|
%w[API_TOKEN PROM_REPORT_IDS PRIVATE].each do |key|
|
|
saved.key?(key) ? ENV[key] = saved[key] : ENV.delete(key)
|
|
end
|
|
end
|
|
|
|
# Run statements against the spec database in one transaction.
|
|
#
|
|
# @yieldparam db [SQLite3::Database] open database handle.
|
|
# @return [void]
|
|
def with_spec_db
|
|
db = SQLite3::Database.new(PotatoMesh::Config.db_path)
|
|
db.transaction { yield db }
|
|
ensure
|
|
db&.close
|
|
end
|
|
|
|
# Store a node row directly, as one written before this process started.
|
|
#
|
|
# @param db [SQLite3::Database] open database handle.
|
|
# @param node_id [String] canonical node id.
|
|
# @param heard [Integer] last-heard time.
|
|
# @param columns [Hash{Symbol => Object}] further columns and their values.
|
|
# @return [void]
|
|
def store_node(db, node_id, heard:, **columns)
|
|
row = { "node_id" => node_id, "long_name" => "Stored #{node_id}", "short_name" => "ST", "role" => "CLIENT",
|
|
"last_heard" => heard, "first_heard" => heard }.merge(columns.transform_keys(&:to_s))
|
|
db.execute("INSERT INTO nodes(#{row.keys.join(", ")}) VALUES (#{(["?"] * row.size).join(", ")})", row.values)
|
|
end
|
|
|
|
# One node entry of a +POST /api/nodes+ body.
|
|
#
|
|
# @param long_name [String] display name.
|
|
# @param heard [Integer] lastHeard, so a later entry wins the upsert guard.
|
|
# @param position [Hash, nil] position section.
|
|
# @return [Hash] node entry.
|
|
def node_entry(long_name, heard, position: nil)
|
|
entry = {
|
|
"user" => { "longName" => long_name, "shortName" => "PG", "hwModel" => "TBEAM", "role" => "CLIENT" },
|
|
"deviceMetrics" => { "batteryLevel" => 80, "voltage" => 4.0 },
|
|
"lastHeard" => heard,
|
|
}
|
|
entry["position"] = position.merge("time" => heard) if position
|
|
entry
|
|
end
|
|
|
|
# POST nodes and expect the route to accept them.
|
|
#
|
|
# @param entries [Hash{String => Hash}] node id to node entry.
|
|
# @return [void]
|
|
def post_nodes(entries)
|
|
post "/api/nodes", entries.to_json, auth_headers
|
|
expect(last_response.status).to eq(201), "POST /api/nodes answered #{last_response.status}: #{last_response.body}"
|
|
end
|
|
|
|
# The HTTP request counter the request-metrics middleware registered.
|
|
#
|
|
# @return [Prometheus::Client::Counter] +http_server_requests_total+.
|
|
def request_counter
|
|
::Prometheus::Client.registry.get(:http_server_requests_total)
|
|
end
|
|
|
|
# Distinct +path+ values of the request counter and duration histogram.
|
|
#
|
|
# @return [Array<String>] path label values recorded so far.
|
|
def request_paths
|
|
%i[http_server_requests_total http_server_request_duration_seconds].flat_map do |name|
|
|
::Prometheus::Client.registry.get(name).values.keys.map { |labels| labels[:path] }
|
|
end.uniq
|
|
end
|
|
|
|
# Every label the collector can give a request: one per route of
|
|
# Sinatra's table, and the three fixed labels.
|
|
#
|
|
# @return [Array<String>] the route labels, then +static+, +metrics+ and
|
|
# +unmatched+.
|
|
def path_labels
|
|
routes = PotatoMesh::Application.routes.flat_map do |verb, table|
|
|
table.map { |pattern, _conditions, _block| PotatoMesh::App::Prometheus::RouteCollector.route_label("#{verb} #{pattern}") }
|
|
end
|
|
routes + %w[static metrics unmatched]
|
|
end
|
|
|
|
describe "HTTP request metrics (SPEC PG1)" do
|
|
it "labels a per-node read with its route, never with the node id from the URL" do
|
|
get "/api/nodes/!0b0b0001"
|
|
expect(last_response.status).to eq(404)
|
|
|
|
expect(request_paths).to include("GET /api/nodes/:id")
|
|
expect(request_paths.grep(/0b0b0001/)).to eq([])
|
|
expect(request_counter.get(labels: { code: "404", method: "get", path: "GET /api/nodes/:id" })).to be >= 1
|
|
end
|
|
|
|
it "keeps 1000 distinct 404 paths to one series, every label a route's or a fixed one" do
|
|
get "/version"
|
|
before = request_counter.get(labels: { code: "404", method: "get", path: "unmatched" })
|
|
|
|
# Letters keep each segment from the gem's all-digit rewrite.
|
|
1000.times { |index| get "/pg-missing/n#{index}" }
|
|
|
|
expect(last_response.status).to eq(404)
|
|
paths = request_paths
|
|
expect(paths.grep(%r{/pg-missing/})).to eq([])
|
|
expect(paths - path_labels).to eq([])
|
|
expect(request_counter.get(labels: { code: "404", method: "get", path: "unmatched" }) - before).to eq(1000)
|
|
end
|
|
|
|
it "labels a static file static and the Exporter's own /metrics answer metrics" do
|
|
get "/version"
|
|
counts = lambda do
|
|
%w[static metrics unmatched].to_h { |path| [path, request_counter.get(labels: { code: "200", method: "get", path: path })] }
|
|
end
|
|
before = counts.call
|
|
|
|
get "/potatomesh-logo.svg"
|
|
expect(last_response.status).to eq(200)
|
|
get "/metrics"
|
|
expect(last_response.status).to eq(200)
|
|
|
|
expect(counts.call.to_h { |path, count| [path, count - before[path]] }).to eq("static" => 1.0, "metrics" => 1.0, "unmatched" => 0.0)
|
|
expect(request_paths).not_to include("/potatomesh-logo.svg", "/metrics")
|
|
end
|
|
|
|
it "labels a regular-expression route readably, with or without its trailing slash" do
|
|
get "/map"
|
|
get "/map/"
|
|
expect(last_response.status).to eq(200)
|
|
|
|
expect(request_counter.get(labels: { code: "200", method: "get", path: "GET /map" })).to be >= 2
|
|
expect(request_paths.grep(/\\/)).to eq([])
|
|
end
|
|
end
|
|
|
|
describe "per-node series built from the node rows (SPEC PG2)" do
|
|
it "replaces a renamed node's presence series instead of adding one" do
|
|
node = "!0b0b0002"
|
|
post_nodes(node => node_entry("First Name", now - 60))
|
|
post_nodes(node => node_entry("Second Name", now - 30))
|
|
|
|
expect(scraped_samples("meshtastic_node", node)).to eq(
|
|
[%(meshtastic_node{node="#{node}",short_name="PG",long_name="Second Name",hw_model="TBEAM",role="CLIENT"} 1.0)],
|
|
)
|
|
end
|
|
|
|
it "exports a reported node from its stored row, also one this process never ingested" do
|
|
node = "!0b0b0003"
|
|
with_spec_db do |db|
|
|
store_node(db, node, heard: now - 40 * 86_400, battery_level: 77, voltage: 3.9, uptime_seconds: 600,
|
|
channel_utilization: 12.5, air_util_tx: 0.75, latitude: 48.1, longitude: 11.5, altitude: 520)
|
|
end
|
|
|
|
expect(scraped_samples("meshtastic_node_battery_level", node)).to eq([%(meshtastic_node_battery_level{node="#{node}"} 77.0)])
|
|
expect(scraped_samples("meshtastic_node_uptime_seconds", node)).to eq([%(meshtastic_node_uptime_seconds{node="#{node}"} 600.0)])
|
|
expect(scraped_samples("meshtastic_node_altitude", node)).to eq([%(meshtastic_node_altitude{node="#{node}"} 520.0)])
|
|
expect(scraped_samples("meshtastic_node", node).length).to eq(1)
|
|
end
|
|
|
|
it "follows the row when a telemetry or position packet updates it" do
|
|
node = "!0b0b0004"
|
|
post_nodes(node => node_entry("Mover", now - 60, position: { "latitude" => 52.5, "longitude" => 13.4 }))
|
|
post "/api/telemetry", [{ id: 940_001, node_id: node, rx_time: now - 20, battery_level: 55, voltage: 3.7 }].to_json, auth_headers
|
|
post "/api/positions", [{ id: 940_002, node_id: node, rx_time: now - 10, latitude: 48.1, longitude: 11.5, altitude: 520 }].to_json, auth_headers
|
|
|
|
expect(scraped_samples("meshtastic_node_battery_level", node)).to eq([%(meshtastic_node_battery_level{node="#{node}"} 55.0)])
|
|
expect(scraped_samples("meshtastic_node_latitude", node)).to eq([%(meshtastic_node_latitude{node="#{node}"} 48.1)])
|
|
expect(scraped_samples("meshtastic_node_longitude", node)).to eq([%(meshtastic_node_longitude{node="#{node}"} 11.5)])
|
|
end
|
|
end
|
|
|
|
describe "coordinate gauges (SPEC PG3)" do
|
|
it "exports no coordinate gauge for a posted off-globe position" do
|
|
far = "!0b0b0005"
|
|
near = "!0b0b0006"
|
|
post_nodes(
|
|
far => node_entry("Far", now - 30, position: { "latitude" => 95.0, "longitude" => 190.0, "altitude" => 40 }),
|
|
near => node_entry("Near", now - 30, position: { "latitude" => 52.5, "longitude" => 13.4, "altitude" => 40 }),
|
|
)
|
|
|
|
%w[latitude longitude altitude].each do |axis|
|
|
expect(scraped_samples("meshtastic_node_#{axis}", far)).to eq([]), "#{axis} of the off-globe node was exported"
|
|
end
|
|
expect(scraped_samples("meshtastic_node_latitude", near)).to eq([%(meshtastic_node_latitude{node="#{near}"} 52.5)])
|
|
expect(scraped_samples("meshtastic_node_altitude", near)).to eq([%(meshtastic_node_altitude{node="#{near}"} 40.0)])
|
|
end
|
|
|
|
it "exports no coordinate gauge for a stored off-globe row or the (0, 0) sentinel, and keeps the globe's edges" do
|
|
with_spec_db do |db|
|
|
store_node(db, "!0b0b0007", heard: now, latitude: 398.761944, longitude: 332.909167, altitude: 10)
|
|
store_node(db, "!0b0b0008", heard: now, latitude: 0.0, longitude: 0.0, altitude: 10)
|
|
store_node(db, "!0b0b0009", heard: now, latitude: 52.5, longitude: 200.0, altitude: 10)
|
|
store_node(db, "!0b0b000a", heard: now, latitude: -90.0, longitude: 180.0, altitude: 10)
|
|
end
|
|
|
|
%w[!0b0b0007 !0b0b0008 !0b0b0009].each do |node|
|
|
%w[latitude longitude altitude].each do |axis|
|
|
expect(scraped_samples("meshtastic_node_#{axis}", node)).to eq([]), "#{axis} of #{node} was exported"
|
|
end
|
|
expect(scraped_samples("meshtastic_node", node).length).to eq(1)
|
|
end
|
|
expect(scraped_samples("meshtastic_node_latitude", "!0b0b000a")).to eq([%(meshtastic_node_latitude{node="!0b0b000a"} -90.0)])
|
|
expect(scraped_samples("meshtastic_node_longitude", "!0b0b000a")).to eq([%(meshtastic_node_longitude{node="!0b0b000a"} 180.0)])
|
|
expect(scraped_samples("meshtastic_node_altitude", "!0b0b000a")).to eq([%(meshtastic_node_altitude{node="!0b0b000a"} 10.0)])
|
|
end
|
|
end
|
|
|
|
describe "node count gauge (SPEC PG4)" do
|
|
it "counts every node GET /api/nodes lists, past its 1000-row cap" do
|
|
with_spec_db do |db|
|
|
3000.times { |index| store_node(db, format("!0c%06x", index), heard: now - 60 - index) }
|
|
end
|
|
|
|
post_nodes("!0b0b0fff" => node_entry("Trigger", now))
|
|
|
|
expect(scraped_samples("meshtastic_nodes")).to eq(["meshtastic_nodes 3001.0"])
|
|
end
|
|
|
|
it "counts with the filters of GET /api/nodes and seeds the gauge at boot" do
|
|
with_spec_db do |db|
|
|
store_node(db, "!0b0b1001", heard: now - 60)
|
|
store_node(db, "!0b0b1002", heard: now - 60, long_name: "Quiet #{marker} Station")
|
|
store_node(db, "!0b0b1003", heard: now - 60, short_name: "Q#{marker}")
|
|
store_node(db, "!0b0b1004", heard: now - 8 * 86_400)
|
|
store_node(db, "!0b0b1005", heard: now - 60, role: "CLIENT_HIDDEN")
|
|
end
|
|
|
|
PotatoMesh::Application.update_all_prometheus_metrics_from_nodes
|
|
expect(scraped_samples("meshtastic_nodes")).to eq(["meshtastic_nodes 2.0"])
|
|
get "/api/nodes?limit=1000"
|
|
expect(JSON.parse(last_response.body).length).to eq(2)
|
|
|
|
ENV["PRIVATE"] = "1"
|
|
PotatoMesh::App::ApiCache.invalidate_all
|
|
post_nodes("!0b0b1006" => node_entry("Private Trigger", now))
|
|
expect(scraped_samples("meshtastic_nodes")).to eq(["meshtastic_nodes 2.0"])
|
|
get "/api/nodes?limit=1000"
|
|
expect(JSON.parse(last_response.body).length).to eq(2)
|
|
end
|
|
end
|
|
end
|