diff --git a/net/net-http_server.c++m b/net/net-http_server.c++m index 1cebb29..1c05de1 100644 --- a/net/net-http_server.c++m +++ b/net/net-http_server.c++m @@ -637,6 +637,8 @@ private: method_allowed = true; try { + // Handlers receive URI only; expose method for metrics middleware. + hs.set("x-http-method"s, method); std::tie(res_status, res_content, res_type, custom_headers) = it->second.render(uri, body, hs); if(not res_type.empty()) break; diff --git a/net/net-http_server_middlewares.c++m b/net/net-http_server_middlewares.c++m index 76629dd..6ef8246 100644 --- a/net/net-http_server_middlewares.c++m +++ b/net/net-http_server_middlewares.c++m @@ -65,8 +65,7 @@ inline callback_with_headers wrap(callback_with_headers endpoint, std::span 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 +inline constexpr std::array http_metrics_histogram_bounds = { + 0.0001, 0.00025, 0.0005, 0.001, 0.0025, 0.005, 0.01, 0.025, + 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0 }; +// Stable decimal le labels (avoid std::format scientific notation like 1e-04). +inline std::string format_histogram_le(double bound) +{ + auto s = std::format("{:.10f}", bound); + while(s.size() > 1 and s.back() == '0') + s.pop_back(); + if(not s.empty() and s.back() == '.') + s.pop_back(); + return s; +} + struct metrics_label_key { std::string method; @@ -498,6 +511,9 @@ struct metrics_registry durations.clear(); } + // Prometheus histogram buckets are cumulative: an observation of d seconds + // increments every bucket with le >= d (and +Inf). Fast requests therefore + // raise all bucket lines to the same count — that is expected, not a bug. void observe(std::string_view method, std::string_view status, double duration_seconds) { auto key = metrics_label_key{std::string{method}, std::string{status}}; @@ -544,7 +560,7 @@ struct metrics_registry "http_request_duration_seconds_bucket{{method=\"{}\",status=\"{}\",le=\"{}\"}} {}\n", key.method, key.status, - http_metrics_histogram_bounds[i], + format_histogram_le(http_metrics_histogram_bounds[i]), series.buckets[i]); } std::format_to( @@ -585,6 +601,8 @@ inline std::string_view metrics_status_code(std::string_view status_line) return status_line.substr(0, space); } +inline constexpr auto metrics_method_header = "x-http-method"sv; + inline std::pair metrics_method_and_target(std::string_view request_line) { auto rest = request_line; @@ -594,10 +612,11 @@ inline std::pair metrics_method_and_target(s if(rest.empty()) return {"UNKNOWN"sv, "/"sv}; + // URI-only (how http::server calls handlers) — method comes from x-http-method when set. if(rest.front() == '/') { const auto sp = rest.find(' '); - return {"GET"sv, sp == std::string_view::npos ? rest : rest.substr(0, sp)}; + return {"UNKNOWN"sv, sp == std::string_view::npos ? rest : rest.substr(0, sp)}; } const auto method_end = rest.find(' '); @@ -614,6 +633,16 @@ inline std::pair metrics_method_and_target(s return {method, target.empty() ? "/"sv : target}; } +inline std::pair metrics_resolve_method_and_target( + std::string_view req, + const headers& hdr) +{ + auto [parsed_method, target] = metrics_method_and_target(req); + if(hdr.contains(std::string{metrics_method_header})) + return {hdr[std::string{metrics_method_header}], target}; + return {std::string{parsed_method}, target}; +} + inline bool is_metrics_scrape_request(std::string_view method, std::string_view target) { if(method != "GET"sv and method != "get"sv) @@ -629,13 +658,14 @@ inline bool is_metrics_scrape_request(std::string_view method, std::string_view // Middleware: Prometheus metrics — records method/status counters and latency histogram; // short-circuits GET /metrics with Prometheus text exposition (scrapes are not counted). +// Prefer method from x-http-method (set by http::server); fall back to parsing a request line. 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); + const auto [method, target] = metrics_resolve_method_and_target(req, hdr); if(is_metrics_scrape_request(method, target)) { diff --git a/net/net-http_server_middlewares.test.c++ b/net/net-http_server_middlewares.test.c++ index 9a1feed..709b1f2 100644 --- a/net/net-http_server_middlewares.test.c++ +++ b/net/net-http_server_middlewares.test.c++ @@ -837,6 +837,56 @@ auto register_middleware_tests() }; }; + tester::bdd::scenario("metrics_middleware - uses x-http-method with URI-only request, [net]") = [] { + tester::bdd::given("A metrics middleware mimicking http::server (URI + method header)") = [] { + ::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_created, "created"s, std::optional<::http::headers>{}); + }; + auto wrapped = mw(handler); + + tester::bdd::when("Handling POST /users with x-http-method and scraping") = [wrapped]() mutable { + auto headers = ::http::headers{}; + headers.set("x-http-method"s, "POST"s); + (void)wrapped("/users"sv, "{}"sv, headers); + + auto scrape_headers = ::http::headers{}; + scrape_headers.set("x-http-method"s, "GET"s); + auto [scrape_status, scrape_body, scrape_hdrs] = wrapped("/metrics"sv, ""sv, scrape_headers); + + tester::bdd::then("Scrape shows POST 201 not GET") = [scrape_status, scrape_body] { + check_eq(scrape_status, ::http::status_ok); + check_contains(scrape_body, "http_requests_total{method=\"POST\",status=\"201\"} 1"); + check_true(scrape_body.find("http_requests_total{method=\"GET\"") == std::string::npos); + }; + }; + }; + }; + + tester::bdd::scenario("metrics histogram buckets are cumulative, [net]") = [] { + tester::bdd::given("A reset metrics registry") = [] { + ::http::middleware::http_metrics().reset(); + + tester::bdd::when("Observing one 1.5ms request") = [] { + // Fine low-end bounds: 0.0015 first lands in le=0.0025. + ::http::middleware::http_metrics().observe("GET"sv, "200"sv, 0.0015); + const auto body = ::http::middleware::http_metrics().render_prometheus_text(); + + tester::bdd::then("Only buckets with le >= 0.0015 are incremented") = [body] { + check_contains(body, R"(http_request_duration_seconds_bucket{method="GET",status="200",le="0.0001"} 0)"); + check_contains(body, R"(http_request_duration_seconds_bucket{method="GET",status="200",le="0.00025"} 0)"); + check_contains(body, R"(http_request_duration_seconds_bucket{method="GET",status="200",le="0.0005"} 0)"); + check_contains(body, R"(http_request_duration_seconds_bucket{method="GET",status="200",le="0.001"} 0)"); + check_contains(body, R"(http_request_duration_seconds_bucket{method="GET",status="200",le="0.0025"} 1)"); + check_contains(body, R"(http_request_duration_seconds_bucket{method="GET",status="200",le="0.005"} 1)"); + check_contains(body, R"(http_request_duration_seconds_bucket{method="GET",status="200",le="+Inf"} 1)"); + check_contains(body, R"(http_request_duration_seconds_count{method="GET",status="200"} 1)"); + }; + }; + }; + }; + return true; }