From d43925db5ce67a84437cb2715020f0045f9fbbaf Mon Sep 17 00:00:00 2001 From: Hesam Norin Date: Thu, 8 Jan 2026 18:38:50 +0330 Subject: [PATCH] plugins(stats_over_http): Add Prometheus v2 output with metric labels The current Prometheus output in stats_over_http dumps flattened metric names (e.g., proxy.process.http.200_responses), which makes it difficult to aggregate data in Grafana or Prometheus. This adds a new "v2" output format that parses these names into proper labels like status="200" or method="GET". The new format can be accessed via /_stats/prometheus_v2 or by passing an Accept header with version=2.0.0. To keep this efficient, the parsing uses swoc::TextView for a zero- allocation approach. It recognizes common ATS patterns such as status codes, HTTP methods, cache results, and thread/volume indices. I've also added deduplication for the HELP and TYPE metadata lines, so that metrics sharing the same base name (but different labels) are grouped correctly in the output. Includes compile-time unit tests (static_assert) to verify the parsing logic and updated AuTests for the new endpoint. --- .../plugins/stats_over_http.en.rst | 24 +- plugins/stats_over_http/stats_over_http.cc | 410 +++++++++++++++++- ...over_http_prometheus_v2_accept_stderr.gold | 3 + .../stats_over_http_prometheus_v2_stderr.gold | 12 + .../stats_over_http/stats_over_http.test.py | 66 +++ 5 files changed, 487 insertions(+), 28 deletions(-) create mode 100644 tests/gold_tests/pluginTest/stats_over_http/gold/stats_over_http_prometheus_v2_accept_stderr.gold create mode 100644 tests/gold_tests/pluginTest/stats_over_http/gold/stats_over_http_prometheus_v2_stderr.gold diff --git a/doc/admin-guide/plugins/stats_over_http.en.rst b/doc/admin-guide/plugins/stats_over_http.en.rst index 93d82422341..264109cdfbd 100644 --- a/doc/admin-guide/plugins/stats_over_http.en.rst +++ b/doc/admin-guide/plugins/stats_over_http.en.rst @@ -105,21 +105,29 @@ if you wish to have it in CSV format you can do so by passing an ``Accept`` head .. option:: Accept: text/csv -Prometheus formatted output is also supported via the ``Accept`` header: +Prometheus formatted output is also supported via the ``Accept`` header. Version 0.0.4 +(flat metric names) and version 2.0.0 (labeled metrics for better aggregation) +are supported: .. option:: Accept: text/plain; version=0.0.4 +.. option:: Accept: text/plain; version=2.0.0 Alternatively, the output format can be specified as a suffix to the configured path in the HTTP request target. The supported suffixes are ``/json``, -``/csv``, and ``/prometheus``. For example, if the path is set to ``/_stats`` -(the default), you can access the stats in CSV format by using the URL:: +``/csv``, ``/prometheus``, and ``/prometheus_v2``. For example, if the path +is set to ``/_stats`` (the default), you can access the stats in CSV format by +using the URL:: http://host:port/_stats/csv -The Prometheus format can be requested by using the URL:: +The Prometheus version 0.0.4 format (flat) can be requested by using the URL:: http://host:port/_stats/prometheus +The Prometheus v2 labeled format can be requested by using the URL:: + + http://host:port/_stats/prometheus_v2 + The JSON format is the default, but you can also access it explicitly by using the URL:: http://host:port/_stats/json @@ -129,9 +137,11 @@ specify a path suffix, the plugin will return the data in that format regardless the ``Accept`` header. In either case the ``Content-Type`` header returned by ``stats_over_http.so`` will -reflect the content that has been returned: ``text/json``, ``text/csv``, or -``text/plain; version=0.0.4; charset=utf-8`` for JSON, CSV, and Prometheus -formats respectively. +reflect the content that has been returned: ``text/json``, ``text/csv``, +``text/plain; version=0.0.4; charset=utf-8``, or +``text/plain; version=2.0.0; charset=utf-8`` for JSON, CSV, Prometheus v1, and +Prometheus v2 formats respectively. + Stats over http also accepts returning data in gzip or br compressed format per the ``Accept-encoding`` header. If the header is present, the plugin will return the diff --git a/plugins/stats_over_http/stats_over_http.cc b/plugins/stats_over_http/stats_over_http.cc index b02b17c44e2..bbeaf97fced 100644 --- a/plugins/stats_over_http/stats_over_http.cc +++ b/plugins/stats_over_http/stats_over_http.cc @@ -39,6 +39,9 @@ #include #include #include +#include +#include +#include #include #include @@ -92,6 +95,30 @@ const int BROTLI_LGW = 16; static bool integer_counters = false; static bool wrap_counters = false; +struct prometheus_v2_metric { + std::string name; + std::string labels; + + bool + operator==(const prometheus_v2_metric &other) const + { + return name == other.name && labels == other.labels; + } +}; + +namespace std +{ +template <> struct hash { + size_t + operator()(const prometheus_v2_metric &m) const + { + size_t h1 = hash()(m.name); + size_t h2 = hash()(m.labels); + return h1 ^ (h2 + 0x9e3779b9 + (h1 << 6) + (h1 >> 2)); + } +}; +} // namespace std + struct config_t { unsigned int recordTypes; std::string stats_path; @@ -103,7 +130,7 @@ struct config_holder_t { config_t *config; }; -enum class output_format_t { JSON_OUTPUT, CSV_OUTPUT, PROMETHEUS_OUTPUT }; +enum class output_format_t { JSON_OUTPUT, CSV_OUTPUT, PROMETHEUS_OUTPUT, PROMETHEUS_V2_OUTPUT }; enum class encoding_format_t { NONE, DEFLATE, GZIP, BR }; int configReloadRequests = 0; @@ -147,11 +174,12 @@ struct stats_state { TSIOBuffer resp_buffer = nullptr; TSIOBufferReader resp_reader = nullptr; - int output_bytes = 0; - int body_written = 0; - output_format_t output_format = output_format_t::JSON_OUTPUT; - encoding_format_t encoding = encoding_format_t::NONE; - z_stream zstrm; + int64_t output_bytes = 0; + int body_written = 0; + output_format_t output_format = output_format_t::JSON_OUTPUT; + encoding_format_t encoding = encoding_format_t::NONE; + z_stream zstrm; + std::unordered_map prometheus_v2_emitted; #if HAVE_BROTLI_ENCODE_H b_stream bstrm; #endif @@ -168,6 +196,9 @@ struct stats_state { static char * nstr(const char *s) { + if (!s) { + return nullptr; + } char *mys = (char *)TSmalloc(strlen(s) + 1); strcpy(mys, s); return mys; @@ -246,7 +277,9 @@ stats_cleanup(TSCont contp, stats_state *my_state) my_state->resp_buffer = nullptr; } - TSVConnClose(my_state->net_vc); + if (my_state->net_vc) { + TSVConnClose(my_state->net_vc); + } delete my_state; TSContDestroy(contp); } @@ -260,10 +293,13 @@ stats_process_accept(TSCont contp, stats_state *my_state) my_state->read_vio = TSVConnRead(my_state->net_vc, contp, my_state->req_buffer, INT64_MAX); } -static int +static int64_t stats_add_data_to_resp_buffer(const char *s, stats_state *my_state) { - int s_len = strlen(s); + if (!s) { + return 0; + } + int64_t s_len = strlen(s); TSIOBufferWrite(my_state->resp_buffer, s, s_len); @@ -293,6 +329,15 @@ static const char RESP_HEADER_PROMETHEUS_DEFLATE[] = "no-cache\r\n\r\n"; static const char RESP_HEADER_PROMETHEUS_BR[] = "HTTP/1.0 200 OK\r\nContent-Type: text/plain; version=0.0.4; " "charset=utf-8\r\nContent-Encoding: br\r\nCache-Control: no-cache\r\n\r\n"; +static const char RESP_HEADER_PROMETHEUS_V2[] = + "HTTP/1.0 200 OK\r\nContent-Type: text/plain; version=2.0.0; charset=utf-8\r\nCache-Control: no-cache\r\n\r\n"; +static const char RESP_HEADER_PROMETHEUS_V2_GZIP[] = "HTTP/1.0 200 OK\r\nContent-Type: text/plain; version=2.0.0; " + "charset=utf-8\r\nContent-Encoding: gzip\r\nCache-Control: no-cache\r\n\r\n"; +static const char RESP_HEADER_PROMETHEUS_V2_DEFLATE[] = + "HTTP/1.0 200 OK\r\nContent-Type: text/plain; version=2.0.0; charset=utf-8\r\nContent-Encoding: deflate\r\nCache-Control: " + "no-cache\r\n\r\n"; +static const char RESP_HEADER_PROMETHEUS_V2_BR[] = "HTTP/1.0 200 OK\r\nContent-Type: text/plain; version=2.0.0; " + "charset=utf-8\r\nContent-Encoding: br\r\nCache-Control: no-cache\r\n\r\n"; static int stats_add_resp_header(stats_state *my_state) @@ -331,6 +376,17 @@ stats_add_resp_header(stats_state *my_state) return stats_add_data_to_resp_buffer(RESP_HEADER_PROMETHEUS, my_state); } break; + case output_format_t::PROMETHEUS_V2_OUTPUT: + if (my_state->encoding == encoding_format_t::GZIP) { + return stats_add_data_to_resp_buffer(RESP_HEADER_PROMETHEUS_V2_GZIP, my_state); + } else if (my_state->encoding == encoding_format_t::DEFLATE) { + return stats_add_data_to_resp_buffer(RESP_HEADER_PROMETHEUS_V2_DEFLATE, my_state); + } else if (my_state->encoding == encoding_format_t::BR) { + return stats_add_data_to_resp_buffer(RESP_HEADER_PROMETHEUS_V2_BR, my_state); + } else { + return stats_add_data_to_resp_buffer(RESP_HEADER_PROMETHEUS_V2, my_state); + } + break; } // Not reached. return stats_add_data_to_resp_buffer(RESP_HEADER_JSON, my_state); @@ -509,32 +565,290 @@ static return sanitized_name; } +/** Parse a Prometheus v2 metric name and return the base name and labels. + * + * @param[in] name The metric name to parse. + * @return A prometheus_v2_metric struct containing the base name and labels. + */ +static +// Remove this check when we drop support for pre-13 GCC versions. +#if defined(__cpp_lib_constexpr_string) && __cpp_lib_constexpr_string >= 201907L +// Clang <= 16 doesn't fully support constexpr std::string. +#if !defined(__clang__) || __clang_major__ > 16 + constexpr +#endif +#endif + prometheus_v2_metric + parse_metric_v2(std::string_view name) +{ + swoc::TextView name_view{name}; + std::string labels; + std::string base_name; + + auto escape_label = [](std::string_view val) { + size_t escaped_len = 0; + for (char c : val) { + if (c == '"' || c == '\\' || c == '\n') { + escaped_len += 2; + } else { + escaped_len += 1; + } + } + + std::string escaped; + if (escaped_len > 0) { + escaped.reserve(escaped_len); + for (char c : val) { + if (c == '"' || c == '\\') { + escaped += '\\'; + escaped += c; + } else if (c == '\n') { + escaped += "\\n"; + } else { + escaped += c; + } + } + } + return escaped; + }; + + auto add_label = [&](std::string_view key, std::string_view val) { + if (!labels.empty()) { + labels += ", "; + } + labels += key; + labels += "=\""; + labels += escape_label(val); + labels += "\""; + }; + + constexpr std::string_view methods[] = {"get", "post", "head", "put", "delete", "options", + "trace", "connect", "push", "purge", "extension_method", "incoming", + "outgoing", "completed", "invalid_client"}; + constexpr std::string_view results[] = {"hit", "miss", "error", "errors", "success", "failure"}; + constexpr std::string_view categories[] = {"volume", "thread", "interface", "net", "host", "port"}; + + auto contains = [](const std::string_view *arr, size_t size, std::string_view token) { + for (size_t i = 0; i < size; ++i) { + if (arr[i] == token) { + return true; + } + } + return false; + }; + + while (!name_view.empty()) { + // take_prefix_at is not constexpr in some versions of swoc, so we do it manually if needed + // or just use it and see. swoc::TextView find_first_of is not constexpr. + size_t sep = name_view.find_first_of("._[]"); + swoc::TextView token; + if (sep == swoc::TextView::npos) { + token = name_view; + name_view.clear(); + } else { + token = name_view.prefix(sep); + name_view.remove_prefix(sep + 1); + } + + if (token.empty()) { + continue; + } + + bool token_handled = false; + + // Status codes (200, 4xx, etc.) + if (token.length() == 3 && (token[0] >= '0' && token[0] <= '9') && ((token[1] >= '0' && token[1] <= '9') || token[1] == 'x') && + ((token[2] >= '0' && token[2] <= '9') || token[2] == 'x')) { + add_label("status", token); + token_handled = true; + } + // Methods + else if (contains(methods, sizeof(methods) / sizeof(methods[0]), token)) { + add_label("method", token); + token_handled = true; + } + // Generic Categories + Index (volume, 0, etc.) + else if (contains(categories, sizeof(categories) / sizeof(categories[0]), token)) { + swoc::TextView next = name_view; + size_t id_sep = next.find_first_of("._[]"); + swoc::TextView id; + if (id_sep == swoc::TextView::npos) { + id = next; + next.clear(); + } else { + id = next.prefix(id_sep); + next.remove_prefix(id_sep + 1); + } + + bool is_id = !id.empty(); + for (char c : id) { + if (!(c >= '0' && c <= '9') && c != 'x') { + is_id = false; + break; + } + } + if (is_id) { + add_label(token, id); + if (!base_name.empty()) { + base_name += "."; + } + base_name += token; + name_view = next; + token_handled = true; + } + } + // Results (hit, miss) + else if (contains(results, sizeof(results) / sizeof(results[0]), token)) { + // 'hit' and 'miss' are almost always labels. + if (token == "hit" || token == "miss" || !name_view.empty()) { + add_label("result", token); + token_handled = true; + } + } + // Buckets (e.g., 10ms) + else { + constexpr std::string_view units[] = {"ms", "us", "s"}; + for (const auto &unit : units) { + size_t unit_len = unit.length(); + if (token.length() > unit_len && token.substr(token.length() - unit_len) == unit) { + bool all_digits = true; + for (size_t j = 0; j < token.length() - unit_len; ++j) { + if (!(token[j] >= '0' && token[j] <= '9')) { + all_digits = false; + break; + } + } + if (all_digits && token.length() > unit_len) { + add_label("le", token); + token_handled = true; + break; + } + } + } + } + + if (!token_handled) { + if (!base_name.empty()) { + base_name += "."; + } + base_name += token; + } + } + + return {base_name, labels}; +} + +static void +prometheus_v2_out_stat(TSRecordType /* rec_type ATS_UNUSED */, void *edata, int /* registered ATS_UNUSED */, const char *name, + TSRecordDataType data_type, TSRecordData *datum) +{ + stats_state *my_state = static_cast(edata); + + if (data_type == TS_RECORDDATATYPE_STRING) { + return; // Prometheus does not support string values. + } + + auto v2 = parse_metric_v2(name); + std::string sanitized_name = sanitize_metric_name_for_prometheus(v2.name); + + if (sanitized_name.empty()) { + return; + } + + // Only emit HELP and TYPE once per base name. + // Note: Prometheus requires all metrics with the same name to have the same type. + // If Traffic Server metrics with different types (e.g., COUNTER and INT) are collapsed + // into the same base name, the first one encountered will determine the reported TYPE. + auto it = my_state->prometheus_v2_emitted.find(sanitized_name); + if (it == my_state->prometheus_v2_emitted.end()) { + APPEND("# HELP "); + APPEND(sanitized_name.c_str()); + APPEND(" "); + APPEND(name); + APPEND("\n"); + + const char *type_str = (data_type == TS_RECORDDATATYPE_COUNTER) ? "counter" : "gauge"; + APPEND("# TYPE "); + APPEND(sanitized_name.c_str()); + APPEND(" "); + APPEND(type_str); + APPEND("\n"); + + my_state->prometheus_v2_emitted[sanitized_name] = data_type; + } else { + // Validate type consistency (at least between counter and gauge). + bool prev_is_counter = (it->second == TS_RECORDDATATYPE_COUNTER); + bool curr_is_counter = (data_type == TS_RECORDDATATYPE_COUNTER); + if (prev_is_counter != curr_is_counter) { + Dbg(dbg_ctl, "Inconsistent types for base metric %s: previously %s, now %s. Labels: %s", sanitized_name.c_str(), + prev_is_counter ? "counter" : "gauge", curr_is_counter ? "counter" : "gauge", v2.labels.c_str()); + } + } + + APPEND(sanitized_name.c_str()); + if (!v2.labels.empty()) { + APPEND("{"); + APPEND(v2.labels.c_str()); + APPEND("}"); + } + APPEND(" "); + + char val_buffer[128]; + int len = 0; + if (data_type == TS_RECORDDATATYPE_COUNTER) { + len = snprintf(val_buffer, sizeof(val_buffer), "%" PRIu64 "\n", wrap_unsigned_counter(datum->rec_counter)); + } else if (data_type == TS_RECORDDATATYPE_INT) { + len = snprintf(val_buffer, sizeof(val_buffer), "%" PRIu64 "\n", wrap_unsigned_counter(datum->rec_int)); + } else if (data_type == TS_RECORDDATATYPE_FLOAT) { + len = snprintf(val_buffer, sizeof(val_buffer), "%g\n", datum->rec_float); + } + + if (len > 0 && len < (int)sizeof(val_buffer)) { + APPEND(val_buffer); + } +} + static void prometheus_out_stat(TSRecordType /* rec_type ATS_UNUSED */, void *edata, int /* registered ATS_UNUSED */, const char *name, TSRecordDataType data_type, TSRecordData *datum) { stats_state *my_state = static_cast(edata); std::string sanitized_name = sanitize_metric_name_for_prometheus(name); - char type_buffer[256]; - char help_buffer[256]; - snprintf(help_buffer, sizeof(help_buffer), "# HELP %s %s\n", sanitized_name.c_str(), name); + if (sanitized_name.empty()) { + return; + } + switch (data_type) { case TS_RECORDDATATYPE_COUNTER: - APPEND(help_buffer); - snprintf(type_buffer, sizeof(type_buffer), "# TYPE %s counter\n", sanitized_name.c_str()); - APPEND(type_buffer); + APPEND("# HELP "); + APPEND(sanitized_name.c_str()); + APPEND(" "); + APPEND(name); + APPEND("\n"); + APPEND("# TYPE "); + APPEND(sanitized_name.c_str()); + APPEND(" counter\n"); APPEND_STAT_PROMETHEUS_NUMERIC(sanitized_name.c_str(), "%" PRIu64, wrap_unsigned_counter(datum->rec_counter)); break; case TS_RECORDDATATYPE_INT: - APPEND(help_buffer); - snprintf(type_buffer, sizeof(type_buffer), "# TYPE %s gauge\n", sanitized_name.c_str()); - APPEND(type_buffer); + APPEND("# HELP "); + APPEND(sanitized_name.c_str()); + APPEND(" "); + APPEND(name); + APPEND("\n"); + APPEND("# TYPE "); + APPEND(sanitized_name.c_str()); + APPEND(" gauge\n"); APPEND_STAT_PROMETHEUS_NUMERIC(sanitized_name.c_str(), "%" PRIu64, wrap_unsigned_counter(datum->rec_int)); break; case TS_RECORDDATATYPE_FLOAT: - APPEND(help_buffer); - APPEND_STAT_PROMETHEUS_NUMERIC(sanitized_name.c_str(), "%f", datum->rec_float); + APPEND("# HELP "); + APPEND(sanitized_name.c_str()); + APPEND(" "); + APPEND(name); + APPEND("\n"); + APPEND_STAT_PROMETHEUS_NUMERIC(sanitized_name.c_str(), "%g", datum->rec_float); break; case TS_RECORDDATATYPE_STRING: Dbg(dbg_ctl, "Prometheus does not support string values, skipping: %s", sanitized_name.c_str()); @@ -644,6 +958,13 @@ prometheus_out_stats(stats_state *my_state) // No version printed, since string stats are not supported by Prometheus. } +static void +prometheus_v2_out_stats(stats_state *my_state) +{ + TSRecordDump((TSRecordType)(TS_RECORDTYPE_PLUGIN | TS_RECORDTYPE_NODE | TS_RECORDTYPE_PROCESS), prometheus_v2_out_stat, my_state); + APPEND_STAT_PROMETHEUS_NUMERIC("current_time_epoch_ms", "%" PRIu64, ms_since_epoch()); +} + static void stats_process_write(TSCont contp, TSEvent event, stats_state *my_state) { @@ -660,6 +981,9 @@ stats_process_write(TSCont contp, TSEvent event, stats_state *my_state) case output_format_t::PROMETHEUS_OUTPUT: prometheus_out_stats(my_state); break; + case output_format_t::PROMETHEUS_V2_OUTPUT: + prometheus_v2_out_stats(my_state); + break; } if ((my_state->encoding == encoding_format_t::GZIP) || (my_state->encoding == encoding_format_t::DEFLATE)) { @@ -753,6 +1077,8 @@ stats_origin(TSCont contp, TSEvent /* event ATS_UNUSED */, void *edata) format_per_path = output_format_t::CSV_OUTPUT; } else if (request_path_suffix == "/prometheus") { format_per_path = output_format_t::PROMETHEUS_OUTPUT; + } else if (request_path_suffix == "/prometheus_v2") { + format_per_path = output_format_t::PROMETHEUS_V2_OUTPUT; } else { Dbg(dbg_ctl, "Unknown suffix for stats path: %.*s", static_cast(request_path_suffix.length()), request_path_suffix.data()); @@ -777,7 +1103,8 @@ stats_origin(TSCont contp, TSEvent /* event ATS_UNUSED */, void *edata) icontp = TSContCreate(stats_dostuff, TSMutexCreate()); if (path_had_explicit_format) { - Dbg(dbg_ctl, "Path had explicit format, ignoring any Accept header: %s", request_path_suffix.data()); + Dbg(dbg_ctl, "Path had explicit format, ignoring any Accept header: %.*s", static_cast(request_path_suffix.length()), + request_path_suffix.data()); my_state->output_format = format_per_path; } else { // Check for an Accept header to determine response type. @@ -795,6 +1122,9 @@ stats_origin(TSCont contp, TSEvent /* event ATS_UNUSED */, void *edata) } else if (!strncasecmp(str, "text/plain; version=0.0.4", len)) { Dbg(dbg_ctl, "Saw text/plain; version=0.0.4 in accept header, sending Prometheus output."); my_state->output_format = output_format_t::PROMETHEUS_OUTPUT; + } else if (!strncasecmp(str, "text/plain; version=2.0.0", len)) { + Dbg(dbg_ctl, "Saw text/plain; version=2.0.0 in accept header, sending Prometheus v2 output."); + my_state->output_format = output_format_t::PROMETHEUS_V2_OUTPUT; } else { Dbg(dbg_ctl, "Saw %.*s in accept header, defaulting to JSON output.", len, str); my_state->output_format = output_format_t::JSON_OUTPUT; @@ -1140,6 +1470,42 @@ config_handler(TSCont cont, TSEvent /* event ATS_UNUSED */, void * /* edata ATS_ #if defined(__cpp_lib_constexpr_string) && __cpp_lib_constexpr_string >= 201907L // Clang <= 16 doesn't fully support constexpr std::string. #if !defined(__clang__) || __clang_major__ > 16 +constexpr void +test_parse_metric_v2() +{ + // Basic method extraction + static_assert(parse_metric_v2("proxy.process.http.get_requests") == + prometheus_v2_metric{"proxy.process.http.requests", "method=\"get\""}); + + // Status code extraction + static_assert(parse_metric_v2("proxy.process.http.200_responses") == + prometheus_v2_metric{"proxy.process.http.responses", "status=\"200\""}); + + // Result extraction + static_assert(parse_metric_v2("proxy.process.http.cache_hit_fresh") == + prometheus_v2_metric{"proxy.process.http.cache_fresh", "result=\"hit\""}); + + // Category + Index extraction (volume_0) + static_assert(parse_metric_v2("proxy.process.cache.volume_0.lookup.success") == + prometheus_v2_metric{"proxy.process.cache.volume.lookup.success", "volume=\"0\""}); + + // Time buckets (le labels) + // Ensure "ms" without a number is NOT a bucket + static_assert(parse_metric_v2("proxy.process.http.avg_close_ms") == prometheus_v2_metric{"proxy.process.http.avg_close.ms", ""}); + + // Time bucket with a number + static_assert(parse_metric_v2("proxy.process.http.time_10ms") == prometheus_v2_metric{"proxy.process.http.time", "le=\"10ms\""}); + + // Multiple labels (method + status) + // proxy.process.http.get.200_responses -> proxy.process.http.responses{method="get", status="200"} + static_assert(parse_metric_v2("proxy.process.http.get.200_responses") == + prometheus_v2_metric{"proxy.process.http.responses", "method=\"get\", status=\"200\""}); + + // Metric with brackets + static_assert(parse_metric_v2("proxy.process.http.connection_errors[500]") == + prometheus_v2_metric{"proxy.process.http.connection_errors", "status=\"500\""}); +} + constexpr void test_sanitize_metric_name_for_prometheus() { @@ -1210,6 +1576,8 @@ test_sanitize_metric_name_for_prometheus() static_assert(sanitize_metric_name_for_prometheus("123foo---bar") == "_123foo___bar"); static_assert(sanitize_metric_name_for_prometheus("foo [[[bar]]]") == "foo____bar___"); static_assert(sanitize_metric_name_for_prometheus("foo@#$%bar") == "foo____bar"); + + test_parse_metric_v2(); } #endif // !defined(__clang__) || __clang_major__ > 16 #endif // defined(__cpp_lib_constexpr_string) && __cpp_lib_constexpr_string >= 201907L diff --git a/tests/gold_tests/pluginTest/stats_over_http/gold/stats_over_http_prometheus_v2_accept_stderr.gold b/tests/gold_tests/pluginTest/stats_over_http/gold/stats_over_http_prometheus_v2_accept_stderr.gold new file mode 100644 index 00000000000..0fbc03cdc48 --- /dev/null +++ b/tests/gold_tests/pluginTest/stats_over_http/gold/stats_over_http_prometheus_v2_accept_stderr.gold @@ -0,0 +1,3 @@ +< HTTP/1.1 200 OK +< Content-Type: text/plain; version=2.0.0; charset=utf-8 +< Cache-Control: no-cache diff --git a/tests/gold_tests/pluginTest/stats_over_http/gold/stats_over_http_prometheus_v2_stderr.gold b/tests/gold_tests/pluginTest/stats_over_http/gold/stats_over_http_prometheus_v2_stderr.gold new file mode 100644 index 00000000000..810d6719dc8 --- /dev/null +++ b/tests/gold_tests/pluginTest/stats_over_http/gold/stats_over_http_prometheus_v2_stderr.gold @@ -0,0 +1,12 @@ +`` +> GET /_stats/prometheus_v2``HTTP/1.1 +`` +< HTTP/1.1 200 OK +< Content-Type: text/plain; version=2.0.0; charset=utf-8 +< Cache-Control: no-cache +< Date:`` +< Age:`` +< Transfer-Encoding: chunked +< Connection:`` +`` + diff --git a/tests/gold_tests/pluginTest/stats_over_http/stats_over_http.test.py b/tests/gold_tests/pluginTest/stats_over_http/stats_over_http.test.py index ab4b450373c..e8d7c026f10 100644 --- a/tests/gold_tests/pluginTest/stats_over_http/stats_over_http.test.py +++ b/tests/gold_tests/pluginTest/stats_over_http/stats_over_http.test.py @@ -93,6 +93,44 @@ def __checkPrometheusMetrics(self, p: 'Test.Process', from_prometheus: bool): p.Streams.stdout += Testers.ContainsExpression( 'proxy_process_http_delete_requests 0', 'Verify the successful parsing of Prometheus metrics for a counter.') + def __checkPrometheusV2Metrics(self, p: "Test.Process"): + """Check the Prometheus v2 metrics output. + :param p: The process whose output to check. + """ + p.Streams.stdout += Testers.ContainsExpression( + "# HELP proxy_process_http_requests proxy.process.http.requests", + "Output should have a help line for the base metric name.", + ) + p.Streams.stdout += Testers.ContainsExpression( + "# TYPE proxy_process_http_requests counter", + "Output should have a type line for the base metric name.", + ) + + p.Streams.stdout += Testers.ContainsExpression( + 'proxy_process_http_requests{method="completed"}', + "Verify that method labels are extracted correctly.", + ) + + p.Streams.stdout += Testers.ContainsExpression( + 'proxy_process_http_cache_fresh{result="hit"}', + "Verify that result labels are extracted correctly.", + ) + + p.Streams.stdout += Testers.ContainsExpression( + 'proxy_process_http_disallowed_continue{method="post", status="100"}', + "Verify that status code labels are extracted correctly.", + ) + + p.Streams.stdout += Testers.ContainsExpression( + 'proxy_process_cache_volume_lookup_active{volume="0"}', + "Verify that volume labels are extracted from volume_N patterns.", + ) + + p.Streams.stdout += Testers.ContainsExpression( + 'proxy_process_eventloop_count{le="', + "Verify that time buckets are correctly transformed into le labels.", + ) + def __testCaseNoAccept(self): tr = Test.AddTestRun('Fetch stats over HTTP in JSON format: no Accept and default path') self.__checkProcessBefore(tr) @@ -127,6 +165,19 @@ def __testCaseAcceptPrometheus(self): tr.Processes.Default.TimeOut = 3 self.__checkProcessAfter(tr) + def __testCaseAcceptPrometheusV2(self): + tr = Test.AddTestRun("Fetch stats over HTTP in Prometheus v2 format via Accept header") + self.__checkProcessBefore(tr) + tr.MakeCurlCommand( + f"-vs -H'Accept: text/plain; version=2.0.0' --http1.1 http://127.0.0.1:{self.ts.Variables.port}/_stats", + ts=self.ts, + ) + tr.Processes.Default.ReturnCode = 0 + self.__checkPrometheusV2Metrics(tr.Processes.Default) + tr.Processes.Default.Streams.stderr = ("gold/stats_over_http_prometheus_v2_accept_stderr.gold") + tr.Processes.Default.TimeOut = 3 + self.__checkProcessAfter(tr) + def __testCasePathJSON(self): tr = Test.AddTestRun('Fetch stats over HTTP in JSON format via /_stats/json') self.__checkProcessBefore(tr) @@ -160,6 +211,19 @@ def __testCasePathPrometheus(self): tr.Processes.Default.TimeOut = 3 self.__checkProcessAfter(tr) + def __testCasePathPrometheusV2(self): + tr = Test.AddTestRun("Fetch stats over HTTP in Prometheus v2 format via /_stats/prometheus_v2") + self.__checkProcessBefore(tr) + tr.MakeCurlCommand( + f"-vs --http1.1 http://127.0.0.1:{self.ts.Variables.port}/_stats/prometheus_v2", + ts=self.ts, + ) + tr.Processes.Default.ReturnCode = 0 + self.__checkPrometheusV2Metrics(tr.Processes.Default) + tr.Processes.Default.Streams.stderr = ("gold/stats_over_http_prometheus_v2_stderr.gold") + tr.Processes.Default.TimeOut = 3 + self.__checkProcessAfter(tr) + def __testCaseAcceptIgnoredIfPathExplicit(self): tr = Test.AddTestRun('Fetch stats over HTTP in Prometheus format with Accept csv header') self.__checkProcessBefore(tr) @@ -189,9 +253,11 @@ def run(self): self.__testCaseNoAccept() self.__testCaseAcceptCSV() self.__testCaseAcceptPrometheus() + self.__testCaseAcceptPrometheusV2() self.__testCasePathJSON() self.__testCasePathCSV() self.__testCasePathPrometheus() + self.__testCasePathPrometheusV2() self.__testCaseAcceptIgnoredIfPathExplicit() self.__queryAndParsePrometheusMetrics()