feat(net): count every server's packets and send a traffic report every 5 seconds

TrafficStats keeps packets and bytes in and out per second and the busiest
packet and game message types. dServer counts at its send and receive calls
and adds RakNet's connection statistics (datagrams, resends, ping); the report
goes to master as SERVER_TRAFFIC (appended), which passes it to the dashboard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-27 08:44:03 -05:00
parent fda512e50a
commit 77ff773dc0
12 changed files with 1015 additions and 1 deletions

View File

@@ -28,6 +28,7 @@ set(DCOMMONTEST_SOURCES
"PropertyRentRulesTests.cpp"
"PropertyReputationRulesTests.cpp"
"BindAddressTests.cpp"
"TrafficStatsTests.cpp"
)
add_subdirectory(dEnumsTests)

View File

@@ -0,0 +1,228 @@
#include <gtest/gtest.h>
#include <chrono>
#include <cstring>
#include "TrafficStats.h"
#include "MessageIdentifiers.h"
#include "ServiceType.h"
#include "MessageType/Client.h"
#include "MessageType/Game.h"
#include "MessageType/World.h"
using namespace TrafficStats;
namespace {
// An LU packet header (and, for game messages, the target object and message ID)
std::vector<uint8_t> LuPacket(ServiceType service, uint32_t packet, int32_t gameMessage = -1, size_t pad = 0) {
std::vector<uint8_t> data{ ID_USER_PACKET_ENUM };
const auto s = static_cast<uint16_t>(service);
data.push_back(static_cast<uint8_t>(s));
data.push_back(static_cast<uint8_t>(s >> 8));
for (int i = 0; i < 4; i++) data.push_back(static_cast<uint8_t>(packet >> (8 * i)));
data.push_back(0);
if (gameMessage >= 0) {
for (int i = 0; i < 8; i++) data.push_back(0x11);
data.push_back(static_cast<uint8_t>(gameMessage));
data.push_back(static_cast<uint8_t>(gameMessage >> 8));
}
data.resize(data.size() + pad);
return data;
}
}
TEST(TrafficStatsTest, HistogramBucketsDoubleEveryThird) {
EXPECT_EQ(Histogram::UpperBound(0), 100u);
EXPECT_EQ(Histogram::UpperBound(3), 200u);
EXPECT_EQ(Histogram::UpperBound(30), 102400u);
EXPECT_EQ(Histogram::UpperBound(Histogram::BUCKETS - 1), UINT64_MAX);
EXPECT_EQ(Histogram::BucketFor(0), 0u);
EXPECT_EQ(Histogram::BucketFor(100), 0u);
EXPECT_EQ(Histogram::BucketFor(101), 1u);
EXPECT_EQ(Histogram::BucketFor(200), 3u);
EXPECT_EQ(Histogram::BucketFor(UINT64_MAX), Histogram::BUCKETS - 1);
for (size_t i = 1; i + 1 < Histogram::BUCKETS; i++) EXPECT_GT(Histogram::UpperBound(i), Histogram::UpperBound(i - 1));
}
TEST(TrafficStatsTest, PercentilesAreWithinABucket) {
Histogram h;
for (uint64_t ms = 1; ms <= 1000; ms++) h.Add(ms * 1000);
EXPECT_EQ(h.Count(), 1000u);
EXPECT_EQ(h.Sum(), 500500000u);
// A bucket spans 26%, so the answer is within that of the exact value
for (const auto [fraction, exact] : { std::pair{ 0.5, 500000.0 }, std::pair{ 0.95, 950000.0 }, std::pair{ 0.99, 990000.0 } }) {
const auto p = static_cast<double>(h.Percentile(fraction));
EXPECT_NEAR(p, exact, exact * 0.26) << fraction;
}
EXPECT_LE(h.Percentile(0.5), h.Percentile(0.95));
EXPECT_LE(h.Percentile(0.95), h.Percentile(0.99));
EXPECT_EQ(Histogram().Percentile(0.5), 0u);
}
TEST(TrafficStatsTest, SinglePercentileStaysInItsBucket) {
Histogram h;
h.Add(150);
const auto p = h.Percentile(0.99);
EXPECT_GT(p, Histogram::UpperBound(0));
EXPECT_LE(p, Histogram::UpperBound(Histogram::BucketFor(150)));
// Overflow reports its lower bound instead of infinity
Histogram slow;
slow.Add(3600ull * 1000000);
EXPECT_EQ(slow.Percentile(0.5), Histogram::UpperBound(Histogram::BUCKETS - 2));
}
TEST(TrafficStatsTest, HistogramsMergeAndSurviveSparse) {
Histogram a, b;
a.Add(500, 3);
b.Add(500);
b.Add(40000, 2);
a.Merge(b);
EXPECT_EQ(a.Count(), 6u);
EXPECT_EQ(a.Sum(), 500u * 4 + 80000u);
const auto sparse = a.Sparse();
ASSERT_EQ(sparse.size(), 2u);
const auto back = Histogram::FromSparse(sparse, a.Sum());
for (size_t i = 0; i < Histogram::BUCKETS; i++) EXPECT_EQ(back.At(i), a.At(i));
EXPECT_EQ(back.Sum(), a.Sum());
EXPECT_EQ(back.Percentile(0.5), a.Percentile(0.5));
}
TEST(TrafficStatsTest, StatusClasses) {
EXPECT_EQ(StatusClass(101), 0u);
EXPECT_EQ(StatusClass(200), 1u);
EXPECT_EQ(StatusClass(304), 2u);
EXPECT_EQ(StatusClass(404), 3u);
EXPECT_EQ(StatusClass(503), 4u);
EXPECT_EQ(StatusClass(0), 4u);
EXPECT_EQ(StatusClass(999), 4u);
}
TEST(TrafficStatsTest, KeysFromPackets) {
const auto world = LuPacket(ServiceType::WORLD, static_cast<uint32_t>(MessageType::World::POSITION_UPDATE), -1, 20);
auto key = KeyOf(world.data(), world.size(), false);
EXPECT_EQ(key.service, static_cast<uint16_t>(ServiceType::WORLD));
EXPECT_EQ(key.packet, static_cast<uint32_t>(MessageType::World::POSITION_UPDATE));
EXPECT_EQ(key.gameMessage, 0);
EXPECT_FALSE(key.outbound);
const auto gm = LuPacket(ServiceType::CLIENT, static_cast<uint32_t>(MessageType::Client::GAME_MSG), static_cast<int32_t>(MessageType::Game::REQUEST_USE));
key = KeyOf(gm.data(), gm.size(), true);
EXPECT_EQ(key.service, static_cast<uint16_t>(ServiceType::CLIENT));
EXPECT_EQ(key.gameMessage, static_cast<uint16_t>(MessageType::Game::REQUEST_USE));
EXPECT_TRUE(key.outbound);
// A game message cut short has no message ID; never reads past the end
key = KeyOf(gm.data(), 17, true);
EXPECT_EQ(key.gameMessage, 0);
const uint8_t replica[] = { ID_REPLICA_MANAGER_SERIALIZE, 1, 2 };
key = KeyOf(replica, sizeof(replica), true);
EXPECT_EQ(key.service, MessageKey::RAKNET);
EXPECT_EQ(key.packet, static_cast<uint32_t>(ID_REPLICA_MANAGER_SERIALIZE));
const uint8_t shortLu[] = { ID_USER_PACKET_ENUM, 4, 0 };
EXPECT_EQ(KeyOf(shortLu, sizeof(shortLu), false).service, MessageKey::RAKNET);
EXPECT_EQ(KeyOf(nullptr, 0, false).service, MessageKey::RAKNET);
}
TEST(TrafficStatsTest, KeysPackAndUnpack) {
for (const auto& key : { MessageKey{ true, 5, 12, 1234 }, MessageKey{ false, MessageKey::RAKNET, 36, 0 }, MessageKey{ false, 4, 0xFFFFFFFF, 0xFFFF } }) {
EXPECT_EQ(MessageKey::Unpack(key.Packed()), key);
}
EXPECT_NE((MessageKey{ true, 5, 12, 0 }).Packed(), (MessageKey{ false, 5, 12, 0 }).Packed());
}
TEST(TrafficStatsTest, RecorderFillsSilentSecondsAndKeepsTheCurrentOne) {
Recorder r;
const MessageKey in{ false, 4, 5, 0 };
const MessageKey out{ true, 5, 12, 0 };
r.Packet(1000, in, 100);
r.Packet(1000, in, 50);
r.Packet(1000, out, 30, 4); // a broadcast to four
r.Packet(1003, in, 10);
r.Packet(1005, in, 10); // the current second: stays
const auto report = r.Take(1005);
ASSERT_EQ(report.seconds.size(), 5u); // 1000..1004
EXPECT_EQ(report.seconds[0].time, 1000);
EXPECT_EQ(report.seconds[0].packetsIn, 2u);
EXPECT_EQ(report.seconds[0].bytesIn, 150u);
EXPECT_EQ(report.seconds[0].packetsOut, 4u);
EXPECT_EQ(report.seconds[0].bytesOut, 120u);
EXPECT_TRUE(report.seconds[1].Idle());
EXPECT_EQ(report.seconds[3].packetsIn, 1u);
EXPECT_EQ(report.seconds[4].time, 1004);
const auto next = r.Take(1008);
ASSERT_EQ(next.seconds.size(), 3u); // 1005..1007, no second twice
EXPECT_EQ(next.seconds[0].time, 1005);
EXPECT_EQ(next.seconds[0].packetsIn, 1u);
}
TEST(TrafficStatsTest, RecorderCapsLongSilences) {
Recorder r;
r.Packet(1000, MessageKey{}, 1);
r.Take(1001);
const auto report = r.Take(1001 + 10000);
EXPECT_EQ(report.seconds.size(), static_cast<size_t>(Recorder::MAX_GAP));
EXPECT_EQ(report.seconds.back().time, 1000 + 10000);
}
TEST(TrafficStatsTest, RecorderTopMessagesPerDirection) {
Recorder r;
for (uint32_t id = 0; id < 40; id++) {
for (uint32_t n = 0; n <= id; n++) {
r.Packet(1, MessageKey{ false, 4, id, 0 }, 10);
r.Packet(1, MessageKey{ true, 5, id, 0 }, 10);
}
}
const auto report = r.Take(2);
ASSERT_EQ(report.messages.size(), Recorder::TOP_MESSAGES * 2);
EXPECT_FALSE(report.messages.front().key.outbound);
EXPECT_EQ(report.messages.front().key.packet, 39u);
EXPECT_EQ(report.messages.front().count, 40u);
EXPECT_TRUE(report.messages[Recorder::TOP_MESSAGES].key.outbound);
EXPECT_TRUE(r.Take(3).messages.empty());
}
TEST(TrafficStatsTest, RecorderHttpAndRoutes) {
Recorder r;
r.Http(10, "GET /api/players", 200, 1500, 2000);
r.Http(10, "GET /api/players", 404, 300, 20);
r.Http(11, "POST /api/login", 500, 90000, 50);
for (size_t i = 0; i < Recorder::MAX_ROUTES + 5; i++) r.Http(11, "GET /r" + std::to_string(i), 200, 100, 1);
r.SetGauge("workers_busy", [] { return 3.0; });
const auto report = r.Take(12);
ASSERT_EQ(report.seconds.size(), 2u);
EXPECT_EQ(report.seconds[0].httpRequests, 2u);
EXPECT_EQ(report.seconds[0].httpStatus[1], 1u);
EXPECT_EQ(report.seconds[0].httpStatus[3], 1u);
EXPECT_EQ(report.seconds[0].httpBytesOut, 2020u);
EXPECT_EQ(report.seconds[0].httpLatency.Count(), 2u);
EXPECT_EQ(report.routes.size(), Recorder::MAX_ROUTES + 1); // the rest counted as "other"
const auto other = std::find_if(report.routes.begin(), report.routes.end(), [](const RouteStats& s) { return s.route == "other"; });
ASSERT_NE(other, report.routes.end());
EXPECT_EQ(other->count, 7u);
ASSERT_EQ(report.gauges.size(), 1u);
EXPECT_EQ(report.gauges[0].second, 3.0);
}
TEST(TrafficStatsTest, DueAfterTheInterval) {
Recorder r;
EXPECT_FALSE(r.Due(100, 5)); // starts the clock
EXPECT_FALSE(r.Due(104, 5));
EXPECT_TRUE(r.Due(105, 5));
r.Take(105);
EXPECT_FALSE(r.Due(106, 5));
}
// Counting is on every packet's path: keep it cheap
TEST(TrafficStatsTest, CountingIsCheap) {
Recorder r;
const auto packet = LuPacket(ServiceType::CLIENT, static_cast<uint32_t>(MessageType::Client::GAME_MSG), 100, 30);
constexpr int N = 1000000;
const auto start = std::chrono::steady_clock::now();
for (int i = 0; i < N; i++) r.Packet(Now(), KeyOf(packet.data(), packet.size(), (i & 1) != 0), packet.size());
const auto ns = std::chrono::duration_cast<std::chrono::nanoseconds>(std::chrono::steady_clock::now() - start).count() / N;
std::printf("[ ] KeyOf + Recorder::Packet: %lld ns per packet\n", static_cast<long long>(ns));
EXPECT_LT(ns, 2000); // generous for slow CI machines and sanitizers
}

View File

@@ -2,6 +2,7 @@ SET(DNET_TESTS
"ChatPacketsTests.cpp"
"CommonAuthPacketsTests.cpp"
"MasterPacketsTests.cpp"
"ServerTrafficTests.cpp"
"WorldPacketsTests.cpp")
# Get the folder name and prepend it to the files above

View File

@@ -0,0 +1,56 @@
#include <gtest/gtest.h>
#include "master/ServerTraffic.h"
using namespace TrafficStats;
TEST(ServerTrafficTest, ServerTrafficRoundTrips) {
ServerTraffic sent;
sent.serverType = ServiceType::WORLD;
sent.zoneId = 1100;
sent.instanceId = 3;
Second second{ .time = 1700000000, .packetsIn = 5, .packetsOut = 9, .bytesIn = 500, .bytesOut = 9000, .httpRequests = 2 };
second.httpStatus[1] = 2;
second.httpLatency.Add(1200);
second.httpLatency.Add(30000);
sent.report.seconds = { second, Second{ .time = 1700000001 } };
sent.report.messages = { MessageCount{ MessageKey{ true, 5, 12, 1234 }, 7, 700 } };
RouteStats route{ .route = "GET /api/players/:id", .count = 2, .bytesOut = 99 };
route.status[1] = 2;
route.latency.Add(800, 2);
sent.report.routes = { route };
sent.report.link = { 12, 100, 90, 10000, 9000, 3, 1, 42 };
sent.report.gauges = { { "workers_busy", 2.0 } };
RakNet::BitStream stream;
sent.WritePacket(stream);
LUBitStream header;
ASSERT_TRUE(header.ReadHeader(stream));
EXPECT_EQ(header.internalPacketID, static_cast<uint32_t>(MessageType::Master::SERVER_TRAFFIC));
ServerTraffic got;
ASSERT_TRUE(got.Deserialize(stream));
EXPECT_EQ(got.serverType, ServiceType::WORLD);
EXPECT_EQ(got.zoneId, 1100u);
EXPECT_EQ(got.instanceId, 3u);
ASSERT_EQ(got.report.seconds.size(), 2u);
EXPECT_EQ(got.report.seconds[0].bytesOut, 9000u);
EXPECT_EQ(got.report.seconds[0].httpStatus[1], 2u);
EXPECT_EQ(got.report.seconds[0].httpLatency.Count(), 2u);
EXPECT_EQ(got.report.seconds[0].httpLatency.Sum(), 31200u);
ASSERT_EQ(got.report.messages.size(), 1u);
EXPECT_EQ(got.report.messages[0].key, (MessageKey{ true, 5, 12, 1234 }));
ASSERT_EQ(got.report.routes.size(), 1u);
EXPECT_EQ(got.report.routes[0].route, "GET /api/players/:id");
EXPECT_EQ(got.report.routes[0].latency.Count(), 2u);
EXPECT_EQ(got.report.link.averagePingMs, 42u);
EXPECT_EQ(got.report.link.connections, 12u);
ASSERT_EQ(got.report.gauges.size(), 1u);
EXPECT_EQ(got.report.gauges[0].first, "workers_busy");
// Truncated reports are refused
RakNet::BitStream partial(stream.GetData(), stream.GetNumberOfBytesUsed() - 3, true);
LUBitStream skip;
ASSERT_TRUE(skip.ReadHeader(partial));
ServerTraffic broken;
EXPECT_FALSE(broken.Deserialize(partial));
}