mirror of
https://github.com/emsesp/EMS-ESP32.git
synced 2025-12-10 01:39:54 +03:00
feat: add /api/system/metrics endpoint
This commit is contained in:
@@ -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 = '_';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user