feat: add /api/system/metrics endpoint

This commit is contained in:
Jakob
2025-12-07 11:31:55 +01:00
parent 64058b0f61
commit a01f10b042
3 changed files with 242 additions and 1 deletions

View File

@@ -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 = '_';
}
}

View File

@@ -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<JsonObject>();
@@ -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<std::string, bool> seen_metrics;
// get system data
JsonDocument doc;
JsonObject root = doc.to<JsonObject>();
(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<void(const JsonObject &, const std::string &)> process_object =
[&](const JsonObject & obj, const std::string & prefix) {
std::vector<std::pair<std::string, std::string>> 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<JsonObject>()) {
// recursive call for nested objects
has_nested_objects = true;
process_object(p.value().as<JsonObject>(), metric_name);
} else if (p.value().is<JsonArray>()) {
// handle arrays (devices)
if (key == "devices") {
JsonArray devices = p.value().as<JsonArray>();
for (JsonObject device : devices) {
std::vector<std::pair<std::string, std::string>> 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<const char *>()) {
std::string val = dp.value().as<const char *>();
if (!val.empty()) {
device_labels.push_back({to_lowercase(dkey), val});
}
}
}
}
// create productID metric
if (device.containsKey("productID") && device["productID"].is<int>()) {
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<int>()) + "\n";
}
// create entities metric
if (device.containsKey("entities") && device["entities"].is<int>()) {
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<int>()) + "\n";
}
}
}
} else {
// handle primitive values
bool is_number = p.value().is<int>() || p.value().is<float>();
bool is_bool = p.value().is<bool>();
bool is_string = p.value().is<const char *>();
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<bool>() ? "1" : "0";
} else if (p.value().is<int>()) {
result += std::to_string(p.value().as<int>());
} else {
char val_str[30];
snprintf(val_str, sizeof(val_str), "%.2f", p.value().as<float>());
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<const char *>();
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) {

View File

@@ -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);