From a01f10b04211eacd53076559ce11236a4d571678 Mon Sep 17 00:00:00 2001 From: Jakob Date: Sun, 7 Dec 2025 11:31:55 +0100 Subject: [PATCH] feat: add /api/system/metrics endpoint --- src/core/emsdevice.cpp | 5 +- src/core/system.cpp | 237 +++++++++++++++++++++++++++++++++++++++++ src/core/system.h | 1 + 3 files changed, 242 insertions(+), 1 deletion(-) diff --git a/src/core/emsdevice.cpp b/src/core/emsdevice.cpp index 59d0a33cf..b5248c545 100644 --- a/src/core/emsdevice.cpp +++ b/src/core/emsdevice.cpp @@ -1773,8 +1773,11 @@ std::string EMSdevice::get_metrics_prometheus(const int8_t tag) { metric_name = metric_name.substr(last_dot + 1); } + // sanitize metric name: convert to lowercase and replace non-alphanumeric with underscores for (char & c : metric_name) { - if (!isalnum(c) && c != '_') { + if (isupper(c)) { + c = tolower(c); + } else if (!isalnum(c) && c != '_') { c = '_'; } } diff --git a/src/core/system.cpp b/src/core/system.cpp index 0d57db613..3f092ff21 100644 --- a/src/core/system.cpp +++ b/src/core/system.cpp @@ -1465,6 +1465,16 @@ bool System::get_value_info(JsonObject output, const char * cmd) { return command_info("", 0, output); } + // check for metrics + if (!strcmp(cmd, F_(metrics))) { + std::string metrics = get_metrics_prometheus(); + if (!metrics.empty()) { + output["api_data"] = metrics; + return true; + } + return false; + } + // fetch all the data from the system in a different json JsonDocument doc; JsonObject root = doc.to(); @@ -1548,6 +1558,233 @@ void System::get_value_json(JsonObject output, const std::string & circuit, cons } } +// generate Prometheus metrics format from system values +std::string System::get_metrics_prometheus() { + std::string result; + std::unordered_map seen_metrics; + + // get system data + JsonDocument doc; + JsonObject root = doc.to(); + (void)command_info("", 0, root); + + // helper function to escape Prometheus label values + auto escape_label = [](const std::string & str) -> std::string { + std::string escaped; + for (char c : str) { + if (c == '\\') { + escaped += "\\\\"; + } else if (c == '"') { + escaped += "\\\""; + } else if (c == '\n') { + escaped += "\\n"; + } else { + escaped += c; + } + } + return escaped; + }; + + // helper function to sanitize metric name (convert to lowercase and replace dots with underscores) + auto sanitize_name = [](const std::string & name) -> std::string { + std::string sanitized = name; + for (char & c : sanitized) { + if (c == '.') { + c = '_'; + } else if (isupper(c)) { + c = tolower(c); + } else if (!isalnum(c) && c != '_') { + c = '_'; + } + } + return sanitized; + }; + + // helper function to convert label name to lowercase + auto to_lowercase = [](const std::string & str) -> std::string { + std::string result = str; + for (char & c : result) { + if (isupper(c)) { + c = tolower(c); + } + } + return result; + }; + + // helper function to check if a field should be ignored + auto should_ignore = [](const std::string & path, const std::string & key) -> bool { + if (path == "system" && key == "uptime") { + return true; + } + if (path == "ntp" && key == "timestamp") { + return true; + } + if (path.find("devices[") != std::string::npos) { + if (key == "handlersReceived" || key == "handlersFetched" || key == "handlersPending" || key == "handlersIgnored") { + return true; + } + } + return false; + }; + + // helper function to process a JSON object recursively + std::function process_object = + [&](const JsonObject & obj, const std::string & prefix) { + std::vector> local_info_labels; + bool has_nested_objects = false; + + for (JsonPair p : obj) { + std::string key = p.key().c_str(); + std::string path = prefix.empty() ? key : prefix + "." + key; + std::string metric_name = prefix.empty() ? key : prefix + "_" + key; + + if (should_ignore(prefix, key)) { + continue; + } + + if (p.value().is()) { + // recursive call for nested objects + has_nested_objects = true; + process_object(p.value().as(), metric_name); + } else if (p.value().is()) { + // handle arrays (devices) + if (key == "devices") { + JsonArray devices = p.value().as(); + for (JsonObject device : devices) { + std::vector> device_labels; + + // collect labels from device object + for (JsonPair dp : device) { + std::string dkey = dp.key().c_str(); + if (dkey == "type" || dkey == "name" || dkey == "deviceID" || dkey == "brand" || dkey == "version") { + if (dp.value().is()) { + std::string val = dp.value().as(); + if (!val.empty()) { + device_labels.push_back({to_lowercase(dkey), val}); + } + } + } + } + + // create productID metric + if (device.containsKey("productID") && device["productID"].is()) { + std::string metric = "emsesp_device_productid"; + if (seen_metrics.find(metric) == seen_metrics.end()) { + result += "# HELP emsesp_device_productid productID\n"; + result += "# TYPE emsesp_device_productid gauge\n"; + seen_metrics[metric] = true; + } + + result += metric; + if (!device_labels.empty()) { + result += "{"; + bool first = true; + for (const auto & label : device_labels) { + if (!first) { + result += ", "; + } + result += label.first + "=\"" + escape_label(label.second) + "\""; + first = false; + } + result += "}"; + } + result += " " + std::to_string(device["productID"].as()) + "\n"; + } + + // create entities metric + if (device.containsKey("entities") && device["entities"].is()) { + std::string metric = "emsesp_device_entities"; + if (seen_metrics.find(metric) == seen_metrics.end()) { + result += "# HELP emsesp_device_entities entities\n"; + result += "# TYPE emsesp_device_entities gauge\n"; + seen_metrics[metric] = true; + } + + result += metric; + if (!device_labels.empty()) { + result += "{"; + bool first = true; + for (const auto & label : device_labels) { + if (!first) { + result += ", "; + } + result += label.first + "=\"" + escape_label(label.second) + "\""; + first = false; + } + result += "}"; + } + result += " " + std::to_string(device["entities"].as()) + "\n"; + } + } + } + } else { + // handle primitive values + bool is_number = p.value().is() || p.value().is(); + bool is_bool = p.value().is(); + bool is_string = p.value().is(); + + if (is_number || is_bool) { + // add metric + std::string full_metric_name = "emsesp_" + sanitize_name(metric_name); + if (seen_metrics.find(full_metric_name) == seen_metrics.end()) { + result += "# HELP emsesp_" + sanitize_name(metric_name) + " " + key + "\n"; + result += "# TYPE emsesp_" + sanitize_name(metric_name) + " gauge\n"; + seen_metrics[full_metric_name] = true; + } + + result += full_metric_name + " "; + if (is_bool) { + result += p.value().as() ? "1" : "0"; + } else if (p.value().is()) { + result += std::to_string(p.value().as()); + } else { + char val_str[30]; + snprintf(val_str, sizeof(val_str), "%.2f", p.value().as()); + result += val_str; + } + result += "\n"; + } else if (is_string) { + // collect string for info metric (skip dynamic strings like uptime and timestamp) + std::string val = p.value().as(); + if (!val.empty() && key != "uptime" && key != "timestamp") { + local_info_labels.push_back({to_lowercase(key), val}); + } + } + } + } + + // create _info metric for this object level if we have labels and this is a leaf node (no nested objects) + if (!local_info_labels.empty() && !prefix.empty() && !has_nested_objects) { + std::string info_metric = "emsesp_" + sanitize_name(prefix) + "_info"; + if (seen_metrics.find(info_metric) == seen_metrics.end()) { + result += "# HELP " + info_metric + " info\n"; + result += "# TYPE " + info_metric + " gauge\n"; + seen_metrics[info_metric] = true; + } + + result += info_metric; + if (!local_info_labels.empty()) { + result += "{"; + bool first = true; + for (const auto & label : local_info_labels) { + if (!first) { + result += ", "; + } + result += label.first + "=\"" + escape_label(label.second) + "\""; + first = false; + } + result += "}"; + } + result += " 1\n"; + } + }; + + // process root object + process_object(root, ""); + + return result; +} + // export status information including the device information // http://ems-esp/api/system/info bool System::command_info(const char * value, const int8_t id, JsonObject output) { diff --git a/src/core/system.h b/src/core/system.h index 796ca5c7b..31df1d0a0 100644 --- a/src/core/system.h +++ b/src/core/system.h @@ -93,6 +93,7 @@ class System { static bool get_value_info(JsonObject root, const char * cmd); static void get_value_json(JsonObject output, const std::string & circuit, const std::string & name, JsonVariant val); + static std::string get_metrics_prometheus(); #if defined(EMSESP_TEST) static bool command_test(const char * value, const int8_t id);