#include "PrometheusMetrics.h" #include "MetricsFormat.h" #include "Traffic.h" #include #include #include #include #include #include #include #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 g_BlockedUntil; // Chat messages counted since the dashboard started, by channel bool g_ChatStarted{}; uint64_t g_ChatSeen{}; std::map g_ChatMessages{ {"zone", 0}, {"whisper", 0}, {"team", 0}, {"guild", 0}, {"web", 0} }; std::map 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> ServerMemory() { std::map> 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 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>> 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(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(std::chrono::duration_cast(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(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.includeWhispers = 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& 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(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(snapshot.accounts)); w.Add("darkflame_characters", "Characters", "gauge", static_cast(snapshot.characters)); w.Add("darkflame_properties", "Properties", "gauge", static_cast(snapshot.properties)); const auto queue = [&](const char* name, uint64_t size) { w.Add("darkflame_moderation_queue", "", "gauge", { {"queue", name} }, static_cast(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(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(count)); } ok &= Timed(w, "player_reports", [&] { IModeration::PlayerReportQuery q; q.status = static_cast(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>& rows) { std::map states; for (const auto state : magic_enum::enum_values()) 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(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(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() : 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(row.value("gained", int64_t{}))); w.Add("darkflame_economy_coins_today", "", "gauge", { {"direction", "spent"}, {"source", source(row)} }, static_cast(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(row.value("created", int64_t{}))); w.Add("darkflame_economy_items_today", "", "gauge", { {"direction", "destroyed"}, {"source", source(row)} }, static_cast(row.value("destroyed", int64_t{}))); } std::map kinds; for (const auto kind : magic_enum::enum_values()) kinds[LowerName(magic_enum::enum_name(kind))] = 0; for (const auto& row : db->GetMapEventsPerDay(today, today, {})) { const auto kind = magic_enum::enum_cast(row.value("kind", 0)); if (kind) kinds[LowerName(magic_enum::enum_name(*kind))] += static_cast(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(run.finishedAt)); w.Add("darkflame_task_last_run_duration_seconds", "How long a scheduled task's last run took", "gauge", labels, static_cast(std::max(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(GeneralUtils::TryParse(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 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 "); }); 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); }); } }