mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-02 10:53:44 +00:00
Keeps every server's traffic reports (last hour at one second, per-minute rows to server_traffic once a minute, pruned after traffic_days) and shows packets, bytes, HTTP requests and latency per second, per server, with the busiest message types and HTTP routes. Live over the traffic WebSocket topic; 1 hour, 24 hours and 7 days ranges. The same counters are in /metrics. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
199 lines
7.0 KiB
C++
199 lines
7.0 KiB
C++
#pragma once
|
|
|
|
#include <algorithm>
|
|
#include <charconv>
|
|
#include <cmath>
|
|
#include <cstdint>
|
|
#include <map>
|
|
#include <optional>
|
|
#include <string>
|
|
#include <string_view>
|
|
#include <utility>
|
|
#include <vector>
|
|
|
|
/**
|
|
* The Prometheus text exposition format (version 0.0.4), and the checks /metrics makes before answering.
|
|
* Pure so it can be unit tested.
|
|
*
|
|
* A Writer collects samples in any order and writes each metric family together, once, under its HELP and TYPE lines:
|
|
* Writer w;
|
|
* w.Add("darkflame_zone_players", "Players in a zone", "gauge", { {"zone_id", "1100"} }, 12);
|
|
* w.Text();
|
|
*/
|
|
namespace MetricsFormat {
|
|
using Labels = std::vector<std::pair<std::string, std::string>>;
|
|
|
|
// Metric and label names are [a-zA-Z_:][a-zA-Z0-9_:]* (labels without ':'); anything else becomes '_'
|
|
inline std::string Name(std::string_view name, bool label = false) {
|
|
std::string out(name);
|
|
for (size_t i = 0; i < out.size(); i++) {
|
|
const char c = out[i];
|
|
const bool letter = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_' || (c == ':' && !label);
|
|
if (!letter && !(i > 0 && c >= '0' && c <= '9')) out[i] = '_';
|
|
}
|
|
return out.empty() ? "_" : out;
|
|
}
|
|
|
|
// Label values escape backslash, double quote and line feed
|
|
inline std::string EscapeLabelValue(std::string_view value) {
|
|
std::string out;
|
|
out.reserve(value.size());
|
|
for (const char c : value) {
|
|
if (c == '\\') out += "\\\\";
|
|
else if (c == '"') out += "\\\"";
|
|
else if (c == '\n') out += "\\n";
|
|
else out += c;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// HELP text escapes backslash and line feed (quotes stay as they are)
|
|
inline std::string EscapeHelp(std::string_view help) {
|
|
std::string out;
|
|
out.reserve(help.size());
|
|
for (const char c : help) {
|
|
if (c == '\\') out += "\\\\";
|
|
else if (c == '\n') out += "\\n";
|
|
else out += c;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// Whole numbers without a decimal point; NaN, +Inf and -Inf as Prometheus spells them
|
|
inline std::string Value(double value) {
|
|
if (std::isnan(value)) return "NaN";
|
|
if (std::isinf(value)) return value > 0 ? "+Inf" : "-Inf";
|
|
if (value == std::floor(value) && std::fabs(value) < 9007199254740992.0) return std::to_string(static_cast<int64_t>(value));
|
|
char buffer[32];
|
|
const auto result = std::to_chars(buffer, buffer + sizeof(buffer), value);
|
|
return std::string(buffer, result.ptr);
|
|
}
|
|
|
|
class Writer {
|
|
public:
|
|
// type: counter or gauge. A counter's name should end in _total.
|
|
void Add(const std::string& name, const std::string& help, const std::string& type, const Labels& labels, double value) {
|
|
AddSample(name, name, help, type, labels, value);
|
|
}
|
|
|
|
/**
|
|
* A sample named differently from its family, for histograms: the family "x_seconds" (type histogram) holds
|
|
* x_seconds_bucket{le=...}, x_seconds_sum and x_seconds_count.
|
|
*/
|
|
void AddSample(const std::string& family, const std::string& sample, const std::string& help, const std::string& type, const Labels& labels, double value) {
|
|
const auto metric = Name(family);
|
|
auto it = m_Index.find(metric);
|
|
if (it == m_Index.end()) {
|
|
it = m_Index.emplace(metric, m_Families.size()).first;
|
|
m_Families.push_back({ metric, help, type, {} });
|
|
}
|
|
std::string line = Name(sample);
|
|
if (!labels.empty()) {
|
|
line += '{';
|
|
for (size_t i = 0; i < labels.size(); i++) {
|
|
if (i) line += ',';
|
|
line += Name(labels[i].first, true) + "=\"" + EscapeLabelValue(labels[i].second) + '"';
|
|
}
|
|
line += '}';
|
|
}
|
|
m_Families[it->second].samples.push_back(line + ' ' + Value(value));
|
|
}
|
|
|
|
void Add(const std::string& name, const std::string& help, const std::string& type, double value) {
|
|
Add(name, help, type, {}, value);
|
|
}
|
|
|
|
// A family with no samples (e.g. no worlds running) still gets its HELP and TYPE, so dashboards know it
|
|
void Declare(const std::string& name, const std::string& help, const std::string& type) {
|
|
const auto metric = Name(name);
|
|
if (m_Index.contains(metric)) return;
|
|
m_Index.emplace(metric, m_Families.size());
|
|
m_Families.push_back({ metric, help, type, {} });
|
|
}
|
|
|
|
std::string Text() const {
|
|
std::string out;
|
|
for (const auto& family : m_Families) {
|
|
out += "# HELP " + family.name + ' ' + EscapeHelp(family.help) + '\n';
|
|
out += "# TYPE " + family.name + ' ' + family.type + '\n';
|
|
for (const auto& sample : family.samples) out += sample + '\n';
|
|
}
|
|
return out;
|
|
}
|
|
|
|
private:
|
|
struct Family {
|
|
std::string name;
|
|
std::string help;
|
|
std::string type;
|
|
std::vector<std::string> samples;
|
|
};
|
|
std::vector<Family> m_Families;
|
|
std::map<std::string, size_t> m_Index;
|
|
};
|
|
|
|
// Compares without stopping at the first difference, so response times don't give a secret away a byte at a time
|
|
inline bool ConstantTimeEquals(std::string_view a, std::string_view b) {
|
|
unsigned char difference = a.size() == b.size() ? 0 : 1;
|
|
const size_t length = std::max(a.size(), b.size());
|
|
for (size_t i = 0; i < length; i++) {
|
|
const unsigned char x = i < a.size() ? static_cast<unsigned char>(a[i]) : 0;
|
|
const unsigned char y = i < b.size() ? static_cast<unsigned char>(b[i]) : 0;
|
|
difference |= x ^ y;
|
|
}
|
|
return difference == 0;
|
|
}
|
|
|
|
namespace Detail {
|
|
inline std::optional<uint32_t> Ipv4(std::string_view text) {
|
|
uint32_t address = 0;
|
|
int parts = 0;
|
|
while (parts < 4) {
|
|
uint32_t part = 0;
|
|
const auto result = std::from_chars(text.data(), text.data() + text.size(), part);
|
|
if (result.ec != std::errc() || part > 255 || result.ptr == text.data()) return std::nullopt;
|
|
address = (address << 8) | part;
|
|
text.remove_prefix(result.ptr - text.data());
|
|
parts++;
|
|
if (parts < 4) {
|
|
if (text.empty() || text.front() != '.') return std::nullopt;
|
|
text.remove_prefix(1);
|
|
}
|
|
}
|
|
if (!text.empty()) return std::nullopt;
|
|
return address;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Whether an address is in a comma separated allow-list of addresses and IPv4 ranges ("127.0.0.1, 10.0.0.0/8, ::1").
|
|
* An empty list allows everyone. IPv6 addresses only match exactly.
|
|
*/
|
|
inline bool AddressAllowed(std::string_view list, std::string_view address) {
|
|
bool any = false;
|
|
while (!list.empty()) {
|
|
const auto comma = list.find(',');
|
|
auto entry = list.substr(0, comma);
|
|
list = comma == std::string_view::npos ? std::string_view{} : list.substr(comma + 1);
|
|
while (!entry.empty() && entry.front() == ' ') entry.remove_prefix(1);
|
|
while (!entry.empty() && entry.back() == ' ') entry.remove_suffix(1);
|
|
if (entry.empty()) continue;
|
|
any = true;
|
|
const auto slash = entry.find('/');
|
|
if (slash == std::string_view::npos) {
|
|
if (entry == address) return true;
|
|
continue;
|
|
}
|
|
const auto network = Detail::Ipv4(entry.substr(0, slash));
|
|
const auto ip = Detail::Ipv4(address);
|
|
uint32_t bits = 0;
|
|
const auto prefix = entry.substr(slash + 1);
|
|
const auto parsed = std::from_chars(prefix.data(), prefix.data() + prefix.size(), bits);
|
|
if (!network || !ip || prefix.empty() || parsed.ec != std::errc() || parsed.ptr != prefix.data() + prefix.size() || bits > 32) continue;
|
|
const uint32_t mask = bits == 0 ? 0 : ~uint32_t{ 0 } << (32 - bits);
|
|
if ((*network & mask) == (*ip & mask)) return true;
|
|
}
|
|
return !any;
|
|
}
|
|
}
|