Files
DarkflameServer/dDashboardServer/routes/PrometheusMetrics.cpp
Aaron Kimbrell 431eddd5bd feat(dashboard): guilds page, guild name review and guild chat in the chat log
A Guilds page (Moderation, permission guilds_manage, GM 5 by default): every guild with its name status, member count,
leader and age, a pending-only switch, and per guild its members (with ranks) and history (guild_events). Staff can
approve a guild name that waits for review, reject it (the guild becomes "Guild <id>"), rename a guild (same name rules
as the game, unique without regard to case), remove a member (a leader's guild goes to the next member, the last
member's guild is deleted) and disband a guild. Every change is audited and added to the guild's history, and the chat
server is asked (GUILD_CHANGED through master) to tell the online members. Pending guild names also show in the Review
Queue. Guild chat ("guild" in the chat log) counts as private chat like whispers and team chat (chat_private), with its
own channel filter and Prometheus counter.

Check on the dashboard: /guilds lists a guild made in game; approve/reject/rename/remove/disband update the in-game guild
window and name billboard of online members; the Review Queue shows a guild whose name waits for review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 06:48:40 -05:00

362 lines
18 KiB
C++

#include "PrometheusMetrics.h"
#include "MetricsFormat.h"
#include "Traffic.h"
#include <algorithm>
#include <chrono>
#include <ctime>
#include <filesystem>
#include <fstream>
#include <map>
#include <sstream>
#include "RouteUtils.h"
#include "ReportRoutes.h"
#include "ServerState.h"
#include "Permissions.h"
#include "Strikes.h"
#include "BinaryPathFinder.h"
#include "Database.h"
#include "Game.h"
#include "Logger.h"
#include "Web.h"
#include "dConfig.h"
#include "dServer.h"
#include "eHTTPMethod.h"
#include "ePlayerReportStatus.h"
#include "magic_enum.hpp"
using namespace RouteUtils;
using namespace MetricsFormat;
namespace {
using Clock = std::chrono::steady_clock;
const auto g_Started = Clock::now();
// A shared metrics_token shorter than this is ignored: it would be too easy to guess
constexpr size_t MIN_TOKEN_LENGTH = 16;
constexpr uint32_t CHAT_PAGE = 500;
constexpr uint32_t CHAT_PAGES = 20; // at most this many pages of new chat are counted per refresh
std::string g_Text;
Clock::time_point g_BuiltAt{};
// Addresses that keep sending wrong tokens wait a minute
RateLimiter g_Refusals(20, std::chrono::seconds(60));
std::map<std::string, Clock::time_point> g_BlockedUntil;
// Chat messages counted since the dashboard started, by channel
bool g_ChatStarted{};
uint64_t g_ChatSeen{};
std::map<std::string, uint64_t> g_ChatMessages{ {"zone", 0}, {"whisper", 0}, {"team", 0}, {"guild", 0}, {"web", 0} };
std::map<std::string, uint64_t> g_ChatBlocked{ {"zone", 0}, {"whisper", 0}, {"team", 0}, {"guild", 0}, {"web", 0} };
std::string LowerName(std::string_view name) {
std::string out(name);
std::transform(out.begin(), out.end(), out.begin(), ::tolower);
return out;
}
// Resident memory and process count of each DarkflameServer program from this build (Linux: /proc; elsewhere none)
std::map<std::string, std::pair<uint32_t, uint64_t>> ServerMemory() {
std::map<std::string, std::pair<uint32_t, uint64_t>> servers;
#ifdef __linux__
namespace fs = std::filesystem;
const auto binaryDir = BinaryPathFinder::GetBinaryDir().string();
std::error_code ec;
for (const auto& entry : fs::directory_iterator("/proc", ec)) {
const auto pid = entry.path().filename().string();
if (pid.empty() || !std::all_of(pid.begin(), pid.end(), ::isdigit)) continue;
std::error_code linkError;
const auto exe = fs::read_symlink(entry.path() / "exe", linkError);
const auto exeText = exe.string();
if (linkError || !exeText.starts_with(binaryDir) || !exeText.ends_with("Server")) continue;
std::ifstream status(entry.path() / "status");
std::string line;
while (std::getline(status, line)) {
if (!line.starts_with("VmRSS:")) continue;
uint64_t kb = 0;
std::istringstream(line.substr(6)) >> kb; // "VmRSS: 12345 kB"
auto& server = servers[exe.filename().string()];
server.first++;
server.second += kb * 1024;
break;
}
}
#endif
return servers;
}
uint32_t WebSocketClients() {
uint32_t count = 0;
for (const auto* connection = Game::web.GetManager().conns; connection; connection = connection->next) {
if (connection->is_websocket && !connection->is_closing) count++;
}
return count;
}
// What the dashboard knows without asking the database: master's server list, its own connections, /proc
void AddLive(MetricsFormat::Writer& w) {
std::vector<WorldInstanceInfo> worlds;
bool authUp = false, chatUp = false, ugcEnabled = false, ugcUp = false;
{
std::lock_guard lock(ServerState::g_StatusMutex);
worlds = ServerState::g_WorldInstances;
authUp = ServerState::g_AuthStatus.online;
chatUp = ServerState::g_ChatStatus.online;
ugcEnabled = ServerState::g_UgcEnabled;
ugcUp = ServerState::g_UgcStatus.online;
}
w.Add("darkflame_master_connected", "Whether the dashboard is connected to the master server", "gauge", Game::server && Game::server->GetIsConnectedToMaster() ? 1 : 0);
w.Add("darkflame_auth_up", "Whether the auth server is up, as master last reported", "gauge", authUp ? 1 : 0);
w.Add("darkflame_chat_up", "Whether the chat server is up, as master last reported", "gauge", chatUp ? 1 : 0);
w.Add("darkflame_ugc_enabled", "Whether master starts the UGC server (enable_ugc_server)", "gauge", ugcEnabled ? 1 : 0);
w.Add("darkflame_ugc_up", "Whether the UGC server is up, as master last reported", "gauge", ugcUp ? 1 : 0);
uint32_t players = 0;
std::map<uint32_t, std::pair<std::string, std::pair<uint32_t, uint32_t>>> zones; // id -> name, {instances, players}
w.Declare("darkflame_instance_players", "Players in one world instance", "gauge");
for (const auto& world : worlds) {
players += world.players;
auto& zone = zones[world.mapID];
zone.first = world.zoneName;
zone.second.first++;
zone.second.second += world.players;
// Property instances are only told apart by kind: their clone ids lead to the owner
const std::string kind = world.cloneID != 0 ? "property" : world.isPrivate ? "private" : "public";
w.Add("darkflame_instance_players", "", "gauge",
{ {"zone_id", std::to_string(world.mapID)}, {"instance_id", std::to_string(world.instanceID)}, {"kind", kind} }, world.players);
}
w.Add("darkflame_players_online", "Players in game (character select included)", "gauge", players);
w.Add("darkflame_world_instances", "World instances running", "gauge", static_cast<double>(worlds.size()));
w.Declare("darkflame_zone_players", "Players in a zone, all its instances together", "gauge");
w.Declare("darkflame_zone_instances", "World instances running for a zone", "gauge");
for (const auto& [id, zone] : zones) {
const Labels labels{ {"zone_id", std::to_string(id)}, {"zone_name", zone.first} };
w.Add("darkflame_zone_players", "", "gauge", labels, zone.second.second);
w.Add("darkflame_zone_instances", "", "gauge", labels, zone.second.first);
}
w.Add("darkflame_dashboard_uptime_seconds", "Seconds since the dashboard started", "gauge",
static_cast<double>(std::chrono::duration_cast<std::chrono::seconds>(Clock::now() - g_Started).count()));
w.Add("darkflame_websocket_clients", "Open dashboard WebSocket connections (browsers and bots)", "gauge", WebSocketClients());
w.Declare("darkflame_server_memory_bytes", "Resident memory of a kind of server process, all of them together (Linux only)", "gauge");
w.Declare("darkflame_server_processes", "Running processes of a kind of server (Linux only)", "gauge");
for (const auto& [server, usage] : ServerMemory()) {
w.Add("darkflame_server_memory_bytes", "", "gauge", { {"server", server} }, static_cast<double>(usage.second));
w.Add("darkflame_server_processes", "", "gauge", { {"server", server} }, usage.first);
}
}
// New chat since the last refresh, counted by channel. The first refresh only notes where the log is.
void CountChat(uint64_t newest) {
if (!g_ChatStarted) {
g_ChatStarted = true;
g_ChatSeen = newest;
return;
}
IChatLog::ChatQuery q;
q.includePrivate = true;
q.limit = CHAT_PAGE;
for (uint32_t page = 0; page < CHAT_PAGES && g_ChatSeen < newest; page++) {
q.afterId = g_ChatSeen;
const auto messages = Database::Get()->GetChatMessages(q);
if (messages.empty()) break;
for (const auto& message : messages) {
g_ChatMessages[message.channel]++;
if (message.blocked) g_ChatBlocked[message.channel]++;
g_ChatSeen = std::max(g_ChatSeen, message.id);
}
}
// A flood bigger than the pages above is skipped rather than read on every refresh after
g_ChatSeen = std::max(g_ChatSeen, newest);
}
// A database step, timed; a failure is logged and leaves its metrics out
bool Timed(MetricsFormat::Writer& w, const std::string& query, const std::function<void()>& step) {
const auto start = Clock::now();
try {
step();
} catch (const std::exception& ex) {
LOG_DEBUG("Metrics: %s failed: %s", query.c_str(), ex.what());
return false;
}
w.Add("darkflame_db_query_seconds", "How long each of the database reads behind these metrics took on the last refresh", "gauge",
{ {"query", query} }, std::chrono::duration<double>(Clock::now() - start).count());
return true;
}
void AddDatabase(MetricsFormat::Writer& w) {
bool ok = true;
auto* db = Database::Get();
w.Declare("darkflame_moderation_queue", "Things waiting for staff: names, pet names and properties to approve, bug reports, economy flags, player reports", "gauge");
ok &= Timed(w, "snapshot", [&] {
const auto snapshot = db->GetDashboardSnapshot();
w.Add("darkflame_accounts", "Accounts", "gauge", static_cast<double>(snapshot.accounts));
w.Add("darkflame_characters", "Characters", "gauge", static_cast<double>(snapshot.characters));
w.Add("darkflame_properties", "Properties", "gauge", static_cast<double>(snapshot.properties));
const auto queue = [&](const char* name, uint64_t size) {
w.Add("darkflame_moderation_queue", "", "gauge", { {"queue", name} }, static_cast<double>(size));
};
queue("names", snapshot.pendingNames);
queue("pet_names", snapshot.pendingPetNames);
queue("properties", snapshot.pendingProperties);
queue("bug_reports", snapshot.unresolvedBugReports);
queue("economy_flags", snapshot.openEconomyFlags);
CountChat(snapshot.chatLogMaxId);
});
for (const auto& [channel, count] : g_ChatMessages) {
w.Add("darkflame_chat_messages_total", "Chat messages logged since the dashboard started, blocked ones included", "counter", { {"channel", channel} }, static_cast<double>(count));
}
for (const auto& [channel, count] : g_ChatBlocked) {
w.Add("darkflame_chat_messages_blocked_total", "Chat messages the chat filter stopped since the dashboard started", "counter", { {"channel", channel} }, static_cast<double>(count));
}
ok &= Timed(w, "player_reports", [&] {
IModeration::PlayerReportQuery q;
q.status = static_cast<int16_t>(ePlayerReportStatus::OPEN);
w.Add("darkflame_moderation_queue", "", "gauge", { {"queue", "player_reports"} }, db->CountPlayerReports(q));
});
// The UGC server's work list: models and modular builds by processing state
ok &= Timed(w, "ugc", [&] {
w.Declare("darkflame_ugc_items", "Player models (model) and cars and rockets (modular) by UGC processing state", "gauge");
const auto add = [&](const char* kind, const std::vector<std::pair<IUgc::eProcessState, uint64_t>>& rows) {
std::map<std::string, uint64_t> states;
for (const auto state : magic_enum::enum_values<IUgc::eProcessState>()) states[IUgc::ProcessStateName(state)] = 0;
for (const auto& [state, count] : rows) states[IUgc::ProcessStateName(state)] += count;
for (const auto& [state, count] : states) w.Add("darkflame_ugc_items", "", "gauge", { {"kind", kind}, {"state", state} }, static_cast<double>(count));
};
add("model", db->GetUgcProcessCounts());
add("modular", db->GetModularBuildProcessCounts());
});
ok &= Timed(w, "strikes", [&] {
w.Add("darkflame_active_strikes", "Strikes that still count, all accounts together", "gauge", db->CountActiveStrikes(0, Strikes::CountsSince()));
});
// Today's ledger, as far as the world servers have written it (they send it in batches). Resets at midnight UTC.
ok &= Timed(w, "economy", [&] {
const auto today = static_cast<uint32_t>(std::time(nullptr) / (24 * 60 * 60));
const auto sources = EconomySourceNames();
const auto source = [&](const nlohmann::json& row) {
const auto id = std::to_string(row.value("source", 0));
return LowerName(sources.contains(id) ? sources[id].get<std::string>() : id);
};
w.Declare("darkflame_economy_coins_today", "Coins gained and spent today (UTC) by source, staff included", "gauge");
for (const auto& row : db->GetCurrencyFlows(today, today, false)) {
w.Add("darkflame_economy_coins_today", "", "gauge", { {"direction", "gained"}, {"source", source(row)} }, static_cast<double>(row.value("gained", int64_t{})));
w.Add("darkflame_economy_coins_today", "", "gauge", { {"direction", "spent"}, {"source", source(row)} }, static_cast<double>(row.value("spent", int64_t{})));
}
w.Declare("darkflame_economy_items_today", "Items created and destroyed today (UTC) by source, staff included", "gauge");
for (const auto& row : db->GetItemFlows(today, today, 0, false)) {
w.Add("darkflame_economy_items_today", "", "gauge", { {"direction", "created"}, {"source", source(row)} }, static_cast<double>(row.value("created", int64_t{})));
w.Add("darkflame_economy_items_today", "", "gauge", { {"direction", "destroyed"}, {"source", source(row)} }, static_cast<double>(row.value("destroyed", int64_t{})));
}
std::map<std::string, double> kinds;
for (const auto kind : magic_enum::enum_values<IEconomyLedger::eMapEvent>()) kinds[LowerName(magic_enum::enum_name(kind))] = 0;
for (const auto& row : db->GetMapEventsPerDay(today, today, {})) {
const auto kind = magic_enum::enum_cast<IEconomyLedger::eMapEvent>(row.value("kind", 0));
if (kind) kinds[LowerName(magic_enum::enum_name(*kind))] += static_cast<double>(row.value("events", int64_t{}));
}
for (const auto& [kind, events] : kinds) {
w.Add("darkflame_map_events_today", "Map events today (UTC) by kind: kills, drops, deaths, smashes, quickbuilds", "gauge", { {"kind", kind} }, events);
}
});
ok &= Timed(w, "tasks", [&] {
w.Declare("darkflame_task_last_run_success", "Whether a scheduled task's last finished run succeeded", "gauge");
for (const auto& run : db->GetLatestTaskRuns()) {
const Labels labels{ {"task", run.task} };
w.Add("darkflame_task_last_run_success", "", "gauge", labels, run.status == IScheduledTasks::eRunStatus::SUCCEEDED ? 1 : 0);
w.Add("darkflame_task_last_run_timestamp_seconds", "When a scheduled task's last run finished (Unix time)", "gauge", labels, static_cast<double>(run.finishedAt));
w.Add("darkflame_task_last_run_duration_seconds", "How long a scheduled task's last run took", "gauge", labels, static_cast<double>(std::max<int64_t>(0, run.finishedAt - run.startedAt)));
}
});
w.Add("darkflame_db_up", "Whether every database read behind these metrics worked on the last refresh", "gauge", ok ? 1 : 0);
}
const std::string& Text() {
const auto cacheSeconds = std::clamp<int64_t>(GeneralUtils::TryParse<int64_t>(Game::config->GetValue("metrics_cache_seconds")).value_or(10), 1, 300);
const auto now = Clock::now();
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;
return g_Text;
}
void Send(HTTPReply& reply) {
reply.status = eHTTPStatusCode::OK;
reply.contentType = eContentType::TEXT_PROMETHEUS;
reply.message = Text();
}
void PlainError(HTTPReply& reply, eHTTPStatusCode status, const std::string& message) {
reply.status = status;
reply.contentType = eContentType::TEXT_PLAIN;
reply.message = message + "\n";
}
bool AddressAllowed(const HTTPContext& context) {
return MetricsFormat::AddressAllowed(Game::config->GetValue("metrics_allowed_ips"), ClientAddress(context));
}
// The shared scraper secret, as Authorization: Bearer <metrics_token>
bool SharedTokenGiven(const HTTPContext& context) {
const auto secret = Game::config->GetValue("metrics_token");
const auto& header = context.GetHeader("Authorization");
if (secret.size() < MIN_TOKEN_LENGTH || !header.starts_with("Bearer ")) return false;
return ConstantTimeEquals(std::string_view(header).substr(7), secret);
}
// A signed-in account (API token or the browser's session) that may see metrics
bool AccountAllowed(const HTTPContext& context) {
if (!context.isAuthenticated || context.userData.contains("needs_2fa")) return false;
const auto source = context.userData.find("auth_source");
if (source != context.userData.end() && source->second == "header" && !Permissions::Allowed(context.gmLevel, "api_access", nullptr, context.grants.get())) return false;
return Permissions::Allowed(context.gmLevel, "metrics_view", context.apiKey.get(), context.grants.get());
}
}
namespace PrometheusMetrics {
void RegisterRoutes() {
// Not behind the sign-in middleware: a scraper may use the shared token instead of an account's
Route(eHTTPMethod::GET, "/metrics", PUBLIC, "Prometheus metrics, when metrics_enabled is on. Send Authorization: Bearer with an API token (metrics_view) or metrics_token",
[](HTTPReply& reply, const HTTPContext& context) {
if (!ConfigFlag("metrics_enabled", false)) return PlainError(reply, eHTTPStatusCode::NOT_FOUND, "Not found");
const auto address = ClientAddress(context);
if (!AddressAllowed(context)) return PlainError(reply, eHTTPStatusCode::FORBIDDEN, "Not allowed from this address");
const auto now = Clock::now();
if (const auto blocked = g_BlockedUntil.find(address); blocked != g_BlockedUntil.end()) {
if (now < blocked->second) return PlainError(reply, eHTTPStatusCode::TOO_MANY_REQUESTS, "Too many refused requests; wait a minute");
g_BlockedUntil.erase(blocked);
}
if (SharedTokenGiven(context) || AccountAllowed(context)) return Send(reply);
if (!g_Refusals.Allow(address)) {
LOG("Metrics: too many refused requests from %s", address.c_str());
if (g_BlockedUntil.size() > 10000) g_BlockedUntil.clear();
g_BlockedUntil[address] = now + std::chrono::seconds(60);
}
if (context.isAuthenticated) return PlainError(reply, eHTTPStatusCode::FORBIDDEN, "Your account may not see metrics (metrics_view)");
reply.headers.push_back("WWW-Authenticate: Bearer realm=\"metrics\"");
PlainError(reply, eHTTPStatusCode::UNAUTHORIZED, "Send Authorization: Bearer <API token or metrics_token>");
});
Route(eHTTPMethod::GET, "/api/metrics", Perm("metrics_view"),
"The same Prometheus metrics as /metrics, for API tokens (text, not JSON). Off until metrics_enabled is on; metrics_allowed_ips applies too. "
"Scrapers without an account can use /metrics with the shared metrics_token as Authorization: Bearer",
[](HTTPReply& reply, const HTTPContext& context) {
if (!ConfigFlag("metrics_enabled", false)) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "Metrics are switched off (metrics_enabled)");
if (!AddressAllowed(context)) return JsonError(reply, eHTTPStatusCode::FORBIDDEN, "Not allowed from this address");
Send(reply);
});
}
}