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>
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
// 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();
|
||||
}
|
||||
Reference in New Issue
Block a user