This is an automated email from the ASF dual-hosted git repository.

bneradt pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/trafficserver.git


The following commit(s) were added to refs/heads/master by this push:
     new 132e01c4ae stats_over_http: Add Prometheus format option (#12302)
132e01c4ae is described below

commit 132e01c4aea486c209fa5ea8e39cb34627442304
Author: Brian Neradt <brian.ner...@gmail.com>
AuthorDate: Tue Jun 24 15:30:51 2025 -0500

    stats_over_http: Add Prometheus format option (#12302)
    
    This adds the Prometheus format as an optional output to the
    stats_over_http plugin. This also adds /json, /csv, and /prometheus
    path suffix support in case using the Accept header is problematic.
---
 doc/admin-guide/plugins/stats_over_http.en.rst     |  35 ++-
 plugins/stats_over_http/stats_over_http.cc         | 278 +++++++++++++++------
 tests/Pipfile                                      |   3 +
 .../gold/stats_over_http_0_stdout.gold             |   4 -
 ...stderr.gold => stats_over_http_csv_stderr.gold} |   4 +-
 ...tderr.gold => stats_over_http_json_stderr.gold} |   2 +-
 ...gold => stats_over_http_prometheus_stderr.gold} |   4 +-
 .../stats_over_http/prometheus_stats_ingester.py   | 117 +++++++++
 .../stats_over_http/stats_over_http.test.py        | 101 +++++++-
 9 files changed, 454 insertions(+), 94 deletions(-)

diff --git a/doc/admin-guide/plugins/stats_over_http.en.rst 
b/doc/admin-guide/plugins/stats_over_http.en.rst
index e4981bd374..93d8242234 100644
--- a/doc/admin-guide/plugins/stats_over_http.en.rst
+++ b/doc/admin-guide/plugins/stats_over_http.en.rst
@@ -105,9 +105,36 @@ if you wish to have it in CSV format you can do so by 
passing an ``Accept`` head
 
 .. option:: Accept: text/csv
 
-In either case the ``Content-Type`` header returned by stats_over_http.so will 
reflect
-the content that has been returned, either ``text/json`` or ``text/csv``.
+Prometheus formatted output is also supported via the ``Accept`` header:
 
-.. option:: Accept-encoding: gzip, br
+.. option:: Accept: text/plain; version=0.0.4
+
+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::
+
+    http://host:port/_stats/csv
+
+The Prometheus format can be requested by using the URL::
+
+    http://host:port/_stats/prometheus
+
+The JSON format is the default, but you can also access it explicitly by using 
the URL::
 
-Stats over http also accepts returning data in gzip or br compressed format
+    http://host:port/_stats/json
+
+Note that using a path suffix overrides any ``Accept`` header. Thus if you
+specify a path suffix, the plugin will return the data in that format 
regardless of
+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.
+
+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
+data in the specified encoding, for example:
+
+.. option:: Accept-encoding: gzip, br
diff --git a/plugins/stats_over_http/stats_over_http.cc 
b/plugins/stats_over_http/stats_over_http.cc
index 07d83b3e07..cc20925202 100644
--- a/plugins/stats_over_http/stats_over_http.cc
+++ b/plugins/stats_over_http/stats_over_http.cc
@@ -24,31 +24,29 @@
 /* stats.c:  expose traffic server stats over http
  */
 
-#include <stdio.h>
-#include <stdlib.h>
-#include <stdbool.h>
-#include <ctype.h>
-#include <limits.h>
-#include <ts/ts.h>
-#include <string.h>
-#include <inttypes.h>
+#include <arpa/inet.h>
+#include <cctype>
+#include <chrono>
+#include <cinttypes>
+#include <cstdio>
+#include <cstdlib>
+#include <cstring>
+#include <ctime>
+#include <fstream>
 #include <getopt.h>
+#include <netinet/in.h>
+#include <string>
+#include <string_view>
 #include <sys/stat.h>
-#include <time.h>
+#include <ts/ts.h>
 #include <unistd.h>
-#include <netinet/in.h>
-#include <arpa/inet.h>
 #include <zlib.h>
-#include <fstream>
-#include <chrono>
 
 #include <ts/remap.h>
-
-#include "swoc/swoc_ip.h"
-
+#include "swoc/TextView.h"
+#include "tscore/ink_config.h"
 #include <tsutil/ts_ip.h>
 
-#include "tscore/ink_config.h"
 #if HAVE_BROTLI_ENCODE_H
 #include <brotli/encode.h>
 #endif
@@ -105,8 +103,8 @@ struct config_holder_t {
   config_t       *config;
 };
 
-enum output_format { JSON_OUTPUT, CSV_OUTPUT };
-enum encoding_format { NONE, DEFLATE, GZIP, BR };
+enum class output_format_t { JSON_OUTPUT, CSV_OUTPUT, PROMETHEUS_OUTPUT };
+enum class encoding_format_t { NONE, DEFLATE, GZIP, BR };
 
 int    configReloadRequests = 0;
 int    configReloads        = 0;
@@ -141,11 +139,11 @@ struct stats_state {
   TSIOBuffer       resp_buffer;
   TSIOBufferReader resp_reader;
 
-  int             output_bytes;
-  int             body_written;
-  output_format   output;
-  encoding_format encoding;
-  z_stream        zstrm;
+  int               output_bytes;
+  int               body_written;
+  output_format_t   output_format;
+  encoding_format_t encoding;
+  z_stream          zstrm;
 #if HAVE_BROTLI_ENCODE_H
   b_stream bstrm;
 #endif
@@ -160,7 +158,7 @@ nstr(const char *s)
 }
 
 #if HAVE_BROTLI_ENCODE_H
-encoding_format
+encoding_format_t
 init_br(stats_state *my_state)
 {
   my_state->bstrm.br = nullptr;
@@ -168,7 +166,7 @@ init_br(stats_state *my_state)
   my_state->bstrm.br = BrotliEncoderCreateInstance(nullptr, nullptr, nullptr);
   if (!my_state->bstrm.br) {
     Dbg(dbg_ctl, "Brotli Encoder Instance Failed");
-    return NONE;
+    return encoding_format_t::NONE;
   }
   BrotliEncoderSetParameter(my_state->bstrm.br, BROTLI_PARAM_QUALITY, 
BROTLI_COMPRESSION_LEVEL);
   BrotliEncoderSetParameter(my_state->bstrm.br, BROTLI_PARAM_LGWIN, 
BROTLI_LGW);
@@ -178,7 +176,7 @@ init_br(stats_state *my_state)
   my_state->bstrm.next_out  = nullptr;
   my_state->bstrm.avail_out = 0;
   my_state->bstrm.total_out = 0;
-  return BR;
+  return encoding_format_t::BR;
 }
 #endif
 
@@ -191,7 +189,7 @@ ms_since_epoch()
 }
 } // namespace
 
-encoding_format
+encoding_format_t
 init_gzip(stats_state *my_state, int mode)
 {
   my_state->zstrm.next_in   = Z_NULL;
@@ -207,16 +205,16 @@ init_gzip(stats_state *my_state, int mode)
   int err = deflateInit2(&my_state->zstrm, ZLIB_COMPRESSION_LEVEL, Z_DEFLATED, 
mode, ZLIB_MEMLEVEL, Z_DEFAULT_STRATEGY);
   if (err != Z_OK) {
     Dbg(dbg_ctl, "gzip initialization failed");
-    return NONE;
+    return encoding_format_t::NONE;
   } else {
     Dbg(dbg_ctl, "gzip initialized successfully");
     if (mode == GZIP_MODE) {
-      return GZIP;
+      return encoding_format_t::GZIP;
     } else if (mode == DEFLATE_MODE) {
-      return DEFLATE;
+      return encoding_format_t::DEFLATE;
     }
   }
-  return NONE;
+  return encoding_format_t::NONE;
 }
 
 static void
@@ -270,37 +268,55 @@ static const char RESP_HEADER_CSV_DEFLATE[] =
   "HTTP/1.0 200 OK\r\nContent-Type: text/csv\r\nContent-Encoding: 
deflate\r\nCache-Control: no-cache\r\n\r\n";
 static const char RESP_HEADER_CSV_BR[] =
   "HTTP/1.0 200 OK\r\nContent-Type: text/csv\r\nContent-Encoding: 
br\r\nCache-Control: no-cache\r\n\r\n";
+static const char RESP_HEADER_PROMETHEUS[] =
+  "HTTP/1.0 200 OK\r\nContent-Type: text/plain; version=0.0.4; 
charset=utf-8\r\nCache-Control: no-cache\r\n\r\n";
+static const char RESP_HEADER_PROMETHEUS_GZIP[] = "HTTP/1.0 200 
OK\r\nContent-Type: text/plain; version=0.0.4; "
+                                                  
"charset=utf-8\r\nContent-Encoding: gzip\r\nCache-Control: no-cache\r\n\r\n";
+static const char RESP_HEADER_PROMETHEUS_DEFLATE[] =
+  "HTTP/1.0 200 OK\r\nContent-Type: text/plain; version=0.0.4; 
charset=utf-8\r\nContent-Encoding: deflate\r\nCache-Control: "
+  "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 int
 stats_add_resp_header(stats_state *my_state)
 {
-  switch (my_state->output) {
-  case JSON_OUTPUT:
-    if (my_state->encoding == GZIP) {
+  switch (my_state->output_format) {
+  case output_format_t::JSON_OUTPUT:
+    if (my_state->encoding == encoding_format_t::GZIP) {
       return stats_add_data_to_resp_buffer(RESP_HEADER_JSON_GZIP, my_state);
-    } else if (my_state->encoding == DEFLATE) {
+    } else if (my_state->encoding == encoding_format_t::DEFLATE) {
       return stats_add_data_to_resp_buffer(RESP_HEADER_JSON_DEFLATE, my_state);
-    } else if (my_state->encoding == BR) {
+    } else if (my_state->encoding == encoding_format_t::BR) {
       return stats_add_data_to_resp_buffer(RESP_HEADER_JSON_BR, my_state);
     } else {
       return stats_add_data_to_resp_buffer(RESP_HEADER_JSON, my_state);
     }
     break;
-  case CSV_OUTPUT:
-    if (my_state->encoding == GZIP) {
+  case output_format_t::CSV_OUTPUT:
+    if (my_state->encoding == encoding_format_t::GZIP) {
       return stats_add_data_to_resp_buffer(RESP_HEADER_CSV_GZIP, my_state);
-    } else if (my_state->encoding == DEFLATE) {
+    } else if (my_state->encoding == encoding_format_t::DEFLATE) {
       return stats_add_data_to_resp_buffer(RESP_HEADER_CSV_DEFLATE, my_state);
-    } else if (my_state->encoding == BR) {
+    } else if (my_state->encoding == encoding_format_t::BR) {
       return stats_add_data_to_resp_buffer(RESP_HEADER_CSV_BR, my_state);
     } else {
       return stats_add_data_to_resp_buffer(RESP_HEADER_CSV, my_state);
     }
     break;
-  default:
-    TSError("stats_add_resp_header: Unknown output format");
+  case output_format_t::PROMETHEUS_OUTPUT:
+    if (my_state->encoding == encoding_format_t::GZIP) {
+      return stats_add_data_to_resp_buffer(RESP_HEADER_PROMETHEUS_GZIP, 
my_state);
+    } else if (my_state->encoding == encoding_format_t::DEFLATE) {
+      return stats_add_data_to_resp_buffer(RESP_HEADER_PROMETHEUS_DEFLATE, 
my_state);
+    } else if (my_state->encoding == encoding_format_t::BR) {
+      return stats_add_data_to_resp_buffer(RESP_HEADER_PROMETHEUS_BR, 
my_state);
+    } else {
+      return stats_add_data_to_resp_buffer(RESP_HEADER_PROMETHEUS, my_state);
+    }
     break;
   }
+  // Not reached.
   return stats_add_data_to_resp_buffer(RESP_HEADER_JSON, my_state);
 }
 
@@ -326,6 +342,10 @@ stats_process_read(TSCont contp, TSEvent event, 
stats_state *my_state)
 }
 
 #define APPEND(a) my_state->output_bytes += stats_add_data_to_resp_buffer(a, 
my_state)
+
+//-----------------------------------------------------------------------------
+// JSON Formatters
+//-----------------------------------------------------------------------------
 #define APPEND_STAT_JSON(a, fmt, v)                                            
  \
   do {                                                                         
  \
     char b[256];                                                               
  \
@@ -346,6 +366,9 @@ stats_process_read(TSCont contp, TSEvent event, stats_state 
*my_state)
     }                                                                          
      \
   } while (0)
 
+//-----------------------------------------------------------------------------
+// CSV Formatters
+//-----------------------------------------------------------------------------
 #define APPEND_STAT_CSV(a, fmt, v)                                     \
   do {                                                                 \
     char b[256];                                                       \
@@ -360,6 +383,18 @@ stats_process_read(TSCont contp, TSEvent event, 
stats_state *my_state)
     }                                                                    \
   } while (0)
 
+//-----------------------------------------------------------------------------
+// Prometheus Formatters
+//-----------------------------------------------------------------------------
+// Note that Prometheus only supports numeric types.
+#define APPEND_STAT_PROMETHEUS_NUMERIC(a, fmt, v)                        \
+  do {                                                                   \
+    char b[256];                                                         \
+    if (snprintf(b, sizeof(b), "%s " fmt "\n", a, v) < (int)sizeof(b)) { \
+      APPEND(b);                                                         \
+    }                                                                    \
+  } while (0)
+
 // This wraps uint64_t values to the int64_t range to fit into a Java long. 
Java 8 has an unsigned long which
 // can interoperate with a full uint64_t, but it's unlikely that much of the 
ecosystem supports that yet.
 static uint64_t
@@ -421,6 +456,49 @@ csv_out_stat(TSRecordType /* rec_type ATS_UNUSED */, void 
*edata, int /* registe
   }
 }
 
+/** Replace characters offensive to Prometheus with '_'.
+ * Prometheus is particular about metric names.
+ * @param[in] name The metric name to sanitize.
+ * @return A sanitized metric name.
+ */
+static std::string
+sanitize_metric_name_for_prometheus(std::string_view name)
+{
+  std::string sanitized_name(name);
+  // Convert certain characters that Prometheus doesn't like to '_'.
+  for (auto &c : sanitized_name) {
+    if (c == '.' || c == '+' || c == '-') {
+      c = '_';
+    }
+  }
+  return sanitized_name;
+}
+
+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<stats_state *>(edata);
+  std::string  sanitized_name = sanitize_metric_name_for_prometheus(name);
+  switch (data_type) {
+  case TS_RECORDDATATYPE_COUNTER:
+    APPEND_STAT_PROMETHEUS_NUMERIC(sanitized_name.c_str(), "%" PRIu64, 
wrap_unsigned_counter(datum->rec_counter));
+    break;
+  case TS_RECORDDATATYPE_INT:
+    APPEND_STAT_PROMETHEUS_NUMERIC(sanitized_name.c_str(), "%" PRIu64, 
wrap_unsigned_counter(datum->rec_int));
+    break;
+  case TS_RECORDDATATYPE_FLOAT:
+    APPEND_STAT_PROMETHEUS_NUMERIC(sanitized_name.c_str(), "%f", 
datum->rec_float);
+    break;
+  case TS_RECORDDATATYPE_STRING:
+    Dbg(dbg_ctl, "Prometheus does not support string values, skipping: %s", 
sanitized_name.c_str());
+    break;
+  default:
+    Dbg(dbg_ctl, "unknown type for %s: %d", sanitized_name.c_str(), data_type);
+    break;
+  }
+}
+
 static void
 json_out_stats(stats_state *my_state)
 {
@@ -511,29 +589,37 @@ csv_out_stats(stats_state *my_state)
   APPEND_STAT_CSV("version", "%s", version);
 }
 
+static void
+prometheus_out_stats(stats_state *my_state)
+{
+  TSRecordDump((TSRecordType)(TS_RECORDTYPE_PLUGIN | TS_RECORDTYPE_NODE | 
TS_RECORDTYPE_PROCESS), prometheus_out_stat, my_state);
+  APPEND_STAT_PROMETHEUS_NUMERIC("current_time_epoch_ms", "%" PRIu64, 
ms_since_epoch());
+  // No version printed, since string stats are not supported by Prometheus.
+}
+
 static void
 stats_process_write(TSCont contp, TSEvent event, stats_state *my_state)
 {
   if (event == TS_EVENT_VCONN_WRITE_READY) {
     if (my_state->body_written == 0) {
       my_state->body_written = 1;
-      switch (my_state->output) {
-      case JSON_OUTPUT:
+      switch (my_state->output_format) {
+      case output_format_t::JSON_OUTPUT:
         json_out_stats(my_state);
         break;
-      case CSV_OUTPUT:
+      case output_format_t::CSV_OUTPUT:
         csv_out_stats(my_state);
         break;
-      default:
-        TSError("stats_process_write: Unknown output type\n");
+      case output_format_t::PROMETHEUS_OUTPUT:
+        prometheus_out_stats(my_state);
         break;
       }
 
-      if ((my_state->encoding == GZIP) || (my_state->encoding == DEFLATE)) {
+      if ((my_state->encoding == encoding_format_t::GZIP) || 
(my_state->encoding == encoding_format_t::DEFLATE)) {
         gzip_out_stats(my_state);
       }
 #if HAVE_BROTLI_ENCODE_H
-      else if (my_state->encoding == BR) {
+      else if (my_state->encoding == encoding_format_t::BR) {
         br_out_stats(my_state);
       }
 #endif
@@ -569,15 +655,19 @@ stats_dostuff(TSCont contp, TSEvent event, void *edata)
 static int
 stats_origin(TSCont contp, TSEvent /* event ATS_UNUSED */, void *edata)
 {
-  TSCont       icontp;
-  stats_state *my_state;
-  config_t    *config;
-  TSHttpTxn    txnp = (TSHttpTxn)edata;
-  TSMBuffer    reqp;
-  TSMLoc       hdr_loc = nullptr, url_loc = nullptr, accept_field = nullptr, 
accept_encoding_field = nullptr;
-  TSEvent      reenable = TS_EVENT_HTTP_CONTINUE;
-  int          path_len = 0;
-  const char  *path     = nullptr;
+  TSCont          icontp;
+  stats_state    *my_state;
+  config_t       *config;
+  TSHttpTxn       txnp = (TSHttpTxn)edata;
+  TSMBuffer       reqp;
+  TSMLoc          hdr_loc = nullptr, url_loc = nullptr, accept_field = 
nullptr, accept_encoding_field = nullptr;
+  TSEvent         reenable = TS_EVENT_HTTP_CONTINUE;
+  int             path_len = 0;
+  const char     *path     = nullptr;
+  swoc::TextView  request_path;
+  swoc::TextView  request_path_suffix;
+  output_format_t format_per_path          = output_format_t::JSON_OUTPUT;
+  bool            path_had_explicit_format = false;
 
   Dbg(dbg_ctl, "in the read stuff");
   config = get_config(contp);
@@ -593,12 +683,37 @@ stats_origin(TSCont contp, TSEvent /* event ATS_UNUSED 
*/, void *edata)
   path = TSUrlPathGet(reqp, url_loc, &path_len);
   Dbg(dbg_ctl, "Path: %.*s", path_len, path);
 
-  if (!(path_len != 0 && path_len == int(config->stats_path.length()) &&
-        !memcmp(path, config->stats_path.c_str(), 
config->stats_path.length()))) {
-    Dbg(dbg_ctl, "not this plugins path, saw: %.*s, looking for: %s", 
path_len, path, config->stats_path.c_str());
+  if (path_len == 0) {
+    Dbg(dbg_ctl, "Empty path");
+    goto notforme;
+  }
+
+  request_path = swoc::TextView{path, static_cast<size_t>(path_len)};
+  if (!request_path.starts_with(config->stats_path)) {
+    Dbg(dbg_ctl, "Not the configured path for stats: %.*s, expected: %s", 
path_len, path, config->stats_path.c_str());
     goto notforme;
   }
 
+  if (request_path == config->stats_path) {
+    Dbg(dbg_ctl, "Exact match for stats path: %s", config->stats_path.c_str());
+    format_per_path          = output_format_t::JSON_OUTPUT;
+    path_had_explicit_format = false;
+  } else {
+    request_path_suffix = 
request_path.remove_prefix(config->stats_path.length());
+    if (request_path_suffix == "/json") {
+      format_per_path = output_format_t::JSON_OUTPUT;
+    } else if (request_path_suffix == "/csv") {
+      format_per_path = output_format_t::CSV_OUTPUT;
+    } else if (request_path_suffix == "/prometheus") {
+      format_per_path = output_format_t::PROMETHEUS_OUTPUT;
+    } else {
+      Dbg(dbg_ctl, "Unknown suffix for stats path: %.*s", 
static_cast<int>(request_path_suffix.length()),
+          request_path_suffix.data());
+      goto notforme;
+    }
+    path_had_explicit_format = true;
+  }
+
   if (auto addr = TSHttpTxnClientAddrGet(txnp); !is_ipmap_allowed(config, 
addr)) {
     Dbg(dbg_ctl, "not right ip");
     TSHttpTxnStatusSet(txnp, TS_HTTP_STATUS_FORBIDDEN);
@@ -615,24 +730,35 @@ stats_origin(TSCont contp, TSEvent /* event ATS_UNUSED 
*/, void *edata)
   memset(my_state, 0, sizeof(*my_state));
   icontp = TSContCreate(stats_dostuff, TSMutexCreate());
 
-  accept_field     = TSMimeHdrFieldFind(reqp, hdr_loc, TS_MIME_FIELD_ACCEPT, 
TS_MIME_LEN_ACCEPT);
-  my_state->output = JSON_OUTPUT; // default to json output
-  // accept header exists, use it to determine response type
-  if (accept_field != TS_NULL_MLOC) {
-    int         len = -1;
-    const char *str = TSMimeHdrFieldValueStringGet(reqp, hdr_loc, 
accept_field, -1, &len);
-
-    // Parse the Accept header, default to JSON output unless its another 
supported format
-    if (!strncasecmp(str, "text/csv", len)) {
-      my_state->output = CSV_OUTPUT;
-    } else {
-      my_state->output = JSON_OUTPUT;
+  if (path_had_explicit_format) {
+    Dbg(dbg_ctl, "Path had explicit format, ignoring any Accept header: %s", 
request_path_suffix.data());
+    my_state->output_format = format_per_path;
+  } else {
+    // Check for an Accept header to determine response type.
+    accept_field            = TSMimeHdrFieldFind(reqp, hdr_loc, 
TS_MIME_FIELD_ACCEPT, TS_MIME_LEN_ACCEPT);
+    my_state->output_format = output_format_t::JSON_OUTPUT; // default to json 
output
+    // accept header exists, use it to determine response type
+    if (accept_field != TS_NULL_MLOC) {
+      int         len = -1;
+      const char *str = TSMimeHdrFieldValueStringGet(reqp, hdr_loc, 
accept_field, -1, &len);
+
+      // Parse the Accept header, default to JSON output unless its another 
supported format
+      if (!strncasecmp(str, "text/csv", len)) {
+        Dbg(dbg_ctl, "Saw text/csv in accept header, sending CSV output.");
+        my_state->output_format = output_format_t::CSV_OUTPUT;
+      } 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 {
+        Dbg(dbg_ctl, "Saw %.*s in accept header, defaulting to JSON output.", 
len, str);
+        my_state->output_format = output_format_t::JSON_OUTPUT;
+      }
     }
   }
 
   // Check for Accept Encoding and init
   accept_encoding_field = TSMimeHdrFieldFind(reqp, hdr_loc, 
TS_MIME_FIELD_ACCEPT_ENCODING, TS_MIME_LEN_ACCEPT_ENCODING);
-  my_state->encoding    = NONE;
+  my_state->encoding    = encoding_format_t::NONE;
   if (accept_encoding_field != TS_NULL_MLOC) {
     int         len = -1;
     const char *str = TSMimeHdrFieldValueStringGet(reqp, hdr_loc, 
accept_encoding_field, -1, &len);
@@ -650,7 +776,7 @@ stats_origin(TSCont contp, TSEvent /* event ATS_UNUSED */, 
void *edata)
     }
 #endif
     else {
-      my_state->encoding = NONE;
+      my_state->encoding = encoding_format_t::NONE;
     }
   }
   Dbg(dbg_ctl, "Finished AE check");
diff --git a/tests/Pipfile b/tests/Pipfile
index 68a4841e36..c2cf352c3f 100644
--- a/tests/Pipfile
+++ b/tests/Pipfile
@@ -56,5 +56,8 @@ grpcio-tools = "*"
 pyOpenSSL = "*"
 eventlet = "*"
 
+# To test stats_over_http prometheus exporter.
+prometheus_client = "*"
+
 [requires]
 python_version = "3"
diff --git 
a/tests/gold_tests/pluginTest/stats_over_http/gold/stats_over_http_0_stdout.gold
 
b/tests/gold_tests/pluginTest/stats_over_http/gold/stats_over_http_0_stdout.gold
deleted file mode 100644
index a9315fe22f..0000000000
--- 
a/tests/gold_tests/pluginTest/stats_over_http/gold/stats_over_http_0_stdout.gold
+++ /dev/null
@@ -1,4 +0,0 @@
-{ "global": {``
-``
-  }
-}
diff --git 
a/tests/gold_tests/pluginTest/stats_over_http/gold/stats_over_http_0_stderr.gold
 
b/tests/gold_tests/pluginTest/stats_over_http/gold/stats_over_http_csv_stderr.gold
similarity index 70%
copy from 
tests/gold_tests/pluginTest/stats_over_http/gold/stats_over_http_0_stderr.gold
copy to 
tests/gold_tests/pluginTest/stats_over_http/gold/stats_over_http_csv_stderr.gold
index 23cfc22ce1..45d326e398 100644
--- 
a/tests/gold_tests/pluginTest/stats_over_http/gold/stats_over_http_0_stderr.gold
+++ 
b/tests/gold_tests/pluginTest/stats_over_http/gold/stats_over_http_csv_stderr.gold
@@ -1,8 +1,8 @@
 ``
-> GET /_stats HTTP/1.1
+> GET /_stats``HTTP/1.1
 ``
 < HTTP/1.1 200 OK
-< Content-Type: text/json
+< Content-Type: text/csv
 < Cache-Control: no-cache
 < Date:``
 < Age:``
diff --git 
a/tests/gold_tests/pluginTest/stats_over_http/gold/stats_over_http_0_stderr.gold
 
b/tests/gold_tests/pluginTest/stats_over_http/gold/stats_over_http_json_stderr.gold
similarity index 85%
copy from 
tests/gold_tests/pluginTest/stats_over_http/gold/stats_over_http_0_stderr.gold
copy to 
tests/gold_tests/pluginTest/stats_over_http/gold/stats_over_http_json_stderr.gold
index 23cfc22ce1..123f2229b6 100644
--- 
a/tests/gold_tests/pluginTest/stats_over_http/gold/stats_over_http_0_stderr.gold
+++ 
b/tests/gold_tests/pluginTest/stats_over_http/gold/stats_over_http_json_stderr.gold
@@ -1,5 +1,5 @@
 ``
-> GET /_stats HTTP/1.1
+> GET /_stats``HTTP/1.1
 ``
 < HTTP/1.1 200 OK
 < Content-Type: text/json
diff --git 
a/tests/gold_tests/pluginTest/stats_over_http/gold/stats_over_http_0_stderr.gold
 
b/tests/gold_tests/pluginTest/stats_over_http/gold/stats_over_http_prometheus_stderr.gold
similarity index 59%
rename from 
tests/gold_tests/pluginTest/stats_over_http/gold/stats_over_http_0_stderr.gold
rename to 
tests/gold_tests/pluginTest/stats_over_http/gold/stats_over_http_prometheus_stderr.gold
index 23cfc22ce1..9caf215489 100644
--- 
a/tests/gold_tests/pluginTest/stats_over_http/gold/stats_over_http_0_stderr.gold
+++ 
b/tests/gold_tests/pluginTest/stats_over_http/gold/stats_over_http_prometheus_stderr.gold
@@ -1,8 +1,8 @@
 ``
-> GET /_stats HTTP/1.1
+> GET /_stats``HTTP/1.1
 ``
 < HTTP/1.1 200 OK
-< Content-Type: text/json
+< Content-Type: text/plain; version=0.0.4; charset=utf-8
 < Cache-Control: no-cache
 < Date:``
 < Age:``
diff --git 
a/tests/gold_tests/pluginTest/stats_over_http/prometheus_stats_ingester.py 
b/tests/gold_tests/pluginTest/stats_over_http/prometheus_stats_ingester.py
new file mode 100644
index 0000000000..16b3701ecc
--- /dev/null
+++ b/tests/gold_tests/pluginTest/stats_over_http/prometheus_stats_ingester.py
@@ -0,0 +1,117 @@
+'''Parse ATS Prometheus stats with Prometheus to verify correct formatting.'''
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+#  limitations under the License.
+
+import argparse
+import sys
+from urllib.request import urlopen
+from prometheus_client.parser import text_string_to_metric_families
+
+
+def parse_args() -> argparse.Namespace:
+    """
+    Parse command line arguments for the Prometheus metrics ingester.
+
+    :return: Parsed arguments with the 'url' attribute.
+    """
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument("url", help="URL to fetch metrics from")
+    return parser.parse_args()
+
+
+def query_ats(url: str) -> str:
+    """
+    Fetch Prometheus metrics from the specified URL.
+
+    :param url: URL to fetch metrics from.
+    :return: Response text containing the metrics.
+    """
+    try:
+        with urlopen(url) as response:
+            return response.read().decode('utf-8')
+    except Exception as e:
+        raise RuntimeError(f"Failed to fetch metrics from {url}: {e}")
+
+
+def parse_ats_metrics(text: str) -> list:
+    """
+    Parse Prometheus metrics from a text string.
+
+    :param text: The ATS output containing Prometheus metrics.
+    :return: List of parsed metric families.
+    """
+    try:
+        families = text_string_to_metric_families(text)
+    except Exception as e:
+        raise RuntimeError(f"Failed to parse metrics: {e}")
+
+    if not families:
+        raise RuntimeError("No metrics found in the provided text")
+    return families
+
+
+def print_metrics(families: list) -> None:
+    """
+    Print parsed metric families in Prometheus format.
+
+    :param families: List of parsed metric families.
+    """
+    try:
+        for family in families:
+            print(f"# HELP {family.name} {family.documentation}")
+            print(f"# TYPE {family.name} {family.type}")
+            for sample in family.samples:
+                name, labels, value = sample.name, sample.labels, sample.value
+                if labels:
+                    label_str = ",".join(f'{k}="{v}"' for k, v in 
labels.items())
+                    print(f"{name}{{{label_str}}} {value}")
+                else:
+                    print(f"{name} {value}")
+    except Exception as e:
+        raise RuntimeError(f"Failed to print metrics: {e}")
+
+
+def main() -> int:
+    """
+    Fetch and parse Prometheus metrics from a given URL.
+
+    :return: Exit code, 0 on success, non-zero on failure.
+    """
+    args = parse_args()
+
+    try:
+        ats_output = query_ats(args.url)
+    except RuntimeError as e:
+        print(f"Error fetching URL {args.url}: {e}", file=sys.stderr)
+        return 1
+
+    try:
+        families = parse_ats_metrics(ats_output)
+    except RuntimeError as e:
+        print(f"Error parsing ATS metrics: {e}", file=sys.stderr)
+        return 1
+
+    # Parsing issues may not arise until we try to print the metrics.
+    try:
+        print_metrics(families)
+    except RuntimeError as e:
+        print(f"Error parsing the metrics when printing them: {e}", 
file=sys.stderr)
+        return 1
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())
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 c330573a39..5ccfbe0d46 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
@@ -17,6 +17,7 @@
 #  limitations under the License.
 
 from enum import Enum
+import sys
 
 Test.Summary = 'Exercise stats-over-http plugin'
 Test.SkipUnless(Condition.PluginExists('stats_over_http.so'))
@@ -62,18 +63,108 @@ class StatsOverHttpPluginTest:
         assert (self.state == self.State.RUNNING)
         tr.StillRunningAfter = self.ts
 
-    def __testCase0(self):
-        tr = Test.AddTestRun()
+    def __testCaseNoAccept(self):
+        tr = Test.AddTestRun('Fetch stats over HTTP in JSON format: no Accept 
and default path')
         self.__checkProcessBefore(tr)
         tr.MakeCurlCommand(f"-vs --http1.1 
http://127.0.0.1:{self.ts.Variables.port}/_stats";)
         tr.Processes.Default.ReturnCode = 0
-        tr.Processes.Default.Streams.stdout = 
"gold/stats_over_http_0_stdout.gold"
-        tr.Processes.Default.Streams.stderr = 
"gold/stats_over_http_0_stderr.gold"
+        tr.Processes.Default.Streams.stdout += Testers.ContainsExpression('{ 
"global": {', 'Output should have the JSON header.')
+        tr.Processes.Default.Streams.stdout += Testers.ContainsExpression(
+            '"proxy.process.http.delete_requests": "0",', 'Output should be 
JSON formatted.')
+        tr.Processes.Default.Streams.stderr = 
"gold/stats_over_http_json_stderr.gold"
         tr.Processes.Default.TimeOut = 3
         self.__checkProcessAfter(tr)
 
+    def __testCaseAcceptCSV(self):
+        tr = Test.AddTestRun('Fetch stats over HTTP in CSV format')
+        self.__checkProcessBefore(tr)
+        tr.MakeCurlCommand(f"-vs -H'Accept: text/csv' --http1.1 
http://127.0.0.1:{self.ts.Variables.port}/_stats";)
+        tr.Processes.Default.ReturnCode = 0
+        tr.Processes.Default.Streams.stdout += Testers.ContainsExpression(
+            'proxy.process.http.delete_requests,0', 'Output should be CSV 
formatted.')
+        tr.Processes.Default.Streams.stderr = 
"gold/stats_over_http_csv_stderr.gold"
+        tr.Processes.Default.TimeOut = 3
+        self.__checkProcessAfter(tr)
+
+    def __testCaseAcceptPrometheus(self):
+        tr = Test.AddTestRun('Fetch stats over HTTP in Prometheus format')
+        self.__checkProcessBefore(tr)
+        tr.MakeCurlCommand(f"-vs -H'Accept: text/plain; version=0.0.4' 
--http1.1 http://127.0.0.1:{self.ts.Variables.port}/_stats";)
+        tr.Processes.Default.ReturnCode = 0
+        tr.Processes.Default.Streams.stdout += Testers.ContainsExpression(
+            'proxy_process_http_delete_requests 0', 'Output should be 
Prometheus formatted.')
+        tr.Processes.Default.Streams.stderr = 
"gold/stats_over_http_prometheus_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)
+        tr.MakeCurlCommand(f"-vs --http1.1 
http://127.0.0.1:{self.ts.Variables.port}/_stats/json";)
+        tr.Processes.Default.ReturnCode = 0
+        tr.Processes.Default.Streams.stdout += Testers.ContainsExpression('{ 
"global": {', 'JSON header expected.')
+        tr.Processes.Default.Streams.stdout += Testers.ContainsExpression(
+            '"proxy.process.http.delete_requests": "0",', 'JSON field 
expected.')
+        tr.Processes.Default.Streams.stderr = 
"gold/stats_over_http_json_stderr.gold"
+        tr.Processes.Default.TimeOut = 3
+        self.__checkProcessAfter(tr)
+
+    def __testCasePathCSV(self):
+        tr = Test.AddTestRun('Fetch stats over HTTP in CSV format via 
/_stats/csv')
+        self.__checkProcessBefore(tr)
+        tr.MakeCurlCommand(f"-vs --http1.1 
http://127.0.0.1:{self.ts.Variables.port}/_stats/csv";)
+        tr.Processes.Default.ReturnCode = 0
+        tr.Processes.Default.Streams.stdout += Testers.ContainsExpression(
+            'proxy.process.http.delete_requests,0', 'CSV output expected.')
+        tr.Processes.Default.Streams.stderr = 
"gold/stats_over_http_csv_stderr.gold"
+        tr.Processes.Default.TimeOut = 3
+        self.__checkProcessAfter(tr)
+
+    def __testCasePathPrometheus(self):
+        tr = Test.AddTestRun('Fetch stats over HTTP in Prometheus format via 
/_stats/prometheus')
+        self.__checkProcessBefore(tr)
+        tr.MakeCurlCommand(f"-vs --http1.1 
http://127.0.0.1:{self.ts.Variables.port}/_stats/prometheus";)
+        tr.Processes.Default.ReturnCode = 0
+        tr.Processes.Default.Streams.stdout += Testers.ContainsExpression(
+            'proxy_process_http_delete_requests 0', 'Prometheus output 
expected.')
+        tr.Processes.Default.Streams.stderr = 
"gold/stats_over_http_prometheus_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)
+        tr.MakeCurlCommand(f"-vs -H'Accept: text/csv' --http1.1 
http://127.0.0.1:{self.ts.Variables.port}/_stats/prometheus";)
+        tr.Processes.Default.ReturnCode = 0
+        tr.Processes.Default.Streams.stdout += Testers.ContainsExpression(
+            'proxy_process_http_delete_requests 0', 'Prometheus output 
expected.')
+        tr.Processes.Default.Streams.stderr = 
"gold/stats_over_http_prometheus_stderr.gold"
+        tr.Processes.Default.TimeOut = 3
+        self.__checkProcessAfter(tr)
+
+    def __queryAndParsePrometheusMetrics(self):
+        """
+        Query the ATS stats over HTTP in Prometheus format and parse the 
output.
+        """
+        tr = Test.AddTestRun('Query and parse Prometheus metrics')
+        ingester = 'prometheus_stats_ingester.py'
+        tr.Setup.CopyAs(ingester)
+        self.__checkProcessBefore(tr)
+        p = tr.Processes.Default
+        p.Command = f'{sys.executable} {ingester} 
http://127.0.0.1:{self.ts.Variables.port}/_stats/prometheus'
+        p.ReturnCode = 0
+        p.Streams.stdout += Testers.ContainsExpression(
+            'proxy_process_http_delete_requests 0', 'Verify the successful 
parsing of Prometheus metrics.')
+
     def run(self):
-        self.__testCase0()
+        self.__testCaseNoAccept()
+        self.__testCaseAcceptCSV()
+        self.__testCaseAcceptPrometheus()
+        self.__testCasePathJSON()
+        self.__testCasePathCSV()
+        self.__testCasePathPrometheus()
+        self.__testCaseAcceptIgnoredIfPathExplicit()
+        self.__queryAndParsePrometheusMetrics()
 
 
 StatsOverHttpPluginTest().run()


Reply via email to