Files
secs-gem/tests/test_metrics_prometheus.cpp
raphael 6c6dc84c22 metrics: Prometheus exporter sample + worked INTEGRATION example
README §3 promised a monitoring story ("aggregate into Prometheus via
a sidecar that polls the data model").  Nothing shipped.  Customers
running a real fab without a metrics pipeline find out about T7
storms, spool blowups, and stalled CJs after their MES does — not
the position you want SRE in.

This commit ships:

- include/secsgem/metrics/prometheus.hpp: header-only.  A Registry
  (counters + gauges + HELP/TYPE descriptions, label-keyed,
  mutex-guarded so updates from the io thread and scrape renders from
  the same io serialize cleanly) plus a PrometheusServer (asio
  acceptor, replies to any GET with the text-exposition rendering,
  no auth — drop nginx in front for that).

- tests/test_metrics_prometheus.cpp: 3 cases / 19 assertions.
  Render counter+gauge with labels, scrape via raw TCP and parse the
  HTTP body, verify live updates land on subsequent scrapes.

- INTEGRATION.md §6.4: worked example that pairs the exporter with the
  Connection + EquipmentDataModel hooks documented in §6.1/§6.2.
  Shows the wrap-around-handler trick for message counters, a 5s
  polling timer for gauges (spool depth, active alarms), and the
  expected /metrics output.

Deliberately *not* shipped:
- A StandardMetrics helper that auto-wires everything — would force
  a single hook owner per store, breaking customers who want
  composable observers.  Customers wire what they need; the registry
  gives them counters + gauges + an HTTP endpoint, no policy.
- TLS / auth on the HTTP endpoint.  Reverse-proxy territory.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 14:41:01 +02:00

114 lines
3.8 KiB
C++

// Prometheus exporter test. Spins up the registry + HTTP server on a
// loopback port, scrapes via raw TCP, and asserts the text-exposition
// format renders correctly with counters, gauges, labels, and HELP/TYPE
// metadata.
#include <doctest/doctest.h>
#include <asio.hpp>
#include <chrono>
#include <memory>
#include <string>
#include "secsgem/metrics/prometheus.hpp"
using namespace secsgem::metrics;
using namespace std::chrono_literals;
namespace {
// Fire one HTTP GET / against the server and return the body.
std::string scrape(asio::io_context& io, uint16_t port) {
asio::ip::tcp::socket sock(io);
asio::error_code ec;
sock.connect({asio::ip::address_v4::loopback(), port}, ec);
REQUIRE_FALSE(ec);
const std::string req = "GET /metrics HTTP/1.1\r\nHost: localhost\r\n\r\n";
asio::write(sock, asio::buffer(req), ec);
REQUIRE_FALSE(ec);
asio::streambuf buf;
asio::read(sock, buf, ec);
// EOF is expected (server closes after reply).
REQUIRE((ec == asio::error::eof || !ec));
std::istream is(&buf);
std::string line, headers, body;
bool in_body = false;
while (std::getline(is, line)) {
if (in_body) body += line + "\n";
else if (line == "\r" || line.empty()) in_body = true;
else headers += line + "\n";
}
return body;
}
} // namespace
TEST_CASE("Prometheus: render counters and gauges") {
Registry r;
r.describe("secsgem_messages_total", "messages dispatched", MetricType::Counter);
r.inc("secsgem_messages_total", {{"dir", "rx"}, {"stream", "1"}}, 3);
r.inc("secsgem_messages_total", {{"dir", "tx"}, {"stream", "1"}}, 5);
r.set_gauge("secsgem_spool_depth", 42);
const auto text = r.render();
CHECK(text.find("# HELP secsgem_messages_total messages dispatched")
!= std::string::npos);
CHECK(text.find("# TYPE secsgem_messages_total counter") != std::string::npos);
CHECK(text.find("secsgem_messages_total{dir=\"rx\",stream=\"1\"} 3")
!= std::string::npos);
CHECK(text.find("secsgem_messages_total{dir=\"tx\",stream=\"1\"} 5")
!= std::string::npos);
CHECK(text.find("# TYPE secsgem_spool_depth gauge") != std::string::npos);
CHECK(text.find("secsgem_spool_depth 42") != std::string::npos);
}
TEST_CASE("Prometheus: HTTP server serves /metrics") {
asio::io_context server_io;
auto reg = std::make_shared<Registry>();
reg->describe("secsgem_test_total", "test counter", MetricType::Counter);
reg->inc("secsgem_test_total", {{"path", "happy"}}, 7);
reg->set_gauge("secsgem_test_gauge", 3.14);
// Port 0 → OS picks a free one.
auto server = std::make_shared<PrometheusServer>(server_io, 0, reg);
server->start();
const auto port = server->port();
std::thread server_thread([&] { server_io.run(); });
// Give the acceptor a tick to arm.
std::this_thread::sleep_for(50ms);
asio::io_context client_io;
const auto body = scrape(client_io, port);
CHECK(body.find("secsgem_test_total{path=\"happy\"} 7") != std::string::npos);
CHECK(body.find("secsgem_test_gauge 3.14") != std::string::npos);
server_io.stop();
server_thread.join();
}
TEST_CASE("Prometheus: live updates picked up on next scrape") {
asio::io_context server_io;
auto reg = std::make_shared<Registry>();
auto server = std::make_shared<PrometheusServer>(server_io, 0, reg);
server->start();
const auto port = server->port();
std::thread server_thread([&] { server_io.run(); });
std::this_thread::sleep_for(50ms);
reg->inc("secsgem_dynamic_total", {{"k", "v"}});
asio::io_context c1;
auto body1 = scrape(c1, port);
CHECK(body1.find("secsgem_dynamic_total{k=\"v\"} 1") != std::string::npos);
reg->inc("secsgem_dynamic_total", {{"k", "v"}}, 4);
asio::io_context c2;
auto body2 = scrape(c2, port);
CHECK(body2.find("secsgem_dynamic_total{k=\"v\"} 5") != std::string::npos);
server_io.stop();
server_thread.join();
}