feat(web): count HTTP clients by the account they were signed in as

Each address's requests are counted per signed-in account (the dashboard's
session or API key, as the auth middleware found it), so several people
behind one address stay apart, with the account's user name; a WebSocket
upgrade counts under its account.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-29 22:00:55 -05:00
parent 7c37fca9fb
commit 569ac6fb31
4 changed files with 58 additions and 13 deletions

View File

@@ -208,16 +208,23 @@ namespace TrafficStats {
if (list.size() > limit) list.resize(limit);
}
void Recorder::HttpClient(const std::string& address, bool fromServer, uint64_t bytesIn, uint64_t bytesOut) {
void Recorder::HttpClient(const std::string& address, bool fromServer, uint64_t bytesIn, uint64_t bytesOut, uint32_t accountId, const std::string& user) {
// One entry per address and signed-in account: people sharing an address (behind one NAT or proxy) stay apart
const auto key = accountId ? address + '\n' + std::to_string(accountId) : address;
std::lock_guard lock(m_Mutex);
auto it = m_HttpClients.find(address);
auto it = m_HttpClients.find(key);
if (it == m_HttpClients.end()) {
const bool full = m_HttpClients.size() >= MAX_HTTP_CLIENTS;
it = m_HttpClients.try_emplace(full ? std::string() : address).first;
it->second.address = it->first;
it = m_HttpClients.try_emplace(full ? std::string() : key).first;
it->second.address = full ? std::string() : address;
it->second.http = true;
if (!full) {
it->second.accountId = accountId;
it->second.account = user;
}
}
auto& client = it->second;
if (client.account.empty() && !user.empty() && client.accountId == accountId) client.account = user; // a WebSocket upgrade knows only the account
if (fromServer) client.peer = Peer::SERVERS;
client.packetsIn++;
client.packetsOut++;

View File

@@ -160,7 +160,7 @@ namespace TrafficStats {
uint64_t bytesIn{}, bytesOut{};
uint32_t resends{};
uint32_t pingMs{};
uint32_t accountId{}; // a logged-in player's (world servers), else 0
uint32_t accountId{}; // a logged-in player's (world servers) or a signed-in dashboard account's (HTTP), else 0
uint64_t characterId{};
std::string account, character;
@@ -198,8 +198,10 @@ namespace TrafficStats {
void Http(int64_t now, const std::string& route, uint16_t status, uint64_t microseconds, uint64_t bytesOut, bool fromServer = false);
// A request this server made to another server's web server (the dashboard to the UGC server); any thread
void HttpOut(int64_t now, uint64_t bytesIn);
// An HTTP client's request by its address (bytes of the request and of the answer's body)
void HttpClient(const std::string& address, bool fromServer, uint64_t bytesIn, uint64_t bytesOut);
// An HTTP client's request by its address (bytes of the request and of the answer's body). `accountId` and `user`:
// the account the request was signed in as (the dashboard's session or API key), 0 and "" when none; each
// signed-in account on an address is counted apart
void HttpClient(const std::string& address, bool fromServer, uint64_t bytesIn, uint64_t bytesOut, uint32_t accountId = 0, const std::string& user = {});
// Evaluated when a report is taken (on the thread that takes it)
void SetGauge(const std::string& name, std::function<double()> source);

View File

@@ -264,6 +264,8 @@ namespace {
bool fromServer{};
std::string address;
uint64_t requestBytes{};
uint32_t accountId{};
std::string user;
};
std::unordered_map<unsigned long, DeferredTiming> g_DeferredTiming;
@@ -284,11 +286,12 @@ namespace {
std::filesystem::remove(reply.file, ec);
}
// `accountId` and `user`: who the request was signed in as (0 and "" for none), for the Network page's web clients
void CountRequest(const std::string& route, uint16_t status, TrafficClock::time_point started, uint64_t bytes, bool fromServer,
const std::string& address, uint64_t requestBytes) {
const std::string& address, uint64_t requestBytes, uint32_t accountId = 0, const std::string& user = {}) {
const auto micros = std::chrono::duration_cast<std::chrono::microseconds>(TrafficClock::now() - started).count();
TrafficStats::Local().Http(TrafficStats::Now(), route, status, static_cast<uint64_t>(std::max<int64_t>(micros, 0)), bytes, fromServer);
TrafficStats::Local().HttpClient(address, fromServer, requestBytes, bytes);
TrafficStats::Local().HttpClient(address, fromServer, requestBytes, bytes, accountId, user);
}
}
@@ -303,6 +306,9 @@ void HandleHTTPMessage(mg_connection* connection, const mg_http_message* http_ms
const bool fromServer = http_msg && mg_http_get_header(const_cast<mg_http_message*>(http_msg), TrafficStats::SERVER_HEADER) != nullptr;
const auto clientAddress = GetClientIP(connection);
const uint64_t requestBytes = http_msg ? http_msg->message.len : 0;
// Who the request was signed in as, once the middleware has looked (the dashboard's session or API key)
uint32_t signedInAccount = 0;
std::string signedInUser;
if (!http_msg) {
reply.status = eHTTPStatusCode::BAD_REQUEST;
@@ -389,7 +395,7 @@ void HandleHTTPMessage(mg_connection* connection, const mg_http_message* http_ms
}
}
CountRequest("GET /ws", level ? 101 : 401, started, 0, fromServer, clientAddress, requestBytes);
CountRequest("GET /ws", level ? 101 : 401, started, 0, fromServer, clientAddress, requestBytes, level ? level->accountId : 0);
if (level) {
mg_ws_upgrade(connection, const_cast<mg_http_message*>(http_msg), NULL);
g_AuthenticatedWSConnections[connection] = { level->level, level->accountId, connectToken, apiToken,
@@ -497,6 +503,11 @@ void HandleHTTPMessage(mg_connection* connection, const mg_http_message* http_ms
}
}
if (context.isAuthenticated) {
signedInAccount = context.accountId;
signedInUser = context.authenticatedUser;
}
// Call handler only if all middleware passed. A failing handler (e.g. a database error) answers 500
// instead of taking the whole server down.
if (chainPassed) {
@@ -523,14 +534,14 @@ void HandleHTTPMessage(mg_connection* connection, const mg_http_message* http_ms
g_Deferred.SetReplyOptions(connection->id, reply.headers, cc && mg_strcasecmp(*cc, mg_str("close")) == 0);
// Requests the answers never came for (the client left) are forgotten now and then
if (g_DeferredTiming.size() > 10000) g_DeferredTiming.clear();
g_DeferredTiming[connection->id] = { std::move(trafficRoute), started, fromServer, clientAddress, requestBytes };
g_DeferredTiming[connection->id] = { std::move(trafficRoute), started, fromServer, clientAddress, requestBytes, signedInAccount, std::move(signedInUser) };
return;
}
// The handler deferred and then failed: its late answer is dropped
if (g_Deferred.IsPending(connection->id)) g_Deferred.Close(connection->id);
SendReply(connection, reply, http_msg);
CountRequest(trafficRoute, static_cast<uint16_t>(reply.status), started, ReplyBytes(reply), fromServer, clientAddress, requestBytes);
CountRequest(trafficRoute, static_cast<uint16_t>(reply.status), started, ReplyBytes(reply), fromServer, clientAddress, requestBytes, signedInAccount, signedInUser);
RemoveSentFile(reply);
}
@@ -812,7 +823,7 @@ void Web::SendDeferredReplies() {
SendReply(connection, finished.reply, nullptr);
if (const auto timing = g_DeferredTiming.find(finished.connection); timing != g_DeferredTiming.end()) {
CountRequest(timing->second.route, static_cast<uint16_t>(finished.reply.status), timing->second.started, ReplyBytes(finished.reply), timing->second.fromServer,
timing->second.address, timing->second.requestBytes);
timing->second.address, timing->second.requestBytes, timing->second.accountId, timing->second.user);
g_DeferredTiming.erase(timing);
}
RemoveSentFile(finished.reply);

View File

@@ -267,3 +267,28 @@ TEST(TrafficStatsTest, RecorderSplitsHttpByWhoAsked) {
EXPECT_EQ(report.seconds[0].httpOutRequests, 1u);
EXPECT_EQ(report.seconds[0].httpOutBytesIn, 700u);
}
TEST(TrafficStatsTest, HttpClientsApartBySignedInAccount) {
Recorder r;
r.HttpClient("203.0.113.5", false, 100, 1000, 7, "alice");
r.HttpClient("203.0.113.5", false, 100, 1000, 7, "alice");
r.HttpClient("203.0.113.5", false, 50, 500, 9, "bob");
r.HttpClient("203.0.113.5", false, 10, 20); // not signed in (the sign-in page)
const auto report = r.Take(1);
ASSERT_EQ(report.connections.size(), 3u);
for (const auto& c : report.connections) {
EXPECT_EQ(c.address, "203.0.113.5");
EXPECT_TRUE(c.http);
if (c.accountId == 7) {
EXPECT_EQ(c.account, "alice");
EXPECT_EQ(c.packetsIn, 2u);
EXPECT_EQ(c.bytesOut, 2000u);
} else if (c.accountId == 9) {
EXPECT_EQ(c.account, "bob");
} else {
EXPECT_EQ(c.accountId, 0u);
EXPECT_TRUE(c.account.empty());
EXPECT_EQ(c.bytesIn, 10u);
}
}
}