From 6011a6054e207a58b2a458046861d5f98fa2bb1d Mon Sep 17 00:00:00 2001 From: --global Date: Thu, 16 Jul 2026 15:26:15 +0300 Subject: [PATCH] feat: add Prometheus HTTP metrics middleware Record method/status counters and latency histograms, and serve GET /metrics as Prometheus text exposition without counting scrapes. Co-authored-by: Cursor --- net/net-http_server_middlewares.c++m | 191 +++++++++++++++++++++++ net/net-http_server_middlewares.test.c++ | 77 +++++++++ 2 files changed, 268 insertions(+) diff --git a/net/net-http_server_middlewares.c++m b/net/net-http_server_middlewares.c++m index d22cbf2..163d7c6 100644 --- a/net/net-http_server_middlewares.c++m +++ b/net/net-http_server_middlewares.c++m @@ -463,6 +463,197 @@ inline auto cors_middleware( }; } +// Prometheus HTTP metrics (minimum): request counters + duration histogram. +// Labels: method + status only. GET /metrics is served by the middleware (not counted). + +inline constexpr std::array http_metrics_histogram_bounds = { + 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0 +}; + +struct metrics_label_key +{ + std::string method; + std::string status; + + friend auto operator<=>(const metrics_label_key&, const metrics_label_key&) = default; +}; + +struct histogram_series +{ + std::array buckets{}; + double sum = 0.0; + std::uint64_t count = 0; +}; + +struct metrics_registry +{ + std::mutex mutex; + std::flat_map request_totals; + std::flat_map durations; + + void reset() + { + std::lock_guard lock{mutex}; + request_totals.clear(); + durations.clear(); + } + + void observe(std::string_view method, std::string_view status, double duration_seconds) + { + auto key = metrics_label_key{std::string{method}, std::string{status}}; + + std::lock_guard lock{mutex}; + ++request_totals[key]; + + auto& series = durations[key]; + for(auto i = std::size_t{0}; i < http_metrics_histogram_bounds.size(); ++i) + { + if(duration_seconds <= http_metrics_histogram_bounds[i]) + ++series.buckets[i]; + } + series.sum += duration_seconds; + ++series.count; + } + + std::string render_prometheus_text() + { + std::lock_guard lock{mutex}; + auto out = std::string{}; + out.reserve(512 + request_totals.size() * 64); + + out += "# HELP http_requests_total Total HTTP requests\n"s; + out += "# TYPE http_requests_total counter\n"s; + for(const auto& [key, total] : request_totals) + { + std::format_to( + std::back_inserter(out), + "http_requests_total{{method=\"{}\",status=\"{}\"}} {}\n", + key.method, + key.status, + total); + } + + out += "# HELP http_request_duration_seconds HTTP request duration\n"s; + out += "# TYPE http_request_duration_seconds histogram\n"s; + for(const auto& [key, series] : durations) + { + for(auto i = std::size_t{0}; i < http_metrics_histogram_bounds.size(); ++i) + { + std::format_to( + std::back_inserter(out), + "http_request_duration_seconds_bucket{{method=\"{}\",status=\"{}\",le=\"{}\"}} {}\n", + key.method, + key.status, + http_metrics_histogram_bounds[i], + series.buckets[i]); + } + std::format_to( + std::back_inserter(out), + "http_request_duration_seconds_bucket{{method=\"{}\",status=\"{}\",le=\"+Inf\"}} {}\n", + key.method, + key.status, + series.count); + std::format_to( + std::back_inserter(out), + "http_request_duration_seconds_sum{{method=\"{}\",status=\"{}\"}} {}\n", + key.method, + key.status, + series.sum); + std::format_to( + std::back_inserter(out), + "http_request_duration_seconds_count{{method=\"{}\",status=\"{}\"}} {}\n", + key.method, + key.status, + series.count); + } + + return out; + } +}; + +inline metrics_registry& http_metrics() +{ + static metrics_registry registry; + return registry; +} + +inline std::string_view metrics_status_code(std::string_view status_line) +{ + const auto space = status_line.find(' '); + if(space == std::string_view::npos) + return status_line.empty() ? "000"sv : status_line; + return status_line.substr(0, space); +} + +inline std::pair metrics_method_and_target(std::string_view request_line) +{ + auto rest = request_line; + while(not rest.empty() && rest.front() == ' ') + rest.remove_prefix(1); + + if(rest.empty()) + return {"UNKNOWN"sv, "/"sv}; + + if(rest.front() == '/') + { + const auto sp = rest.find(' '); + return {"GET"sv, sp == std::string_view::npos ? rest : rest.substr(0, sp)}; + } + + const auto method_end = rest.find(' '); + if(method_end == std::string_view::npos) + return {rest, "/"sv}; + + const auto method = rest.substr(0, method_end); + auto target = rest.substr(method_end + 1); + while(not target.empty() && target.front() == ' ') + target.remove_prefix(1); + const auto target_end = target.find(' '); + if(target_end != std::string_view::npos) + target = target.substr(0, target_end); + return {method, target.empty() ? "/"sv : target}; +} + +inline bool is_metrics_scrape_request(std::string_view method, std::string_view target) +{ + if(method != "GET"sv && method != "get"sv) + return false; + + auto path = target; + const auto q = path.find('?'); + if(q != std::string_view::npos) + path = path.substr(0, q); + + return path == "/metrics"sv; +} + +// Middleware: Prometheus metrics — records method/status counters and latency histogram; +// short-circuits GET /metrics with Prometheus text exposition (scrapes are not counted). +inline auto metrics_middleware() +{ + return [](callback_with_headers next) + { + return [next = std::move(next)](auto req, auto body, auto hdr) mutable + { + const auto [method, target] = metrics_method_and_target(req); + + if(is_metrics_scrape_request(method, target)) + { + auto response_headers = headers{}; + response_headers.set("content-type"s, "text/plain; version=0.0.4; charset=utf-8"s); + return make_response(status_ok, http_metrics().render_prometheus_text(), std::make_optional(response_headers)); + } + + const auto started = std::chrono::steady_clock::now(); + auto response = next(req, body, hdr); + const auto elapsed = std::chrono::duration(std::chrono::steady_clock::now() - started).count(); + + http_metrics().observe(method, metrics_status_code(std::get<0>(response)), elapsed); + return response; + }; + }; +} + } // namespace middleware } // namespace http diff --git a/net/net-http_server_middlewares.test.c++ b/net/net-http_server_middlewares.test.c++ index 93df159..26a4103 100644 --- a/net/net-http_server_middlewares.test.c++ +++ b/net/net-http_server_middlewares.test.c++ @@ -760,6 +760,83 @@ auto register_middleware_tests() }; }; + tester::bdd::scenario("metrics_middleware - records OK request and scrape body, [net]") = [] { + tester::bdd::given("A metrics middleware with a successful handler") = [] { + ::http::middleware::http_metrics().reset(); + auto mw = ::http::middleware::metrics_middleware(); + auto handler = [](auto&&, auto&&, auto&&) -> ::http::response_with_headers { + return ::http::make_response(::http::status_ok, "OK"s, std::optional<::http::headers>{}); + }; + auto wrapped = mw(handler); + + tester::bdd::when("Handling one GET and scraping /metrics") = [wrapped]() mutable { + auto headers = ::http::headers{}; + auto [status, content, hdrs] = wrapped("GET /users HTTP/1.1"sv, ""sv, headers); + check_eq(status, ::http::status_ok); + + auto scrape_headers = ::http::headers{}; + auto [scrape_status, scrape_body, scrape_hdrs] = wrapped("GET /metrics HTTP/1.1"sv, ""sv, scrape_headers); + + tester::bdd::then("Scrape shows counter and histogram for 200") = [scrape_status, scrape_body, scrape_hdrs] { + check_eq(scrape_status, ::http::status_ok); + check_contains(scrape_body, "http_requests_total{method=\"GET\",status=\"200\"} 1"); + check_contains(scrape_body, "http_request_duration_seconds_count{method=\"GET\",status=\"200\"} 1"); + check_true(scrape_hdrs.has_value()); + if(scrape_hdrs.has_value()) + { + check_true(scrape_hdrs->contains("content-type"s)); + check_contains((*scrape_hdrs)["content-type"s], "text/plain"); + } + }; + }; + }; + }; + + tester::bdd::scenario("metrics_middleware - records error status, [net]") = [] { + tester::bdd::given("A metrics middleware with a 404 handler") = [] { + ::http::middleware::http_metrics().reset(); + auto mw = ::http::middleware::metrics_middleware(); + auto handler = [](auto&&, auto&&, auto&&) -> ::http::response_with_headers { + return ::http::make_response(::http::status_not_found, "missing"s, std::optional<::http::headers>{}); + }; + auto wrapped = mw(handler); + + tester::bdd::when("Handling one GET that returns 404") = [wrapped]() mutable { + auto headers = ::http::headers{}; + (void)wrapped("GET /missing HTTP/1.1"sv, ""sv, headers); + auto scrape_headers = ::http::headers{}; + auto [scrape_status, scrape_body, scrape_hdrs] = wrapped("GET /metrics HTTP/1.1"sv, ""sv, scrape_headers); + + tester::bdd::then("Scrape shows the 404 series") = [scrape_status, scrape_body] { + check_eq(scrape_status, ::http::status_ok); + check_contains(scrape_body, "http_requests_total{method=\"GET\",status=\"404\"} 1"); + }; + }; + }; + }; + + tester::bdd::scenario("metrics_middleware - scrape does not inflate request totals, [net]") = [] { + tester::bdd::given("A reset metrics registry") = [] { + ::http::middleware::http_metrics().reset(); + auto mw = ::http::middleware::metrics_middleware(); + auto handler = [](auto&&, auto&&, auto&&) -> ::http::response_with_headers { + return ::http::make_response(::http::status_ok, "OK"s, std::optional<::http::headers>{}); + }; + auto wrapped = mw(handler); + + tester::bdd::when("Only scraping /metrics") = [wrapped]() mutable { + auto headers = ::http::headers{}; + auto [scrape_status, scrape_body, scrape_hdrs] = wrapped("GET /metrics HTTP/1.1"sv, ""sv, headers); + + tester::bdd::then("Body has HELP lines but no request totals") = [scrape_status, scrape_body] { + check_eq(scrape_status, ::http::status_ok); + check_contains(scrape_body, "# TYPE http_requests_total counter"); + check_true(scrape_body.find("http_requests_total{") == std::string::npos); + }; + }; + }; + }; + return true; }