diff --git a/CMakeLists.txt b/CMakeLists.txt index 0ff9983..ccf588b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -9,6 +9,8 @@ option(LOGIT_WITH_GZIP "Enable gzip via zlib" OFF) option(LOGIT_WITH_ZSTD "Enable zstd" OFF) option(LOGIT_WITH_FMT "Enable fmt support" OFF) option(LOGIT_WITH_OTLP "Enable OTLP/HTTP log export via optional kurlyk dependency" OFF) +option(LOGIT_WITH_PROMETHEUS "Enable Prometheus text payload support" OFF) +option(LOGIT_WITH_PROMETHEUS_SERVER "Enable Prometheus HTTP server backend" OFF) option(LOGIT_USE_SUBMODULES "Allow bundled optional dependency fallback" OFF) option(LOGIT_WITH_SYSLOG "Enable POSIX syslog backend" ON) option(LOGIT_WITH_WIN_EVENT_LOG "Enable Windows Event Log backend" ON) @@ -21,7 +23,7 @@ option(LOGIT_USE_MPSC_RING "Enable lock-free TaskExecutor queue" ON) option(LOGIT_ENABLE_DROP_OLDEST_SLOWPATH "Enable TaskExecutor DropOldest slow-path" ON) if(NOT DEFINED CMAKE_CXX_STANDARD) - if(LOGIT_WITH_OTLP) + if(LOGIT_WITH_OTLP OR LOGIT_WITH_PROMETHEUS_SERVER) set(CMAKE_CXX_STANDARD 17) else() set(CMAKE_CXX_STANDARD 11) @@ -130,6 +132,54 @@ if(LOGIT_WITH_OTLP) target_link_libraries(log-it-cpp INTERFACE kurlyk) endif() +# ---------- Prometheus ---------- +if(LOGIT_WITH_PROMETHEUS) + if(EMSCRIPTEN) + message(FATAL_ERROR "LOGIT_WITH_PROMETHEUS is not supported for Emscripten.") + endif() + target_compile_definitions(log-it-cpp INTERFACE LOGIT_WITH_PROMETHEUS=1) +endif() + +if(LOGIT_WITH_PROMETHEUS_SERVER) + if(EMSCRIPTEN) + message(FATAL_ERROR "LOGIT_WITH_PROMETHEUS_SERVER is not supported for Emscripten.") + endif() + + target_compile_definitions(log-it-cpp INTERFACE ASIO_STANDALONE) + + # Prefer standalone external/Simple-Web-Server + if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/external/Simple-Web-Server/server_http.hpp") + target_include_directories(log-it-cpp INTERFACE + $ + ) + if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/external/Simple-Web-Server/../asio/include/asio.hpp") + target_include_directories(log-it-cpp INTERFACE + $ + ) + endif() + elseif(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/external/kurlyk/external/Simple-Web-Server/server_http.hpp") + target_include_directories(log-it-cpp INTERFACE + $ + ) + if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/external/kurlyk/external/asio/include/asio.hpp") + target_include_directories(log-it-cpp INTERFACE + $ + ) + endif() + else() + message(FATAL_ERROR "Simple-Web-Server not found. Add it as external/Simple-Web-Server or enable/provide kurlyk submodule.") + endif() + + if(NOT LOGIT_WITH_PROMETHEUS) + set(LOGIT_WITH_PROMETHEUS ON) + target_compile_definitions(log-it-cpp INTERFACE LOGIT_WITH_PROMETHEUS=1) + endif() + target_compile_definitions(log-it-cpp INTERFACE LOGIT_WITH_PROMETHEUS_SERVER=1) + if(WIN32) + target_link_libraries(log-it-cpp INTERFACE ws2_32 wsock32) + endif() +endif() + # ---------- GZIP (zlib) ---------- if(LOGIT_WITH_GZIP) if(NOT TARGET ZLIB::ZLIB) diff --git a/docs/PrometheusLogger.md b/docs/PrometheusLogger.md new file mode 100644 index 0000000..7bae655 --- /dev/null +++ b/docs/PrometheusLogger.md @@ -0,0 +1,116 @@ +# Prometheus Logger + +## Overview + +LogIt++ provides two Prometheus backends for exposing internal log metrics in the +[Prometheus text exposition format](https://prometheus.io/docs/instrumenting/exposition_formats/): + +- **PrometheusPayloadLogger** -- callback-based; delivers the serialized payload to a + user-provided function. Useful when you have your own HTTP server or want to push to + a Prometheus Pushgateway. + +- **PrometheusHttpServerLogger** -- embedded HTTP server; serves `/metrics` on a + configurable port using Simple-Web-Server. Ideal for simple services without a + separate metrics endpoint. + +## Built-in Metrics + +| Metric | Type | Description | +|--------|------|-------------| +| `logit_log_records_total` | counter | Total log records processed | +| `logit_dropped_logs_total` | counter | Dropped log records | +| `logit_failed_exports_total` | counter | Failed export/callback attempts | +| `logit_last_log_timestamp_ms` | gauge | Timestamp of last log (ms) | +| `logit_time_since_last_log_ms` | gauge | Time since last log (ms) | +| `logit_build_info` | gauge | Build info (value=1, labels: version, compiler) | + +The `metric_prefix` config option (default: `logit_`) is applied to all metric names. + +## CMake Options + +```cmake +option(LOGIT_WITH_PROMETHEUS "Enable Prometheus text payload support" OFF) +option(LOGIT_WITH_PROMETHEUS_SERVER "Enable Prometheus HTTP server backend" OFF) +``` + +`LOGIT_WITH_PROMETHEUS_SERVER` implies `LOGIT_WITH_PROMETHEUS` and requires C++17 +(Simple-Web-Server dependency). + +## Usage: PrometheusPayloadLogger + +```cpp +#include + +logit::PrometheusPayloadLogger::Config config; +config.format.metric_prefix = "myapp_"; +config.emit_on_wait = true; +config.on_payload = [](std::string payload) { + // Send to your HTTP endpoint or Pushgateway +}; + +LOGIT_ADD_LOGGER( + logit::PrometheusPayloadLogger, + (config), + logit::SimpleLogFormatter, + ("%v") +); + +LOGIT_INFO("Application started"); +LOGIT_WAIT(); // triggers on_payload with current metrics +``` + +## Usage: PrometheusHttpServerLogger + +```cpp +#include + +logit::PrometheusHttpServerLogger::Config config; +config.port = 9090; +config.path = "/metrics"; + +LOGIT_ADD_LOGGER( + logit::PrometheusHttpServerLogger, + (config), + logit::SimpleLogFormatter, + ("%v") +); + +LOGIT_INFO("Server started"); +// Scrape http://localhost:9090/metrics +``` + +## Custom Metrics + +Use the `on_collect` callback to add application-specific metrics on each scrape: + +```cpp +config.on_collect = [](std::vector& families) { + logit::PrometheusMetricFamily mf; + mf.name = "myapp_queue_size"; + mf.help = "Current queue depth"; + mf.type = logit::PrometheusMetricType::Gauge; + logit::PrometheusSample s; + s.name = "myapp_queue_size"; + s.value = get_queue_depth(); + mf.samples.push_back(s); + families.push_back(mf); +}; +``` + +## Prometheus Scrape Config + +```yaml +scrape_configs: + - job_name: 'logit-app' + scrape_interval: 15s + static_configs: + - targets: ['localhost:9090'] + metrics_path: /metrics +``` + +## Limitations + +- Text exposition format only (no protobuf, no OpenMetrics `# EOF`). +- No histograms or summaries -- use `on_collect` for custom metric types. +- No TLS or authentication on the HTTP server. +- No metric renaming conflicts resolution -- user must ensure unique names. diff --git a/examples/example_logit_prometheus_payload.cpp b/examples/example_logit_prometheus_payload.cpp new file mode 100644 index 0000000..c8f1a1e --- /dev/null +++ b/examples/example_logit_prometheus_payload.cpp @@ -0,0 +1,35 @@ +#include + +int main() { +#ifndef LOGIT_WITH_PROMETHEUS + LOGIT_ADD_CONSOLE_DEFAULT(); + LOGIT_WARN("Prometheus payload example requires LOGIT_WITH_PROMETHEUS=ON"); + LOGIT_WAIT(); + return 0; +#else + logit::PrometheusPayloadLogger::Config config; + config.format.metric_prefix = "myapp_"; + config.format.include_build_info = true; + config.emit_on_wait = true; + config.on_payload = [](std::string payload) { + // In a real application, send payload to your Prometheus push gateway + // or expose it via your own HTTP endpoint. + (void)payload; + }; + + LOGIT_ADD_LOGGER( + logit::PrometheusPayloadLogger, + (config), + logit::SimpleLogFormatter, + ("%v") + ); + + LOGIT_INFO("Prometheus payload logger started"); + LOGIT_WARN("Example warning message"); + LOGIT_ERROR("Example error message"); + + LOGIT_WAIT(); + LOGIT_SHUTDOWN(); + return 0; +#endif +} diff --git a/examples/example_logit_prometheus_server.cpp b/examples/example_logit_prometheus_server.cpp new file mode 100644 index 0000000..b8a3ee2 --- /dev/null +++ b/examples/example_logit_prometheus_server.cpp @@ -0,0 +1,47 @@ +#include + +int main() { +#ifndef LOGIT_WITH_PROMETHEUS_SERVER + LOGIT_ADD_CONSOLE_DEFAULT(); + LOGIT_WARN("Prometheus server example requires LOGIT_WITH_PROMETHEUS_SERVER=ON"); + LOGIT_WAIT(); + return 0; +#else + logit::PrometheusHttpServerLogger::Config config; + config.port = 9090; + config.path = "/metrics"; + config.format.metric_prefix = "myapp_"; + config.format.include_build_info = true; + + // Optional: add custom metrics on each scrape + config.on_collect = [](std::vector& families) { + logit::PrometheusMetricFamily mf; + mf.name = "myapp_uptime_seconds"; + mf.help = "Application uptime in seconds"; + mf.type = logit::PrometheusMetricType::Gauge; + logit::PrometheusSample s; + s.name = "myapp_uptime_seconds"; + s.value = 42.0; + mf.samples.push_back(s); + families.push_back(mf); + }; + + LOGIT_ADD_LOGGER( + logit::PrometheusHttpServerLogger, + (config), + logit::SimpleLogFormatter, + ("%v") + ); + + LOGIT_INFO("Prometheus HTTP server started on port 9090"); + LOGIT_WARN("Scrape metrics at http://localhost:9090/metrics"); + + for (int i = 0; i < 5; ++i) { + LOGIT_INFO("Logging iteration %d", (i + 1)); + std::this_thread::sleep_for(std::chrono::seconds(1)); + } + + LOGIT_SHUTDOWN(); + return 0; +#endif +} diff --git a/include/logit_cpp/logit/loggers.hpp b/include/logit_cpp/logit/loggers.hpp index 59dec00..f72d3c4 100644 --- a/include/logit_cpp/logit/loggers.hpp +++ b/include/logit_cpp/logit/loggers.hpp @@ -33,4 +33,11 @@ #include "loggers/OtlpPayloadLogger.hpp" #endif +#ifdef LOGIT_WITH_PROMETHEUS +#include "loggers/PrometheusPayloadLogger.hpp" +#endif +#ifdef LOGIT_WITH_PROMETHEUS_SERVER +#include "loggers/PrometheusHttpServerLogger.hpp" +#endif + #endif // _LOGIT_LOGGERS_HPP_INCLUDED diff --git a/include/logit_cpp/logit/loggers/PrometheusHttpServerLogger.hpp b/include/logit_cpp/logit/loggers/PrometheusHttpServerLogger.hpp new file mode 100644 index 0000000..985d9b0 --- /dev/null +++ b/include/logit_cpp/logit/loggers/PrometheusHttpServerLogger.hpp @@ -0,0 +1,383 @@ +#pragma once +#ifndef _LOGIT_PROMETHEUS_HTTP_SERVER_LOGGER_HPP_INCLUDED +#define _LOGIT_PROMETHEUS_HTTP_SERVER_LOGGER_HPP_INCLUDED + +/// \file PrometheusHttpServerLogger.hpp +/// \brief Prometheus HTTP server logger backend exposing /metrics endpoint. + +#ifndef LOGIT_WITH_PROMETHEUS_SERVER +# error "PrometheusHttpServerLogger requires LOGIT_WITH_PROMETHEUS_SERVER=1. Enable LOGIT_WITH_PROMETHEUS_SERVER in CMake." +#endif + +#include "ILogger.hpp" +#include "prometheus/PrometheusTextFormatConfig.hpp" +#include "prometheus/PrometheusTextSerializer.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace logit { + + /// \class PrometheusHttpServerLogger + /// \ingroup LogBackends + /// \brief Serves Prometheus metrics via an embedded HTTP server. + /// + /// This backend starts a Simple-Web-Server HTTP server and serves Prometheus + /// text exposition format on the configured path (default: /metrics). + /// It tracks the same internal counters and gauges as PrometheusPayloadLogger. + class PrometheusHttpServerLogger final : public ILogger { + public: + using HttpServer = SimpleWeb::Server; + + struct Config { + PrometheusTextFormatConfig format; + std::string address = "0.0.0.0"; + unsigned short port = 9090; + std::string path = "/metrics"; + std::string health_path = "/health"; + bool enable_health_endpoint = true; + std::function&)> on_collect; + bool start_immediately = true; + }; + + /// \brief Constructs Prometheus HTTP server logger with default configuration. + PrometheusHttpServerLogger() : PrometheusHttpServerLogger(Config()) {} + + /// \brief Constructs Prometheus HTTP server logger with custom configuration. + /// \param config Server and format configuration. + explicit PrometheusHttpServerLogger(const Config& config) + : m_config(config) { + + m_server.config.address = m_config.address; + m_server.config.port = m_config.port; + + // Lifecycle note: this lambda captures `this`. The destructor calls stop() + // which invokes server.stop() and joins m_server_thread, ensuring no + // active handlers reference the logger after destruction. + m_server.resource[m_config.path]["GET"] = + [this](std::shared_ptr response, + std::shared_ptr) { + try { + std::string payload = this->collect_payload(); + response->write( + SimpleWeb::StatusCode::success_ok, + payload, + {{"Content-Type", "text/plain; version=0.0.4; charset=utf-8"}, + {"Cache-Control", "no-store"}}); + } catch (...) { + response->write(SimpleWeb::StatusCode::server_error_internal_server_error); + } + }; + + if (m_config.enable_health_endpoint) { + m_server.resource[m_config.health_path]["GET"] = + [](std::shared_ptr response, + std::shared_ptr) { + response->write(SimpleWeb::StatusCode::success_ok, "ok"); + }; + } + + m_server.default_resource["GET"] = + [](std::shared_ptr response, + std::shared_ptr) { + response->write( + SimpleWeb::StatusCode::client_error_not_found, + "not found"); + }; + + if (m_config.start_immediately) { + start(); + } + } + + ~PrometheusHttpServerLogger() override { + stop(); + } + + PrometheusHttpServerLogger(const PrometheusHttpServerLogger&) = delete; + PrometheusHttpServerLogger& operator=(const PrometheusHttpServerLogger&) = delete; + + /// \brief Starts the HTTP server thread. + void start() { + if (m_running.load()) { + return; + } + m_running.store(true); + m_server_thread = std::thread([this]() { + m_server.start(); + }); + } + + /// \brief Updates internal metric counters for a log message. + /// \param record Structured log record. + /// \param message Formatted log message (unused by Prometheus metrics). + void log(const LogRecord& record, const std::string& message) override { + (void)message; + m_last_log_ts.store(record.timestamp_ms); + ++m_log_records_total; + } + + /// \brief No-op; server is independently serving metrics. + void wait() override {} + + /// \brief Stops the HTTP server and joins the server thread. + void shutdown() override { + stop(); + } + + /// \brief Collects current metrics and returns serialized Prometheus text payload. + /// \return Complete Prometheus text exposition format string. + std::string collect_payload() { + std::vector families; + { + std::lock_guard lock(m_collect_mutex); + build_builtin_metrics(families); + if (m_config.on_collect) { + try { + m_config.on_collect(families); + } catch (...) { + ++m_failed_exports; + } + } + } + return build_prometheus_text_payload(families, m_config.format); + } + + /// \brief Retrieves a string parameter from the logger. + /// \param param Parameter to retrieve. + /// \return Parameter value, or empty string when unsupported. + std::string get_string_param(const LoggerParam& param) const override { + switch (param) { + case LoggerParam::LastLogTimestamp: return std::to_string(get_last_log_ts()); + case LoggerParam::TimeSinceLastLog: return std::to_string(get_time_since_last_log()); + case LoggerParam::DroppedLogCount: return std::to_string(dropped_count()); + case LoggerParam::FailedExportCount: return std::to_string(failed_export_count()); + default: + break; + } + return std::string(); + } + + /// \brief Retrieves an integer parameter from the logger. + /// \param param Parameter to retrieve. + /// \return Parameter value, or 0 when unsupported. + int64_t get_int_param(const LoggerParam& param) const override { + switch (param) { + case LoggerParam::LastLogTimestamp: return get_last_log_ts(); + case LoggerParam::TimeSinceLastLog: return get_time_since_last_log(); + case LoggerParam::DroppedLogCount: return counter_to_int64(dropped_count()); + case LoggerParam::FailedExportCount: return counter_to_int64(failed_export_count()); + default: + break; + } + return 0; + } + + /// \brief Retrieves a floating-point parameter from the logger. + /// \param param Parameter to retrieve. + /// \return Parameter value in seconds for time params, or 0.0 when unsupported. + double get_float_param(const LoggerParam& param) const override { + switch (param) { + case LoggerParam::LastLogTimestamp: + return static_cast(get_last_log_ts()) / 1000.0; + case LoggerParam::TimeSinceLastLog: + return static_cast(get_time_since_last_log()) / 1000.0; + case LoggerParam::DroppedLogCount: + return static_cast(dropped_count()); + case LoggerParam::FailedExportCount: + return static_cast(failed_export_count()); + default: + break; + } + return 0.0; + } + + /// \brief Sets minimal log level for this logger. + /// \param level Minimum log level. + void set_log_level(LogLevel level) override { + m_log_level = static_cast(level); + } + + /// \brief Gets minimal log level for this logger. + /// \return Current minimal log level. + LogLevel get_log_level() const override { + return static_cast(m_log_level.load()); + } + + /// \brief Returns number of dropped records. + uint64_t dropped_count() const { + return m_dropped.load(); + } + + /// \brief Returns number of failed export attempts. + uint64_t failed_export_count() const { + return m_failed_exports.load(); + } + + private: + Config m_config; + HttpServer m_server; + std::thread m_server_thread; + std::mutex m_collect_mutex; + std::atomic m_running = ATOMIC_VAR_INIT(false); + + std::atomic m_log_level = ATOMIC_VAR_INIT(static_cast(LogLevel::LOG_LVL_TRACE)); + std::atomic m_last_log_ts = ATOMIC_VAR_INIT(0); + std::atomic m_log_records_total = ATOMIC_VAR_INIT(0); + std::atomic m_dropped = ATOMIC_VAR_INIT(0); + std::atomic m_failed_exports = ATOMIC_VAR_INIT(0); + + void stop() { + if (!m_running.exchange(false)) { + return; + } + m_server.stop(); + if (m_server_thread.joinable()) { + m_server_thread.join(); + } + } + + int64_t get_last_log_ts() const { + return m_last_log_ts.load(); + } + + int64_t get_time_since_last_log() const { + const int64_t last = get_last_log_ts(); + if (last <= 0) { + return 0; + } + const int64_t now = LOGIT_CURRENT_TIMESTAMP_MS(); + return now > last ? now - last : 0; + } + + static int64_t counter_to_int64(uint64_t value) { + const uint64_t max_value = static_cast((std::numeric_limits::max)()); + return value > max_value ? (std::numeric_limits::max)() : static_cast(value); + } + + void build_builtin_metrics(std::vector& families) const { + const std::string& prefix = m_config.format.metric_prefix; + + // logit_log_records_total (counter) + { + PrometheusMetricFamily mf; + mf.name = prefix + "log_records_total"; + mf.help = "Total number of log records processed"; + mf.type = PrometheusMetricType::Counter; + PrometheusSample s; + s.name = prefix + "log_records_total"; + s.value = static_cast(m_log_records_total.load()); + add_common_labels(s); + mf.samples.push_back(s); + families.push_back(mf); + } + + // logit_dropped_logs_total (counter) + { + PrometheusMetricFamily mf; + mf.name = prefix + "dropped_logs_total"; + mf.help = "Total number of dropped log records"; + mf.type = PrometheusMetricType::Counter; + PrometheusSample s; + s.name = prefix + "dropped_logs_total"; + s.value = static_cast(m_dropped.load()); + add_common_labels(s); + mf.samples.push_back(s); + families.push_back(mf); + } + + // logit_failed_exports_total (counter) + { + PrometheusMetricFamily mf; + mf.name = prefix + "failed_exports_total"; + mf.help = "Total number of failed export attempts"; + mf.type = PrometheusMetricType::Counter; + PrometheusSample s; + s.name = prefix + "failed_exports_total"; + s.value = static_cast(m_failed_exports.load()); + add_common_labels(s); + mf.samples.push_back(s); + families.push_back(mf); + } + + // logit_last_log_timestamp_ms (gauge) + { + PrometheusMetricFamily mf; + mf.name = prefix + "last_log_timestamp_ms"; + mf.help = "Timestamp of the last log record in milliseconds"; + mf.type = PrometheusMetricType::Gauge; + PrometheusSample s; + s.name = prefix + "last_log_timestamp_ms"; + s.value = static_cast(m_last_log_ts.load()); + add_common_labels(s); + mf.samples.push_back(s); + families.push_back(mf); + } + + // logit_time_since_last_log_ms (gauge) + { + PrometheusMetricFamily mf; + mf.name = prefix + "time_since_last_log_ms"; + mf.help = "Milliseconds since the last log record"; + mf.type = PrometheusMetricType::Gauge; + PrometheusSample s; + s.name = prefix + "time_since_last_log_ms"; + s.value = static_cast(get_time_since_last_log()); + add_common_labels(s); + mf.samples.push_back(s); + families.push_back(mf); + } + + // logit_build_info (gauge, value=1) + if (m_config.format.include_build_info) { + PrometheusMetricFamily mf; + mf.name = prefix + "build_info"; + mf.help = "Build information for logit-cpp"; + mf.type = PrometheusMetricType::Gauge; + PrometheusSample s; + s.name = prefix + "build_info"; + s.value = 1.0; +#ifdef LOGIT_VERSION + s.labels.push_back({"version", LOGIT_VERSION}); +#else + s.labels.push_back({"version", "1.0.2"}); +#endif +#if defined(__GNUC__) && !defined(__clang__) + s.labels.push_back({"compiler", "gcc"}); +#elif defined(__clang__) + s.labels.push_back({"compiler", "clang"}); +#elif defined(_MSC_VER) + s.labels.push_back({"compiler", "msvc"}); +#else + s.labels.push_back({"compiler", "unknown"}); +#endif + add_common_labels(s); + mf.samples.push_back(s); + families.push_back(mf); + } + } + + void add_common_labels(PrometheusSample& sample) const { + if (m_config.format.include_logger_label) { + sample.labels.push_back( + {m_config.format.logger_label_name, "prometheus_http_server"}); + } + if (m_config.format.include_instance_label) { + sample.labels.push_back( + {m_config.format.instance_label_name, m_config.format.instance_label_value}); + } + } + }; + +} // namespace logit + +#endif // _LOGIT_PROMETHEUS_HTTP_SERVER_LOGGER_HPP_INCLUDED diff --git a/include/logit_cpp/logit/loggers/PrometheusPayloadLogger.hpp b/include/logit_cpp/logit/loggers/PrometheusPayloadLogger.hpp new file mode 100644 index 0000000..8fdcbe5 --- /dev/null +++ b/include/logit_cpp/logit/loggers/PrometheusPayloadLogger.hpp @@ -0,0 +1,324 @@ +#pragma once +#ifndef _LOGIT_PROMETHEUS_PAYLOAD_LOGGER_HPP_INCLUDED +#define _LOGIT_PROMETHEUS_PAYLOAD_LOGGER_HPP_INCLUDED + +/// \file PrometheusPayloadLogger.hpp +/// \brief Prometheus text payload callback logger backend. + +#ifndef LOGIT_WITH_PROMETHEUS +# error "PrometheusPayloadLogger requires LOGIT_WITH_PROMETHEUS=1. Enable LOGIT_WITH_PROMETHEUS in CMake." +#endif + +#include "ILogger.hpp" +#include "prometheus/PrometheusTextFormatConfig.hpp" +#include "prometheus/PrometheusTextSerializer.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace logit { + + /// \class PrometheusPayloadLogger + /// \ingroup LogBackends + /// \brief Exposes log metrics as Prometheus text exposition format via user-provided callback. + /// + /// This backend tracks internal counters and gauges (log records total, dropped, + /// failed exports, last log timestamp, time since last log) and serializes them + /// to Prometheus text exposition format. The payload is passed to a user-provided + /// callback on wait() or on-demand via collect_payload(). + class PrometheusPayloadLogger final : public ILogger { + public: + struct Config { + PrometheusTextFormatConfig format; + std::function on_payload; + std::function&)> on_collect; + bool emit_on_log = false; + bool emit_on_wait = true; + }; + + /// \brief Constructs Prometheus payload logger with default configuration. + PrometheusPayloadLogger() : PrometheusPayloadLogger(Config()) {} + + /// \brief Constructs Prometheus payload logger with custom configuration. + /// \param config Export configuration. + explicit PrometheusPayloadLogger(const Config& config) + : m_config(config) {} + + ~PrometheusPayloadLogger() override { + stop(); + } + + PrometheusPayloadLogger(const PrometheusPayloadLogger&) = delete; + PrometheusPayloadLogger& operator=(const PrometheusPayloadLogger&) = delete; + + /// \brief Updates internal metric counters for a log message. + /// \param record Structured log record. + /// \param message Formatted log message (unused by Prometheus metrics). + void log(const LogRecord& record, const std::string& message) override { + (void)message; + m_last_log_ts.store(record.timestamp_ms); + ++m_log_records_total; + + if (m_config.emit_on_log && m_config.on_payload) { + try { + std::string payload = collect_payload(); + m_config.on_payload(std::move(payload)); + } catch (...) { + ++m_failed_collects; + } + } + } + + /// \brief If emit_on_wait, collects metrics and invokes on_payload callback. + void wait() override { + if (m_config.emit_on_wait && m_config.on_payload) { + try { + std::string payload = collect_payload(); + m_config.on_payload(std::move(payload)); + } catch (...) { + ++m_failed_collects; + } + } + } + + /// \brief Stops the logger (no worker thread to drain for payload logger). + void shutdown() override { + stop(); + } + + /// \brief Collects current metrics and returns serialized Prometheus text payload. + /// \return Complete Prometheus text exposition format string. + std::string collect_payload() { + std::vector families; + { + std::lock_guard lock(m_collect_mutex); + build_builtin_metrics(families); + if (m_config.on_collect) { + try { + m_config.on_collect(families); + } catch (...) { + ++m_failed_collects; + } + } + } + return build_prometheus_text_payload(families, m_config.format); + } + + /// \brief Retrieves a string parameter from the logger. + /// \param param Parameter to retrieve. + /// \return Parameter value, or empty string when unsupported. + std::string get_string_param(const LoggerParam& param) const override { + switch (param) { + case LoggerParam::LastLogTimestamp: return std::to_string(get_last_log_ts()); + case LoggerParam::TimeSinceLastLog: return std::to_string(get_time_since_last_log()); + case LoggerParam::DroppedLogCount: return std::to_string(m_dropped.load()); + case LoggerParam::FailedExportCount: return std::to_string(m_failed_collects.load()); + default: + break; + } + return std::string(); + } + + /// \brief Retrieves an integer parameter from the logger. + /// \param param Parameter to retrieve. + /// \return Parameter value, or 0 when unsupported. + int64_t get_int_param(const LoggerParam& param) const override { + switch (param) { + case LoggerParam::LastLogTimestamp: return get_last_log_ts(); + case LoggerParam::TimeSinceLastLog: return get_time_since_last_log(); + case LoggerParam::DroppedLogCount: return counter_to_int64(m_dropped.load()); + case LoggerParam::FailedExportCount: return counter_to_int64(m_failed_collects.load()); + default: + break; + } + return 0; + } + + /// \brief Retrieves a floating-point parameter from the logger. + /// \param param Parameter to retrieve. + /// \return Parameter value in seconds for time params, or 0.0 when unsupported. + double get_float_param(const LoggerParam& param) const override { + switch (param) { + case LoggerParam::LastLogTimestamp: + return static_cast(get_last_log_ts()) / 1000.0; + case LoggerParam::TimeSinceLastLog: + return static_cast(get_time_since_last_log()) / 1000.0; + case LoggerParam::DroppedLogCount: + return static_cast(m_dropped.load()); + case LoggerParam::FailedExportCount: + return static_cast(m_failed_collects.load()); + default: + break; + } + return 0.0; + } + + /// \brief Sets minimal log level for this logger. + /// \param level Minimum log level. + void set_log_level(LogLevel level) override { + m_log_level = static_cast(level); + } + + /// \brief Gets minimal log level for this logger. + /// \return Current minimal log level. + LogLevel get_log_level() const override { + return static_cast(m_log_level.load()); + } + + private: + Config m_config; + std::mutex m_collect_mutex; + bool m_stopped = false; + + std::atomic m_log_level = ATOMIC_VAR_INIT(static_cast(LogLevel::LOG_LVL_TRACE)); + std::atomic m_last_log_ts = ATOMIC_VAR_INIT(0); + std::atomic m_log_records_total = ATOMIC_VAR_INIT(0); + std::atomic m_dropped = ATOMIC_VAR_INIT(0); + std::atomic m_failed_collects = ATOMIC_VAR_INIT(0); + + void stop() { + std::lock_guard lock(m_collect_mutex); + m_stopped = true; + } + + int64_t get_last_log_ts() const { + return m_last_log_ts.load(); + } + + int64_t get_time_since_last_log() const { + const int64_t last = get_last_log_ts(); + if (last <= 0) { + return 0; + } + const int64_t now = LOGIT_CURRENT_TIMESTAMP_MS(); + return now > last ? now - last : 0; + } + + static int64_t counter_to_int64(uint64_t value) { + const uint64_t max_value = static_cast((std::numeric_limits::max)()); + return value > max_value ? (std::numeric_limits::max)() : static_cast(value); + } + + void build_builtin_metrics(std::vector& families) const { + const std::string& prefix = m_config.format.metric_prefix; + + // logit_log_records_total (counter) + { + PrometheusMetricFamily mf; + mf.name = prefix + "log_records_total"; + mf.help = "Total number of log records processed"; + mf.type = PrometheusMetricType::Counter; + PrometheusSample s; + s.name = prefix + "log_records_total"; + s.value = static_cast(m_log_records_total.load()); + add_common_labels(s); + mf.samples.push_back(s); + families.push_back(mf); + } + + // logit_dropped_logs_total (counter) + { + PrometheusMetricFamily mf; + mf.name = prefix + "dropped_logs_total"; + mf.help = "Total number of dropped log records"; + mf.type = PrometheusMetricType::Counter; + PrometheusSample s; + s.name = prefix + "dropped_logs_total"; + s.value = static_cast(m_dropped.load()); + add_common_labels(s); + mf.samples.push_back(s); + families.push_back(mf); + } + + // logit_failed_exports_total (counter) + { + PrometheusMetricFamily mf; + mf.name = prefix + "failed_exports_total"; + mf.help = "Total number of failed export attempts"; + mf.type = PrometheusMetricType::Counter; + PrometheusSample s; + s.name = prefix + "failed_exports_total"; + s.value = static_cast(m_failed_collects.load()); + add_common_labels(s); + mf.samples.push_back(s); + families.push_back(mf); + } + + // logit_last_log_timestamp_ms (gauge) + { + PrometheusMetricFamily mf; + mf.name = prefix + "last_log_timestamp_ms"; + mf.help = "Timestamp of the last log record in milliseconds"; + mf.type = PrometheusMetricType::Gauge; + PrometheusSample s; + s.name = prefix + "last_log_timestamp_ms"; + s.value = static_cast(m_last_log_ts.load()); + add_common_labels(s); + mf.samples.push_back(s); + families.push_back(mf); + } + + // logit_time_since_last_log_ms (gauge) + { + PrometheusMetricFamily mf; + mf.name = prefix + "time_since_last_log_ms"; + mf.help = "Milliseconds since the last log record"; + mf.type = PrometheusMetricType::Gauge; + PrometheusSample s; + s.name = prefix + "time_since_last_log_ms"; + s.value = static_cast(get_time_since_last_log()); + add_common_labels(s); + mf.samples.push_back(s); + families.push_back(mf); + } + + // logit_build_info (gauge, value=1) with version/compiler labels + if (m_config.format.include_build_info) { + PrometheusMetricFamily mf; + mf.name = prefix + "build_info"; + mf.help = "Build information for logit-cpp"; + mf.type = PrometheusMetricType::Gauge; + PrometheusSample s; + s.name = prefix + "build_info"; + s.value = 1.0; +#ifdef LOGIT_VERSION + s.labels.push_back({"version", LOGIT_VERSION}); +#else + s.labels.push_back({"version", "1.0.2"}); +#endif +#if defined(__GNUC__) && !defined(__clang__) + s.labels.push_back({"compiler", "gcc"}); +#elif defined(__clang__) + s.labels.push_back({"compiler", "clang"}); +#elif defined(_MSC_VER) + s.labels.push_back({"compiler", "msvc"}); +#else + s.labels.push_back({"compiler", "unknown"}); +#endif + add_common_labels(s); + mf.samples.push_back(s); + families.push_back(mf); + } + } + + void add_common_labels(PrometheusSample& sample) const { + if (m_config.format.include_logger_label) { + sample.labels.push_back( + {m_config.format.logger_label_name, "prometheus_payload"}); + } + if (m_config.format.include_instance_label) { + sample.labels.push_back( + {m_config.format.instance_label_name, m_config.format.instance_label_value}); + } + } + }; + +} // namespace logit + +#endif // _LOGIT_PROMETHEUS_PAYLOAD_LOGGER_HPP_INCLUDED diff --git a/include/logit_cpp/logit/loggers/prometheus/PrometheusTextFormatConfig.hpp b/include/logit_cpp/logit/loggers/prometheus/PrometheusTextFormatConfig.hpp new file mode 100644 index 0000000..136e069 --- /dev/null +++ b/include/logit_cpp/logit/loggers/prometheus/PrometheusTextFormatConfig.hpp @@ -0,0 +1,61 @@ +#pragma once +#ifndef _LOGIT_PROMETHEUS_TEXT_FORMAT_CONFIG_HPP_INCLUDED +#define _LOGIT_PROMETHEUS_TEXT_FORMAT_CONFIG_HPP_INCLUDED + +/// \file PrometheusTextFormatConfig.hpp +/// \brief Defines Prometheus text exposition format types and configuration. + +#include +#include +#include + +namespace logit { + + /// \enum PrometheusMetricType + /// \brief Prometheus metric type identifiers. + enum class PrometheusMetricType { Counter, Gauge, Untyped }; + + /// \struct PrometheusLabel + /// \brief A single Prometheus label key-value pair. + struct PrometheusLabel { + std::string name; ///< Label name. + std::string value; ///< Label value. + }; + + /// \struct PrometheusSample + /// \brief A single metric sample with optional labels and timestamp. + struct PrometheusSample { + std::string name; ///< Full metric name (including suffixes). + double value = 0.0; ///< Sample value. + std::vector labels; ///< Labels for this sample. + int64_t timestamp_ms = 0; ///< Timestamp in ms; 0 = omit. + }; + + /// \struct PrometheusMetricFamily + /// \brief A group of related samples sharing a name, help, and type. + struct PrometheusMetricFamily { + std::string name; ///< Metric family name. + std::string help; ///< HELP text. + PrometheusMetricType type = PrometheusMetricType::Untyped; ///< Metric type. + std::vector samples; ///< Samples in this family. + }; + + /// \struct PrometheusTextFormatConfig + /// \brief Configuration for Prometheus text exposition format output. + struct PrometheusTextFormatConfig { + bool include_help = true; ///< Emit HELP lines. + bool include_type = true; ///< Emit TYPE lines. + bool include_timestamp = false; ///< Emit sample timestamps. + std::string metric_prefix = "logit_";///< Prefix applied to built-in metric names. + std::vector const_labels; ///< Labels added to every sample. + bool include_logger_label = true; ///< Add logger label to built-in metrics. + std::string logger_label_name = "logger"; ///< Name of logger label. + bool include_instance_label = false; ///< Add instance label to built-in metrics. + std::string instance_label_name = "instance"; ///< Name of instance label. + std::string instance_label_value; ///< Value of instance label. + bool include_build_info = true; ///< Emit logit_build_info metric. + }; + +} // namespace logit + +#endif // _LOGIT_PROMETHEUS_TEXT_FORMAT_CONFIG_HPP_INCLUDED diff --git a/include/logit_cpp/logit/loggers/prometheus/PrometheusTextSerializer.hpp b/include/logit_cpp/logit/loggers/prometheus/PrometheusTextSerializer.hpp new file mode 100644 index 0000000..46c81d7 --- /dev/null +++ b/include/logit_cpp/logit/loggers/prometheus/PrometheusTextSerializer.hpp @@ -0,0 +1,212 @@ +#pragma once +#ifndef _LOGIT_PROMETHEUS_TEXT_SERIALIZER_HPP_INCLUDED +#define _LOGIT_PROMETHEUS_TEXT_SERIALIZER_HPP_INCLUDED + +/// \file PrometheusTextSerializer.hpp +/// \brief Prometheus text exposition format serialization helpers. + +#include "PrometheusTextFormatConfig.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace logit { + + /// \brief Escapes help string for Prometheus text format. + /// \param value Raw help string. + /// \return Escaped string with backslash and newline escaped. + inline std::string prometheus_escape_help(const std::string& value) { + std::string out; + out.reserve(value.size()); + for (size_t i = 0; i < value.size(); ++i) { + const char c = value[i]; + if (c == '\\') { + out += "\\\\"; + } else if (c == '\n') { + out += "\\n"; + } else { + out += c; + } + } + return out; + } + + /// \brief Escapes label value for Prometheus text format. + /// \param value Raw label value. + /// \return Escaped string with backslash, double quote, and newline escaped. + inline std::string prometheus_escape_label_value(const std::string& value) { + std::string out; + out.reserve(value.size()); + for (size_t i = 0; i < value.size(); ++i) { + const char c = value[i]; + if (c == '\\') { + out += "\\\\"; + } else if (c == '"') { + out += "\\\""; + } else if (c == '\n') { + out += "\\n"; + } else { + out += c; + } + } + return out; + } + + /// \brief Sanitizes a metric name to match Prometheus naming rules. + /// \param name Raw metric name. + /// \return Sanitized name: [a-zA-Z_:][a-zA-Z0-9_:]*; invalid chars replaced by _. + inline std::string prometheus_sanitize_metric_name(const std::string& name) { + std::string out; + out.reserve(name.size()); + for (size_t i = 0; i < name.size(); ++i) { + const unsigned char c = static_cast(name[i]); + if (i == 0) { + if (std::isalpha(c) || c == '_' || c == ':') { + out += static_cast(c); + } else { + out += '_'; + } + } else { + if (std::isalnum(c) || c == '_' || c == ':') { + out += static_cast(c); + } else { + out += '_'; + } + } + } + if (out.empty()) { + out = "_"; + } + return out; + } + + /// \brief Sanitizes a label name to match Prometheus naming rules. + /// \param name Raw label name. + /// \return Sanitized name; first char must be letter or underscore. + inline std::string prometheus_sanitize_label_name(const std::string& name) { + std::string out; + out.reserve(name.size()); + for (size_t i = 0; i < name.size(); ++i) { + const unsigned char c = static_cast(name[i]); + if (i == 0) { + if (std::isalpha(c) || c == '_') { + out += static_cast(c); + } else { + out += '_'; + } + } else { + if (std::isalnum(c) || c == '_') { + out += static_cast(c); + } else { + out += '_'; + } + } + } + if (out.empty()) { + out = "_"; + } + return out; + } + + /// \brief Formats a Prometheus sample value according to text format rules. + /// \param os Output stream. + /// \param value Double value to format. + inline void prometheus_format_value(std::ostringstream& os, double value) { + if (std::isnan(value)) { + os << "NaN"; + } else if (std::isinf(value)) { + if (value > 0) { + os << "+Inf"; + } else { + os << "-Inf"; + } + } else { + os << std::setprecision(std::numeric_limits::max_digits10) << value; + } + } + + /// \brief Writes one PrometheusMetricFamily to an output stream. + /// \param os Output string stream. + /// \param family Metric family to write. + /// \param config Format configuration. + inline void prometheus_write_metric_family( + std::ostringstream& os, + const PrometheusMetricFamily& family, + const PrometheusTextFormatConfig& config) { + const std::string safe_name = prometheus_sanitize_metric_name(family.name); + + if (config.include_help && !family.help.empty()) { + os << "# HELP " << safe_name << " " << prometheus_escape_help(family.help) << "\n"; + } + + if (config.include_type) { + const char* type_str = "untyped"; + switch (family.type) { + case PrometheusMetricType::Counter: type_str = "counter"; break; + case PrometheusMetricType::Gauge: type_str = "gauge"; break; + default: break; + } + os << "# TYPE " << safe_name << " " << type_str << "\n"; + } + + for (size_t si = 0; si < family.samples.size(); ++si) { + const PrometheusSample& sample = family.samples[si]; + const std::string sample_name = prometheus_sanitize_metric_name(sample.name); + + os << sample_name; + + // Merge labels: const_labels first, then sample labels, then logger/instance. + std::vector merged; + if (!config.const_labels.empty()) { + merged.insert(merged.end(), config.const_labels.begin(), config.const_labels.end()); + } + if (!sample.labels.empty()) { + merged.insert(merged.end(), sample.labels.begin(), sample.labels.end()); + } + + if (!merged.empty()) { + os << "{"; + for (size_t li = 0; li < merged.size(); ++li) { + if (li != 0) { + os << ","; + } + os << prometheus_sanitize_label_name(merged[li].name) + << "=\"" << prometheus_escape_label_value(merged[li].value) << "\""; + } + os << "}"; + } + + os << " "; + prometheus_format_value(os, sample.value); + + if (config.include_timestamp && sample.timestamp_ms != 0) { + os << " " << sample.timestamp_ms; + } + + os << "\n"; + } + } + + /// \brief Builds a complete Prometheus text exposition format payload. + /// \param families Metric families to serialize. + /// \param config Format configuration. + /// \return Complete text exposition payload string. + inline std::string build_prometheus_text_payload( + const std::vector& families, + const PrometheusTextFormatConfig& config) { + std::ostringstream os; + for (size_t i = 0; i < families.size(); ++i) { + prometheus_write_metric_family(os, families[i], config); + } + return os.str(); + } + +} // namespace logit + +#endif // _LOGIT_PROMETHEUS_TEXT_SERIALIZER_HPP_INCLUDED diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index a1ca75e..0ca98c1 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -54,6 +54,9 @@ else() otlp_json_serializer_test.cpp otlp_structured_attributes_test.cpp otlp_payload_logger_test.cpp + prometheus_text_serializer_test.cpp + prometheus_payload_logger_test.cpp + prometheus_http_server_logger_test.cpp per_logger_isolation_test.cpp per_logger_mixed_mode_test.cpp printf_format_macros_test.cpp @@ -85,6 +88,14 @@ else() list(REMOVE_ITEM TEST_SOURCES otlp_structured_attributes_test.cpp) list(REMOVE_ITEM TEST_SOURCES otlp_payload_logger_test.cpp) endif() + if(NOT LOGIT_WITH_PROMETHEUS) + list(REMOVE_ITEM TEST_SOURCES prometheus_text_serializer_test.cpp) + list(REMOVE_ITEM TEST_SOURCES prometheus_payload_logger_test.cpp) + list(REMOVE_ITEM TEST_SOURCES prometheus_http_server_logger_test.cpp) + endif() + if(LOGIT_WITH_PROMETHEUS AND NOT LOGIT_WITH_PROMETHEUS_SERVER) + list(REMOVE_ITEM TEST_SOURCES prometheus_http_server_logger_test.cpp) + endif() foreach(test_src ${TEST_SOURCES}) get_filename_component(test_name ${test_src} NAME_WE) add_executable(${test_name} ${test_src}) @@ -94,6 +105,18 @@ else() target_include_directories(${test_name} PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/../external/kurlyk/external/Simple-Web-Server") endif() + if(LOGIT_WITH_PROMETHEUS_SERVER AND test_name STREQUAL "prometheus_http_server_logger_test") + if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/../external/Simple-Web-Server/server_http.hpp") + target_include_directories(${test_name} PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/../external/Simple-Web-Server") + else() + target_include_directories(${test_name} PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/../external/kurlyk/external/Simple-Web-Server") + endif() + if(WIN32) + target_link_libraries(${test_name} PRIVATE ws2_32 wsock32) + endif() + endif() if(test_name STREQUAL "backpressure_policy_test" OR test_name STREQUAL "backpressure_ordering_test") set_tests_properties(${test_name} PROPERTIES LABELS "tsan") diff --git a/tests/prometheus_http_server_logger_test.cpp b/tests/prometheus_http_server_logger_test.cpp new file mode 100644 index 0000000..5c4c7f4 --- /dev/null +++ b/tests/prometheus_http_server_logger_test.cpp @@ -0,0 +1,289 @@ +#include +#include + +#if defined(LOGIT_WITH_PROMETHEUS_SERVER) + +#include +#include +#include +#include +#include + +#include + +int main() { + // Test 1: Construction and shutdown without deadlock + { + logit::PrometheusHttpServerLogger::Config config; + config.port = 43191; + config.path = "/metrics"; + config.start_immediately = false; + + logit::PrometheusHttpServerLogger logger(config); + + logit::LogRecord record( + logit::LogLevel::LOG_LVL_WARN, 1710000000123LL, + "test.cpp", 10, "test_func", "shutdown test", "", + -1, false, false, false); + logger.log(record, "shutdown test"); + + auto start = std::chrono::steady_clock::now(); + logger.shutdown(); + auto elapsed = std::chrono::duration_cast( + std::chrono::steady_clock::now() - start).count(); + + assert(elapsed < 5000); + } + + // Test 2: collect_payload() returns valid Prometheus text format + { + logit::PrometheusHttpServerLogger::Config config; + config.port = 43192; + config.path = "/metrics"; + config.format.metric_prefix = "logit_"; + config.format.include_build_info = true; + config.start_immediately = false; + + logit::PrometheusHttpServerLogger logger(config); + + logit::LogRecord record( + logit::LogLevel::LOG_LVL_WARN, 1710000000123LL, + "test.cpp", 20, "test_func", "payload test", "", + -1, false, false, false); + logger.log(record, "payload test"); + + std::string payload = logger.collect_payload(); + + assert(payload.find("# HELP logit_log_records_total") != std::string::npos); + assert(payload.find("# TYPE logit_log_records_total counter") != std::string::npos); + assert(payload.find("logit_log_records_total") != std::string::npos); + assert(payload.find("# TYPE logit_last_log_timestamp_ms gauge") != std::string::npos); + assert(payload.find("# TYPE logit_build_info gauge") != std::string::npos); + assert(payload.find("version=") != std::string::npos); + assert(payload.find("compiler=") != std::string::npos); + + logger.shutdown(); + } + + // Test 3: metric_prefix applied + { + logit::PrometheusHttpServerLogger::Config config; + config.port = 43193; + config.format.metric_prefix = "app_"; + config.start_immediately = false; + + logit::PrometheusHttpServerLogger logger(config); + + logit::LogRecord record( + logit::LogLevel::LOG_LVL_INFO, 1710000000123LL, + "test.cpp", 30, "test_func", "prefix test", "", + -1, false, false, false); + logger.log(record, "prefix test"); + + std::string payload = logger.collect_payload(); + + assert(payload.find("app_log_records_total") != std::string::npos); + assert(payload.find("app_build_info") != std::string::npos); + + logger.shutdown(); + } + + // Test 4: custom on_collect adds user metric + { + logit::PrometheusHttpServerLogger::Config config; + config.port = 43194; + config.on_collect = [](std::vector& families) { + logit::PrometheusMetricFamily mf; + mf.name = "custom_metric"; + mf.help = "A custom metric"; + mf.type = logit::PrometheusMetricType::Gauge; + logit::PrometheusSample s; + s.name = "custom_metric"; + s.value = 77.0; + mf.samples.push_back(s); + families.push_back(mf); + }; + config.start_immediately = false; + + logit::PrometheusHttpServerLogger logger(config); + + std::string payload = logger.collect_payload(); + + assert(payload.find("# HELP custom_metric A custom metric") != std::string::npos); + assert(payload.find("# TYPE custom_metric gauge") != std::string::npos); + assert(payload.find("custom_metric 77") != std::string::npos); + + logger.shutdown(); + } + + // Test 5: get_string_param / get_int_param / get_float_param + { + logit::PrometheusHttpServerLogger::Config config; + config.port = 43195; + config.start_immediately = false; + + logit::PrometheusHttpServerLogger logger(config); + + logit::LogRecord record( + logit::LogLevel::LOG_LVL_ERROR, 1710000000123LL, + "test.cpp", 40, "test_func", "param test", "", + -1, false, false, false); + logger.log(record, "param test"); + + std::string ts_str = logger.get_string_param(logit::LoggerParam::LastLogTimestamp); + assert(!ts_str.empty()); + assert(ts_str == "1710000000123"); + + int64_t ts_i = logger.get_int_param(logit::LoggerParam::LastLogTimestamp); + assert(ts_i == 1710000000123LL); + + double ts_f = logger.get_float_param(logit::LoggerParam::LastLogTimestamp); + assert(ts_f > 0.0); + + logger.shutdown(); + } + + // Test 6: start/stop lifecycle + { + logit::PrometheusHttpServerLogger::Config config; + config.port = 43196; + config.start_immediately = false; + + logit::PrometheusHttpServerLogger logger(config); + + logger.start(); + std::this_thread::sleep_for(std::chrono::milliseconds(200)); + + logit::LogRecord record( + logit::LogLevel::LOG_LVL_INFO, 1710000000123LL, + "test.cpp", 50, "test_func", "start/stop test", "", + -1, false, false, false); + logger.log(record, "start/stop test"); + + auto start = std::chrono::steady_clock::now(); + logger.shutdown(); + auto elapsed = std::chrono::duration_cast( + std::chrono::steady_clock::now() - start).count(); + + assert(elapsed < 5000); + } + + // Test 7: HTTP GET /metrics returns valid payload + { + logit::PrometheusHttpServerLogger::Config config; + config.port = 43197; + config.path = "/metrics"; + config.format.metric_prefix = "logit_"; + config.start_immediately = false; + + logit::PrometheusHttpServerLogger logger(config); + + logit::LogRecord record( + logit::LogLevel::LOG_LVL_INFO, 1710000000123LL, + "test.cpp", 60, "test_func", "http metrics test", "", + -1, false, false, false); + logger.log(record, "http metrics test"); + + logger.start(); + std::this_thread::sleep_for(std::chrono::milliseconds(300)); + + using HttpClient = SimpleWeb::Client; + HttpClient client("localhost:43197"); + auto response = client.request("GET", "/metrics"); + + assert(response->status_code.find("200") != std::string::npos); + + auto ct_it = response->header.find("Content-Type"); + assert(ct_it != response->header.end()); + assert(ct_it->second.find("text/plain") != std::string::npos); + + std::string body = response->content.string(); + assert(body.find("# HELP logit_log_records_total") != std::string::npos); + assert(body.find("# TYPE logit_log_records_total counter") != std::string::npos); + assert(body.find("logit_log_records_total") != std::string::npos); + + logger.shutdown(); + } + + // Test 8: HTTP GET /health returns 200 + { + logit::PrometheusHttpServerLogger::Config config; + config.port = 43198; + config.enable_health_endpoint = true; + config.start_immediately = false; + + logit::PrometheusHttpServerLogger logger(config); + logger.start(); + std::this_thread::sleep_for(std::chrono::milliseconds(300)); + + using HttpClient = SimpleWeb::Client; + HttpClient client("localhost:43198"); + auto response = client.request("GET", "/health"); + + assert(response->status_code.find("200") != std::string::npos); + assert(response->content.string() == "ok"); + + logger.shutdown(); + } + + // Test 9: Unknown path returns 404 + { + logit::PrometheusHttpServerLogger::Config config; + config.port = 43199; + config.start_immediately = false; + + logit::PrometheusHttpServerLogger logger(config); + logger.start(); + std::this_thread::sleep_for(std::chrono::milliseconds(300)); + + using HttpClient = SimpleWeb::Client; + HttpClient client("localhost:43199"); + auto response = client.request("GET", "/unknown"); + + assert(response->status_code.find("404") != std::string::npos); + + logger.shutdown(); + } + + // Test 10: Custom metrics path works via HTTP + { + logit::PrometheusHttpServerLogger::Config config; + config.port = 43200; + config.path = "/custom_metrics"; + config.format.metric_prefix = "app_"; + config.start_immediately = false; + + logit::PrometheusHttpServerLogger logger(config); + + logit::LogRecord record( + logit::LogLevel::LOG_LVL_WARN, 1710000000123LL, + "test.cpp", 70, "test_func", "custom path test", "", + -1, false, false, false); + logger.log(record, "custom path test"); + + logger.start(); + std::this_thread::sleep_for(std::chrono::milliseconds(300)); + + using HttpClient = SimpleWeb::Client; + HttpClient client("localhost:43200"); + auto response = client.request("GET", "/custom_metrics"); + + assert(response->status_code.find("200") != std::string::npos); + + std::string body = response->content.string(); + assert(body.find("# HELP app_log_records_total") != std::string::npos); + assert(body.find("app_log_records_total") != std::string::npos); + + logger.shutdown(); + } + + return 0; +} + +#else + +int main() { + return 0; +} + +#endif diff --git a/tests/prometheus_payload_logger_test.cpp b/tests/prometheus_payload_logger_test.cpp new file mode 100644 index 0000000..e840ee6 --- /dev/null +++ b/tests/prometheus_payload_logger_test.cpp @@ -0,0 +1,226 @@ +#include +#include + +#ifdef LOGIT_WITH_PROMETHEUS + +#include +#include +#include +#include +#include +#include + +int main() { + // Test 1: collect_payload() contains logit_* metrics after a log + { + logit::PrometheusPayloadLogger::Config config; + config.format.metric_prefix = "logit_"; + config.format.include_build_info = true; + + auto logger = std::unique_ptr(new logit::PrometheusPayloadLogger(config)); + + logit::LogRecord record( + logit::LogLevel::LOG_LVL_WARN, 1710000000123LL, + "test.cpp", 10, "test_func", "test message", "", + -1, false, false, false); + logger->log(record, "test message"); + + std::string payload = logger->collect_payload(); + + assert(payload.find("# HELP logit_log_records_total") != std::string::npos); + assert(payload.find("# TYPE logit_log_records_total counter") != std::string::npos); + assert(payload.find("logit_log_records_total") != std::string::npos); + assert(payload.find("# TYPE logit_last_log_timestamp_ms gauge") != std::string::npos); + assert(payload.find("# TYPE logit_time_since_last_log_ms gauge") != std::string::npos); + assert(payload.find("# TYPE logit_build_info gauge") != std::string::npos); + assert(payload.find("version=") != std::string::npos); + assert(payload.find("compiler=") != std::string::npos); + + logger->shutdown(); + } + + // Test 2: on_payload called on wait() when emit_on_wait=true + { + std::atomic payload_count{0}; + std::string last_payload; + + logit::PrometheusPayloadLogger::Config config; + config.emit_on_wait = true; + config.on_payload = [&payload_count, &last_payload](std::string payload) { + last_payload = payload; + ++payload_count; + }; + + auto logger = std::unique_ptr(new logit::PrometheusPayloadLogger(config)); + + logit::LogRecord record( + logit::LogLevel::LOG_LVL_WARN, 1710000000123LL, + "test.cpp", 20, "test_func", "wait test", "", + -1, false, false, false); + logger->log(record, "wait test"); + + logger->wait(); + + assert(payload_count.load() >= 1); + assert(!last_payload.empty()); + assert(last_payload.find("logit_log_records_total") != std::string::npos); + + logger->shutdown(); + } + + // Test 3: callback exception increments FailedExportCount + { + logit::PrometheusPayloadLogger::Config config; + config.emit_on_wait = true; + config.on_payload = [](std::string) { + throw std::runtime_error("callback failed"); + }; + + auto logger = std::unique_ptr(new logit::PrometheusPayloadLogger(config)); + + logit::LogRecord record( + logit::LogLevel::LOG_LVL_WARN, 1710000000123LL, + "test.cpp", 30, "test_func", "exception test", "", + -1, false, false, false); + logger->log(record, "exception test"); + logger->wait(); + + int64_t failed = logger->get_int_param(logit::LoggerParam::FailedExportCount); + assert(failed >= 1); + + logger->shutdown(); + } + + // Test 4: no callback does not crash + { + logit::PrometheusPayloadLogger::Config config; + + auto logger = std::unique_ptr(new logit::PrometheusPayloadLogger(config)); + + logit::LogRecord record( + logit::LogLevel::LOG_LVL_WARN, 1710000000123LL, + "test.cpp", 40, "test_func", "no callback test", "", + -1, false, false, false); + logger->log(record, "no callback test"); + logger->wait(); + + logger->shutdown(); + } + + // Test 5: custom on_collect adds user metric + { + logit::PrometheusPayloadLogger::Config config; + config.on_collect = [](std::vector& families) { + logit::PrometheusMetricFamily mf; + mf.name = "custom_metric"; + mf.help = "A custom metric"; + mf.type = logit::PrometheusMetricType::Gauge; + logit::PrometheusSample s; + s.name = "custom_metric"; + s.value = 99.0; + mf.samples.push_back(s); + families.push_back(mf); + }; + + auto logger = std::unique_ptr(new logit::PrometheusPayloadLogger(config)); + + logit::LogRecord record( + logit::LogLevel::LOG_LVL_WARN, 1710000000123LL, + "test.cpp", 50, "test_func", "custom collect test", "", + -1, false, false, false); + logger->log(record, "custom collect test"); + + std::string payload = logger->collect_payload(); + + assert(payload.find("# HELP custom_metric A custom metric") != std::string::npos); + assert(payload.find("# TYPE custom_metric gauge") != std::string::npos); + assert(payload.find("custom_metric 99") != std::string::npos); + + logger->shutdown(); + } + + // Test 6: metric_prefix is applied + { + logit::PrometheusPayloadLogger::Config config; + config.format.metric_prefix = "app_"; + + auto logger = std::unique_ptr(new logit::PrometheusPayloadLogger(config)); + + logit::LogRecord record( + logit::LogLevel::LOG_LVL_WARN, 1710000000123LL, + "test.cpp", 60, "test_func", "prefix test", "", + -1, false, false, false); + logger->log(record, "prefix test"); + + std::string payload = logger->collect_payload(); + + assert(payload.find("app_log_records_total") != std::string::npos); + assert(payload.find("app_build_info") != std::string::npos); + + logger->shutdown(); + } + + // Test 7: emit_on_log triggers callback on each log + { + std::atomic payload_count{0}; + + logit::PrometheusPayloadLogger::Config config; + config.emit_on_log = true; + config.on_payload = [&payload_count](std::string) { + ++payload_count; + }; + + auto logger = std::unique_ptr(new logit::PrometheusPayloadLogger(config)); + + logit::LogRecord record1( + logit::LogLevel::LOG_LVL_WARN, 1710000000123LL, + "test.cpp", 70, "test_func", "emit on log 1", "", + -1, false, false, false); + logger->log(record1, "emit on log 1"); + + logit::LogRecord record2( + logit::LogLevel::LOG_LVL_WARN, 1710000000124LL, + "test.cpp", 71, "test_func", "emit on log 2", "", + -1, false, false, false); + logger->log(record2, "emit on log 2"); + + assert(payload_count.load() >= 2); + + logger->shutdown(); + } + + // Test 8: get_string_param / get_float_param + { + logit::PrometheusPayloadLogger::Config config; + + auto logger = std::unique_ptr(new logit::PrometheusPayloadLogger(config)); + + logit::LogRecord record( + logit::LogLevel::LOG_LVL_INFO, 1710000000123LL, + "test.cpp", 80, "test_func", "param test", "", + -1, false, false, false); + logger->log(record, "param test"); + + std::string ts_str = logger->get_string_param(logit::LoggerParam::LastLogTimestamp); + assert(!ts_str.empty()); + assert(ts_str == "1710000000123"); + + double ts_f = logger->get_float_param(logit::LoggerParam::LastLogTimestamp); + assert(ts_f > 0.0); + + int64_t ts_i = logger->get_int_param(logit::LoggerParam::LastLogTimestamp); + assert(ts_i == 1710000000123LL); + + logger->shutdown(); + } + + return 0; +} + +#else + +int main() { + return 0; +} + +#endif diff --git a/tests/prometheus_text_serializer_test.cpp b/tests/prometheus_text_serializer_test.cpp new file mode 100644 index 0000000..73ed6c1 --- /dev/null +++ b/tests/prometheus_text_serializer_test.cpp @@ -0,0 +1,246 @@ +#include + +#ifdef LOGIT_WITH_PROMETHEUS + +#include +#include +#include +#include + +int main() { + // Test 1: prometheus_escape_help + { + assert(logit::prometheus_escape_help("hello") == "hello"); + assert(logit::prometheus_escape_help("back\\slash") == "back\\\\slash"); + assert(logit::prometheus_escape_help("new\nline") == "new\\nline"); + assert(logit::prometheus_escape_help("both\\\n") == "both\\\\\\n"); + } + + // Test 2: prometheus_escape_label_value + { + assert(logit::prometheus_escape_label_value("simple") == "simple"); + assert(logit::prometheus_escape_label_value("with\"quote") == "with\\\"quote"); + assert(logit::prometheus_escape_label_value("back\\slash") == "back\\\\slash"); + assert(logit::prometheus_escape_label_value("new\nline") == "new\\nline"); + } + + // Test 3: prometheus_sanitize_metric_name + { + assert(logit::prometheus_sanitize_metric_name("valid_name") == "valid_name"); + assert(logit::prometheus_sanitize_metric_name("valid:name") == "valid:name"); + assert(logit::prometheus_sanitize_metric_name("123bad") == "_23bad"); + assert(logit::prometheus_sanitize_metric_name("my-metric") == "my_metric"); + assert(logit::prometheus_sanitize_metric_name("a.b") == "a_b"); + assert(logit::prometheus_sanitize_metric_name("") == "_"); + } + + // Test 4: prometheus_sanitize_label_name + { + assert(logit::prometheus_sanitize_label_name("valid_name") == "valid_name"); + assert(logit::prometheus_sanitize_label_name("123bad") == "_23bad"); + assert(logit::prometheus_sanitize_label_name("my-label") == "my_label"); + assert(logit::prometheus_sanitize_label_name("") == "_"); + } + + // Test 5: HELP and TYPE output for counter + { + logit::PrometheusMetricFamily mf; + mf.name = "http_requests_total"; + mf.help = "Total HTTP requests"; + mf.type = logit::PrometheusMetricType::Counter; + logit::PrometheusSample s; + s.name = "http_requests_total"; + s.value = 42.0; + mf.samples.push_back(s); + + logit::PrometheusTextFormatConfig config; + std::string payload = logit::build_prometheus_text_payload({mf}, config); + + assert(payload.find("# HELP http_requests_total Total HTTP requests") != std::string::npos); + assert(payload.find("# TYPE http_requests_total counter") != std::string::npos); + assert(payload.find("http_requests_total 42") != std::string::npos); + } + + // Test 6: Gauge type + { + logit::PrometheusMetricFamily mf; + mf.name = "temperature"; + mf.help = "Current temperature"; + mf.type = logit::PrometheusMetricType::Gauge; + logit::PrometheusSample s; + s.name = "temperature"; + s.value = 23.5; + mf.samples.push_back(s); + + logit::PrometheusTextFormatConfig config; + std::string payload = logit::build_prometheus_text_payload({mf}, config); + + assert(payload.find("# TYPE temperature gauge") != std::string::npos); + assert(payload.find("temperature 23.") != std::string::npos); + } + + // Test 7: Untyped metric + { + logit::PrometheusMetricFamily mf; + mf.name = "mystery"; + mf.help = ""; + mf.type = logit::PrometheusMetricType::Untyped; + logit::PrometheusSample s; + s.name = "mystery"; + s.value = 7.0; + mf.samples.push_back(s); + + logit::PrometheusTextFormatConfig config; + std::string payload = logit::build_prometheus_text_payload({mf}, config); + + assert(payload.find("# TYPE mystery untyped") != std::string::npos); + } + + // Test 8: Labels with escaping + { + logit::PrometheusMetricFamily mf; + mf.name = "test_metric"; + mf.help = "test"; + mf.type = logit::PrometheusMetricType::Counter; + logit::PrometheusSample s; + s.name = "test_metric"; + s.value = 1.0; + s.labels.push_back({"method", "GET"}); + s.labels.push_back({"path", "/api/\"test\""}); + mf.samples.push_back(s); + + logit::PrometheusTextFormatConfig config; + std::string payload = logit::build_prometheus_text_payload({mf}, config); + + assert(payload.find("method=\"GET\"") != std::string::npos); + assert(payload.find("path=\"/api/\\\"test\\\"\"") != std::string::npos); + } + + // Test 9: NaN, +Inf, -Inf value formatting + { + logit::PrometheusMetricFamily mf; + mf.name = "special_values"; + mf.help = "Special float values"; + mf.type = logit::PrometheusMetricType::Gauge; + + logit::PrometheusSample s1; + s1.name = "special_values"; + s1.value = std::numeric_limits::quiet_NaN(); + s1.labels.push_back({"case", "nan"}); + mf.samples.push_back(s1); + + logit::PrometheusSample s2; + s2.name = "special_values"; + s2.value = std::numeric_limits::infinity(); + s2.labels.push_back({"case", "pos_inf"}); + mf.samples.push_back(s2); + + logit::PrometheusSample s3; + s3.name = "special_values"; + s3.value = -std::numeric_limits::infinity(); + s3.labels.push_back({"case", "neg_inf"}); + mf.samples.push_back(s3); + + logit::PrometheusTextFormatConfig config; + std::string payload = logit::build_prometheus_text_payload({mf}, config); + + assert(payload.find("NaN") != std::string::npos); + assert(payload.find("+Inf") != std::string::npos); + assert(payload.find("-Inf") != std::string::npos); + } + + // Test 10: const_labels + { + logit::PrometheusMetricFamily mf; + mf.name = "with_const"; + mf.help = "With const labels"; + mf.type = logit::PrometheusMetricType::Counter; + logit::PrometheusSample s; + s.name = "with_const"; + s.value = 5.0; + s.labels.push_back({"dynamic", "val"}); + mf.samples.push_back(s); + + logit::PrometheusTextFormatConfig config; + config.const_labels.push_back({"job", "test_job"}); + std::string payload = logit::build_prometheus_text_payload({mf}, config); + + assert(payload.find("job=\"test_job\"") != std::string::npos); + assert(payload.find("dynamic=\"val\"") != std::string::npos); + // const_labels come before sample labels + size_t job_pos = payload.find("job=\"test_job\""); + size_t dyn_pos = payload.find("dynamic=\"val\""); + assert(job_pos < dyn_pos); + } + + // Test 11: timestamp optional + { + logit::PrometheusMetricFamily mf; + mf.name = "ts_metric"; + mf.help = "With timestamp"; + mf.type = logit::PrometheusMetricType::Gauge; + logit::PrometheusSample s; + s.name = "ts_metric"; + s.value = 10.0; + s.timestamp_ms = 1710000000123LL; + mf.samples.push_back(s); + + logit::PrometheusTextFormatConfig config_with_ts; + config_with_ts.include_timestamp = true; + std::string payload_ts = logit::build_prometheus_text_payload({mf}, config_with_ts); + assert(payload_ts.find("1710000000123") != std::string::npos); + + logit::PrometheusTextFormatConfig config_no_ts; + config_no_ts.include_timestamp = false; + std::string payload_no_ts = logit::build_prometheus_text_payload({mf}, config_no_ts); + assert(payload_no_ts.find("1710000000123") == std::string::npos); + } + + // Test 12: include_help=false and include_type=false + { + logit::PrometheusMetricFamily mf; + mf.name = "bare_metric"; + mf.help = "Should not appear"; + mf.type = logit::PrometheusMetricType::Counter; + logit::PrometheusSample s; + s.name = "bare_metric"; + s.value = 1.0; + mf.samples.push_back(s); + + logit::PrometheusTextFormatConfig config; + config.include_help = false; + config.include_type = false; + std::string payload = logit::build_prometheus_text_payload({mf}, config); + + assert(payload.find("# HELP") == std::string::npos); + assert(payload.find("# TYPE") == std::string::npos); + assert(payload.find("bare_metric 1") != std::string::npos); + } + + // Test 13: finite double precision + { + logit::PrometheusMetricFamily mf; + mf.name = "precision_metric"; + mf.help = "Precision test"; + mf.type = logit::PrometheusMetricType::Gauge; + logit::PrometheusSample s; + s.name = "precision_metric"; + s.value = 0.1 + 0.2; // classic floating-point imprecision + mf.samples.push_back(s); + + logit::PrometheusTextFormatConfig config; + std::string payload = logit::build_prometheus_text_payload({mf}, config); + // Should contain enough digits to round-trip + assert(payload.find("0.30000000000000004") != std::string::npos); + } + + return 0; +} + +#else + +int main() { + return 0; +} + +#endif