From fb6d73e4bd486178a5f02eb58c0cf84e5c5495dc Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Tue, 29 Sep 2026 22:26:12 -0500 Subject: [PATCH] feat(web): API key traffic is its own Network row, named after the key Co-Authored-By: Claude Opus 5.5 --- dCommon/TrafficStats.cpp | 13 ++++++++----- dCommon/TrafficStats.h | 3 ++- dWeb/Web.cpp | 17 ++++++++++------- tests/dCommonTests/TrafficStatsTests.cpp | 17 +++++++++++++++++ 4 files changed, 37 insertions(+), 13 deletions(-) diff --git a/dCommon/TrafficStats.cpp b/dCommon/TrafficStats.cpp index e3c7c05b5..1be974a65 100644 --- a/dCommon/TrafficStats.cpp +++ b/dCommon/TrafficStats.cpp @@ -208,9 +208,12 @@ namespace TrafficStats { if (list.size() > limit) list.resize(limit); } - 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; + void Recorder::HttpClient(const std::string& address, bool fromServer, uint64_t bytesIn, uint64_t bytesOut, uint32_t accountId, const std::string& user, + const std::string& apiKeyName) { + // One entry per address and signed-in account: people sharing an address (behind one NAT or proxy) stay apart. + // An API key is its own entry, named after the key, apart from its owner's browser sessions + const auto key = accountId ? address + '\n' + std::to_string(accountId) + (apiKeyName.empty() ? std::string() : "\nkey:" + apiKeyName) : address; + const auto label = apiKeyName.empty() || user.empty() ? user : user + " (API key \"" + apiKeyName + "\")"; std::lock_guard lock(m_Mutex); auto it = m_HttpClients.find(key); if (it == m_HttpClients.end()) { @@ -220,11 +223,11 @@ namespace TrafficStats { it->second.http = true; if (!full) { it->second.accountId = accountId; - it->second.account = user; + it->second.account = label; } } auto& client = it->second; - if (client.account.empty() && !user.empty() && client.accountId == accountId) client.account = user; // a WebSocket upgrade knows only the account + if (client.account.empty() && !label.empty() && client.accountId == accountId) client.account = label; // a WebSocket upgrade knows only the account if (fromServer) client.peer = Peer::SERVERS; client.packetsIn++; client.packetsOut++; diff --git a/dCommon/TrafficStats.h b/dCommon/TrafficStats.h index 2ced14758..fe367435d 100644 --- a/dCommon/TrafficStats.h +++ b/dCommon/TrafficStats.h @@ -201,7 +201,8 @@ namespace TrafficStats { // 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 = {}); + void HttpClient(const std::string& address, bool fromServer, uint64_t bytesIn, uint64_t bytesOut, uint32_t accountId = 0, const std::string& user = {}, + const std::string& apiKeyName = {}); // Evaluated when a report is taken (on the thread that takes it) void SetGauge(const std::string& name, std::function source); diff --git a/dWeb/Web.cpp b/dWeb/Web.cpp index cde0ba749..f0f746f56 100644 --- a/dWeb/Web.cpp +++ b/dWeb/Web.cpp @@ -266,6 +266,7 @@ namespace { uint64_t requestBytes{}; uint32_t accountId{}; std::string user; + std::string apiKey; // the API key's name, when the request used one }; std::unordered_map g_DeferredTiming; @@ -288,10 +289,10 @@ namespace { // `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, uint32_t accountId = 0, const std::string& user = {}) { + const std::string& address, uint64_t requestBytes, uint32_t accountId = 0, const std::string& user = {}, const std::string& apiKeyName = {}) { const auto micros = std::chrono::duration_cast(TrafficClock::now() - started).count(); TrafficStats::Local().Http(TrafficStats::Now(), route, status, static_cast(std::max(micros, 0)), bytes, fromServer); - TrafficStats::Local().HttpClient(address, fromServer, requestBytes, bytes, accountId, user); + TrafficStats::Local().HttpClient(address, fromServer, requestBytes, bytes, accountId, user, apiKeyName); } } @@ -308,7 +309,7 @@ void HandleHTTPMessage(mg_connection* connection, const mg_http_message* http_ms 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; + std::string signedInUser, signedInKey; // signedInKey: the API key's name when the request used one if (!http_msg) { reply.status = eHTTPStatusCode::BAD_REQUEST; @@ -395,7 +396,8 @@ void HandleHTTPMessage(mg_connection* connection, const mg_http_message* http_ms } } - CountRequest("GET /ws", level ? 101 : 401, started, 0, fromServer, clientAddress, requestBytes, level ? level->accountId : 0); + CountRequest("GET /ws", level ? 101 : 401, started, 0, fromServer, clientAddress, requestBytes, level ? level->accountId : 0, {}, + level && level->apiKey ? level->apiKey->name : std::string()); if (level) { mg_ws_upgrade(connection, const_cast(http_msg), NULL); g_AuthenticatedWSConnections[connection] = { level->level, level->accountId, connectToken, apiToken, @@ -506,6 +508,7 @@ void HandleHTTPMessage(mg_connection* connection, const mg_http_message* http_ms if (context.isAuthenticated) { signedInAccount = context.accountId; signedInUser = context.authenticatedUser; + if (context.apiKey) signedInKey = context.apiKey->name; } // Call handler only if all middleware passed. A failing handler (e.g. a database error) answers 500 @@ -534,14 +537,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, signedInAccount, std::move(signedInUser) }; + g_DeferredTiming[connection->id] = { std::move(trafficRoute), started, fromServer, clientAddress, requestBytes, signedInAccount, std::move(signedInUser), std::move(signedInKey) }; 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(reply.status), started, ReplyBytes(reply), fromServer, clientAddress, requestBytes, signedInAccount, signedInUser); + CountRequest(trafficRoute, static_cast(reply.status), started, ReplyBytes(reply), fromServer, clientAddress, requestBytes, signedInAccount, signedInUser, signedInKey); RemoveSentFile(reply); } @@ -823,7 +826,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(finished.reply.status), timing->second.started, ReplyBytes(finished.reply), timing->second.fromServer, - timing->second.address, timing->second.requestBytes, timing->second.accountId, timing->second.user); + timing->second.address, timing->second.requestBytes, timing->second.accountId, timing->second.user, timing->second.apiKey); g_DeferredTiming.erase(timing); } RemoveSentFile(finished.reply); diff --git a/tests/dCommonTests/TrafficStatsTests.cpp b/tests/dCommonTests/TrafficStatsTests.cpp index fabd6a1e3..4ae0ce4f7 100644 --- a/tests/dCommonTests/TrafficStatsTests.cpp +++ b/tests/dCommonTests/TrafficStatsTests.cpp @@ -292,3 +292,20 @@ TEST(TrafficStatsTest, HttpClientsApartBySignedInAccount) { } } } + +TEST(TrafficStatsTest, ApiKeyTrafficIsNamedAfterTheKey) { + Recorder r; + r.HttpClient("203.0.113.5", false, 100, 1000, 7, "alice"); // alice's browser + r.HttpClient("203.0.113.5", false, 50, 500, 7, "alice", "status bot"); // alice's API key + r.HttpClient("203.0.113.5", false, 50, 500, 7, "alice", "status bot"); + const auto report = r.Take(1); + ASSERT_EQ(report.connections.size(), 2u); + bool sawKey = false, sawBrowser = false; + for (const auto& c : report.connections) { + EXPECT_EQ(c.accountId, 7u); + if (c.account == "alice (API key \"status bot\")") { sawKey = true; EXPECT_EQ(c.packetsIn, 2u); } + else if (c.account == "alice") sawBrowser = true; + } + EXPECT_TRUE(sawKey); + EXPECT_TRUE(sawBrowser); +}