feat(dashboard): Diagnostics page with live traffic of every server

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>
This commit is contained in:
Aaron Kimbrell
2026-09-27 08:50:54 -05:00
parent 01d10f4401
commit e6148a4021
18 changed files with 1292 additions and 4 deletions

View File

@@ -84,6 +84,8 @@
#include "InstanceLoad.h"
#include "WorldView.h"
#include "PrometheusMetrics.h"
#include "Traffic.h"
#include "master/ServerTraffic.h"
#include "Background.h"
#include "master/DashboardMessages.h"
#include "master/DataChanged.h"
@@ -322,6 +324,7 @@ namespace {
handlers.On<DataChanged>(Master::DATA_CHANGED, [](const DataChanged& changed, const SystemAddress&) { BroadcastDataChanged(changed); });
handlers.On<PlayerActionResult>(Master::PLAYER_ACTION_RESULT, [](const PlayerActionResult& result, const SystemAddress&) { PlayerActions::HandleResult(result); });
handlers.On<MasterPackets::WorldShutDown>(Master::SHUTDOWN_RESPONSE, OnWorldShutDown);
handlers.On<ServerTraffic>(Master::SERVER_TRAFFIC, [](const ServerTraffic& report, const SystemAddress&) { Traffic::Ingest(report); });
return handlers;
}();
return handlers;
@@ -524,6 +527,7 @@ int main(int argc, char** argv) {
WorldView::RegisterRoutes();
Scenery::RegisterRoutes();
PrometheusMetrics::RegisterRoutes();
Traffic::RegisterRoutes();
RegisterPublicRoutes();
RegisterShowcaseRoutes();
FeaturedProperties::RegisterRoutes();
@@ -582,6 +586,7 @@ int main(int argc, char** argv) {
LiveEventRoutes::Update();
ChallengeRoutes::Update();
InstanceLoad::Update();
Traffic::Update();
// Broadcast dashboard updates periodically
if (elapsedSinceBroadcast >= broadcastInterval) {

View File

@@ -27,6 +27,8 @@ set(DASHBOARDROUTES_SOURCES
"MissionTools.cpp"
"CharacterProgress.cpp"
"ServerRoutes.cpp"
"Traffic.cpp"
"TrafficHistory.cpp"
"LeaderboardRoutes.cpp"
"VanityRoutes.cpp"
"ChatRoutes.cpp"

View File

@@ -465,6 +465,7 @@ void RegisterDashboardRoutes() {
SimplePage("/vanity", Perm("vanity_manage"), "vanity.jinja2", "vanity", "Vanity: the vanity files and their NPCs, plaque texts, vanity events and a preview of what the worlds load");
SimplePage("/leaderboards", Perm("leaderboards_view"), "leaderboards.jinja2", "leaderboards", "Leaderboards for every activity");
SimplePage("/health", Perm("health_view"), "health.jinja2", "health", "Player counts, worlds, uptime and memory over time; crash dumps");
SimplePage("/diagnostics", Perm("health_view"), "diagnostics.jinja2", "diagnostics", "Packets, bytes and HTTP requests per second of every server");
SimplePage("/players", Perm("players_view"), "players.jinja2", "players", "Who is online, with kick, rescue and teleport");
SimplePage("/backups", Perm("backups"), "backups.jinja2", "backups", "Database backups");
SimplePage("/permissions", Perm("permissions_manage"), "permissions.jinja2", "permissions", "What each GM level may do");

View File

@@ -215,6 +215,7 @@ namespace {
std::tuple{ std::optional(eLog::LOGIN_ADDRESS), "log_login_address_days", 90 },
std::tuple{ std::optional<eLog>(), "log_task_days", 90 },
std::tuple{ std::optional<eLog>(), "health_days", 30 },
std::tuple{ std::optional<eLog>(), "traffic_days", 30 },
std::tuple{ std::optional<eLog>(), "position_history_days", 3 } }) {
const auto days = Setting(key, fallback);
run->Log(std::string(key) + " = " + (days > 0 ? std::to_string(days) : "0 (keep everything)"));
@@ -227,6 +228,7 @@ namespace {
if (days <= 0) continue;
const auto before = now - days * DAY_SECONDS;
pruned[key] = log ? db.PruneLog(*log, before) : key == std::string("health_days") ? db.PruneHealthSamples(before)
: key == std::string("traffic_days") ? db.PruneTrafficMinutes(before)
: key == std::string("position_history_days") ? db.PrunePositionSamples(before) : db.PruneTaskRuns(before);
}
return pruned;

View File

@@ -73,13 +73,21 @@ namespace MetricsFormat {
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) {
const auto metric = Name(name);
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 = metric;
std::string line = Name(sample);
if (!labels.empty()) {
line += '{';
for (size_t i = 0; i < labels.size(); i++) {

View File

@@ -1,5 +1,6 @@
#include "PrometheusMetrics.h"
#include "MetricsFormat.h"
#include "Traffic.h"
#include <algorithm>
#include <chrono>
@@ -266,6 +267,7 @@ namespace {
if (!g_Text.empty() && now - g_BuiltAt < std::chrono::seconds(cacheSeconds)) return g_Text;
MetricsFormat::Writer w;
AddLive(w);
Traffic::AddMetrics(w);
AddDatabase(w);
g_Text = w.Text();
g_BuiltAt = now;

View File

@@ -386,6 +386,7 @@ namespace {
c.Add(Days("log_chat_days", "Chat log", "", "90"));
c.Add(Days("log_login_address_days", "Login addresses", "Addresses an account hasn't logged in from for this long are forgotten.", "90"));
c.Add(Days("health_days", "Server health history", "Minute-by-minute player counts and uptime.", "30"));
c.Add(Days("traffic_days", "Server traffic history", "Minute-by-minute packets, bytes and HTTP requests of every server (Diagnostics). The last hour at one second is only kept in memory.", "30"));
c.AddSection("Player movement", "Where players went, for replays on the 3D world view (staff with players_history only).");
c.Add(Bool(DASHBOARD, "position_history", "Record player movement", "Keeps a player's position every few seconds while they move, and every 30 seconds while they stand still.", true));

View File

@@ -0,0 +1,408 @@
#include "Traffic.h"
#include <algorithm>
#include <chrono>
#include <cmath>
#include <map>
#include <mutex>
#include "Background.h"
#include "Database.h"
#include "Game.h"
#include "Logger.h"
#include "MessageIdentifiers.h"
#include "MetricsFormat.h"
#include "Permissions.h"
#include "RouteUtils.h"
#include "ServerState.h"
#include "ServiceType.h"
#include "TrafficHistory.h"
#include "TrafficStats.h"
#include "Web.h"
#include "Workers.h"
#include "dServer.h"
#include "eHTTPMethod.h"
#include "magic_enum.hpp"
#include "master/ServerTraffic.h"
#include "MessageType/Auth.h"
#include "MessageType/Chat.h"
#include "MessageType/Client.h"
#include "MessageType/Game.h"
#include "MessageType/Master.h"
#include "MessageType/Server.h"
#include "MessageType/World.h"
using namespace RouteUtils;
namespace {
constexpr const char* PERMISSION = "health_view";
constexpr const char* TOPIC = "traffic";
constexpr auto PUSH_INTERVAL = std::chrono::seconds(2);
constexpr auto WRITE_INTERVAL = std::chrono::seconds(15);
constexpr int64_t ONLINE_SECONDS = 20; // a server that reported this recently is shown as up
TrafficHistory g_History;
bool g_Changed = false;
std::chrono::steady_clock::time_point g_NextPush{};
std::chrono::steady_clock::time_point g_NextWrite{};
// Minutes the background writer couldn't take yet (it was still busy)
std::vector<IServerTraffic::TrafficMinute> g_Unwritten;
template<typename Enum>
std::string EnumName(uint32_t value) {
const auto name = magic_enum::enum_name(static_cast<Enum>(value));
return name.empty() ? "#" + std::to_string(value) : std::string(name);
}
std::string RakNetName(uint32_t id) {
switch (id) {
case ID_REPLICA_MANAGER_CONSTRUCTION: return "REPLICA_CONSTRUCTION";
case ID_REPLICA_MANAGER_SERIALIZE: return "REPLICA_SERIALIZE";
case ID_REPLICA_MANAGER_DESTRUCTION: return "REPLICA_DESTRUCTION";
case ID_NEW_INCOMING_CONNECTION: return "NEW_INCOMING_CONNECTION";
case ID_CONNECTION_REQUEST_ACCEPTED: return "CONNECTION_REQUEST_ACCEPTED";
case ID_DISCONNECTION_NOTIFICATION: return "DISCONNECTION_NOTIFICATION";
case ID_CONNECTION_LOST: return "CONNECTION_LOST";
default: return "RAKNET_" + std::to_string(id);
}
}
// {service, packet, game message} names of a message key
struct MessageNames {
std::string service;
std::string packet;
std::string gameMessage; // empty unless a game message
};
MessageNames NamesOf(const TrafficStats::MessageKey& key) {
if (key.service == TrafficStats::MessageKey::RAKNET) return { "RAKNET", RakNetName(key.packet), "" };
MessageNames names;
const auto service = static_cast<ServiceType>(key.service);
const auto serviceName = magic_enum::enum_name(service);
names.service = serviceName.empty() ? "SERVICE_" + std::to_string(key.service) : std::string(serviceName);
switch (service) {
case ServiceType::COMMON: names.packet = EnumName<MessageType::Server>(key.packet); break;
case ServiceType::AUTH: names.packet = EnumName<MessageType::Auth>(key.packet); break;
case ServiceType::CHAT: names.packet = EnumName<MessageType::Chat>(key.packet); break;
case ServiceType::WORLD: names.packet = EnumName<MessageType::World>(key.packet); break;
case ServiceType::CLIENT: names.packet = EnumName<MessageType::Client>(key.packet); break;
case ServiceType::MASTER: names.packet = EnumName<MessageType::Master>(key.packet); break;
default: names.packet = "#" + std::to_string(key.packet); break;
}
const bool gameMessage = (service == ServiceType::WORLD && key.packet == static_cast<uint32_t>(MessageType::World::GAME_MSG)) ||
(service == ServiceType::CLIENT && key.packet == static_cast<uint32_t>(MessageType::Client::GAME_MSG));
if (gameMessage) names.gameMessage = EnumName<MessageType::Game>(key.gameMessage);
return names;
}
// "World 1100 Avant Gardens #3" for world:1100:3, else the key capitalised
std::string LabelOf(const TrafficHistory::Server& server) {
switch (static_cast<ServiceType>(server.type)) {
case ServiceType::MASTER: return "Master";
case ServiceType::AUTH: return "Auth";
case ServiceType::CHAT: return "Chat";
case ServiceType::DASHBOARD: return "Dashboard";
case ServiceType::UGC: return "UGC";
case ServiceType::WORLD: {
std::string zoneName;
{
std::lock_guard lock(ServerState::g_StatusMutex);
for (const auto& world : ServerState::g_WorldInstances) {
if (world.mapID == server.zoneId && world.instanceID == server.instanceId) zoneName = world.zoneName;
}
}
return "World " + std::to_string(server.zoneId) + (zoneName.empty() ? "" : " " + zoneName) + " #" + std::to_string(server.instanceId);
}
default: return server.key;
}
}
double Rate(uint64_t value, int64_t seconds) {
return std::round(static_cast<double>(value) / static_cast<double>(std::max<int64_t>(seconds, 1)) * 100.0) / 100.0;
}
double Millis(uint64_t microseconds) {
return std::round(static_cast<double>(microseconds) / 10.0) / 100.0;
}
// One server's chart lines: per-second rates of each bucket, latency in ms
struct Lines {
nlohmann::json packetsIn = nlohmann::json::array(), packetsOut = nlohmann::json::array(), bytesIn = nlohmann::json::array(), bytesOut = nlohmann::json::array();
nlohmann::json http = nlohmann::json::array(), httpErrors = nlohmann::json::array(), p50 = nlohmann::json::array(), p95 = nlohmann::json::array(), p99 = nlohmann::json::array();
bool anyHttp = false;
void Add(uint64_t pin, uint64_t pout, uint64_t bin, uint64_t bout, uint64_t requests, uint64_t errors, double lat50, double lat95, double lat99, int64_t step) {
packetsIn.push_back(Rate(pin, step));
packetsOut.push_back(Rate(pout, step));
bytesIn.push_back(Rate(bin, step));
bytesOut.push_back(Rate(bout, step));
http.push_back(Rate(requests, step));
httpErrors.push_back(Rate(errors, step));
p50.push_back(requests ? nlohmann::json(lat50) : nlohmann::json(nullptr));
p95.push_back(requests ? nlohmann::json(lat95) : nlohmann::json(nullptr));
p99.push_back(requests ? nlohmann::json(lat99) : nlohmann::json(nullptr));
anyHttp = anyHttp || requests > 0;
}
nlohmann::json Json() const {
nlohmann::json out = { {"packets_in", packetsIn}, {"packets_out", packetsOut}, {"bytes_in", bytesIn}, {"bytes_out", bytesOut} };
if (anyHttp) {
out["http"] = http;
out["http_errors"] = httpErrors;
out["p50"] = p50;
out["p95"] = p95;
out["p99"] = p99;
}
return out;
}
};
nlohmann::json ServerInfo(const std::string& key, int64_t now) {
const auto& servers = g_History.Servers();
const auto it = servers.find(key);
nlohmann::json info = { {"key", key}, {"label", key}, {"online", false} };
if (it == servers.end()) {
// Only in the database (e.g. a world that has since closed)
if (key.starts_with("world:")) info["label"] = "World " + key.substr(6);
return info;
}
const auto& server = it->second;
info["label"] = LabelOf(server);
info["type"] = std::string(magic_enum::enum_name(static_cast<ServiceType>(server.type)));
info["online"] = now - server.lastSeen <= ONLINE_SECONDS;
info["last_seen"] = server.lastSeen;
info["link"] = { {"connections", server.link.connections}, {"ping_ms", server.link.averagePingMs}, {"resend_queue", server.link.resendQueue},
{"resends", server.link.resends}, {"datagrams_sent", server.link.datagramsSent}, {"datagrams_received", server.link.datagramsReceived} };
nlohmann::json gauges = nlohmann::json::object();
for (const auto& [name, value] : server.gauges) gauges[name] = value;
info["gauges"] = gauges;
return info;
}
// The chart data of a range: live (5m) and 1h from memory, 24h and 7d from the database plus the minutes not written yet
nlohmann::json Series(const std::string& range, int64_t now) {
int64_t span = 300, step = 1;
if (range == "1h") { span = 3600; step = 10; }
else if (range == "24h") { span = 86400; step = 300; }
else if (range == "7d") { span = 7 * 86400; step = 1800; }
const int64_t to = now - (now % step); // the current bucket isn't complete yet
const int64_t from = to - span;
const auto count = static_cast<size_t>(span / step);
nlohmann::json times = nlohmann::json::array();
for (size_t i = 0; i < count; i++) times.push_back(from + static_cast<int64_t>(i) * step);
std::map<std::string, Lines> lines;
if (step < 60) {
for (const auto& [key, points] : g_History.Buckets(from, to, step)) {
auto& line = lines[key];
for (const auto& p : points) {
const auto latency = p.Latency();
line.Add(p.packetsIn, p.packetsOut, p.bytesIn, p.bytesOut, p.httpRequests, p.http4xx + p.http5xx,
Millis(latency.Percentile(0.5)), Millis(latency.Percentile(0.95)), Millis(latency.Percentile(0.99)), step);
}
}
} else {
// Database rows per bucket and server, then the minutes still in memory
std::map<std::string, std::vector<IServerTraffic::TrafficMinute>> buckets;
const auto add = [&](const IServerTraffic::TrafficMinute& row) {
if (row.time < from || row.time >= to + step) return;
auto& list = buckets[row.server];
if (list.empty()) {
list.resize(count);
for (size_t i = 0; i < count; i++) list[i].time = from + static_cast<int64_t>(i) * step;
}
const auto index = std::min(count - 1, static_cast<size_t>((row.time - from) / step));
auto& b = list[index];
b.packetsIn += row.packetsIn; b.packetsOut += row.packetsOut; b.bytesIn += row.bytesIn; b.bytesOut += row.bytesOut;
b.httpRequests += row.httpRequests; b.http4xx += row.http4xx; b.http5xx += row.http5xx;
// Several minutes in one bucket: the worst minute's percentiles
b.latencyP50Us = std::max(b.latencyP50Us, row.latencyP50Us);
b.latencyP95Us = std::max(b.latencyP95Us, row.latencyP95Us);
b.latencyP99Us = std::max(b.latencyP99Us, row.latencyP99Us);
};
try {
for (const auto& row : Database::Get()->GetTrafficMinutes(from, to + step, step)) add(row);
} catch (const std::exception& ex) {
LOG_DEBUG("Traffic: reading server_traffic failed: %s", ex.what());
}
for (const auto& row : g_Unwritten) add(row);
for (const auto& [key, server] : g_History.Servers()) {
for (const auto& minute : server.minutes) {
const auto latency = minute.point.Latency();
add({ .time = minute.point.time, .server = key, .packetsIn = minute.point.packetsIn, .packetsOut = minute.point.packetsOut,
.bytesIn = minute.point.bytesIn, .bytesOut = minute.point.bytesOut, .httpRequests = minute.point.httpRequests,
.http4xx = minute.point.http4xx, .http5xx = minute.point.http5xx,
.latencyP50Us = static_cast<uint32_t>(latency.Percentile(0.5)), .latencyP95Us = static_cast<uint32_t>(latency.Percentile(0.95)),
.latencyP99Us = static_cast<uint32_t>(latency.Percentile(0.99)) });
}
}
for (const auto& [key, list] : buckets) {
auto& line = lines[key];
for (const auto& b : list) {
line.Add(b.packetsIn, b.packetsOut, b.bytesIn, b.bytesOut, b.httpRequests, b.http4xx + b.http5xx,
Millis(b.latencyP50Us), Millis(b.latencyP95Us), Millis(b.latencyP99Us), step);
}
}
}
nlohmann::json servers = nlohmann::json::array();
for (const auto& [key, line] : lines) {
auto info = ServerInfo(key, now);
info["series"] = line.Json();
servers.push_back(std::move(info));
}
// Servers that are up but had nothing in the range yet
for (const auto& [key, server] : g_History.Servers()) {
if (!lines.contains(key) && now - server.lastSeen <= ONLINE_SECONDS) servers.push_back(ServerInfo(key, now));
}
const int64_t since = step < 60 ? from : std::max<int64_t>(from, now - 86400);
nlohmann::json messages = nlohmann::json::array();
for (const auto& count : g_History.TopMessages("", since, 15)) {
const auto names = NamesOf(count.key);
messages.push_back({ {"direction", count.key.outbound ? "out" : "in"}, {"service", names.service}, {"packet", names.packet},
{"game_message", names.gameMessage}, {"count", count.count}, {"bytes", count.bytes} });
}
nlohmann::json routes = nlohmann::json::array();
for (const auto& [server, stats] : g_History.Routes(std::max<int64_t>(from, now - 3600))) {
if (routes.size() >= 30) break;
routes.push_back({ {"server", server}, {"route", stats.route}, {"count", stats.count}, {"status_4xx", stats.status[3]}, {"status_5xx", stats.status[4]},
{"bytes", stats.bytesOut}, {"p50", Millis(stats.latency.Percentile(0.5))}, {"p95", Millis(stats.latency.Percentile(0.95))},
{"p99", Millis(stats.latency.Percentile(0.99))} });
}
return { {"range", range}, {"from", from}, {"to", to}, {"step", step}, {"times", times}, {"servers", servers}, {"messages", messages},
{"messages_since", since}, {"routes", routes} };
}
// What open pages get every few seconds: each server's rates over its last five reported seconds
nlohmann::json Summary(int64_t now) {
nlohmann::json servers = nlohmann::json::object();
for (const auto& [key, server] : g_History.Servers()) {
if (now - server.lastSeen > ONLINE_SECONDS || server.seconds.empty()) continue;
TrafficHistory::Point p;
const auto first = server.seconds.back().time - 4;
for (auto it = server.seconds.rbegin(); it != server.seconds.rend() && it->time >= first; ++it) p.Add(*it);
servers[key] = { {"packets_in", Rate(p.packetsIn, 5)}, {"packets_out", Rate(p.packetsOut, 5)}, {"bytes_in", Rate(p.bytesIn, 5)},
{"bytes_out", Rate(p.bytesOut, 5)}, {"http", Rate(p.httpRequests, 5)} };
}
return { {"time", now}, {"servers", servers} };
}
void Write() {
auto rows = g_History.TakeFinishedMinutes(TrafficStats::Now());
g_Unwritten.insert(g_Unwritten.end(), rows.begin(), rows.end());
if (g_Unwritten.empty()) return;
// A day of a busy universe at most, should the database be away for long
if (g_Unwritten.size() > 100000) g_Unwritten.erase(g_Unwritten.begin(), g_Unwritten.begin() + static_cast<std::ptrdiff_t>(g_Unwritten.size() - 100000));
auto batch = std::move(g_Unwritten);
g_Unwritten.clear();
const bool queued = Background::Run("traffic_minutes", [batch](GameDatabase& db) -> nlohmann::json {
db.InsertTrafficMinutes(batch);
return nlohmann::json::object();
}, [](nlohmann::json, const std::string& error) {
if (!error.empty()) LOG("Traffic: writing minutes failed: %s", error.c_str());
});
if (!queued) g_Unwritten = std::move(batch); // still writing the last batch: next time
}
}
namespace Traffic {
void Ingest(const ServerTraffic& report) {
g_History.Ingest(static_cast<uint16_t>(report.serverType), report.zoneId, report.instanceId, report.report, TrafficStats::Now());
g_Changed = true;
}
void Update() {
const auto now = std::chrono::steady_clock::now();
if (now >= g_NextWrite) {
g_NextWrite = now + WRITE_INTERVAL;
Write();
g_History.Forget(TrafficStats::Now());
}
if (g_Changed && now >= g_NextPush) {
g_NextPush = now + PUSH_INTERVAL;
g_Changed = false;
auto summary = Summary(TrafficStats::Now());
Web::SendWSMessage(TOPIC, summary);
}
}
void AddMetrics(MetricsFormat::Writer& w) {
using MetricsFormat::Labels;
w.Declare("darkflame_net_packets_total", "Packets a server sent and received (LU packets and RakNet messages), since the dashboard started", "counter");
w.Declare("darkflame_net_bytes_total", "Bytes of the packets a server sent and received, since the dashboard started", "counter");
w.Declare("darkflame_net_messages_total", "Packets by type (the busiest types each server reports), since the dashboard started", "counter");
w.Declare("darkflame_net_datagrams_total", "UDP datagrams (acknowledgements and resends included, as RakNet counts them), since the dashboard started", "counter");
w.Declare("darkflame_net_resends_total", "Messages RakNet had to send again, since the dashboard started", "counter");
w.Declare("darkflame_net_connections", "Open RakNet connections of a server", "gauge");
w.Declare("darkflame_net_ping_milliseconds", "Average ping over a server's connections", "gauge");
w.Declare("darkflame_http_requests_total", "HTTP requests answered, by route pattern and status class, since the dashboard started", "counter");
w.Declare("darkflame_http_response_bytes_total", "HTTP response bodies sent, since the dashboard started", "counter");
w.Declare("darkflame_http_request_duration_seconds", "HTTP request latency (deferred requests until their answer went out)", "histogram");
const auto now = TrafficStats::Now();
for (const auto& [key, server] : g_History.Servers()) {
const auto& t = server.totals;
const auto labels = [&key](std::initializer_list<std::pair<std::string, std::string>> more) {
Labels out{ {"server", key} };
out.insert(out.end(), more.begin(), more.end());
return out;
};
w.Add("darkflame_net_packets_total", "", "counter", labels({ {"direction", "in"} }), static_cast<double>(t.packetsIn));
w.Add("darkflame_net_packets_total", "", "counter", labels({ {"direction", "out"} }), static_cast<double>(t.packetsOut));
w.Add("darkflame_net_bytes_total", "", "counter", labels({ {"direction", "in"} }), static_cast<double>(t.bytesIn));
w.Add("darkflame_net_bytes_total", "", "counter", labels({ {"direction", "out"} }), static_cast<double>(t.bytesOut));
w.Add("darkflame_net_datagrams_total", "", "counter", labels({ {"direction", "in"} }), static_cast<double>(t.datagramsReceived));
w.Add("darkflame_net_datagrams_total", "", "counter", labels({ {"direction", "out"} }), static_cast<double>(t.datagramsSent));
w.Add("darkflame_net_resends_total", "", "counter", labels({}), static_cast<double>(t.resends));
for (const auto& [_, count] : t.messages) {
const auto names = NamesOf(count.key);
w.Add("darkflame_net_messages_total", "", "counter", labels({ {"direction", count.key.outbound ? "out" : "in"}, {"service", names.service},
{"message", names.gameMessage.empty() ? names.packet : names.gameMessage} }), static_cast<double>(count.count));
}
if (now - server.lastSeen <= ONLINE_SECONDS) {
w.Add("darkflame_net_connections", "", "gauge", labels({}), server.link.connections);
w.Add("darkflame_net_ping_milliseconds", "", "gauge", labels({}), server.link.averagePingMs);
for (const auto& [name, value] : server.gauges) {
w.Add("darkflame_server_" + name, "Reported by a server with its traffic (see docs/Dashboard.md, Traffic diagnostics)", "gauge", labels({}), value);
}
}
static constexpr const char* CLASSES[] = { "1xx", "2xx", "3xx", "4xx", "5xx" };
for (const auto& [route, stats] : t.routes) {
for (size_t i = 0; i < 5; i++) {
if (stats.status[i]) w.Add("darkflame_http_requests_total", "", "counter", labels({ {"route", route}, {"status", CLASSES[i]} }), static_cast<double>(stats.status[i]));
}
w.Add("darkflame_http_response_bytes_total", "", "counter", labels({ {"route", route} }), static_cast<double>(stats.bytesOut));
// Buckets at every doubling from 0.1 ms (every third histogram bucket), cumulative
uint64_t cumulative = 0;
const std::string family = "darkflame_http_request_duration_seconds";
for (size_t i = 0; i + 1 < TrafficStats::Histogram::BUCKETS; i++) {
cumulative += stats.latency.At(i);
if (i % 3 != 0) continue;
w.AddSample(family, family + "_bucket", "", "histogram", labels({ {"route", route}, {"le", MetricsFormat::Value(static_cast<double>(TrafficStats::Histogram::UpperBound(i)) / 1e6)} }), static_cast<double>(cumulative));
}
w.AddSample(family, family + "_bucket", "", "histogram", labels({ {"route", route}, {"le", "+Inf"} }), static_cast<double>(stats.latency.Count()));
w.AddSample(family, family + "_sum", "", "histogram", labels({ {"route", route} }), static_cast<double>(stats.latency.Sum()) / 1e6);
w.AddSample(family, family + "_count", "", "histogram", labels({ {"route", route} }), static_cast<double>(stats.latency.Count()));
}
}
}
void RegisterRoutes() {
Game::web.RegisterWSSubscription(TOPIC, std::function<uint8_t()>([] { return Permissions::Level(PERMISSION); }));
// The dashboard's own report stays here; the worker pool is what its deferred requests wait for
Game::server->SetTrafficSink([](ServerTraffic& report) { Ingest(report); });
TrafficStats::Local().SetGauge("workers_busy", [] { return static_cast<double>(Workers::Pool().Active()); });
TrafficStats::Local().SetGauge("workers_queued", [] { return static_cast<double>(Workers::Pool().Queued()); });
TrafficStats::Local().SetGauge("workers_threads", [] { return static_cast<double>(Workers::Pool().Threads()); });
Route(eHTTPMethod::GET, "/api/diagnostics/traffic", Perm(PERMISSION),
"Packets, bytes and HTTP requests per second of every server. Query: ?range=5m|1h|24h|7d. Series are per-second rates of each step, latency in ms",
[](HTTPReply& reply, const HTTPContext& context) {
auto range = QueryValue(context.queryString, "range");
if (range != "1h" && range != "24h" && range != "7d") range = "5m";
JsonSuccess(reply, Series(range, TrafficStats::Now()));
});
}
}

View File

@@ -0,0 +1,21 @@
#pragma once
struct ServerTraffic;
namespace MetricsFormat { class Writer; }
/**
* Traffic diagnostics: every server's packets, bytes and HTTP requests (see ServerTraffic.h and TrafficHistory.h).
* The last hour is kept in memory at one second; finished minutes go to the server_traffic table once a minute.
*/
namespace Traffic {
void RegisterRoutes();
// SERVER_TRAFFIC from a server (via master), or the dashboard's own report
void Ingest(const ServerTraffic& report);
// Main loop: write finished minutes, tell open pages there is news
void Update();
// Counters for /metrics
void AddMetrics(MetricsFormat::Writer& w);
}

View File

@@ -0,0 +1,260 @@
#include "TrafficHistory.h"
#include <algorithm>
#include "ServiceType.h"
namespace {
using Sparse = std::vector<std::pair<uint8_t, uint32_t>>;
void MergeSparse(Sparse& into, const Sparse& from) {
if (from.empty()) return;
if (into.empty()) {
into = from;
return;
}
auto histogram = TrafficStats::Histogram::FromSparse(into, 0);
histogram.Merge(TrafficStats::Histogram::FromSparse(from, 0));
into = histogram.Sparse();
}
int64_t Floor(int64_t time, int64_t step) {
return time - ((time % step) + step) % step;
}
template<typename Counts>
void AddCounts(TrafficHistory::MessageCounts& into, const Counts& from, size_t cap = SIZE_MAX) {
for (const auto& count : from) {
const auto packed = count.key.Packed();
auto it = into.find(packed);
if (it == into.end()) {
if (into.size() >= cap) continue;
it = into.emplace(packed, TrafficStats::MessageCount{ count.key }).first;
}
it->second.count += count.count;
it->second.bytes += count.bytes;
}
}
void AddRoutes(std::map<std::string, TrafficStats::RouteStats>& into, const std::vector<TrafficStats::RouteStats>& from, size_t cap) {
for (const auto& route : from) {
auto it = into.find(route.route);
if (it == into.end()) {
if (into.size() >= cap) continue;
it = into.emplace(route.route, TrafficStats::RouteStats{ .route = route.route }).first;
}
it->second.Merge(route);
}
}
// The entry of `time` at the back of a time-ordered deque, added when it isn't there yet
template<typename Value>
Value& Slot(std::deque<std::pair<int64_t, Value>>& slots, int64_t time, size_t keep) {
if (slots.empty() || slots.back().first < time) {
slots.emplace_back(time, Value{});
while (slots.size() > keep) slots.pop_front();
return slots.back().second;
}
for (auto it = slots.rbegin(); it != slots.rend(); ++it) {
if (it->first == time) return it->second;
}
return slots.back().second; // older than everything kept: count it with the newest
}
}
void TrafficHistory::Point::Add(const TrafficStats::Second& second) {
packetsIn += second.packetsIn;
packetsOut += second.packetsOut;
bytesIn += second.bytesIn;
bytesOut += second.bytesOut;
httpRequests += second.httpRequests;
http4xx += second.httpStatus[3];
http5xx += second.httpStatus[4];
httpBytesOut += second.httpBytesOut;
if (!second.httpLatency.Empty()) {
MergeSparse(latency, second.httpLatency.Sparse());
latencySum += second.httpLatency.Sum();
}
}
void TrafficHistory::Point::Add(const Point& other) {
packetsIn += other.packetsIn;
packetsOut += other.packetsOut;
bytesIn += other.bytesIn;
bytesOut += other.bytesOut;
httpRequests += other.httpRequests;
http4xx += other.http4xx;
http5xx += other.http5xx;
httpBytesOut += other.httpBytesOut;
MergeSparse(latency, other.latency);
latencySum += other.latencySum;
}
TrafficStats::Histogram TrafficHistory::Point::Latency() const {
return TrafficStats::Histogram::FromSparse(latency, latencySum);
}
std::string TrafficHistory::KeyFor(uint16_t serviceType, uint32_t zoneId, uint32_t instanceId) {
switch (static_cast<ServiceType>(serviceType)) {
case ServiceType::MASTER: return "master";
case ServiceType::AUTH: return "auth";
case ServiceType::CHAT: return "chat";
case ServiceType::DASHBOARD: return "dashboard";
case ServiceType::UGC: return "ugc";
case ServiceType::WORLD: return "world:" + std::to_string(zoneId) + ":" + std::to_string(instanceId);
default: return "service:" + std::to_string(serviceType) + ":" + std::to_string(zoneId) + ":" + std::to_string(instanceId);
}
}
void TrafficHistory::Ingest(uint16_t serviceType, uint32_t zoneId, uint32_t instanceId, const TrafficStats::Report& report, int64_t now) {
const auto key = KeyFor(serviceType, zoneId, instanceId);
auto [it, added] = m_Servers.try_emplace(key);
auto& server = it->second;
if (added) {
server.key = key;
server.type = serviceType;
server.zoneId = zoneId;
server.instanceId = instanceId;
server.firstSeen = now;
// Minutes before the first report were never seen, so nothing before it is written
server.writtenUntil = Floor(now, 60) - 60;
}
server.lastSeen = now;
server.link = report.link;
server.gauges = report.gauges;
auto& totals = server.totals;
int64_t lastSecond = now;
for (const auto& second : report.seconds) {
// A server whose clock is far off is still shown, at the dashboard's time
if (second.time < now - SECONDS_KEPT || second.time > now + 60) continue;
lastSecond = second.time;
totals.packetsIn += second.packetsIn;
totals.packetsOut += second.packetsOut;
totals.bytesIn += second.bytesIn;
totals.bytesOut += second.bytesOut;
if (server.seconds.empty() || server.seconds.back().time < second.time) {
server.seconds.push_back(Point{ .time = second.time });
server.seconds.back().Add(second);
} else {
const auto at = std::lower_bound(server.seconds.begin(), server.seconds.end(), second.time, [](const Point& p, int64_t t) { return p.time < t; });
if (at != server.seconds.end() && at->time == second.time) at->Add(second);
else server.seconds.insert(at, Point{ .time = second.time })->Add(second);
}
const auto minute = Floor(second.time, 60);
if (minute < server.writtenUntil) continue; // that minute is in the database already
auto slot = std::find_if(server.minutes.rbegin(), server.minutes.rend(), [minute](const Minute& m) { return m.point.time <= minute; });
if (slot == server.minutes.rend() || slot->point.time != minute) {
const auto at = server.minutes.insert(slot.base(), Minute{ .point = Point{ .time = minute } });
at->point.Add(second);
} else {
slot->point.Add(second);
}
}
while (!server.seconds.empty() && server.seconds.front().time < now - SECONDS_KEPT) server.seconds.pop_front();
const auto& link = report.link;
totals.datagramsSent += link.datagramsSent;
totals.datagramsReceived += link.datagramsReceived;
totals.linkBytesSent += link.bytesSent;
totals.linkBytesReceived += link.bytesReceived;
totals.resends += link.resends;
if (link.resends && !server.minutes.empty()) {
const auto minute = Floor(lastSecond, 60);
auto slot = std::find_if(server.minutes.rbegin(), server.minutes.rend(), [minute](const Minute& m) { return m.point.time == minute; });
(slot != server.minutes.rend() ? *slot : server.minutes.back()).resends += link.resends;
}
AddCounts(totals.messages, report.messages, MAX_TOTAL_MESSAGES);
AddCounts(Slot(server.messageMinutes, Floor(now, 60), MESSAGE_MINUTES), report.messages);
AddCounts(Slot(server.messageHours, Floor(now, 3600), MESSAGE_HOURS), report.messages);
AddRoutes(totals.routes, report.routes, MAX_TOTAL_ROUTES);
if (!report.routes.empty()) AddRoutes(Slot(server.routeMinutes, Floor(now, 60), ROUTE_MINUTES), report.routes, MAX_TOTAL_ROUTES);
}
std::vector<IServerTraffic::TrafficMinute> TrafficHistory::TakeFinishedMinutes(int64_t now) {
std::vector<IServerTraffic::TrafficMinute> rows;
for (auto& [key, server] : m_Servers) {
while (!server.minutes.empty() && server.minutes.front().point.time + 60 + MINUTE_SLACK <= now) {
const auto& minute = server.minutes.front();
const auto& p = minute.point;
const auto latency = p.Latency();
rows.push_back({ .time = p.time, .server = key, .packetsIn = p.packetsIn, .packetsOut = p.packetsOut, .bytesIn = p.bytesIn, .bytesOut = p.bytesOut,
.resends = minute.resends, .httpRequests = p.httpRequests, .http4xx = p.http4xx, .http5xx = p.http5xx, .httpBytesOut = p.httpBytesOut,
.latencyP50Us = static_cast<uint32_t>(std::min<uint64_t>(latency.Percentile(0.50), UINT32_MAX)),
.latencyP95Us = static_cast<uint32_t>(std::min<uint64_t>(latency.Percentile(0.95), UINT32_MAX)),
.latencyP99Us = static_cast<uint32_t>(std::min<uint64_t>(latency.Percentile(0.99), UINT32_MAX)) });
server.writtenUntil = std::max(server.writtenUntil, p.time + 60);
server.minutes.pop_front();
}
}
return rows;
}
void TrafficHistory::Forget(int64_t now) {
for (auto it = m_Servers.begin(); it != m_Servers.end();) {
auto& seconds = it->second.seconds;
while (!seconds.empty() && seconds.front().time < now - SECONDS_KEPT) seconds.pop_front();
if (it->second.lastSeen < now - FORGET_AFTER && it->second.minutes.empty()) it = m_Servers.erase(it);
else ++it;
}
}
std::map<std::string, std::vector<TrafficHistory::Point>> TrafficHistory::Buckets(int64_t from, int64_t to, int64_t step) const {
std::map<std::string, std::vector<Point>> out;
if (step <= 0 || to <= from) return out;
const auto count = static_cast<size_t>((to - from + step - 1) / step);
for (const auto& [key, server] : m_Servers) {
auto it = std::lower_bound(server.seconds.begin(), server.seconds.end(), from, [](const Point& p, int64_t t) { return p.time < t; });
if (it == server.seconds.end() || it->time >= to) continue;
auto& points = out[key];
points.resize(count);
for (size_t i = 0; i < count; i++) points[i].time = from + static_cast<int64_t>(i) * step;
for (; it != server.seconds.end() && it->time < to; ++it) points[static_cast<size_t>((it->time - from) / step)].Add(*it);
}
return out;
}
std::vector<TrafficStats::MessageCount> TrafficHistory::TopMessages(const std::string& serverKey, int64_t since, size_t limit) const {
MessageCounts counts;
for (const auto& [key, server] : m_Servers) {
if (!serverKey.empty() && key != serverKey) continue;
const bool minutes = !server.messageMinutes.empty() && server.messageMinutes.front().first <= Floor(since, 60);
const auto& slots = minutes || server.messageHours.empty() ? server.messageMinutes : server.messageHours;
const auto start = minutes ? Floor(since, 60) : Floor(since, 3600);
for (const auto& [time, slot] : slots) {
if (time < start) continue;
for (const auto& [packed, count] : slot) {
auto& total = counts[packed];
total.key = count.key;
total.count += count.count;
total.bytes += count.bytes;
}
}
}
std::vector<TrafficStats::MessageCount> list;
list.reserve(counts.size());
for (const auto& [_, count] : counts) list.push_back(count);
return TrafficStats::Top(list, limit);
}
std::vector<std::pair<std::string, TrafficStats::RouteStats>> TrafficHistory::Routes(int64_t since) const {
std::vector<std::pair<std::string, TrafficStats::RouteStats>> out;
for (const auto& [key, server] : m_Servers) {
std::map<std::string, TrafficStats::RouteStats> routes;
for (const auto& [time, slot] : server.routeMinutes) {
if (time < Floor(since, 60)) continue;
for (const auto& [name, stats] : slot) {
auto& total = routes[name];
total.route = name;
total.Merge(stats);
}
}
for (auto& [_, stats] : routes) out.emplace_back(key, std::move(stats));
}
std::sort(out.begin(), out.end(), [](const auto& a, const auto& b) { return a.second.count != b.second.count ? a.second.count > b.second.count : a.second.route < b.second.route; });
return out;
}

View File

@@ -0,0 +1,102 @@
#pragma once
#include <array>
#include <cstdint>
#include <deque>
#include <map>
#include <string>
#include <utility>
#include <vector>
#include "IServerTraffic.h"
#include "TrafficStats.h"
/**
* The traffic reports of every server, kept by the dashboard: the last hour at one second, the minutes that are not in
* the database yet, the busiest message types of the last hour and day, and totals since the dashboard started (for
* Prometheus). Pure (no database, network or clock), so it is unit tested; Traffic.cpp feeds it and writes the minutes.
*/
class TrafficHistory {
public:
static constexpr int64_t SECONDS_KEPT = 3600;
static constexpr int64_t MINUTE_SLACK = 20; // seconds a minute waits for late reports before it is final
static constexpr int64_t FORGET_AFTER = 86400; // a server silent this long is dropped
static constexpr size_t MESSAGE_MINUTES = 60; // per-minute message counts kept
static constexpr size_t MESSAGE_HOURS = 24; // per-hour message counts kept
static constexpr size_t MAX_TOTAL_MESSAGES = 256; // message types a server's totals keep
static constexpr size_t MAX_TOTAL_ROUTES = 128; // routes a server's totals keep
static constexpr size_t ROUTE_MINUTES = 60; // per-minute route stats kept
// One second (or a bucket of them) of one server
struct Point {
int64_t time{};
uint64_t packetsIn{}, packetsOut{}, bytesIn{}, bytesOut{};
uint64_t httpRequests{}, http4xx{}, http5xx{}, httpBytesOut{};
std::vector<std::pair<uint8_t, uint32_t>> latency; // sparse HTTP latency histogram, empty without requests
uint64_t latencySum{};
void Add(const TrafficStats::Second& second);
void Add(const Point& other);
TrafficStats::Histogram Latency() const;
};
struct Minute {
Point point;
uint64_t resends{};
};
using MessageCounts = std::map<uint64_t, TrafficStats::MessageCount>; // by MessageKey::Packed
struct Totals {
uint64_t packetsIn{}, packetsOut{}, bytesIn{}, bytesOut{};
uint64_t datagramsSent{}, datagramsReceived{}, linkBytesSent{}, linkBytesReceived{}, resends{};
MessageCounts messages;
std::map<std::string, TrafficStats::RouteStats> routes;
};
struct Server {
std::string key;
uint16_t type{}; // ServiceType
uint32_t zoneId{};
uint32_t instanceId{};
int64_t firstSeen{};
int64_t lastSeen{};
std::deque<Point> seconds; // oldest first, up to SECONDS_KEPT
std::deque<Minute> minutes; // not written yet
int64_t writtenUntil{}; // minutes before this were handed out by TakeFinishedMinutes
TrafficStats::Link link; // the last report's
std::vector<std::pair<std::string, double>> gauges;
Totals totals;
std::deque<std::pair<int64_t, MessageCounts>> messageMinutes;
std::deque<std::pair<int64_t, MessageCounts>> messageHours;
std::deque<std::pair<int64_t, std::map<std::string, TrafficStats::RouteStats>>> routeMinutes;
};
// "master", "auth", "chat", "dashboard", "ugc", "world:<zone>:<instance>"
static std::string KeyFor(uint16_t serviceType, uint32_t zoneId, uint32_t instanceId);
void Ingest(uint16_t serviceType, uint32_t zoneId, uint32_t instanceId, const TrafficStats::Report& report, int64_t now);
// Minutes that are final (and not handed out before), as database rows
std::vector<IServerTraffic::TrafficMinute> TakeFinishedMinutes(int64_t now);
// Drop servers silent for FORGET_AFTER and seconds older than SECONDS_KEPT
void Forget(int64_t now);
const std::map<std::string, Server>& Servers() const { return m_Servers; }
/**
* The seconds from `from` (inclusive) to `to` (exclusive) summed into buckets of `step` seconds, per server:
* index i is the bucket starting at from + i * step. Servers with nothing in the range are left out.
*/
std::map<std::string, std::vector<Point>> Buckets(int64_t from, int64_t to, int64_t step) const;
// The busiest message types since `since` (minute counts for the last hour, hour counts before), one server or all ("")
std::vector<TrafficStats::MessageCount> TopMessages(const std::string& server, int64_t since, size_t limit) const;
// The HTTP routes since `since` (the last hour at most) of every server with a web server, as (server, route); busiest first
std::vector<std::pair<std::string, TrafficStats::RouteStats>> Routes(int64_t since) const;
private:
std::map<std::string, Server> m_Servers;
};

View File

@@ -0,0 +1,327 @@
{% extends "base.jinja2" %}
{% block title %}Diagnostics - DarkflameServer{% endblock %}
{% block css %}
<style>
/* Series colours in a fixed order (checked for colour blindness against both themes); grey is "other servers" */
:root, [data-bs-theme=light] { --tr-1: #0d6efd; --tr-2: #fd7e14; --tr-3: #20c997; --tr-4: #6f42c1; --tr-5: #d63384; --tr-6: #0aa2c0; --tr-other: #6c757d; }
[data-bs-theme=dark] { --tr-1: #3d8bfd; --tr-2: #ca6510; --tr-3: #1aa179; --tr-4: #8c68cd; --tr-5: #d63384; --tr-6: #0aa2c0; --tr-other: #8c959f; }
.tr-chart-box { position: relative; min-height: 150px; }
.tr-chart { width: 100%; height: 100%; display: block; position: absolute; inset: 0; }
.tr-chart .grid { stroke: var(--bs-border-color); stroke-width: 1; }
.tr-chart .axis { fill: var(--bs-secondary-color); font-size: 11px; }
.tr-chart .line { fill: none; stroke-width: 2; stroke-linejoin: round; }
.tr-chart .cross { stroke: var(--bs-secondary-color); stroke-width: 1; stroke-dasharray: 3 3; }
.tr-tip { position: absolute; pointer-events: none; z-index: 5; background: var(--bs-body-bg); border: 1px solid var(--bs-border-color); border-radius: .375rem;
padding: .35rem .5rem; font-size: .75rem; white-space: nowrap; box-shadow: var(--bs-box-shadow-sm); }
.tr-legend { font-size: .75rem; color: var(--bs-secondary-color); }
.tr-legend span.key { display: inline-block; width: 10px; height: 3px; border-radius: 2px; vertical-align: middle; margin-right: 4px; }
.tr-tile .value { font-size: 1.35rem; font-weight: 700; font-variant-numeric: tabular-nums; }
.tr-table td, .tr-table th { font-variant-numeric: tabular-nums; }
.tr-table tr[data-server] { cursor: pointer; }
@media (min-width: 1200px) { .tr-grid { display: grid; grid-template-rows: repeat(3, minmax(0, 1fr)); gap: 1rem; height: 100%; } .tr-grid > .row { min-height: 0; --bs-gutter-y: 0; } .tr-grid > .row > * { height: 100%; } }
@media (max-width: 1199.98px) { .tr-chart-box { height: 200px; } .tr-grid > .row + .row { margin-top: 1rem; } }
</style>
{% endblock %}
{% block content %}
{% include "health_tabs.jinja2" %}
<div class="d-flex flex-wrap justify-content-between align-items-end gap-2 mb-3">
<p class="text-body-secondary mb-0 small">Packets and bytes every server sends and receives, and the HTTP requests of the dashboard and UGC server.
The last hour is kept at one second in memory; minutes are stored for <code>traffic_days</code>. Prometheus: <code>darkflame_net_*</code>, <code>darkflame_http_*</code>.</p>
<div class="d-flex flex-wrap gap-2 align-items-center">
<select class="form-select form-select-sm w-auto" id="serverFilter" aria-label="Server"><option value="">All servers</option></select>
<div class="btn-group btn-group-sm text-nowrap" role="group" aria-label="Range">
<input type="radio" class="btn-check" name="trRange" id="range5m" value="5m" checked data-pref="diagnostics.range"><label class="btn btn-outline-secondary" for="range5m">Live</label>
<input type="radio" class="btn-check" name="trRange" id="range1h" value="1h" data-pref="diagnostics.range"><label class="btn btn-outline-secondary" for="range1h">1 hour</label>
<input type="radio" class="btn-check" name="trRange" id="range24h" value="24h" data-pref="diagnostics.range"><label class="btn btn-outline-secondary" for="range24h">24 hours</label>
<input type="radio" class="btn-check" name="trRange" id="range7d" value="7d" data-pref="diagnostics.range"><label class="btn btn-outline-secondary" for="range7d">7 days</label>
</div>
</div>
</div>
<div class="row g-2 mb-3" id="tiles"></div>
<div class="row g-3 fill-viewport-xl">
<div class="col-xl-8">
<div class="tr-grid">
<div class="row g-3">
<div class="col-md-6"><div class="card h-100 panel-fill"><div class="card-header py-2 d-flex justify-content-between"><h5 class="mb-0 fs-6">Packets per second</h5><span class="tr-legend" id="legendPackets"></span></div><div class="card-body p-2 tr-chart-box"><svg class="tr-chart" id="chartPackets" role="img" aria-label="Packets in and out per second"></svg></div></div></div>
<div class="col-md-6"><div class="card h-100 panel-fill"><div class="card-header py-2 d-flex justify-content-between"><h5 class="mb-0 fs-6">Bytes per second</h5><span class="tr-legend" id="legendBytes"></span></div><div class="card-body p-2 tr-chart-box"><svg class="tr-chart" id="chartBytes" role="img" aria-label="Bytes in and out per second"></svg></div></div></div>
</div>
<div class="row g-3">
<div class="col-12"><div class="card h-100 panel-fill"><div class="card-header py-2 d-flex justify-content-between flex-wrap gap-1"><h5 class="mb-0 fs-6">Packets per second by server <span class="text-body-secondary fw-normal">(in + out)</span></h5><span class="tr-legend" id="legendServers"></span></div><div class="card-body p-2 tr-chart-box"><svg class="tr-chart" id="chartServers" role="img" aria-label="Packets per second of each server"></svg></div></div></div>
</div>
<div class="row g-3">
<div class="col-md-6"><div class="card h-100 panel-fill"><div class="card-header py-2 d-flex justify-content-between"><h5 class="mb-0 fs-6">HTTP requests per second</h5><span class="tr-legend" id="legendHttp"></span></div><div class="card-body p-2 tr-chart-box"><svg class="tr-chart" id="chartHttp" role="img" aria-label="HTTP requests per second"></svg></div></div></div>
<div class="col-md-6"><div class="card h-100 panel-fill"><div class="card-header py-2 d-flex justify-content-between"><h5 class="mb-0 fs-6">HTTP latency</h5><span class="tr-legend" id="legendLatency"></span></div><div class="card-body p-2 tr-chart-box"><svg class="tr-chart" id="chartLatency" role="img" aria-label="HTTP latency percentiles"></svg></div></div></div>
</div>
</div>
</div>
<div class="col-xl-4 d-flex flex-column gap-3 mt-3 mt-xl-0">
<div class="card panel-fill" style="flex: 1 1 0; min-height: 10rem"><div class="card-header py-2"><h5 class="mb-0 fs-6">Servers <span class="text-body-secondary fw-normal small">(averages over the range; click to filter)</span></h5></div><div class="card-body p-0" id="servers"></div></div>
<div class="card panel-fill" style="flex: 1 1 0; min-height: 10rem"><div class="card-header py-2 d-flex justify-content-between align-items-center"><h5 class="mb-0 fs-6">Busiest message types</h5>
<div class="btn-group btn-group-sm" role="group" aria-label="Direction"><input type="radio" class="btn-check" name="msgDir" id="dirIn" value="in" checked><label class="btn btn-outline-secondary py-0" for="dirIn">In</label><input type="radio" class="btn-check" name="msgDir" id="dirOut" value="out"><label class="btn btn-outline-secondary py-0" for="dirOut">Out</label></div></div>
<div class="card-body p-0" id="messages"></div></div>
<div class="card panel-fill" style="flex: 1 1 0; min-height: 10rem"><div class="card-header py-2"><h5 class="mb-0 fs-6">HTTP routes <span class="text-body-secondary fw-normal small">(last hour at most)</span></h5></div><div class="card-body p-0" id="routes"></div></div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
(function () {
var nf = new Intl.NumberFormat(undefined, { maximumFractionDigits: 1 });
var COLORS = ['var(--tr-1)', 'var(--tr-2)', 'var(--tr-3)', 'var(--tr-4)', 'var(--tr-5)', 'var(--tr-6)'];
var OTHER = 'var(--tr-other)';
var range = (document.querySelector('input[name="trRange"]:checked') || {}).value || '5m';
var server = '';
var direction = 'in';
var data = null;
function bytes(v) {
if (v >= 1073741824) return (v / 1073741824).toFixed(2) + ' GB';
if (v >= 1048576) return (v / 1048576).toFixed(1) + ' MB';
if (v >= 1024) return (v / 1024).toFixed(1) + ' kB';
return Math.round(v) + ' B';
}
function rate(v) { return nf.format(v); }
function ms(v) { return v == null ? '-' : (v >= 1000 ? (v / 1000).toFixed(2) + ' s' : nf.format(v) + ' ms'); }
function timeLabel(t, withSeconds) {
var d = new Date(t * 1000);
if (range === '7d') return d.toLocaleDateString([], { month: 'short', day: 'numeric' }) + (withSeconds ? ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '');
return d.toLocaleTimeString([], range === '5m' || withSeconds ? { hour: '2-digit', minute: '2-digit', second: '2-digit' } : { hour: '2-digit', minute: '2-digit' });
}
function niceMax(v) {
if (v <= 0) return 1;
var p = Math.pow(10, Math.floor(Math.log10(v))), n = v / p;
return (n <= 1 ? 1 : n <= 2 ? 2 : n <= 5 ? 5 : 10) * p;
}
/**
* Line chart in the page's style: x is time, y from 0; series [{name, color, values}] (null values leave a gap).
* Hovering shows a crosshair and every series' value at that time.
*/
function chart(svg, legendEl, times, series, format) {
var box = svg.parentNode;
var width = box.clientWidth || 600, height = box.clientHeight || 170, left = 56, bottom = 20, top = 8, right = 8;
var max = 0;
series.forEach(function (s) { s.values.forEach(function (v) { if (v != null && v > max) max = v; }); });
max = niceMax(max);
var n = times.length, from = times[0], to = times[n - 1];
var x = function (i) { return left + (n > 1 ? i / (n - 1) : 0) * (width - left - right); };
var y = function (v) { return top + (1 - v / max) * (height - top - bottom); };
var html = '';
[0, 0.5, 1].forEach(function (f) {
html += '<line class="grid" x1="' + left + '" x2="' + (width - right) + '" y1="' + y(max * f) + '" y2="' + y(max * f) + '"/>' +
'<text class="axis" x="' + (left - 4) + '" y="' + (y(max * f) + 4) + '" text-anchor="end">' + esc(format(max * f)) + '</text>';
});
if (n) [0, Math.floor((n - 1) / 2), n - 1].forEach(function (i, k) {
html += '<text class="axis" x="' + x(i) + '" y="' + (height - 4) + '" text-anchor="' + ['start', 'middle', 'end'][k] + '">' + esc(timeLabel(times[i])) + '</text>';
});
var any = false;
series.forEach(function (s) {
var d = '', pen = false;
s.values.forEach(function (v, i) {
if (v == null) { pen = false; return; }
d += (pen ? 'L' : 'M') + x(i).toFixed(1) + ',' + y(v).toFixed(1);
pen = true;
any = true;
});
if (d) html += '<path class="line" stroke="' + s.color + '" d="' + d + '"/>';
});
if (!any) html += '<text class="axis" x="' + ((left + width) / 2) + '" y="' + (height / 2) + '" text-anchor="middle">No traffic reported in this range</text>';
html += '<line class="cross" id="' + svg.id + 'Cross" y1="' + top + '" y2="' + (height - bottom) + '" x1="-10" x2="-10"/>';
html += '<rect x="' + left + '" y="0" width="' + Math.max(0, width - left - right) + '" height="' + height + '" fill="transparent" data-hit="1"/>';
svg.setAttribute('viewBox', '0 0 ' + width + ' ' + height);
svg.innerHTML = html;
if (legendEl) legendEl.innerHTML = series.length > 1 ? series.map(function (s) { return '<span class="me-2 text-nowrap"><span class="key" style="background:' + s.color + '"></span>' + esc(s.name) + '</span>'; }).join('') : '';
var tip = box.querySelector('.tr-tip');
if (!tip) { tip = document.createElement('div'); tip.className = 'tr-tip d-none'; box.appendChild(tip); }
var cross = svg.querySelector('.cross');
svg.onmousemove = function (e) {
if (!n) return;
var rect = svg.getBoundingClientRect();
var px = e.clientX - rect.left;
var i = Math.max(0, Math.min(n - 1, Math.round((px - left) / Math.max(1, width - left - right) * (n - 1))));
cross.setAttribute('x1', x(i)); cross.setAttribute('x2', x(i));
tip.innerHTML = '<div class="fw-semibold mb-1">' + esc(timeLabel(times[i], true)) + '</div>' + series.map(function (s) {
return '<div><span class="key d-inline-block me-1" style="width:10px;height:3px;border-radius:2px;vertical-align:middle;background:' + s.color + '"></span>' + esc(s.name) + ': <b>' + esc(s.values[i] == null ? '-' : format(s.values[i])) + '</b></div>';
}).join('');
tip.classList.remove('d-none');
var tx = x(i) + 12;
if (tx + tip.offsetWidth > width) tx = x(i) - tip.offsetWidth - 12;
tip.style.left = Math.max(0, tx) + 'px';
tip.style.top = '8px';
};
svg.onmouseleave = function () { tip.classList.add('d-none'); cross.setAttribute('x1', -10); cross.setAttribute('x2', -10); };
}
function sum(list, field) {
var out = null;
list.forEach(function (s) {
var v = s.series && s.series[field];
if (!v) return;
if (!out) out = v.map(function () { return 0; });
v.forEach(function (x, i) { out[i] += x || 0; });
});
return out || data.times.map(function () { return 0; });
}
function average(values) {
var total = 0, count = 0;
(values || []).forEach(function (v) { if (v != null) { total += v; count++; } });
return count ? total / count : 0;
}
// Colours follow the server (sorted by key), not its rank, so a filter never repaints the others
function colorOf(key) {
var keys = data.servers.map(function (s) { return s.key; }).sort();
var i = keys.indexOf(key);
return i >= 0 && i < COLORS.length ? COLORS[i] : OTHER;
}
function render() {
if (!data) return;
var selected = data.servers.filter(function (s) { return !server || s.key === server; });
var times = data.times;
var pin = sum(selected, 'packets_in'), pout = sum(selected, 'packets_out'), bin = sum(selected, 'bytes_in'), bout = sum(selected, 'bytes_out');
chart(document.getElementById('chartPackets'), document.getElementById('legendPackets'), times,
[{ name: 'In', color: COLORS[0], values: pin }, { name: 'Out', color: COLORS[1], values: pout }], rate);
chart(document.getElementById('chartBytes'), document.getElementById('legendBytes'), times,
[{ name: 'In', color: COLORS[0], values: bin }, { name: 'Out', color: COLORS[1], values: bout }], bytes);
// Every server its own line: the six busiest in colour order of their keys, the rest as one grey line
var withTraffic = data.servers.filter(function (s) { return s.series; });
var sorted = withTraffic.slice().sort(function (a, b) { return (average(b.series.packets_in) + average(b.series.packets_out)) - (average(a.series.packets_in) + average(a.series.packets_out)); });
var shown = sorted.filter(function (s) { return colorOf(s.key) !== OTHER; });
var rest = sorted.filter(function (s) { return colorOf(s.key) === OTHER; });
var serverSeries = shown.map(function (s) {
return { name: s.label, color: colorOf(s.key), values: s.series.packets_in.map(function (v, i) { return v + s.series.packets_out[i]; }) };
});
if (rest.length) {
var a = sum(rest, 'packets_in'), b = sum(rest, 'packets_out');
serverSeries.push({ name: rest.length + ' other', color: OTHER, values: a.map(function (v, i) { return v + b[i]; }) });
}
chart(document.getElementById('chartServers'), document.getElementById('legendServers'), times, serverSeries, rate);
var http = selected.filter(function (s) { return s.series && s.series.http; });
chart(document.getElementById('chartHttp'), document.getElementById('legendHttp'), times, http.map(function (s) {
return { name: s.label, color: colorOf(s.key), values: s.series.http };
}).concat(http.length ? [{ name: 'Errors (4xx/5xx)', color: OTHER, values: sum(http, 'http_errors') }] : []), rate);
// Latency of the busiest HTTP server (or the one picked): percentiles of one server, never averaged across servers
var latencyServer = http.slice().sort(function (a, b) { return average(b.series.http) - average(a.series.http); })[0];
chart(document.getElementById('chartLatency'), document.getElementById('legendLatency'), times, latencyServer ? [
{ name: latencyServer.label + ' p50', color: COLORS[2], values: latencyServer.series.p50 },
{ name: 'p95', color: COLORS[3], values: latencyServer.series.p95 },
{ name: 'p99', color: COLORS[4], values: latencyServer.series.p99 }] : [], ms);
renderTiles(selected);
renderServers();
renderMessages();
renderRoutes();
}
function renderTiles(selected) {
var latest = function (field) { var v = sum(selected, field); for (var i = v.length - 1; i >= 0; i--) if (v[i]) return v[i]; return 0; };
var avg = function (field) { return average(sum(selected, field)); };
var label = range === '5m' ? 'now' : 'average';
var pick = range === '5m' ? latest : avg;
var tiles = [['Packets in/s', rate(pick('packets_in'))], ['Packets out/s', rate(pick('packets_out'))], ['Received/s', bytes(pick('bytes_in'))],
['Sent/s', bytes(pick('bytes_out'))], ['HTTP requests/s', rate(pick('http'))],
['Servers reporting', nf.format(data.servers.filter(function (s) { return s.online; }).length)]];
document.getElementById('tiles').innerHTML = tiles.map(function (t) {
return '<div class="col-6 col-md-4 col-xl-2"><div class="card tr-tile"><div class="card-body py-2 px-3"><div class="text-body-secondary small">' + esc(t[0]) +
(t[0] !== 'Servers reporting' ? ' <span class="opacity-75">(' + label + ')</span>' : '') + '</div><div class="value">' + esc(t[1]) + '</div></div></div></div>';
}).join('');
}
function renderServers() {
var rows = data.servers.slice().sort(function (a, b) { return a.label.localeCompare(b.label, undefined, { numeric: true }); }).map(function (s) {
var p = s.series ? average(s.series.packets_in) + average(s.series.packets_out) : 0;
var b = s.series ? average(s.series.bytes_in) + average(s.series.bytes_out) : 0;
var link = s.link || {};
var gauges = s.gauges || {};
var extra = [];
if (gauges.workers_threads) extra.push('workers ' + gauges.workers_busy + '/' + gauges.workers_threads + (gauges.workers_queued ? ' +' + gauges.workers_queued + ' queued' : ''));
if (gauges.http_deferred_pending) extra.push(gauges.http_deferred_pending + ' deferred');
return '<tr data-server="' + esc(s.key) + '"' + (server === s.key ? ' class="table-active"' : '') + '><td><span class="d-inline-block rounded-circle me-1" style="width:8px;height:8px;background:' + colorOf(s.key) + '"></span>' +
esc(s.label) + (s.online ? '' : ' <span class="badge text-bg-secondary">gone</span>') +
(extra.length ? '<div class="small text-body-secondary">' + esc(extra.join(', ')) + '</div>' : '') + '</td>' +
'<td class="text-end">' + esc(rate(p)) + '</td><td class="text-end">' + esc(bytes(b)) + '</td>' +
'<td class="text-end" title="Connections, average ping, messages RakNet resent in the last report">' + (s.online ? esc(link.connections + ' / ' + link.ping_ms + ' ms' + (link.resends ? ' / ' + link.resends + ' resent' : '')) : '-') + '</td></tr>';
});
document.getElementById('servers').innerHTML = rows.length ? '<table class="table table-sm table-hover align-middle mb-0 tr-table small"><thead><tr><th>Server</th><th class="text-end">Pkts/s</th><th class="text-end">Bytes/s</th><th class="text-end">Conn / ping</th></tr></thead><tbody>' + rows.join('') + '</tbody></table>'
: '<p class="text-body-secondary p-3 mb-0">No server has reported yet. Servers report every 5 seconds through master.</p>';
var select = document.getElementById('serverFilter');
var options = '<option value="">All servers</option>' + data.servers.map(function (s) { return '<option value="' + esc(s.key) + '">' + esc(s.label) + '</option>'; }).join('');
if (select.dataset.options !== options) { select.innerHTML = options; select.dataset.options = options; }
select.value = server;
}
function renderMessages() {
var list = data.messages.filter(function (m) { return m.direction === direction; });
var total = list.reduce(function (t, m) { return t + m.count; }, 0) || 1;
document.getElementById('messages').innerHTML = list.length ? '<table class="table table-sm align-middle mb-0 tr-table small"><thead><tr><th>Type</th><th class="text-end">Count</th><th class="text-end">Bytes</th></tr></thead><tbody>' +
list.map(function (m) {
var name = m.game_message ? m.game_message : m.packet;
return '<tr><td><code>' + esc(name) + '</code> <span class="text-body-secondary">' + esc(m.game_message ? m.service + ' game message' : m.service) + '</span>' +
'<div class="progress mt-1" style="height:3px" role="presentation"><div class="progress-bar" style="width:' + (m.count / total * 100).toFixed(1) + '%"></div></div></td>' +
'<td class="text-end">' + esc(nf.format(m.count)) + '</td><td class="text-end">' + esc(bytes(m.bytes)) + '</td></tr>';
}).join('') + '</tbody></table><p class="small text-body-secondary px-2 py-1 mb-0">All servers, since ' + esc(fmt.unix(data.messages_since)) + '. Only what the dashboard saw since it started.</p>'
: '<p class="text-body-secondary p-3 mb-0">Nothing yet.</p>';
}
function renderRoutes() {
document.getElementById('routes').innerHTML = data.routes.length ? '<table class="table table-sm align-middle mb-0 tr-table small"><thead><tr><th>Route</th><th class="text-end">Requests</th><th class="text-end">Errors</th><th class="text-end" title="p50 / p95 / p99">Latency</th></tr></thead><tbody>' +
data.routes.map(function (r) {
var errors = r.status_4xx + r.status_5xx;
return '<tr><td><code class="text-break">' + esc(r.route) + '</code>' + (r.server !== 'dashboard' ? ' <span class="badge text-bg-secondary">' + esc(r.server) + '</span>' : '') + '</td>' +
'<td class="text-end">' + esc(nf.format(r.count)) + '</td><td class="text-end">' + (errors ? '<span class="text-warning-emphasis">' + esc(nf.format(errors)) + '</span>' : '0') + '</td>' +
'<td class="text-end text-nowrap">' + esc(ms(r.p50) + ' / ' + ms(r.p95) + ' / ' + ms(r.p99)) + '</td></tr>';
}).join('') + '</tbody></table>' : '<p class="text-body-secondary p-3 mb-0">No HTTP requests yet.</p>';
}
var loading = false;
function load() {
if (loading) return;
loading = true;
api.get('/api/diagnostics/traffic?range=' + range).then(function (d) {
loading = false;
if (!d.success) return;
data = d;
render();
}, function () { loading = false; });
}
document.querySelectorAll('input[name="trRange"]').forEach(function (r) {
r.addEventListener('change', function () { range = r.value; load(); schedule(); });
});
document.querySelectorAll('input[name="msgDir"]').forEach(function (r) {
r.addEventListener('change', function () { direction = r.value; renderMessages(); });
});
document.getElementById('serverFilter').addEventListener('change', function (e) { server = e.target.value; render(); });
document.getElementById('servers').addEventListener('click', function (e) {
var row = e.target.closest('tr[data-server]');
if (!row) return;
server = server === row.dataset.server ? '' : row.dataset.server;
render();
});
// Live: new data comes over the socket every few seconds; longer ranges refresh on a timer
Live.onTopic('traffic', Live.throttle(function () { if (range === '5m' || range === '1h') load(); }, 2000));
var timer = null;
function schedule() {
clearInterval(timer);
timer = setInterval(load, range === '5m' ? 10000 : range === '1h' ? 15000 : 60000);
}
window.addEventListener('resize', Live.throttle(render, 300));
schedule();
load();
})();
</script>
{% endblock %}

View File

@@ -99,7 +99,7 @@
</div>
{% endif %}
{% set logsPages = ["health", "instances", "activity_log", "command_log", "audit_log", "system_log"] %}
{% set logsPages = ["health", "instances", "diagnostics", "activity_log", "command_log", "audit_log", "system_log"] %}
{% if can.health_view or can.logs_activity or can.logs_command or can.logs_audit or can.logs_system %}
<div class="nav-group">
<button class="nav-group-toggle" type="button" data-bs-toggle="collapse" data-bs-target="#nav-logs" aria-expanded="{% if current_page in logsPages %}true{% else %}false{% endif %}" aria-controls="nav-logs">
@@ -107,7 +107,7 @@
</button>
<div class="collapse{% if current_page in logsPages %} show{% endif %}" id="nav-logs">
<div class="list-group list-group-flush">
{% if can.health_view %}<a href="/health" class="list-group-item list-group-item-action{% if current_page == "health" %} active{% endif %}">Server Health</a><a href="/instances" class="list-group-item list-group-item-action{% if current_page == "instances" %} active{% endif %}">Instance Load</a>{% endif %}
{% if can.health_view %}<a href="/health" class="list-group-item list-group-item-action{% if current_page == "health" %} active{% endif %}">Server Health</a><a href="/instances" class="list-group-item list-group-item-action{% if current_page == "instances" %} active{% endif %}">Instance Load</a><a href="/diagnostics" class="list-group-item list-group-item-action{% if current_page == "diagnostics" %} active{% endif %}">Diagnostics</a>{% endif %}
{% if can.logs_activity %}<a href="/activity_log" class="list-group-item list-group-item-action{% if current_page == "activity_log" %} active{% endif %}">Activity Log</a>{% endif %}
{% if can.logs_command %}<a href="/command_log" class="list-group-item list-group-item-action{% if current_page == "command_log" %} active{% endif %}">Command Log</a>{% endif %}
{% if can.logs_audit %}<a href="/audit_log" class="list-group-item list-group-item-action{% if current_page == "audit_log" %} active{% endif %}">Audit Log</a>{% endif %}

View File

@@ -12,6 +12,7 @@
{% endblock %}
{% block content %}
{% include "health_tabs.jinja2" %}
<div class="d-flex flex-wrap justify-content-between align-items-end gap-3 mb-3">
<div>
<h2 class="mb-1">Server Health</h2>

View File

@@ -0,0 +1,5 @@
<ul class="nav nav-tabs mb-3">
<li class="nav-item"><a class="nav-link{% if current_page == "health" %} active" aria-current="page{% endif %}" href="/health">Server Health</a></li>
<li class="nav-item"><a class="nav-link{% if current_page == "instances" %} active" aria-current="page{% endif %}" href="/instances">Instance Load</a></li>
<li class="nav-item"><a class="nav-link{% if current_page == "diagnostics" %} active" aria-current="page{% endif %}" href="/diagnostics">Diagnostics</a></li>
</ul>

View File

@@ -13,6 +13,7 @@
{% endblock %}
{% block content %}
{% include "health_tabs.jinja2" %}
<div class="d-flex flex-wrap justify-content-between align-items-end gap-3 mb-3">
<div>
<h2 class="mb-1">Instance Load</h2>

View File

@@ -43,6 +43,8 @@ set(DWEBTESTS_SOURCES
"SecurityFixesTests.cpp"
"${PROJECT_SOURCE_DIR}/dDashboardServer/routes/BackupFiles.cpp"
"MetricsFormatTests.cpp"
"TrafficHistoryTests.cpp"
"${PROJECT_SOURCE_DIR}/dDashboardServer/routes/TrafficHistory.cpp"
"${PROJECT_SOURCE_DIR}/dDashboardServer/routes/SettingsCatalog.cpp"
"AlertsAndTwoFactorTests.cpp"
"${PROJECT_SOURCE_DIR}/dDashboardServer/auth/Totp.cpp"

View File

@@ -0,0 +1,140 @@
#include <gtest/gtest.h>
#include "TrafficHistory.h"
#include "ServiceType.h"
using namespace TrafficStats;
namespace {
constexpr int64_t T0 = 1700000040; // a minute starts at 1700000040
Report Seconds(int64_t from, int64_t count, uint64_t packetsIn, uint64_t bytesOut = 0) {
Report report;
for (int64_t t = from; t < from + count; t++) report.seconds.push_back(Second{ .time = t, .packetsIn = packetsIn, .bytesOut = bytesOut });
return report;
}
const uint16_t WORLD = static_cast<uint16_t>(ServiceType::WORLD);
const uint16_t DASHBOARD = static_cast<uint16_t>(ServiceType::DASHBOARD);
}
TEST(TrafficHistoryTest, Keys) {
EXPECT_EQ(TrafficHistory::KeyFor(static_cast<uint16_t>(ServiceType::MASTER), 0, 0), "master");
EXPECT_EQ(TrafficHistory::KeyFor(static_cast<uint16_t>(ServiceType::UGC), 0, 0), "ugc");
EXPECT_EQ(TrafficHistory::KeyFor(WORLD, 1100, 3), "world:1100:3");
}
TEST(TrafficHistoryTest, SecondsAndBuckets) {
TrafficHistory h;
h.Ingest(WORLD, 1100, 1, Seconds(T0, 5, 10, 100), T0 + 5);
h.Ingest(WORLD, 1100, 1, Seconds(T0 + 5, 5, 20, 100), T0 + 10);
h.Ingest(DASHBOARD, 0, 0, Seconds(T0, 10, 1), T0 + 10);
const auto& server = h.Servers().at("world:1100:1");
EXPECT_EQ(server.seconds.size(), 10u);
EXPECT_EQ(server.totals.packetsIn, 150u);
EXPECT_EQ(server.totals.bytesOut, 1000u);
const auto buckets = h.Buckets(T0, T0 + 10, 5);
ASSERT_EQ(buckets.size(), 2u);
const auto& world = buckets.at("world:1100:1");
ASSERT_EQ(world.size(), 2u);
EXPECT_EQ(world[0].time, T0);
EXPECT_EQ(world[0].packetsIn, 50u);
EXPECT_EQ(world[1].time, T0 + 5);
EXPECT_EQ(world[1].packetsIn, 100u);
// A range with nothing leaves the server out
EXPECT_TRUE(h.Buckets(T0 + 100, T0 + 200, 10).empty());
}
TEST(TrafficHistoryTest, LateAndRepeatedSecondsMerge) {
TrafficHistory h;
h.Ingest(WORLD, 1, 1, Seconds(T0 + 2, 2, 1), T0 + 5);
h.Ingest(WORLD, 1, 1, Seconds(T0, 3, 1), T0 + 5); // T0, T0+1 before, T0+2 again
const auto& seconds = h.Servers().at("world:1:1").seconds;
ASSERT_EQ(seconds.size(), 4u);
EXPECT_EQ(seconds[0].time, T0);
EXPECT_EQ(seconds[2].time, T0 + 2);
EXPECT_EQ(seconds[2].packetsIn, 2u);
for (size_t i = 1; i < seconds.size(); i++) EXPECT_LT(seconds[i - 1].time, seconds[i].time);
}
TEST(TrafficHistoryTest, MinutesAreHandedOutOnceWhenFinal) {
TrafficHistory h;
auto report = Seconds(T0, 60, 2);
report.seconds[10].httpRequests = 4;
report.seconds[10].httpStatus = { 0, 2, 0, 1, 1 };
for (int i = 0; i < 4; i++) report.seconds[10].httpLatency.Add(1000 * (i + 1));
report.link.resends = 7;
h.Ingest(WORLD, 1, 1, report, T0 + 60);
h.Ingest(WORLD, 1, 1, Seconds(T0 + 60, 5, 1), T0 + 65);
// Not final until the slack has passed
EXPECT_TRUE(h.TakeFinishedMinutes(T0 + 60 + TrafficHistory::MINUTE_SLACK - 1).empty());
auto rows = h.TakeFinishedMinutes(T0 + 60 + TrafficHistory::MINUTE_SLACK);
ASSERT_EQ(rows.size(), 1u);
EXPECT_EQ(rows[0].time, T0);
EXPECT_EQ(rows[0].server, "world:1:1");
EXPECT_EQ(rows[0].packetsIn, 120u);
EXPECT_EQ(rows[0].httpRequests, 4u);
EXPECT_EQ(rows[0].http4xx, 1u);
EXPECT_EQ(rows[0].http5xx, 1u);
EXPECT_EQ(rows[0].resends, 7u);
EXPECT_GT(rows[0].latencyP50Us, 1000u);
EXPECT_LE(rows[0].latencyP50Us, rows[0].latencyP95Us);
EXPECT_LE(rows[0].latencyP95Us, rows[0].latencyP99Us);
EXPECT_TRUE(h.TakeFinishedMinutes(T0 + 60 + TrafficHistory::MINUTE_SLACK).empty());
// A second of a written minute arriving late isn't written again
h.Ingest(WORLD, 1, 1, Seconds(T0 + 30, 1, 5), T0 + 100);
rows = h.TakeFinishedMinutes(T0 + 200);
ASSERT_EQ(rows.size(), 1u);
EXPECT_EQ(rows[0].time, T0 + 60);
EXPECT_EQ(rows[0].packetsIn, 5u);
}
TEST(TrafficHistoryTest, TopMessagesAndRoutes) {
TrafficHistory h;
Report a = Seconds(T0, 1, 0);
a.messages = { MessageCount{ MessageKey{ false, 4, 5, 100 }, 10, 1000 }, MessageCount{ MessageKey{ true, 5, 12, 0 }, 3, 30 } };
RouteStats route{ .route = "GET /api/x", .count = 2, .bytesOut = 10 };
route.status[1] = 2;
route.latency.Add(500, 2);
a.routes = { route };
h.Ingest(WORLD, 1, 1, a, T0 + 1);
Report b = Seconds(T0, 1, 0);
b.messages = { MessageCount{ MessageKey{ false, 4, 5, 100 }, 5, 500 } };
b.routes = { route };
h.Ingest(DASHBOARD, 0, 0, b, T0 + 2);
const auto all = h.TopMessages("", T0 - 60, 10);
ASSERT_EQ(all.size(), 2u);
EXPECT_EQ(all[0].count, 15u);
EXPECT_EQ(all[0].bytes, 1500u);
EXPECT_EQ(h.TopMessages("dashboard", T0 - 60, 10).size(), 1u);
EXPECT_EQ(h.Servers().at("world:1:1").totals.messages.size(), 2u);
const auto routes = h.Routes(T0 - 60);
ASSERT_EQ(routes.size(), 2u);
EXPECT_EQ(routes[0].second.count, 2u);
EXPECT_EQ(routes[0].second.latency.Count(), 2u);
EXPECT_TRUE(h.Routes(T0 + 3600).empty());
}
TEST(TrafficHistoryTest, OldSecondsAndSilentServersGo) {
TrafficHistory h;
h.Ingest(WORLD, 1, 1, Seconds(T0, 10, 1), T0 + 10);
h.Ingest(WORLD, 1, 2, Seconds(T0, 10, 1), T0 + 10);
h.Ingest(WORLD, 1, 2, Seconds(T0 + TrafficHistory::SECONDS_KEPT + 20, 5, 1), T0 + TrafficHistory::SECONDS_KEPT + 25);
EXPECT_EQ(h.Servers().at("world:1:2").seconds.size(), 5u);
h.TakeFinishedMinutes(T0 + TrafficHistory::FORGET_AFTER + 100);
h.Forget(T0 + TrafficHistory::FORGET_AFTER + 100);
EXPECT_FALSE(h.Servers().contains("world:1:1"));
EXPECT_TRUE(h.Servers().contains("world:1:2"));
}
TEST(TrafficHistoryTest, ClockFarOffIsIgnored) {
TrafficHistory h;
h.Ingest(WORLD, 1, 1, Seconds(T0 + 1000, 5, 1), T0);
EXPECT_TRUE(h.Servers().at("world:1:1").seconds.empty());
EXPECT_EQ(h.Servers().at("world:1:1").totals.packetsIn, 0u);
}