From 9c352d73606e5d6c0e8bca231d71b540fa7a9a92 Mon Sep 17 00:00:00 2001
From: tintinhamans <5984296+tintinhamans@users.noreply.github.com>
Date: Thu, 29 Oct 2026 06:07:40 +0100
Subject: [PATCH 01/18] fix(deps): bump MySqlConnector to 2.6.2 for
GHSA-473q-m89c-ghf8
---
GenOnlineService/GenOnlineService.csproj | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/GenOnlineService/GenOnlineService.csproj b/GenOnlineService/GenOnlineService.csproj
index 7ea8fd5..276a616 100644
--- a/GenOnlineService/GenOnlineService.csproj
+++ b/GenOnlineService/GenOnlineService.csproj
@@ -47,7 +47,7 @@
-
+
From a96191ca8901ac1ccc668c8cd66b2022d9fc5442 Mon Sep 17 00:00:00 2001
From: tintinhamans <5984296+tintinhamans@users.noreply.github.com>
Date: Sun, 30 Aug 2026 10:58:18 +0200
Subject: [PATCH 02/18] refactor(logging): standardize structured logging
---
GenOnlineService/AppLog.cs | 73 +++++++++++++++++++
GenOnlineService/Constants.cs | 62 +++++++++-------
.../Controllers/Lobby/LobbyController.cs | 2 +-
.../LoginCode/LoginCodeController.cs | 9 ++-
.../Matchmaking/MatchmakingController.cs | 3 +-
.../Monitoring/MonitoringController.cs | 9 ++-
.../RefreshToken/RefreshTokenController.cs | 7 +-
.../VersionCheck/VersionCheckController.cs | 8 +-
.../WebSocket/WebSocketController.cs | 18 ++---
.../Database/Database.AntiCheat.cs | 18 ++---
.../Database/Database.ConnectionOutcomes.cs | 5 +-
.../Database/Database.DailyStats.cs | 12 +--
.../Database/Database.Leaderboards.cs | 9 ++-
.../Database/Database.MatchHistory.cs | 51 +++++++------
.../Database/Database.PlayerStats.cs | 11 ++-
.../Database/Database.ServiceStats.cs | 6 +-
GenOnlineService/Database/Database.Social.cs | 32 +++-----
GenOnlineService/Database/Database.User.cs | 58 ++++++---------
GenOnlineService/Discord.cs | 6 +-
.../ExternalLeaderboardPublicationQueue.cs | 29 ++++----
.../ExternalLeaderboardsClient.cs | 11 +--
GenOnlineService/GenOnlineService.csproj | 1 +
GenOnlineService/LobbyManager.cs | 51 +++++++------
GenOnlineService/MatchmakingManager.cs | 18 +++--
GenOnlineService/Moderation.cs | 2 +
GenOnlineService/Program.cs | 36 +++++----
GenOnlineService/TokenRevocation.cs | 20 ++---
27 files changed, 330 insertions(+), 237 deletions(-)
create mode 100644 GenOnlineService/AppLog.cs
diff --git a/GenOnlineService/AppLog.cs b/GenOnlineService/AppLog.cs
new file mode 100644
index 0000000..dfc73ba
--- /dev/null
+++ b/GenOnlineService/AppLog.cs
@@ -0,0 +1,73 @@
+/*
+** GeneralsOnline Game Services - Backend Services for Command & Conquer Generals Online: Zero Hour
+** Copyright (C) 2025 GeneralsOnline Development Team
+**
+** This program is free software: you can redistribute it and/or modify
+** it under the terms of the GNU Affero General Public License as
+** published by the Free Software Foundation, either version 3 of the
+** License, or (at your option) any later version.
+**
+** This program is distributed in the hope that it will be useful,
+** but WITHOUT ANY WARRANTY; without even the implied warranty of
+** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+** GNU Affero General Public License for more details.
+**
+** You should have received a copy of the GNU Affero General Public License
+** along with this program. If not, see .
+*/
+
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace GenOnlineService
+{
+ // Logger for types without constructor injection.
+ public static class AppLog
+ {
+ public static ILogger For(Type category)
+ {
+ return new DeferredLogger(category.FullName ?? category.Name);
+ }
+
+ public static ILogger For()
+ {
+ return For(typeof(T));
+ }
+
+ // static fields can initialize before ServiceLocator.Services is set, so resolve on first use
+ private sealed class DeferredLogger : ILogger
+ {
+ private readonly string _category;
+ private ILogger? _logger;
+
+ public DeferredLogger(string category)
+ {
+ _category = category;
+ }
+
+ private ILogger Inner
+ {
+ get
+ {
+ if (_logger != null)
+ {
+ return _logger;
+ }
+
+ ILoggerFactory? factory = ServiceLocator.Services?.GetService();
+ return factory == null ? NullLogger.Instance : (_logger = factory.CreateLogger(_category));
+ }
+ }
+
+ public IDisposable? BeginScope(TState state) where TState : notnull => Inner.BeginScope(state);
+
+ public bool IsEnabled(LogLevel logLevel) => Inner.IsEnabled(logLevel);
+
+ public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter)
+ {
+ Inner.Log(logLevel, eventId, state, exception, formatter);
+ }
+ }
+ }
+}
diff --git a/GenOnlineService/Constants.cs b/GenOnlineService/Constants.cs
index d717c00..0e28804 100644
--- a/GenOnlineService/Constants.cs
+++ b/GenOnlineService/Constants.cs
@@ -22,6 +22,7 @@
using Microsoft.EntityFrameworkCore;
using System;
using System.Collections.Concurrent;
+using System.Diagnostics;
using System.Diagnostics.Metrics;
using System.Globalization;
using System.Net;
@@ -373,6 +374,8 @@ public enum EKnownClients
// TODO
static class WebSocketManager
{
+ private static readonly ILogger s_log = AppLog.For(typeof(WebSocketManager));
+
public static int g_PeakConnectionCount = 0;
public static async Task CreateSession(AppDbContext _db, EUserSessionType sessionType, bool bIsReconnect, Int64 ownerID, KnownClients.EKnownClients client_id, string ipAddr, string strContinent, string strCountry, double dLatitude, double dLongitude, bool bIsAdmin)
{
@@ -383,7 +386,7 @@ public static async Task CreateSession(AppDbContext _db,
if (bIsReconnect)
{
// this is a reconnect, re-use cache
- Console.WriteLine("--> WEBSOCKET RECONNECT");
+ s_log.LogDebug("WebSocket reconnect for user {UserId} ({SessionType})", ownerID, sessionType);
// if its a reconnect, and we dont have cache OR shared data, its probably a server restart, so return null
if (userCacheData == null)
@@ -425,7 +428,7 @@ public static async Task CreateSession(AppDbContext _db,
}
else
{
- Console.WriteLine("--> WEBSOCKET CONNECT");
+ s_log.LogDebug("WebSocket connect for user {UserId} ({SessionType})", ownerID, sessionType);
// how many other sessions do they have online?
bool bIsFirstSessionForUser = WebSocketManager.GetAllDataFromUser(ownerID).Count == 0;
@@ -433,7 +436,7 @@ public static async Task CreateSession(AppDbContext _db,
// kill any existing sessions for this user of same session type
if (m_dictWebsockets[sessionType].TryGetValue(ownerID, out UserWebSocketInstance? existingSession))
{
- Console.WriteLine("Killing existing session for {0} ({1})", ownerID, strDisplayName);
+ s_log.LogDebug("Killing existing session for {UserId} ({DisplayName})", ownerID, strDisplayName);
await DeleteSession(ownerID, sessionType, existingSession, !bIsReconnect);
}
@@ -483,7 +486,7 @@ public static async Task CreateSession(AppDbContext _db,
// close the previous socket first: it may still be open, and its send loop must stop reading the session's queue
if (m_dictWebsockets[sessionType].TryRemove(ownerID, out UserWebSocketInstance? supersededSess) && supersededSess != null)
{
- Console.WriteLine("Closing superseded websocket for {0} ({1})", ownerID, strDisplayName);
+ s_log.LogDebug("Closing superseded websocket for {UserId} ({DisplayName})", ownerID, strDisplayName);
await supersededSess.CloseAsync(WebSocketCloseStatus.NormalClosure, "Superseded by a newer connection");
}
@@ -678,17 +681,18 @@ public static async Task DeleteSession(Int64 user_id, EUserSessionType sessionTy
}
else
{
- Console.WriteLine($"[WARN] DeleteSession(false): skipped RecordPlayerIngameAbandon for user {user_id} — lobby={sourceData.currentLobbyID} found={ingameLobby != null} state={ingameLobby?.State}");
+ s_log.LogWarning("DeleteSession(false): skipped RecordPlayerIngameAbandon for user {UserId}, lobby={LobbyId} found={LobbyFound} state={LobbyState}",
+ user_id, sourceData.currentLobbyID, ingameLobby != null, ingameLobby?.State);
}
}
else
{
- Console.WriteLine($"[WARN] DeleteSession(false): skipped RecordPlayerIngameAbandon for user {user_id} — currentLobbyID==-1 (session has no lobby)");
+ s_log.LogWarning("DeleteSession(false): skipped RecordPlayerIngameAbandon for user {UserId}, currentLobbyID==-1 (session has no lobby)", user_id);
}
}
catch (Exception ex)
{
- Console.WriteLine($"[WARN] Failed to record in-game abandon for user {user_id}: {ex.Message}");
+ s_log.LogWarning(ex, "Failed to record in-game abandon for user {UserId}", user_id);
}
}
}
@@ -855,8 +859,7 @@ public static async Task DisconnectUser(Int64 userID, byte[] finalMessage)
}
catch (Exception ex)
{
- Console.WriteLine($"[ERROR] DisconnectUser failed for user {userID}, session {userSession.GetSessionType()}: {ex.Message}");
- SentrySdk.CaptureException(ex);
+ s_log.LogError(ex, "DisconnectUser failed for user {UserId}, session {SessionType}", userID, userSession.GetSessionType());
}
}
}
@@ -885,7 +888,7 @@ public static async Task ClearDataFromUser(Int64 userID, EUserSessionType
}
else
{
- Console.WriteLine("Error: Could not find shared data for user {0} when deleting session", userID);
+ s_log.LogWarning("Could not find shared data for user {UserId} when deleting session", userID);
}
}
catch
@@ -1175,6 +1178,8 @@ private static Int64 Get(string key, Int64 defaultValue)
public class UserSession
{
+ private static readonly ILogger s_log = AppLog.For(typeof(UserSession));
+
public Int64 m_UserID = -1;
public string m_strContinent;
@@ -1291,7 +1296,7 @@ public void QueueWebsocketSend(byte[] bytesJSON)
// too far behind to catch up: drop the connection so the client reconnects
if (WebSocketManager.GetWebSocketForSession(this)?.AbortIfOpen() == true)
{
- Console.WriteLine("[WebSocket] Outbound queue full for {0}, connection aborted", m_UserID);
+ s_log.LogWarning("Outbound websocket queue full for user {UserId}; connection aborted", m_UserID);
}
}
}
@@ -1441,7 +1446,8 @@ public bool TryUpdateSessionNetworkRoom(Int16 newRoomID)
{
if (!RoomCatalog.TryResolveTargetRoomID(newRoomID, out Int16 targetRoomID))
{
- Console.WriteLine($"Rejected invalid network room selection {newRoomID} from user {m_UserID}; keeping selected room {selectedNetworkRoomID}.");
+ s_log.LogWarning("Rejected invalid network room selection {RequestedRoomId} from user {UserId}, keeping selected room {SelectedRoomId}",
+ newRoomID, m_UserID, selectedNetworkRoomID);
return false;
}
@@ -1479,6 +1485,8 @@ public void UpdateSessionLobbyID(Int64 newLobbyID)
public class UserWebSocketInstance
{
+ private static readonly ILogger s_log = AppLog.For(typeof(UserWebSocketInstance));
+
// cached user data, useful
public EUserSessionType m_SessionType = EUserSessionType.None;
public Int64 m_UserID = -1;
@@ -1567,7 +1575,7 @@ private async Task RunSendLoop()
}
catch (Exception ex)
{
- Console.WriteLine($"[WebSocket] Send loop failed for {m_UserID}: {ex}");
+ s_log.LogError(ex, "Websocket send loop failed for user {UserId}", m_UserID);
}
}
@@ -1587,7 +1595,7 @@ private async Task SendFrameAsync(byte[] buffer)
}
catch (OperationCanceledException)
{
- Console.WriteLine("[WebSocket] Send to {0} stalled for {1}s, connection aborted", m_UserID, c_SendStallTimeout.TotalSeconds);
+ s_log.LogWarning("Websocket send to user {UserId} stalled for {StallSeconds}s; connection aborted", m_UserID, c_SendStallTimeout.TotalSeconds);
return false;
}
catch
@@ -1630,6 +1638,8 @@ public enum ESessionAccessType
};
public static class SessionHelpers
{
+ private static readonly ILogger s_log = AppLog.For(typeof(SessionHelpers));
+
public static bool SessionTypeHasAccessTo(EUserSessionType sessType, ESessionAccessType accessType)
{
if (sessType == EUserSessionType.GameClient) // client can do anything
@@ -1650,9 +1660,7 @@ public static bool SessionTypeHasAccessTo(EUserSessionType sessType, ESessionAcc
public static async Task FullyDestroyPlayerSession(Int64 user_id, UserSession? userData, bool bMigrateLobbyIfPresent)
{
// NOTE: Dont assume userData is valid, use user_id for user id
- Console.ForegroundColor = ConsoleColor.Cyan;
- Console.WriteLine("FullyDestroyPlayerSession for user {0}", user_id);
- Console.ForegroundColor = ConsoleColor.Gray;
+ s_log.LogInformation("FullyDestroyPlayerSession for user {UserId}", user_id);
// invalidate any TURN credentials
TURNCredentialManager.DeleteCredentialsForUser(user_id);
@@ -1665,7 +1673,7 @@ public static async Task FullyDestroyPlayerSession(Int64 user_id, UserSession? u
//await m_Inst.Query("DELETE FROM sessions WHERE user_id={0} AND session_type={1};", user_id, (int)ESessionType.Game);
// leave any lobby
- Console.WriteLine("[Source 2] User {0} Leave Any Lobby", user_id);
+ s_log.LogInformation("User {UserId} leaving any lobby (session teardown)", user_id);
var lobbyManager = ServiceLocator.Services.GetRequiredService();
await lobbyManager.LeaveAnyLobby(user_id);
@@ -1687,18 +1695,14 @@ public async static Task SetUsedLoggedIn(Int64 userID, KnownClients.EKnownClient
// TODO_EFCORE: Move away from db for this and just have website login call endpoint on service
//UInt16 clientID = clientIDStr == "gen_online_60hz" ? (UInt16)1 : (UInt16)0;
- Console.ForegroundColor = ConsoleColor.Cyan;
- Console.WriteLine("StartSession deleing other sessions for user {0}", userID);
- Console.ForegroundColor = ConsoleColor.Gray;
+ s_log.LogInformation("StartSession deleting other sessions for user {UserId}", userID);
// kill any WS they had too, StartSession comes before WS connects
// disconnect any other sessions with this ID
UserSession? sess = GenOnlineService.WebSocketManager.GetSessionFromUser(userID, sessionType);
if (sess != null)
{
- Console.ForegroundColor = ConsoleColor.Cyan;
- Console.WriteLine("Found duplicate session for user {0}", userID);
- Console.ForegroundColor = ConsoleColor.Gray;
+ s_log.LogInformation("Found duplicate session for user {UserId}", userID);
UserWebSocketInstance? oldWS = GenOnlineService.WebSocketManager.GetWebSocketForSession(sess);
await GenOnlineService.WebSocketManager.DeleteSession(userID, sessionType, oldWS, false);
@@ -2397,6 +2401,8 @@ public class TURNResponse
public static class S3CredentialManager
{
+ private static readonly ILogger s_log = AppLog.For(typeof(S3CredentialManager));
+
private static AmazonS3Client m_s3client = null;
private static bool s_uploadsEnabled = false;
@@ -2412,7 +2418,7 @@ public static void Initialize()
// When uploads are disabled, the S3 settings are intentionally optional.
if (!s_uploadsEnabled)
{
- Console.WriteLine("MatchData: upload_match_data is false, replay and screenshot uploads are disabled.");
+ s_log.LogInformation("MatchData: upload_match_data is false, replay and screenshot uploads are disabled");
return;
}
@@ -2567,6 +2573,8 @@ private static void GetS3Config(out int TTL, out string access_key, out string s
public static class TURNCredentialManager
{
+ private static readonly ILogger s_log = AppLog.For(typeof(TURNCredentialManager));
+
private static ConcurrentDictionary g_DictTURNUsernames = new();
private static void GetTURNConfig(out int TTL, out string token, out string key, out bool bShouldInvalidateTokensAutomatically)
@@ -2683,13 +2691,13 @@ private static void GetTURNConfig(out int TTL, out string token, out string key,
//client.DefaultRequestHeaders.Add("Content-Type", "application/json");
try
{
- Console.WriteLine("Start req turn credentials at {0}", Environment.TickCount);
+ var sw = Stopwatch.StartNew();
string strURI = String.Format("https://rtc.live.cloudflare.com/v1/turn/keys/{0}/credentials/generate-ice-servers", TurnKey);
HttpResponseMessage response = await client.PostAsync(strURI, requestContent);
if (response.IsSuccessStatusCode)
{
- Console.WriteLine("Finish req turn credentials at {0}", Environment.TickCount);
+ s_log.LogDebug("TURN credential request for user {UserId} took {ElapsedMs}ms", userID, sw.ElapsedMilliseconds);
string responseBody = await response.Content.ReadAsStringAsync();
TURNResponse? resp = JsonSerializer.Deserialize(responseBody);
diff --git a/GenOnlineService/Controllers/Lobby/LobbyController.cs b/GenOnlineService/Controllers/Lobby/LobbyController.cs
index 336e93b..8bf8325 100644
--- a/GenOnlineService/Controllers/Lobby/LobbyController.cs
+++ b/GenOnlineService/Controllers/Lobby/LobbyController.cs
@@ -245,7 +245,7 @@ public async Task Delete(Int64 lobbyID)
}
}
- Console.WriteLine("[Source 1] User {0} Leave Any Lobby", user_id);
+ _logger.LogDebug("User {UserId} leaving any lobby after lobby request", user_id);
await _lobbyManager.LeaveAnyLobby(user_id);
// cleanup TURN credentials
diff --git a/GenOnlineService/Controllers/LoginCode/LoginCodeController.cs b/GenOnlineService/Controllers/LoginCode/LoginCodeController.cs
index 99824c4..34ed874 100644
--- a/GenOnlineService/Controllers/LoginCode/LoginCodeController.cs
+++ b/GenOnlineService/Controllers/LoginCode/LoginCodeController.cs
@@ -150,6 +150,7 @@ public override Type GetReturnType()
[Route("env/{environment}/contract/{contract_version}/[controller]")]
public class LoginCode : ControllerBase
{
+ private static readonly ILogger s_log = AppLog.For(typeof(LoginCode));
[HttpPost]
public async Task Post([FromHeader(Name = "X-Api-Key")] string apiKey)
@@ -203,22 +204,26 @@ public async Task Post([FromHeader(Name = "X-Api-Key")] string apiKey)
if (!bUpdated)
{
+ s_log.LogWarning("Login-code callback referenced a missing, expired, or completed pending login for user {UserId}", user_id);
Response.StatusCode = (int)HttpStatusCode.NotFound;
}
}
- catch
+ catch (Exception ex)
{
+ s_log.LogWarning(ex, "Login-code callback had invalid field values");
Response.StatusCode = (int)HttpStatusCode.PreconditionFailed;
}
}
else
{
+ s_log.LogWarning("Login-code callback was missing required fields");
Response.StatusCode = (int)HttpStatusCode.Unauthorized;
}
}
- catch
+ catch (Exception ex)
{
+ s_log.LogError(ex, "Login-code callback could not be processed");
Response.StatusCode = (int)HttpStatusCode.InternalServerError;
}
}
diff --git a/GenOnlineService/Controllers/Matchmaking/MatchmakingController.cs b/GenOnlineService/Controllers/Matchmaking/MatchmakingController.cs
index 1de844d..a84ea59 100644
--- a/GenOnlineService/Controllers/Matchmaking/MatchmakingController.cs
+++ b/GenOnlineService/Controllers/Matchmaking/MatchmakingController.cs
@@ -88,8 +88,9 @@ public MatchmakingController(ILogger logger)
}
}
}
- catch
+ catch (Exception ex)
{
+ _logger.LogError(ex, "Malformed or failed matchmaking registration request");
Response.StatusCode = (int)HttpStatusCode.InternalServerError;
}
}
diff --git a/GenOnlineService/Controllers/Monitoring/MonitoringController.cs b/GenOnlineService/Controllers/Monitoring/MonitoringController.cs
index 58bff4b..4dde8c3 100644
--- a/GenOnlineService/Controllers/Monitoring/MonitoringController.cs
+++ b/GenOnlineService/Controllers/Monitoring/MonitoringController.cs
@@ -228,8 +228,9 @@ public async Task Monitor_Database()
GenOnlineService.Controllers.LoginWithToken.POST_LoginWithToken_Result internalResult = (GenOnlineService.Controllers.LoginWithToken.POST_LoginWithToken_Result)await loginWithTokenController.Post_InternalHandler("{\"challenge\": \"abc\", \"token\": \"iamatest\", \"client_id\": \"gen_online_60hz\"}", IPAddress.Loopback.ToString(), Program.BuildWebSocketUrl(Request, environment, contractVersion));
return internalResult;
}
- catch
+ catch (Exception ex)
{
+ _logger.LogError(ex, "Monitoring LoginWithToken check failed");
Response.StatusCode = (int)HttpStatusCode.InternalServerError;
}
}
@@ -279,8 +280,9 @@ public APIResult Monitor_Uptime()
return internalResult;
}
- catch
+ catch (Exception ex)
{
+ _logger.LogError(ex, "Monitoring VersionCheck failed");
Response.StatusCode = (int)HttpStatusCode.InternalServerError;
}
}
@@ -308,8 +310,9 @@ public APIResult Monitor_Uptime()
APIResult internalResult = await checkLoginController.Post_InternalHandler("{\"challenge\": \"abc\", \"nonce\": \"def\", \"code\": \"iamatest\", \"client_id\": \"gen_online_30hz\"}", IPAddress.Loopback.ToString(), Program.BuildWebSocketUrl(Request, environment, contractVersion));
return internalResult;
}
- catch
+ catch (Exception ex)
{
+ _logger.LogError(ex, "Monitoring CheckLogin check failed");
Response.StatusCode = (int)HttpStatusCode.InternalServerError;
}
}
diff --git a/GenOnlineService/Controllers/RefreshToken/RefreshTokenController.cs b/GenOnlineService/Controllers/RefreshToken/RefreshTokenController.cs
index 1c65a3a..3b5eede 100644
--- a/GenOnlineService/Controllers/RefreshToken/RefreshTokenController.cs
+++ b/GenOnlineService/Controllers/RefreshToken/RefreshTokenController.cs
@@ -49,10 +49,12 @@ public override Type GetReturnType()
public class RefreshTokenController : ControllerBase
{
private readonly IDbContextFactory _dbFactory;
+ private readonly ILogger _logger;
- public RefreshTokenController(IDbContextFactory dbFactory)
+ public RefreshTokenController(IDbContextFactory dbFactory, ILogger logger)
{
_dbFactory = dbFactory;
+ _logger = logger;
}
[HttpPost(Name = "PostRefreshToken")]
@@ -128,8 +130,7 @@ public async Task Post_InternalHandler(string ipAddr)
}
catch (Exception ex)
{
- Console.WriteLine($"[ERROR] RefreshToken failed: {ex.Message}");
- SentrySdk.CaptureException(ex);
+ _logger.LogError(ex, "RefreshToken failed");
result.result = EPendingLoginState.LoginFailed;
Response.StatusCode = (int)HttpStatusCode.InternalServerError;
diff --git a/GenOnlineService/Controllers/VersionCheck/VersionCheckController.cs b/GenOnlineService/Controllers/VersionCheck/VersionCheckController.cs
index c8b41aa..bc138aa 100644
--- a/GenOnlineService/Controllers/VersionCheck/VersionCheckController.cs
+++ b/GenOnlineService/Controllers/VersionCheck/VersionCheckController.cs
@@ -128,6 +128,7 @@ public async Task Get()
class VersionHelper
{
+ private static readonly ILogger s_log = AppLog.For(typeof(VersionHelper));
public static APIResult Get_ManifestHandler()
{
GET_VersionManifest_Result manifest = new GET_VersionManifest_Result();
@@ -140,8 +141,9 @@ public static APIResult Get_ManifestHandler()
manifest.execrc_30 = CRC32Calculator.CalculateCRC32(Path.Combine(Directory.GetCurrentDirectory(), "crcfiles", "GeneralsOnlineZH_30.exe"));
manifest.execrc_60 = CRC32Calculator.CalculateCRC32(Path.Combine(Directory.GetCurrentDirectory(), "crcfiles", "GeneralsOnlineZH_60.exe"));
}
- catch (Exception)
+ catch (Exception ex)
{
+ s_log.LogError(ex, "Failed to calculate executable CRCs for the version manifest");
// Keep default 0 values on failure
}
#endif
@@ -242,9 +244,9 @@ async Task GetHTTPSize(string url)
return response.Content.Headers.ContentLength.Value;
}
}
- catch (HttpRequestException)
+ catch (HttpRequestException ex)
{
- // TODO: Log exception or handle error
+ s_log.LogWarning(ex, "Failed to determine patch download size");
}
return -1; // Return -1 if the size could not be determined
diff --git a/GenOnlineService/Controllers/WebSocket/WebSocketController.cs b/GenOnlineService/Controllers/WebSocket/WebSocketController.cs
index ce70dfd..1f5f219 100644
--- a/GenOnlineService/Controllers/WebSocket/WebSocketController.cs
+++ b/GenOnlineService/Controllers/WebSocket/WebSocketController.cs
@@ -34,11 +34,14 @@ public class WebSocketController : ControllerBase
{
private readonly LobbyManager _lobbyManager;
private readonly IDbContextFactory _dbFactory;
+ private readonly ILogger _logger;
+ private static readonly ILogger s_log = AppLog.For(typeof(WebSocketController));
- public WebSocketController(LobbyManager lobbyManager, IDbContextFactory dbFactory)
+ public WebSocketController(LobbyManager lobbyManager, IDbContextFactory dbFactory, ILogger logger)
{
_lobbyManager = lobbyManager;
_dbFactory = dbFactory;
+ _logger = logger;
}
// longer chat is dropped rather than relayed
@@ -149,7 +152,7 @@ private async Task ProcessModerationCommand(
}
catch (Exception ex)
{
- Console.WriteLine($"[WS] GeoIP DB unavailable ({ex.Message}); using fallback coordinates");
+ s_log.LogError(ex, "GeoIP DB unavailable; using fallback coordinates");
return null;
}
}
@@ -310,8 +313,7 @@ public async Task Get([FromHeader(Name = "is-reconnect")] bool bIsReconnect)
catch (Exception ex)
{
// Log unexpected errors
- Console.WriteLine($"WebSocket error: {ex}");
- SentrySdk.CaptureException(ex);
+ _logger.LogError(ex, "WebSocket error");
break;
}
@@ -366,10 +368,8 @@ public async Task Get([FromHeader(Name = "is-reconnect")] bool bIsReconnect)
await ProcessWSMessage(wsSess, sourceUserData, receiveResult, segment);
}
- Console.ForegroundColor = ConsoleColor.Cyan;
SharedUserData? sourceData = WebSocketManager.GetSharedDataForUser(user_id);
- Console.WriteLine("WEBSOCKET DISCONNECT FOR {0}", sourceData == null ? "NULL" : sourceData.m_strDisplayName);
- Console.ForegroundColor = ConsoleColor.Gray;
+ _logger.LogDebug("WebSocket disconnect for user {UserId}", user_id);
// close the session
if (wsSess != null)
@@ -413,14 +413,14 @@ private static void ProcessNetworkRoomChange(UserSession session, Dictionary.
*/
+using GenOnlineService;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using System.Text.Json;
@@ -181,6 +182,8 @@ namespace Database
{
public static class AntiCheat
{
+ private static readonly ILogger s_log = AppLog.For(typeof(AntiCheat));
+
public static async Task AC_BanUser(AppDbContext db, long userId, string reason)
{
try
@@ -197,8 +200,7 @@ await db.Users
}
catch (Exception ex)
{
- Console.WriteLine($"[ERROR] AC_BanUser failed: {ex.Message}");
- SentrySdk.CaptureException(ex);
+ s_log.LogError(ex, "AC_BanUser failed");
}
}
@@ -224,8 +226,7 @@ public static async Task FlagAccountForReview(
}
catch (Exception ex)
{
- Console.WriteLine($"[ERROR] FlagAccountForReview failed: {ex.Message}");
- SentrySdk.CaptureException(ex);
+ s_log.LogError(ex, "FlagAccountForReview failed");
}
}
@@ -253,8 +254,7 @@ public static async Task FlagAccountForReview_Module(
}
catch (Exception ex)
{
- Console.WriteLine($"[ERROR] FlagAccountForReview_Module failed: {ex.Message}");
- SentrySdk.CaptureException(ex);
+ s_log.LogError(ex, "FlagAccountForReview_Module failed");
}
}
@@ -279,8 +279,7 @@ public static async Task FlagAccountForReview_SuspectProbes(
}
catch (Exception ex)
{
- Console.WriteLine($"[ERROR] FlagAccountForReview_SuspectProbes failed: {ex.Message}");
- SentrySdk.CaptureException(ex);
+ s_log.LogError(ex, "FlagAccountForReview_SuspectProbes failed");
}
}
@@ -302,8 +301,7 @@ public static async Task FlagAccountForReview_NewAccount_FirstMatches(
}
catch (Exception ex)
{
- Console.WriteLine($"[ERROR] FlagAccountForReview_NewAccount_FirstMatches failed: {ex.Message}");
- SentrySdk.CaptureException(ex);
+ s_log.LogError(ex, "FlagAccountForReview_NewAccount_FirstMatches failed");
}
}
diff --git a/GenOnlineService/Database/Database.ConnectionOutcomes.cs b/GenOnlineService/Database/Database.ConnectionOutcomes.cs
index 5f90445..f296951 100644
--- a/GenOnlineService/Database/Database.ConnectionOutcomes.cs
+++ b/GenOnlineService/Database/Database.ConnectionOutcomes.cs
@@ -61,6 +61,8 @@ namespace Database
{
public static class ConnectionOutcomes
{
+ private static readonly ILogger s_log = AppLog.For(typeof(ConnectionOutcomes));
+
public static async Task StoreConnectionOutcome(
AppDbContext db,
EIPVersion protocol,
@@ -125,8 +127,7 @@ await db.ConnectionOutcomes
}
catch (Exception ex)
{
- Console.WriteLine($"[ERROR] StoreConnectionOutcome failed: {ex.Message}");
- SentrySdk.CaptureException(ex);
+ s_log.LogError(ex, "StoreConnectionOutcome failed");
}
}
diff --git a/GenOnlineService/Database/Database.DailyStats.cs b/GenOnlineService/Database/Database.DailyStats.cs
index b6e3118..103f7f5 100644
--- a/GenOnlineService/Database/Database.DailyStats.cs
+++ b/GenOnlineService/Database/Database.DailyStats.cs
@@ -16,6 +16,7 @@
** along with this program. If not, see .
*/
+using GenOnlineService;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using System.Text.Json;
@@ -64,6 +65,8 @@ public void Configure(EntityTypeBuilder builder)
public static class DailyStatsManager
{
+ private static readonly ILogger s_log = AppLog.For(typeof(DailyStatsManager));
+
public static DailyStat g_StatsContainer = new();
public static async Task LoadFromDB(AppDbContext db)
@@ -81,8 +84,7 @@ public static async Task LoadFromDB(AppDbContext db)
}
catch (Exception ex)
{
- Console.WriteLine($"[ERROR] DailyStats.LoadFromDB failed: {ex.Message}");
- SentrySdk.CaptureException(ex);
+ s_log.LogError(ex, "DailyStats.LoadFromDB failed");
g_StatsContainer = new DailyStat();
}
}
@@ -114,8 +116,7 @@ public static async Task SaveToDB(AppDbContext db)
}
catch (Exception ex)
{
- Console.WriteLine($"[ERROR] DailyStats.SaveToDB failed: {ex.Message}");
- SentrySdk.CaptureException(ex);
+ s_log.LogError(ex, "DailyStats.SaveToDB failed");
}
}
@@ -144,8 +145,7 @@ public static void RegisterOutcome(int army, bool bWon)
}
catch (Exception ex)
{
- Console.WriteLine($"[ERROR] RegisterOutcome failed: {ex.Message}");
- SentrySdk.CaptureException(ex);
+ s_log.LogError(ex, "RegisterOutcome failed");
}
}
}
\ No newline at end of file
diff --git a/GenOnlineService/Database/Database.Leaderboards.cs b/GenOnlineService/Database/Database.Leaderboards.cs
index 69c41df..c10976d 100644
--- a/GenOnlineService/Database/Database.Leaderboards.cs
+++ b/GenOnlineService/Database/Database.Leaderboards.cs
@@ -16,6 +16,7 @@
** along with this program. If not, see .
*/
+using GenOnlineService;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using System;
@@ -146,6 +147,8 @@ namespace Database
{
public static class Leaderboards
{
+ private static readonly ILogger s_log = AppLog.For(typeof(Leaderboards));
+
public struct LeaderboardPoints
{
@@ -265,8 +268,7 @@ public static async Task CreateUserEntriesIfNotExists(AppDbContext db, long play
}
catch (Exception ex)
{
- Console.WriteLine($"[ERROR] CreateUserEntriesIfNotExists failed: {ex.Message}");
- SentrySdk.CaptureException(ex);
+ s_log.LogError(ex, "CreateUserEntriesIfNotExists failed");
}
}
@@ -338,8 +340,7 @@ public async static ValueTask> GetBulkLeader
}
catch (Exception ex)
{
- Console.WriteLine($"[ERROR] GetBulkLeaderboardData failed: {ex.Message}");
- SentrySdk.CaptureException(ex);
+ s_log.LogError(ex, "GetBulkLeaderboardData failed");
}
return results;
diff --git a/GenOnlineService/Database/Database.MatchHistory.cs b/GenOnlineService/Database/Database.MatchHistory.cs
index dc944bf..ccbde57 100644
--- a/GenOnlineService/Database/Database.MatchHistory.cs
+++ b/GenOnlineService/Database/Database.MatchHistory.cs
@@ -261,6 +261,8 @@ namespace Database
// TODO_EFCORE: Consider moving to zero-serialization model
public static class MatchHistory
{
+ private static readonly ILogger s_log = AppLog.For(typeof(MatchHistory));
+
private static readonly Expression>[] _slotSelectors =
{
m => m.MemberSlot0,
@@ -415,8 +417,7 @@ public static async Task CommitPlayerOutcome(
}
catch (Exception ex)
{
- Console.WriteLine($"[ERROR] CommitPlayerOutcome failed: {ex.Message}");
- SentrySdk.CaptureException(ex);
+ s_log.LogError(ex, "CommitPlayerOutcome failed");
}
}
@@ -561,8 +562,7 @@ public static async Task CreatePlaceholderMatchHistory(
}
catch (Exception ex)
{
- Console.WriteLine($"[ERROR] CreatePlaceholderMatchHistory failed: {ex.Message}");
- SentrySdk.CaptureException(ex);
+ s_log.LogError(ex, "CreatePlaceholderMatchHistory failed");
return 0;
}
}
@@ -714,11 +714,12 @@ public static async Task DetermineLobbyWinnerIfNotPresent(
// in-game) over TimeMemberLeft (recorded when the player was structurally removed from the
// lobby, which can happen much later, or earlier due to a fresh-session reconnect, skewing
// the order relative to the actual in-game quit order).
- Console.WriteLine($"[WinnerDet] Match={lobby.MatchID} LobbyID={lobby.LobbyID}: no winner in DB — timestamp fallback. IngameAbandon={lobby.TimePlayerAbandonedIngame.Count} MemberLeft={lobby.TimeMemberLeft.Count}");
+ s_log.LogInformation("Match {MatchId} lobby {LobbyId}: no winner in DB, falling back to abandon timestamps. IngameAbandon={IngameAbandonCount} MemberLeft={MemberLeftCount}",
+ lobby.MatchID, lobby.LobbyID, lobby.TimePlayerAbandonedIngame.Count, lobby.TimeMemberLeft.Count);
foreach (var _kv in lobby.TimePlayerAbandonedIngame)
- Console.WriteLine($"[WinnerDet] IngameAbandon: user={_kv.Key} at={_kv.Value:O}");
+ s_log.LogDebug("Match {MatchId}: IngameAbandon user={UserId} at={AbandonTime}", lobby.MatchID, _kv.Key, _kv.Value);
foreach (var _kv in lobby.TimeMemberLeft)
- Console.WriteLine($"[WinnerDet] MemberLeft: user={_kv.Key} at={_kv.Value:O}");
+ s_log.LogDebug("Match {MatchId}: MemberLeft user={UserId} at={LeftTime}", lobby.MatchID, _kv.Key, _kv.Value);
// 6a. In a 1v1, drop anyone who reported a loss - see BuildFallbackCandidates.
string strRosterType = await db.MatchHistory
.Where(m => m.MatchId == (long)lobby.MatchID)
@@ -726,7 +727,8 @@ public static async Task DetermineLobbyWinnerIfNotPresent(
.FirstOrDefaultAsync() ?? String.Empty;
List lstCandidateSlots = BuildFallbackCandidates(members, strRosterType, lobby.ReportedOutcomes);
- Console.WriteLine($"[WinnerDet] Match={lobby.MatchID}: rosterType='{strRosterType}' reported={lobby.ReportedOutcomes.Count} candidates=[{String.Join(",", lstCandidateSlots)}]");
+ s_log.LogInformation("Match {MatchId}: rosterType={RosterType} reported={ReportedOutcomeCount} candidates=[{CandidateSlots}]",
+ lobby.MatchID, strRosterType, lobby.ReportedOutcomes.Count, String.Join(",", lstCandidateSlots));
DateTime latestLeave = DateTime.MinValue;
MatchdataMemberModel? lastPlayerNullable = null;
@@ -757,7 +759,8 @@ public static async Task DetermineLobbyWinnerIfNotPresent(
abandonSource = "MemberLeft";
}
- Console.WriteLine($"[WinnerDet] Slot={kv.Key} user={model.user_id} side={model.side} team={model.team} time={abandonTime:O} src={abandonSource}");
+ s_log.LogDebug("Match {MatchId}: slot={Slot} user={UserId} side={Side} team={Team} time={AbandonTime} src={AbandonSource}",
+ lobby.MatchID, kv.Key, model.user_id, model.side, model.team, abandonTime, abandonSource);
if (abandonTime > latestLeave)
{
latestLeave = abandonTime;
@@ -772,12 +775,13 @@ public static async Task DetermineLobbyWinnerIfNotPresent(
{
lastSlot = lstCandidateSlots[0];
lastPlayerNullable = members[lastSlot];
- Console.WriteLine($"[WinnerDet] Match={lobby.MatchID}: sole remaining candidate slot={lastSlot} user={lastPlayerNullable.Value.user_id} has no abandon timestamp — awarding anyway.");
+ s_log.LogInformation("Match {MatchId}: sole remaining candidate slot={Slot} user={UserId} has no abandon timestamp, awarding anyway",
+ lobby.MatchID, lastSlot, lastPlayerNullable.Value.user_id);
}
if (lastPlayerNullable == null)
{
- Console.WriteLine($"[WinnerDet] Match={lobby.MatchID}: no valid abandon timestamps found — fully inconclusive, clearing won flags.");
+ s_log.LogWarning("Match {MatchId}: no valid abandon timestamps found, fully inconclusive, clearing won flags", lobby.MatchID);
foreach (var kv in members)
{
if (kv.Value.side == Constants.OBSERVER_SIDE_VALUE)
@@ -790,7 +794,8 @@ public static async Task DetermineLobbyWinnerIfNotPresent(
MatchdataMemberModel lastPlayer = lastPlayerNullable.Value;
int winningTeam = lastPlayer.team;
- Console.WriteLine($"[WinnerDet] Match={lobby.MatchID}: winner selected → user={lastPlayer.user_id} slot={lastSlot} team={winningTeam} at={latestLeave:O}");
+ s_log.LogInformation("Match {MatchId}: winner selected, user={UserId} slot={Slot} team={Team} at={LeaveTime}",
+ lobby.MatchID, lastPlayer.user_id, lastSlot, winningTeam, latestLeave);
// 7. Mark last player + teammates as winners, explicitly clear everyone else.
foreach (var kv in members)
@@ -802,13 +807,14 @@ public static async Task DetermineLobbyWinnerIfNotPresent(
bool isWinner = model.user_id == lastPlayer.user_id ||
(winningTeam != -1 && model.team == winningTeam);
- Console.WriteLine($"[WinnerDet] Match={lobby.MatchID}: marking slot={kv.Key} user={model.user_id} as {(isWinner ? "WINNER" : "loser")}.");
+ s_log.LogDebug("Match {MatchId}: marking slot={Slot} user={UserId} as {Result}",
+ lobby.MatchID, kv.Key, model.user_id, isWinner ? "WINNER" : "loser");
await UpdateMatchHistorySetWinFlag(db, lobby.MatchID, kv.Key, isWinner, cancellationToken);
}
}
catch (Exception ex)
{
- Console.WriteLine($"[ERROR] DetermineLobbyWinnerIfNotPresent failed: {ex.Message}");
+ s_log.LogError(ex, "DetermineLobbyWinnerIfNotPresent failed");
throw;
}
}
@@ -855,7 +861,7 @@ public static async Task UpdateMatchHistorySetWinFlag(
}
catch (Exception ex)
{
- Console.WriteLine($"[ERROR] UpdateMatchHistorySetWinFlag failed: {ex.Message}");
+ s_log.LogError(ex, "UpdateMatchHistorySetWinFlag failed");
throw;
}
}
@@ -942,8 +948,7 @@ public static async Task GetMatchesInRange(
}
catch (Exception ex)
{
- Console.WriteLine($"[ERROR] GetMatchesInRange failed: {ex.Message}");
- SentrySdk.CaptureException(ex);
+ s_log.LogError(ex, "GetMatchesInRange failed");
}
return collection;
@@ -997,8 +1002,7 @@ public static async Task GetMatchesSince(
}
catch (Exception ex)
{
- Console.WriteLine($"[ERROR] GetMatchesSince failed: {ex.Message}");
- SentrySdk.CaptureException(ex);
+ s_log.LogError(ex, "GetMatchesSince failed");
}
return collection;
@@ -1025,8 +1029,7 @@ public static async Task GetHighestMatchID(AppDbContext db)
}
catch (Exception ex)
{
- Console.WriteLine($"[ERROR] GetHighestMatchID failed: {ex.Message}");
- SentrySdk.CaptureException(ex);
+ s_log.LogError(ex, "GetHighestMatchID failed");
return -1;
}
}
@@ -1295,8 +1298,7 @@ await UpdatePeriodEloAndLeaderboardsAsync(
}
catch (Exception ex)
{
- Console.WriteLine($"[ERROR] UpdateLeaderboardAndElo failed: {ex.Message}");
- SentrySdk.CaptureException(ex);
+ s_log.LogError(ex, "UpdateLeaderboardAndElo failed");
}
}
private static async Task> LoadMatchMembersAsync(
@@ -1349,7 +1351,8 @@ private static async Task UpdateCurrentEloAsync(
if (a.user_id >= b.user_id)
continue;
- Console.WriteLine($"[ELO] Pairing a={a.user_id}(won={a.won}) vs b={b.user_id}(won={b.won}) → result={(a.won ? "PlayerAWins" : "PlayerBWins")}");
+ s_log.LogDebug("ELO pairing a={PlayerA}(won={AWon}) vs b={PlayerB}(won={BWon}), result={Result}",
+ a.user_id, a.won, b.user_id, b.won, a.won ? "PlayerAWins" : "PlayerBWins");
ref EloData A = ref CollectionsMarshal.GetValueRefOrAddDefault(
dictElo, a.user_id, out _);
diff --git a/GenOnlineService/Database/Database.PlayerStats.cs b/GenOnlineService/Database/Database.PlayerStats.cs
index 597380c..2183d66 100644
--- a/GenOnlineService/Database/Database.PlayerStats.cs
+++ b/GenOnlineService/Database/Database.PlayerStats.cs
@@ -59,6 +59,8 @@ namespace Database
{
public static class UserStats
{
+ private static readonly ILogger s_log = AppLog.For(typeof(UserStats));
+
private static readonly Func> _getUserStatsJson =
EF.CompileAsyncQuery(
(AppDbContext db, long userId) =>
@@ -90,8 +92,7 @@ public static async Task GetPlayerStats(
}
catch (Exception ex)
{
- Console.WriteLine($"[ERROR] GetPlayerStats failed: {ex.Message}");
- SentrySdk.CaptureException(ex);
+ s_log.LogError(ex, "GetPlayerStats failed");
return new PlayerStats(userId, EloConfig.BaseRating, 0, EloConfig.BaseRating);
}
@@ -123,8 +124,7 @@ public static async Task GetPlayerStats(
}
catch (Exception ex)
{
- Console.WriteLine($"[ERROR] GetPlayerStats failed: {ex.Message}");
- SentrySdk.CaptureException(ex);
+ s_log.LogError(ex, "GetPlayerStats failed");
}
return ps;
@@ -186,8 +186,7 @@ await db.UserStats
}
catch (Exception ex)
{
- Console.WriteLine($"[ERROR] UpdatePlayerStat failed: {ex.Message}");
- SentrySdk.CaptureException(ex);
+ s_log.LogError(ex, "UpdatePlayerStat failed");
}
}
diff --git a/GenOnlineService/Database/Database.ServiceStats.cs b/GenOnlineService/Database/Database.ServiceStats.cs
index 44ef93b..f049a24 100644
--- a/GenOnlineService/Database/Database.ServiceStats.cs
+++ b/GenOnlineService/Database/Database.ServiceStats.cs
@@ -16,6 +16,7 @@
** along with this program. If not, see .
*/
+using GenOnlineService;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using System.Text.Json;
@@ -55,6 +56,8 @@ namespace Database
{
public static class ServiceStats
{
+ private static readonly ILogger s_log = AppLog.For(typeof(ServiceStats));
+
public static readonly Func> FindStatTracked =
EF.CompileAsyncQuery(
(AppDbContext db, int day, int hour) =>
@@ -114,8 +117,7 @@ public static async Task CommitStats(
}
catch (Exception ex)
{
- Console.WriteLine($"[ERROR] CommitStats failed: {ex.Message}");
- SentrySdk.CaptureException(ex);
+ s_log.LogError(ex, "CommitStats failed");
}
}
diff --git a/GenOnlineService/Database/Database.Social.cs b/GenOnlineService/Database/Database.Social.cs
index 63ae0f2..42d034d 100644
--- a/GenOnlineService/Database/Database.Social.cs
+++ b/GenOnlineService/Database/Database.Social.cs
@@ -93,6 +93,8 @@ namespace Database
{
public static class Social
{
+ private static readonly ILogger s_log = AppLog.For(typeof(Social));
+
private static readonly Func> _getFriends =
EF.CompileAsyncQuery(
(AppDbContext db, long userId) =>
@@ -138,8 +140,7 @@ public static async Task> GetFriends(AppDbContext db, long userId)
}
catch (Exception ex)
{
- Console.WriteLine($"[ERROR] GetFriends failed: {ex.Message}");
- SentrySdk.CaptureException(ex);
+ s_log.LogError(ex, "GetFriends failed");
}
return result;
@@ -154,8 +155,7 @@ public static async Task CountFriends(AppDbContext db, long userId)
}
catch (Exception ex)
{
- Console.WriteLine($"[ERROR] CountFriends failed: {ex.Message}");
- SentrySdk.CaptureException(ex);
+ s_log.LogError(ex, "CountFriends failed");
// Treat an unreadable count as full so the friends limit is never bypassed.
return int.MaxValue;
@@ -174,8 +174,7 @@ public static async Task> GetBlocked(AppDbContext db, long sourceU
}
catch (Exception ex)
{
- Console.WriteLine($"[ERROR] GetBlocked failed: {ex.Message}");
- SentrySdk.CaptureException(ex);
+ s_log.LogError(ex, "GetBlocked failed");
}
return result;
@@ -193,8 +192,7 @@ public static async Task> GetPendingFriendsRequests(AppDbContext d
}
catch (Exception ex)
{
- Console.WriteLine($"[ERROR] GetPendingFriendsRequests failed: {ex.Message}");
- SentrySdk.CaptureException(ex);
+ s_log.LogError(ex, "GetPendingFriendsRequests failed");
}
return result;
@@ -213,8 +211,7 @@ await db.FriendRequests
}
catch (Exception ex)
{
- Console.WriteLine($"[ERROR] RemovePendingFriendRequest failed: {ex.Message}");
- SentrySdk.CaptureException(ex);
+ s_log.LogError(ex, "RemovePendingFriendRequest failed");
}
}
@@ -246,8 +243,7 @@ public static async Task CreateFriendship(AppDbContext db, long userId1, long us
if (ex.InnerException is MySqlConnector.MySqlException mysqlEx && mysqlEx.Number == 1062)
return;
- Console.WriteLine($"[ERROR] CreateFriendship failed: {ex.Message}");
- SentrySdk.CaptureException(ex);
+ s_log.LogError(ex, "CreateFriendship failed");
}
}
@@ -263,8 +259,7 @@ await db.Friends
}
catch (Exception ex)
{
- Console.WriteLine($"[ERROR] RemoveFriendship failed: {ex.Message}");
- SentrySdk.CaptureException(ex);
+ s_log.LogError(ex, "RemoveFriendship failed");
}
}
@@ -282,8 +277,7 @@ public static async Task AddBlock(AppDbContext db, long sourceUserId, long targe
}
catch (Exception ex)
{
- Console.WriteLine($"[ERROR] AddBlock failed: {ex.Message}");
- SentrySdk.CaptureException(ex);
+ s_log.LogError(ex, "AddBlock failed");
}
}
@@ -297,8 +291,7 @@ await db.BlockedUsers
}
catch (Exception ex)
{
- Console.WriteLine($"[ERROR] RemoveBlock failed: {ex.Message}");
- SentrySdk.CaptureException(ex);
+ s_log.LogError(ex, "RemoveBlock failed");
}
}
@@ -316,8 +309,7 @@ public static async Task AddPendingFriendRequest(AppDbContext db, long sourceUse
}
catch (Exception ex)
{
- Console.WriteLine($"[ERROR] AddPendingFriendRequest failed: {ex.Message}");
- SentrySdk.CaptureException(ex);
+ s_log.LogError(ex, "AddPendingFriendRequest failed");
}
}
diff --git a/GenOnlineService/Database/Database.User.cs b/GenOnlineService/Database/Database.User.cs
index af66d34..24a01b2 100644
--- a/GenOnlineService/Database/Database.User.cs
+++ b/GenOnlineService/Database/Database.User.cs
@@ -157,6 +157,8 @@ namespace Database
{
public static class UserDevices
{
+ private static readonly ILogger s_log = AppLog.For(typeof(UserDevices));
+
public static readonly Func> FindDevice =
EF.CompileAsyncQuery(
(AppDbContext db, long userId, string h0, string h1, string h2) =>
@@ -226,8 +228,7 @@ public static async Task RegisterUserDevice(
}
catch (Exception ex)
{
- Console.WriteLine($"[ERROR] RegisterUserDevice failed: {ex.Message}");
- SentrySdk.CaptureException(ex);
+ s_log.LogError(ex, "RegisterUserDevice failed");
}
}
}
@@ -235,6 +236,8 @@ public static async Task RegisterUserDevice(
public static class Users
{
+ private static readonly ILogger s_log = AppLog.For(typeof(Users));
+
private static readonly Func> GetEloData =
EF.CompileAsyncQuery(
(AppDbContext db, long userId) =>
@@ -341,8 +344,7 @@ public static async Task> GetBulkELOData(
}
catch (Exception ex)
{
- Console.WriteLine($"[ERROR] GetBulkELOData failed: {ex.Message}");
- SentrySdk.CaptureException(ex);
+ s_log.LogError(ex, "GetBulkELOData failed");
}
return results;
@@ -396,8 +398,7 @@ public static async Task IsUserAdmin(AppDbContext db, long userId)
}
catch (Exception ex)
{
- Console.WriteLine($"[ERROR] IsUserAdmin failed: {ex.Message}");
- SentrySdk.CaptureException(ex);
+ s_log.LogError(ex, "IsUserAdmin failed");
return false;
}
}
@@ -418,8 +419,7 @@ public static async Task> GetDisplayNameBulk(AppDbConte
}
catch (Exception ex)
{
- Console.WriteLine($"[ERROR] GetDisplayNameBulk failed: {ex.Message}");
- SentrySdk.CaptureException(ex);
+ s_log.LogError(ex, "GetDisplayNameBulk failed");
}
return dict;
@@ -433,8 +433,7 @@ public static async Task IsUserBanned(AppDbContext db, long userId)
}
catch (Exception ex)
{
- Console.WriteLine($"[ERROR] IsUserBanned failed: {ex.Message}");
- SentrySdk.CaptureException(ex);
+ s_log.LogError(ex, "IsUserBanned failed");
return false;
}
}
@@ -447,8 +446,7 @@ public static async Task IsUserBanned(AppDbContext db, long userId)
}
catch (Exception ex)
{
- Console.WriteLine($"[ERROR] GetUserBanStatus failed: {ex.Message}");
- SentrySdk.CaptureException(ex);
+ s_log.LogError(ex, "GetUserBanStatus failed");
return null;
}
}
@@ -462,8 +460,7 @@ public static async Task GetDisplayName(AppDbContext db, long userId)
}
catch (Exception ex)
{
- Console.WriteLine($"[ERROR] GetDisplayName failed: {ex.Message}");
- SentrySdk.CaptureException(ex);
+ s_log.LogError(ex, "GetDisplayName failed");
return string.Empty;
}
}
@@ -476,8 +473,7 @@ public static async Task GetDisplayName(AppDbContext db, long userId)
}
catch (Exception ex)
{
- Console.WriteLine($"[ERROR] GetUserLobbyPreferences failed: {ex.Message}");
- SentrySdk.CaptureException(ex);
+ s_log.LogError(ex, "GetUserLobbyPreferences failed");
return null;
}
}
@@ -495,8 +491,7 @@ public static async Task SetFavorite_LimitSuperweapons(
}
catch (Exception ex)
{
- Console.WriteLine($"[ERROR] SetFavorite_LimitSuperweapons failed: {ex.Message}");
- SentrySdk.CaptureException(ex);
+ s_log.LogError(ex, "SetFavorite_LimitSuperweapons failed");
}
}
@@ -515,8 +510,7 @@ public static async Task GetELOData(AppDbContext db, long userId)
}
catch (Exception ex)
{
- Console.WriteLine($"[ERROR] GetELOData API call failed: {ex.Message}");
- SentrySdk.CaptureException(ex);
+ s_log.LogError(ex, "GetELOData API call failed");
}
try
@@ -528,8 +522,7 @@ public static async Task GetELOData(AppDbContext db, long userId)
}
catch (Exception ex)
{
- Console.WriteLine($"[ERROR] GetELOData fallback failed: {ex.Message}");
- SentrySdk.CaptureException(ex);
+ s_log.LogError(ex, "GetELOData fallback failed");
}
return new EloData(EloConfig.BaseRating, EloConfig.BaseRating, 0);
@@ -547,8 +540,7 @@ public static async Task SetFavorite_Map(
}
catch (Exception ex)
{
- Console.WriteLine($"[ERROR] SetFavorite_Map failed: {ex.Message}");
- SentrySdk.CaptureException(ex);
+ s_log.LogError(ex, "SetFavorite_Map failed");
}
}
@@ -565,8 +557,7 @@ await db.Users
}
catch (Exception ex)
{
- Console.WriteLine($"[ERROR] UpdateLastLoginData failed: {ex.Message}");
- SentrySdk.CaptureException(ex);
+ s_log.LogError(ex, "UpdateLastLoginData failed");
}
}
@@ -582,8 +573,7 @@ public static async Task SetFavorite_StartingMoney(
}
catch (Exception ex)
{
- Console.WriteLine($"[ERROR] SetFavorite_StartingMoney failed: {ex.Message}");
- SentrySdk.CaptureException(ex);
+ s_log.LogError(ex, "SetFavorite_StartingMoney failed");
}
}
@@ -598,8 +588,7 @@ public static async Task SetFavorite_Side(
}
catch (Exception ex)
{
- Console.WriteLine($"[ERROR] SetFavorite_Side failed: {ex.Message}");
- SentrySdk.CaptureException(ex);
+ s_log.LogError(ex, "SetFavorite_Side failed");
}
}
@@ -623,8 +612,7 @@ await db.Users
}
catch (Exception ex)
{
- Console.WriteLine($"[ERROR] SetDisplayName failed: {ex.Message}");
- SentrySdk.CaptureException(ex);
+ s_log.LogError(ex, "SetDisplayName failed");
return false;
}
}
@@ -640,8 +628,7 @@ public static async Task SetFavorite_Color(
}
catch (Exception ex)
{
- Console.WriteLine($"[ERROR] SetFavorite_Color failed: {ex.Message}");
- SentrySdk.CaptureException(ex);
+ s_log.LogError(ex, "SetFavorite_Color failed");
}
}
@@ -667,8 +654,7 @@ await db.Users
}
catch (Exception ex)
{
- Console.WriteLine($"[ERROR] SaveELOData failed: {ex.Message}");
- SentrySdk.CaptureException(ex);
+ s_log.LogError(ex, "SaveELOData failed");
}
}
diff --git a/GenOnlineService/Discord.cs b/GenOnlineService/Discord.cs
index 85814d9..75c24d7 100644
--- a/GenOnlineService/Discord.cs
+++ b/GenOnlineService/Discord.cs
@@ -98,6 +98,7 @@ public static string FormatString(string strFormat, params object[] strParams)
public class DiscordBot
{
+ private static readonly Microsoft.Extensions.Logging.ILogger s_log = AppLog.For(typeof(DiscordBot));
enum EBotAction
{
PushScriptedMessage
@@ -114,7 +115,7 @@ public DiscordBot()
_ = InitAsync().ContinueWith(t =>
{
if (t.IsFaulted)
- Console.WriteLine("Discord initialization failed: " + t.Exception);
+ s_log.LogError(t.Exception, "Discord initialization failed");
}, TaskContinuationOptions.OnlyOnFaulted);
#endif
}
@@ -705,8 +706,7 @@ private async Task OnMessageReceived(SocketMessage message)
private static Task LogAsync(LogMessage log)
{
- Console.WriteLine(log.ToString());
- System.Diagnostics.Debug.WriteLine(log.ToString());
+ s_log.LogDebug("Discord: {DiscordLogMessage}", log.ToString());
return Task.CompletedTask;
}
diff --git a/GenOnlineService/ExternalLeaderboardPublicationQueue.cs b/GenOnlineService/ExternalLeaderboardPublicationQueue.cs
index 5efe8b1..705aabe 100644
--- a/GenOnlineService/ExternalLeaderboardPublicationQueue.cs
+++ b/GenOnlineService/ExternalLeaderboardPublicationQueue.cs
@@ -21,10 +21,12 @@ public sealed class ExternalLeaderboardPublicationWorker : BackgroundService
private const int c_MaxBatchSize = 8;
private readonly IDbContextFactory _dbFactory;
+ private readonly ILogger _logger;
- public ExternalLeaderboardPublicationWorker(IDbContextFactory dbFactory)
+ public ExternalLeaderboardPublicationWorker(IDbContextFactory dbFactory, ILogger logger)
{
_dbFactory = dbFactory;
+ _logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
@@ -43,8 +45,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
}
catch (Exception ex)
{
- Console.WriteLine($"[ERROR] External leaderboard publication poll failed: {ex}");
- SentrySdk.CaptureException(ex);
+ _logger.LogError(ex, "External leaderboard publication poll failed");
}
try
@@ -86,8 +87,7 @@ private async Task PublishPendingMatches(CancellationToken stoppingToken)
}
catch (Exception ex)
{
- Console.WriteLine($"[ERROR] External publication bookkeeping failed for match {item.MatchId}: {ex}");
- SentrySdk.CaptureException(ex);
+ _logger.LogError(ex, "External publication bookkeeping failed for match {MatchId}", item.MatchId);
throw;
}
}
@@ -124,7 +124,7 @@ private async Task PublishItem(
}
catch (Exception ex)
{
- Console.WriteLine($"[WARNING] Match {item.MatchId} was ingested, but its optional ratings response could not be applied: {ex}");
+ _logger.LogWarning(ex, "Match {MatchId} was ingested, but its optional ratings response could not be applied", item.MatchId);
SentrySdk.CaptureException(ex);
}
@@ -141,7 +141,7 @@ await Database.MatchHistory.MarkExternalPublicationSucceeded(
}
catch (Exception ex)
{
- Console.WriteLine($"[WARNING] Failed to publish match {item.MatchId} to the external leaderboard (attempt {item.Attempt}): {ex.Message}");
+ _logger.LogWarning(ex, "Failed to publish match {MatchId} to the external leaderboard on attempt {Attempt}", item.MatchId, item.Attempt);
await RecordFailure(item, ex, stoppingToken);
}
}
@@ -155,7 +155,7 @@ private async Task ApplyRatingsResponse(
if (string.IsNullOrWhiteSpace(responseBody))
{
if (matchEntry.lobby_type == ELobbyType.QuickMatch)
- Console.WriteLine($"[WARNING] External Match Ingest response for QuickMatch {matchId} contained no ratings body.");
+ _logger.LogWarning("External Match Ingest response for QuickMatch {MatchId} contained no ratings body", matchId);
return;
}
@@ -166,14 +166,14 @@ private async Task ApplyRatingsResponse(
}
catch (JsonException ex)
{
- Console.WriteLine($"[WARNING] External Match Ingest response for match {matchId} could not be deserialized: {ex.Message}");
+ _logger.LogWarning(ex, "External Match Ingest response for match {MatchId} could not be deserialized", matchId);
SentrySdk.CaptureException(ex);
return;
}
if (refreshResponse?.data == null)
{
- Console.WriteLine($"[WARNING] External Match Ingest response for match {matchId} contained no ratings data.");
+ _logger.LogWarning("External Match Ingest response for match {MatchId} contained no ratings data", matchId);
return;
}
@@ -187,13 +187,13 @@ private async Task ApplyRatingsResponse(
{
if (!expectedPlayerIds.Contains(userId))
{
- Console.WriteLine($"[WARNING] External Match Ingest response for match {matchId} contained unexpected player_id {userId}; skipping (ELO left unchanged).");
+ _logger.LogWarning("External Match Ingest response for match {MatchId} contained unexpected player ID {UserId}; skipping", matchId, userId);
continue;
}
if (updatedPlayer?.overall == null || updatedPlayer.season == null)
{
- Console.WriteLine($"[WARNING] External Match Ingest response for match {matchId} contained incomplete ratings for player_id {userId}; skipping.");
+ _logger.LogWarning("External Match Ingest response for match {MatchId} contained incomplete ratings for player ID {UserId}; skipping", matchId, userId);
continue;
}
@@ -223,7 +223,7 @@ private async Task ApplyRatingsResponse(
if (saved)
savedUpdates.Add((userId, data));
else
- Console.WriteLine($"[WARNING] External Match Ingest response for match {matchId} referenced missing user_id {userId}; skipping.");
+ _logger.LogWarning("External Match Ingest response for match {MatchId} referenced missing user ID {UserId}; skipping", matchId, userId);
}
await transaction.CommitAsync(stoppingToken);
@@ -262,8 +262,7 @@ await Database.MatchHistory.MarkExternalPublicationFailed(
if (!retry)
{
- Console.WriteLine($"[ERROR] External publication for match {item.MatchId} stopped after {item.Attempt} attempts.");
- SentrySdk.CaptureException(publicationException);
+ _logger.LogError(publicationException, "External publication for match {MatchId} stopped after {Attempt} attempts", item.MatchId, item.Attempt);
}
}
diff --git a/GenOnlineService/ExternalLeaderboardsClient.cs b/GenOnlineService/ExternalLeaderboardsClient.cs
index af697f3..1968183 100644
--- a/GenOnlineService/ExternalLeaderboardsClient.cs
+++ b/GenOnlineService/ExternalLeaderboardsClient.cs
@@ -33,6 +33,7 @@ public class EloRefreshRating
public static class ExternalLeaderboardsClient
{
+ private static readonly ILogger s_log = AppLog.For(typeof(ExternalLeaderboardsClient));
private static IConfigurationSection GetExternalLeaderboardsConfigSection()
{
if (Program.g_Config == null)
@@ -145,7 +146,7 @@ public static async Task PostMatchResultAsync(MatchHistory_Entry matchEn
{
sw.Stop();
- Console.WriteLine($"[INFO] External Match Ingest POST Response for match {matchID} was received in {sw.ElapsedMilliseconds}ms (status: {response.StatusCode}).");
+ s_log.LogDebug("External Match Ingest POST response for match {MatchId} received in {ElapsedMilliseconds} ms with status {StatusCode}", matchID, sw.ElapsedMilliseconds, response.StatusCode);
responseBody = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode)
@@ -182,11 +183,11 @@ public static async Task PostMatchResultAsync(MatchHistory_Entry matchEn
using (var response = await client.SendAsync(request))
{
sw.Stop();
- Console.WriteLine($"[INFO] External ELO API call for player {playerId} took {sw.ElapsedMilliseconds}ms (status: {response.StatusCode}).");
+ s_log.LogDebug("External ELO API call for player {PlayerId} took {ElapsedMilliseconds} ms with status {StatusCode}", playerId, sw.ElapsedMilliseconds, response.StatusCode);
if (!response.IsSuccessStatusCode)
{
- Console.WriteLine($"[ERROR] External ELO API call failed for player {playerId} with status: {response.StatusCode}");
+ s_log.LogError("External ELO API call failed for player {PlayerId} with status {StatusCode}", playerId, response.StatusCode);
return null;
}
@@ -194,7 +195,7 @@ public static async Task PostMatchResultAsync(MatchHistory_Entry matchEn
var result = JsonSerializer.Deserialize(responseBody);
if (result?.data == null || !result.data.TryGetValue(playerId, out var entry))
{
- Console.WriteLine($"[ERROR] External ELO API response for player {playerId} did not contain that player_id or could not be deserialized: {responseBody}");
+ s_log.LogError("External ELO API response for player {PlayerId} did not contain that player ID or could not be deserialized", playerId);
return null;
}
@@ -205,7 +206,7 @@ public static async Task PostMatchResultAsync(MatchHistory_Entry matchEn
}
catch (Exception ex)
{
- Console.WriteLine($"[ERROR] Exception during external ELO API call for player {playerId}: {ex.Message}");
+ s_log.LogError(ex, "Exception during external ELO API call for player {PlayerId}", playerId);
return null;
}
}
diff --git a/GenOnlineService/GenOnlineService.csproj b/GenOnlineService/GenOnlineService.csproj
index 276a616..62ae699 100644
--- a/GenOnlineService/GenOnlineService.csproj
+++ b/GenOnlineService/GenOnlineService.csproj
@@ -50,6 +50,7 @@
+
diff --git a/GenOnlineService/LobbyManager.cs b/GenOnlineService/LobbyManager.cs
index 5a677d7..008539c 100644
--- a/GenOnlineService/LobbyManager.cs
+++ b/GenOnlineService/LobbyManager.cs
@@ -88,6 +88,8 @@ private static int Get(string key, int defaultValue)
public class Lobby
{
+ private static readonly ILogger s_log = AppLog.For(typeof(Lobby));
+
public Int64 LobbyID { get; private set; } = -1;
public Int64 Owner { get; private set; } = -1;
public string Name { get; private set; } = "";
@@ -305,7 +307,7 @@ private void LogFullMeshCheckAttempt(bool bMeshComplete, List $"{p.Item1}<->{p.Item2}"));
- Console.WriteLine("[Lobby {0}] Mesh check {1} attempt {2}/{3}: {4} after {5} ms with {6} humans{7}",
+ s_log.LogInformation("Lobby {LobbyId}: mesh check {MeshCheckId} attempt {Attempt}/{MaxAttempts} {MeshResult} after {ElapsedMs} ms with {HumanCount} humans{MeshDetail}",
LobbyID, FullMeshCheckID, FullMeshCheckAttempt, FullMeshCheckSettings.MaxAttempts,
bMeshComplete ? "complete" : "incomplete", elapsedMS, GetNumberOfHumans(),
bMeshComplete ? "" : $", missing {strMissing}{(strConnecting.Length > 0 ? $" (still connecting {strConnecting})" : "")}");
@@ -352,7 +354,7 @@ private void RestartSignallingForMissingConnections(List
continue;
}
- Console.WriteLine("[Lobby {0}] Re-signalling {1} <-> {2} before mesh check retry", LobbyID, sourceMember.UserID, targetMember.UserID);
+ s_log.LogDebug("Lobby {LobbyId}: re-signalling {SourceUserId} <-> {TargetUserId} before mesh check retry", LobbyID, sourceMember.UserID, targetMember.UserID);
SendStartSignallingToMember(sourceMember, targetMember);
SendStartSignallingToMember(targetMember, sourceMember);
@@ -755,7 +757,7 @@ public void RecordPlayerIngameAbandon(Int64 userId)
DateTime abandonTime = DateTime.UtcNow;
if (TimePlayerAbandonedIngame.TryAdd(userId, abandonTime))
{
- Console.WriteLine("[Lobby {0}] Recorded in-game abandon for user {1} at {2:O}", LobbyID, userId, abandonTime);
+ s_log.LogInformation("Lobby {LobbyId}: recorded in-game abandon for user {UserId} at {AbandonTime}", LobbyID, userId, abandonTime);
}
}
@@ -767,7 +769,7 @@ public void ClearPlayerIngameAbandon(Int64 userId)
{
if (TimePlayerAbandonedIngame.TryRemove(userId, out _))
{
- Console.WriteLine("[Lobby {0}] Cleared in-game abandon record for reconnected user {1}", LobbyID, userId);
+ s_log.LogInformation("Lobby {LobbyId}: cleared in-game abandon record for reconnected user {UserId}", LobbyID, userId);
}
}
@@ -780,7 +782,7 @@ public void RecordPlayerOutcome(Int64 userId, bool bWon)
{
if (ReportedOutcomes.TryAdd(userId, bWon))
{
- Console.WriteLine("[Lobby {0}] Recorded reported outcome for user {1}: won={2}", LobbyID, userId, bWon);
+ s_log.LogInformation("Lobby {LobbyId}: recorded reported outcome for user {UserId}, won={Won}", LobbyID, userId, bWon);
}
}
@@ -874,9 +876,7 @@ private void OnAfterPlayerLeftLocked(Int64 leavingUserID)
if (numHumanMembers == 0)
{
- Console.ForegroundColor = ConsoleColor.Cyan;
- Console.WriteLine("DeleteLobby: Source A");
- Console.ForegroundColor = ConsoleColor.Gray;
+ s_log.LogInformation("Lobby {LobbyId}: last human member left, requesting destruction", LobbyID);
OnLobbyNeedsDestroyed?.Invoke(this);
}
@@ -1071,7 +1071,7 @@ public async Task Tick()
UserSession? sess = WebSocketManager.GetSessionFromUser(session.m_UserID, session.GetSessionType());
if (sess != null)
{
- Console.WriteLine("[DIRTY LOBBY] Sending WS lobby update for lobby {0}", LobbyID);
+ s_log.LogDebug("Lobby {LobbyId}: sending WebSocket lobby update", LobbyID);
sess.QueueWebsocketSend(bytesJSON);
}
}
@@ -1279,7 +1279,7 @@ public async Task AddMember(UserSession playerSession, string strDisplayNa
// Notify lobby members and room browsers.
DirtyRetransmitLobbyList();
- Console.WriteLine("User {0} joined lobby {1}: {2} (Slot was {3})", playerSession.m_UserID, LobbyID, true, slotIndex);
+ s_log.LogInformation("User {UserId} joined lobby {LobbyId} in slot {SlotIndex}", playerSession.m_UserID, LobbyID, slotIndex);
return true;
});
}
@@ -1347,7 +1347,7 @@ public async Task RemoveMember(LobbyMember member)
// TODO_LOBBY: Optimize this
Int64 UserID = member.UserID;
- Console.WriteLine("User {0} left lobby {1}", UserID, LobbyID);
+ s_log.LogInformation("User {UserId} left lobby {LobbyId}", UserID, LobbyID);
// AC dergister
WebSocketMessage_ACDeregisterPlayer remotePlayerAcMsg = new WebSocketMessage_ACDeregisterPlayer();
@@ -1360,7 +1360,7 @@ public async Task RemoveMember(LobbyMember member)
{
if (remoteSession != null)
{
- Console.WriteLine("Sent AC deregister for user {0} to user {1}", member.UserID, remoteMember.UserID);
+ s_log.LogDebug("Lobby {LobbyId}: sent AC deregister for user {UserId} to user {RemoteUserId}", LobbyID, member.UserID, remoteMember.UserID);
remoteSession.QueueWebsocketSend(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(remotePlayerAcMsg)));
}
}
@@ -1381,7 +1381,7 @@ public async Task RemoveMember(LobbyMember member)
{
if (remoteSession != null)
{
- Console.WriteLine("Sent network disconnect for user {0} to user {1}", member.UserID, remoteMember.UserID);
+ s_log.LogDebug("Lobby {LobbyId}: sent network disconnect for user {UserId} to user {RemoteUserId}", LobbyID, member.UserID, remoteMember.UserID);
remoteSession.QueueWebsocketSend(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(remotePlayerMsg)));
}
}
@@ -1450,7 +1450,7 @@ public async Task DirtyRetransmitToSingleMember(Int64 targetUserID)
var session = WebSocketManager.GetSessionFromUser(targetUserID, EUserSessionType.GameClient); // lobby member must be a game client
if (session != null)
{
- Console.WriteLine("[DIRTY LOBBY] Sending WS lobby update for lobby {0}", LobbyID);
+ s_log.LogDebug("Lobby {LobbyId}: sending WebSocket lobby update", LobbyID);
WebSocketMessage_CurrentLobbyUpdate lobbyUpdate = new WebSocketMessage_CurrentLobbyUpdate();
lobbyUpdate.msg_id = (int)EWebSocketMessageID.LOBBY_CURRENT_LOBBY_UPDATE;
@@ -1614,8 +1614,7 @@ await RunExclusiveAsync(() =>
}
catch (Exception ex)
{
- Console.WriteLine($"[ERROR] UpdateState placeholder creation failed: {ex.Message}");
- SentrySdk.CaptureException(ex);
+ s_log.LogError(ex, "Lobby {LobbyId}: UpdateState placeholder creation failed", LobbyID);
}
// calculate first probe time
@@ -1855,10 +1854,12 @@ internal Int64 GenerateNextLobbyID()
}
private readonly IServiceProvider _services;
+ private readonly ILogger _logger;
- public LobbyManager(IServiceProvider services)
+ public LobbyManager(IServiceProvider services, ILogger logger)
{
_services = services;
+ _logger = logger;
}
public async Task Cleanup()
@@ -1872,7 +1873,7 @@ public async Task Cleanup()
TimeSpan timeSinceCreated = DateTime.UtcNow.Subtract(iterLobby.TimeCreated);
if (iterLobby.GetNumberOfHumans() == 0 && timeSinceCreated.TotalMinutes >= 1.0)
{
- Console.WriteLine("Garbage collecting lobby {0}", iterLobby.LobbyID);
+ _logger.LogInformation("Garbage collecting lobby {LobbyId}", iterLobby.LobbyID);
// mark for removal
lstLobbiesToRemove.Add(iterLobby);
@@ -1911,8 +1912,7 @@ public async Task ProcessLobbiesNeedingDestroyed()
}
catch (Exception ex)
{
- Console.WriteLine($"[ERROR] Failed to destroy lobby {lobbyToDestroy.LobbyID}: {ex}");
- SentrySdk.CaptureException(ex);
+ _logger.LogError(ex, "Failed to destroy lobby {LobbyId}", lobbyToDestroy.LobbyID);
}
}
}
@@ -1921,11 +1921,11 @@ public async Task CreateLobby(AppDbContext _db, UserSession owningSession
UInt16 hostPreferredPort, bool bVanillaTeams, bool bTrackStats, UInt32 default_starting_cash, bool bPassworded, String strPassword, Int16 parentNetworkRoom, bool bAllowObservers,
UInt16 maxCamHeight, UInt32 exe_crc, UInt32 ini_crc, ELobbyType lobbyType, EKnownAnticheatID anticheatID)
{
- Console.WriteLine("Created lobby");
+ _logger.LogInformation("Creating lobby for user {UserId}", owningSession.m_UserID);
// cant own two lobbies at once, unless in gameplay
await CleanupUserLobbiesNotStarted(owningSession.m_UserID);
- Console.WriteLine("[Source 3] User {0} Leave Any Lobby", owningSession.m_UserID);
+ _logger.LogDebug("User {UserId} leaving any lobby before creating a lobby", owningSession.m_UserID);
await this.LeaveAnyLobby(owningSession.m_UserID);
int rng_seed = new Random().Next();
@@ -2134,7 +2134,7 @@ public async Task LeaveSpecificLobby(Int64 userID, Int64 lobbyID)
LobbyMember? memberEntry = targetLobby.GetMemberFromUserID(userID);
if (memberEntry != null)
{
- Console.WriteLine("User {0} Leave Specific Lobby", userID);
+ _logger.LogDebug("User {UserId} leaving specific lobby {LobbyId}", userID, targetLobby.LobbyID);
await targetLobby.RemoveMember(memberEntry);
}
}
@@ -2147,7 +2147,7 @@ public async Task LeaveAnyLobby(Int64 userID)
LobbyMember? member = lobbyInst.GetMemberFromUserID(userID);
if (member != null)
{
- Console.WriteLine("User {0} Leave Any Lobby", userID);
+ _logger.LogDebug("User {UserId} leaving lobby {LobbyId}", userID, lobbyInst.LobbyID);
await lobbyInst.RemoveMember(member);
}
}
@@ -2182,8 +2182,7 @@ public async Task DeleteLobby(Lobby lobby)
}
catch (Exception ex)
{
- Console.WriteLine($"[ERROR] DeleteLobby failed: {ex.Message}");
- SentrySdk.CaptureException(ex);
+ _logger.LogError(ex, "DeleteLobby failed");
return false;
}
}
diff --git a/GenOnlineService/MatchmakingManager.cs b/GenOnlineService/MatchmakingManager.cs
index 95d02e3..d8ce575 100644
--- a/GenOnlineService/MatchmakingManager.cs
+++ b/GenOnlineService/MatchmakingManager.cs
@@ -180,6 +180,7 @@ public Playlist(UInt16 a_PlaylistID, string a_strName,
static class MatchmakingManager
{
+ private static readonly ILogger s_log = AppLog.For(typeof(MatchmakingManager));
// World Series 2026 Qualification in September requires the matchmaking to be
// based off the monthly ELO.
internal static int GetMatchmakingElo(PlayerStats stats)
@@ -303,6 +304,7 @@ public bool Is(UserSession playerSession)
public class MatchmakingBucket
{
+ private static readonly ILogger s_log = AppLog.For(typeof(MatchmakingBucket));
private ConcurrentList m_lstMembers = new();
public ConcurrentList lstMapIndices { get; set; } = new();
@@ -451,7 +453,7 @@ public void DetermineMap(out string strMapName, out string strMapPath)
else
{
// pick a sensible default (biggest map in playlist), probably not what the players asked for, but we cant play on no map
- Console.WriteLine("WARNING: No mutually agreed upon map found for matchmaking bucket, falling back to largest map in playlist");
+ s_log.LogWarning("No mutually agreed upon map found for matchmaking bucket; falling back to the largest map in the playlist");
if (MatchmakingManager.g_Playlists.TryGetValue(PlaylistID, out Playlist? playlist))
{
@@ -867,7 +869,7 @@ public Int64 GetLobbyID()
private async Task StartGameAfterSuccessfulMeshCheck(Lobby lobby)
{
- Console.WriteLine("START GAME");
+ s_log.LogInformation("Starting QuickMatch game for lobby {LobbyId}", lobby.LobbyID);
WebSocketMessage_MatchmakerStartGame startGameAction = new WebSocketMessage_MatchmakerStartGame();
startGameAction.msg_id = (int)EWebSocketMessageID.MATCHMAKING_ACTION_START_GAME;
@@ -1339,7 +1341,7 @@ await SendMatchmakingMessage(memberSession,
if (lobby == null)
{
// the lobby went away underneath us (deleted/failed) - don't leave everyone stuck waiting forever
- Console.WriteLine("Matchmaking bucket lost its QuickMatch lobby {0} while waiting on joins, abandoning bucket", m_LobbyID);
+ s_log.LogWarning("Matchmaking bucket lost QuickMatch lobby {LobbyId} while waiting on joins; abandoning bucket", m_LobbyID);
foreach (MatchmakingBucketMember member in m_lstMembers)
{
@@ -1370,7 +1372,7 @@ await SendMatchmakingMessage(memberSession,
{
if (memberSession != null)
{
- Console.WriteLine("User {0} failed to join QuickMatch lobby {1} in time, dropping from bucket", memberSession.m_UserID, m_LobbyID);
+ s_log.LogInformation("User {UserId} failed to join QuickMatch lobby {LobbyId} in time; dropping from bucket", memberSession.m_UserID, m_LobbyID);
memberSession.IsRegisteredForMatchmaking = false;
await SendMatchmakingMessage(memberSession, "You failed to join the QuickMatch lobby in time and have been removed from matchmaking.");
}
@@ -1858,6 +1860,7 @@ public static async Task RegisterPlayer(UserSession plr, UInt16 playlistID, List
// validate the request - a bad playlist or out of range map index from a client must never reach a bucket
if (!g_Playlists.TryGetValue(playlistID, out Playlist? playlist))
{
+ s_log.LogWarning("User {UserId} requested unavailable matchmaking playlist {PlaylistId}", plr.m_UserID, playlistID);
await SendMatchmakingMessage(plr, "That playlist is not available. Matchmaking was not started.");
return;
}
@@ -1870,6 +1873,7 @@ public static async Task RegisterPlayer(UserSession plr, UInt16 playlistID, List
int minSelectedMaps = Math.Max(1, playlist.MinSelectedMaps);
if (validatedMapIndices.Count < minSelectedMaps)
{
+ s_log.LogWarning("User {UserId} selected {SelectedMapCount} valid maps for playlist {PlaylistId}; minimum is {MinimumMapCount}", plr.m_UserID, validatedMapIndices.Count, playlistID, minSelectedMaps);
await SendMatchmakingMessage(plr, String.Format("You must select at least {0} valid map(s) to matchmake in this playlist.", minSelectedMaps));
return;
}
@@ -1901,10 +1905,12 @@ public static async Task RegisterPlayer(UserSession plr, UInt16 playlistID, List
if (bCancellationRejected)
{
+ s_log.LogWarning("User {UserId} could not restart matchmaking because the game is starting", plr.m_UserID);
await SendMatchmakingMessage(plr, "Matchmaking cannot be restarted because your game is already starting.");
return;
}
+ s_log.LogInformation("User {UserId} started matchmaking in playlist {PlaylistId} with {SelectedMapCount} maps", plr.m_UserID, playlistID, validatedMapIndices.Count);
await SendMatchmakingMessage(plr, "Started matchmaking... Searching for players...");
}
@@ -1930,7 +1936,7 @@ private static async Task RemovePlayerFromAllBuckets(UserSession plr)
LobbyMember? lobbyMember = lobby.GetMemberFromUserID(plr.m_UserID);
if (lobbyMember != null)
{
- Console.WriteLine("User {0} Leave MM Lobby", plr.m_UserID);
+ s_log.LogDebug("User {UserId} leaving matchmaking lobby", plr.m_UserID);
await lobby.RemoveMember(lobbyMember);
}
}
@@ -1966,7 +1972,7 @@ public static async Task DeregisterPlayer(UserSession plr)
// leave QM lobby too
if (!bCancellationRejected)
{
- Console.WriteLine("[Source 4] User {0} Leave Any Lobby", plr.m_UserID);
+ s_log.LogDebug("User {UserId} leaving any lobby during matchmaking cleanup", plr.m_UserID);
await lobbyManager.LeaveAnyLobby(plr.m_UserID);
}
}
diff --git a/GenOnlineService/Moderation.cs b/GenOnlineService/Moderation.cs
index 7d0b9b7..feade47 100644
--- a/GenOnlineService/Moderation.cs
+++ b/GenOnlineService/Moderation.cs
@@ -34,6 +34,7 @@ public sealed class ModerationResult
public static class ModerationManager
{
+ private static readonly ILogger s_log = AppLog.For(typeof(ModerationManager));
public const int MaximumReasonLength = 256;
public static async Task KickUser(Int64 targetUserID, string reason)
@@ -59,6 +60,7 @@ public static async Task KickUser(Int64 targetUserID, string r
public static async Task DisconnectUser(Int64 userID, EModerationAction action, string? reason)
{
+ s_log.LogInformation("Applying moderation action {Action} to user {UserId}", action, userID);
WebSocketMessage_ModerationNotice notice = new WebSocketMessage_ModerationNotice
{
msg_id = (int)EWebSocketMessageID.MODERATION_NOTICE,
diff --git a/GenOnlineService/Program.cs b/GenOnlineService/Program.cs
index 7de732d..ebf9f8c 100644
--- a/GenOnlineService/Program.cs
+++ b/GenOnlineService/Program.cs
@@ -1229,6 +1229,15 @@ public static async Task Main(string[] args)
options.Release = "generalsonline-services@082826";
});
+
+ // Bridge ILogger into Sentry: Warning+ ride along as breadcrumbs, Error+ are captured as events.
+ // InitializeSdk is false because we already initialized the SDK above with our own config.
+ builder.Logging.AddSentry(options =>
+ {
+ options.InitializeSdk = false;
+ options.MinimumBreadcrumbLevel = LogLevel.Warning;
+ options.MinimumEventLevel = LogLevel.Error;
+ });
}
S3CredentialManager.Initialize();
@@ -1603,9 +1612,7 @@ public static async Task Main(string[] args)
}
else
{
- Console.ForegroundColor = ConsoleColor.Red;
- Console.WriteLine("*** WARNING: Core:enforce_https is disabled. Bearer tokens will be sent in clear text over any plain-HTTP listener. ***");
- Console.ForegroundColor = ConsoleColor.Gray;
+ app.Logger.LogWarning("Core:enforce_https is disabled. Bearer tokens will be sent in clear text over any plain-HTTP listener.");
}
app.UseCors();
@@ -1631,11 +1638,15 @@ public static async Task Main(string[] args)
await lobbyManager.Cleanup();
- PendingLoginManager.CleanupExpiredLogins();
+ int expiredLoginCount = PendingLoginManager.CleanupExpiredLogins();
+ if (expiredLoginCount > 0)
+ {
+ app.Logger.LogDebug("Removed {ExpiredLoginCount} expired pending logins", expiredLoginCount);
+ }
}
catch (Exception ex)
{
- Console.WriteLine($"[timerCleanup] Exception: {ex}");
+ app.Logger.LogError(ex, "Cleanup timer failed");
}
finally
{
@@ -1658,7 +1669,7 @@ public static async Task Main(string[] args)
}
catch (Exception ex)
{
- Console.WriteLine($"[timerTick lobby] Exception: {ex}");
+ app.Logger.LogError(ex, "Lobby tick timer failed");
}
finally
{
@@ -1682,7 +1693,7 @@ public static async Task Main(string[] args)
}
catch (Exception ex)
{
- Console.WriteLine($"[cleanupTick lobby] Exception: {ex}");
+ app.Logger.LogError(ex, "Lobby cleanup timer failed");
}
finally
{
@@ -1704,7 +1715,7 @@ public static async Task Main(string[] args)
}
catch (Exception ex)
{
- Console.WriteLine($"[timerTick matchmaking] Exception: {ex}");
+ app.Logger.LogError(ex, "Matchmaking tick timer failed");
}
finally
{
@@ -1726,7 +1737,7 @@ public static async Task Main(string[] args)
}
catch (Exception ex)
{
- Console.WriteLine($"[timerTick rooms] Exception: {ex}");
+ app.Logger.LogError(ex, "Network-room tick timer failed");
}
finally
{
@@ -1753,7 +1764,7 @@ public static async Task Main(string[] args)
}
catch (Exception ex)
{
- Console.WriteLine($"[timerTick dailystats] Exception: {ex}");
+ app.Logger.LogError(ex, "Daily-stats timer failed");
}
finally
{
@@ -1780,7 +1791,7 @@ public static async Task Main(string[] args)
}
catch (Exception ex)
{
- Console.WriteLine($"[timerTick tokenrevocation] Exception: {ex}");
+ app.Logger.LogError(ex, "Token-revocation timer failed");
}
finally
{
@@ -1792,8 +1803,7 @@ public static async Task Main(string[] args)
AppDomain.CurrentDomain.ProcessExit += (_, _) =>
{
- Console.ForegroundColor = ConsoleColor.Red;
- Console.WriteLine("EXIT REQUESTED!");
+ app.Logger.LogInformation("Exit requested");
};
// create a token
diff --git a/GenOnlineService/TokenRevocation.cs b/GenOnlineService/TokenRevocation.cs
index 7370808..e16a008 100644
--- a/GenOnlineService/TokenRevocation.cs
+++ b/GenOnlineService/TokenRevocation.cs
@@ -64,6 +64,8 @@ namespace Database
{
public static class UserTokens
{
+ private static readonly ILogger s_log = AppLog.For(typeof(UserTokens));
+
public static async Task> GetAll(AppDbContext db)
{
try
@@ -72,8 +74,7 @@ public static async Task> GetAll(AppDbContext db)
}
catch (Exception ex)
{
- Console.WriteLine($"[ERROR] UserTokens.GetAll failed: {ex.Message}");
- SentrySdk.CaptureException(ex);
+ s_log.LogError(ex, "UserTokens.GetAll failed");
return new List();
}
}
@@ -96,8 +97,7 @@ ON DUPLICATE KEY UPDATE
}
catch (Exception ex)
{
- Console.WriteLine($"[ERROR] UserTokens.Upsert failed: {ex.Message}");
- SentrySdk.CaptureException(ex);
+ s_log.LogError(ex, "UserTokens.Upsert failed");
}
}
@@ -109,8 +109,7 @@ public static async Task> GetBannedUserIDs(AppDbContext db)
}
catch (Exception ex)
{
- Console.WriteLine($"[ERROR] UserTokens.GetBannedUserIDs failed: {ex.Message}");
- SentrySdk.CaptureException(ex);
+ s_log.LogError(ex, "UserTokens.GetBannedUserIDs failed");
throw;
}
}
@@ -124,6 +123,8 @@ namespace GenOnlineService
// survives a restart.
public static class TokenRevocationManager
{
+ private static readonly ILogger s_log = AppLog.For(typeof(TokenRevocationManager));
+
private sealed class CachedState
{
public int Generation;
@@ -176,7 +177,7 @@ public static async Task Initialize(IDbContextFactory dbFactory, A
s_bannedUsers = new HashSet(banned);
}
- Console.WriteLine($"[TokenRevocation] Loaded {s_state.Count} token state entries, {banned.Count} banned users.");
+ s_log.LogInformation("Loaded {StateEntryCount} token state entries, {BannedUserCount} banned users", s_state.Count, banned.Count);
}
public static int GetGeneration(Int64 userID, EUserSessionType sessionType)
@@ -247,7 +248,7 @@ public static async Task OnTokensIssued(Int64 userID, EUserSessionType sessionTy
// Invalidates every token previously issued to this user across all session types.
public static async Task RevokeAllTokensForUser(Int64 userID, string reason)
{
- Console.WriteLine($"[TokenRevocation] Revoking all tokens for user {userID} ({reason}).");
+ s_log.LogInformation("Revoking all tokens for user {UserId} ({Reason})", userID, reason);
foreach (EUserSessionType sessionType in s_allSessionTypes)
{
@@ -309,8 +310,7 @@ private static async Task Persist(Int64 userID, EUserSessionType sessionType, Ca
}
catch (Exception ex)
{
- Console.WriteLine($"[ERROR] TokenRevocation.Persist failed: {ex.Message}");
- SentrySdk.CaptureException(ex);
+ s_log.LogError(ex, "TokenRevocation.Persist failed");
}
}
From 139741392d57ef2fb2c38bc5e4f1caea4d03bfe7 Mon Sep 17 00:00:00 2001
From: tintinhamans <5984296+tintinhamans@users.noreply.github.com>
Date: Mon, 21 Sep 2026 20:39:12 +0200
Subject: [PATCH 03/18] feat(observability): add telemetry primitives
---
GenOnlineService/AppMetrics.cs | 213 ++++++++++++++++++
GenOnlineService/GenOnlineService.csproj | 6 +
.../SlowDatabaseCommandInterceptor.cs | 84 +++++++
.../TelemetryRedactionProcessor.cs | 56 +++++
4 files changed, 359 insertions(+)
create mode 100644 GenOnlineService/AppMetrics.cs
create mode 100644 GenOnlineService/SlowDatabaseCommandInterceptor.cs
create mode 100644 GenOnlineService/TelemetryRedactionProcessor.cs
diff --git a/GenOnlineService/AppMetrics.cs b/GenOnlineService/AppMetrics.cs
new file mode 100644
index 0000000..4baefbf
--- /dev/null
+++ b/GenOnlineService/AppMetrics.cs
@@ -0,0 +1,213 @@
+/*
+** GeneralsOnline Game Services - Backend Services for Command & Conquer Generals Online: Zero Hour
+** Copyright (C) 2025 GeneralsOnline Development Team
+**
+** This program is free software: you can redistribute it and/or modify
+** it under the terms of the GNU Affero General Public License as
+** published by the Free Software Foundation, either version 3 of the
+** License, or (at your option) any later version.
+*/
+
+using System.Collections.Concurrent;
+using System.Diagnostics;
+using System.Diagnostics.Metrics;
+
+namespace GenOnlineService
+{
+ public static class AppMetrics
+ {
+ public const string MeterName = "GenOnlineService";
+ public const string ActivitySourceName = MeterName;
+ public const string MySqlActivitySourceName = "MySqlConnector";
+ public const string MySqlMeterName = "MySqlConnector";
+
+ private static readonly Meter s_meter = new(MeterName);
+ private static readonly ActivitySource s_activitySource = new(ActivitySourceName);
+
+ private static int s_onlinePlayers;
+ private static int s_activeLobbies;
+ private static int s_webSocketSendQueueDepth;
+ private static double s_externalPublicationBacklogAgeSeconds;
+ private static readonly ConcurrentDictionary s_webSocketConnections = new();
+ private static readonly ConcurrentDictionary s_matchmakingQueuedPlayers = new();
+ private static readonly ConcurrentDictionary s_backgroundJobLastSuccess = new();
+
+ private static readonly Counter s_authenticationAttempts =
+ s_meter.CreateCounter("genonline.authentication.attempts", "{attempt}", "Authentication attempts by flow and outcome");
+ private static readonly Counter s_webSocketFailures =
+ s_meter.CreateCounter("genonline.websocket.failures", "{message}", "Inbound or outbound WebSocket messages that failed");
+ private static readonly Histogram s_matchmakingWaitDuration =
+ s_meter.CreateHistogram("genonline.matchmaking.wait.duration", "s", "Time in the matchmaking queue by exit outcome");
+ private static readonly Counter s_matchmakingMatches =
+ s_meter.CreateCounter("genonline.matchmaking.matches", "{match}", "Matchmaking matches started or aborted during setup");
+ private static readonly Counter s_backgroundJobFailures =
+ s_meter.CreateCounter("genonline.background_job.failures", "{run}", "Failed background job runs");
+ private static readonly Counter s_externalPublicationFailures =
+ s_meter.CreateCounter("genonline.external_publication.failures", "{attempt}", "Failed external leaderboard publication attempts");
+ private static readonly Counter s_securityActions =
+ s_meter.CreateCounter("genonline.security.actions", "{action}", "Rate limit rejections, moderation and anti-cheat actions");
+
+ static AppMetrics()
+ {
+ s_meter.CreateObservableGauge("genonline.players.online", () => Volatile.Read(ref s_onlinePlayers), "{player}", "Players currently online");
+ s_meter.CreateObservableGauge("genonline.lobbies.active", () => Volatile.Read(ref s_activeLobbies), "{lobby}", "Lobbies currently active");
+ s_meter.CreateObservableGauge("genonline.websocket.connections.active",
+ () => s_webSocketConnections.Select(entry => new Measurement(entry.Value, new KeyValuePair("session.type", entry.Key.ToMetricLabel()))),
+ "{connection}", "Open WebSocket connections by session type");
+ s_meter.CreateObservableGauge("genonline.websocket.send_queue.depth", () => Volatile.Read(ref s_webSocketSendQueueDepth), "{message}", "Messages waiting for WebSocket delivery");
+ s_meter.CreateObservableGauge("genonline.matchmaking.queue.players",
+ () => s_matchmakingQueuedPlayers.Select(entry => new Measurement(entry.Value, new KeyValuePair("playlist", entry.Key))),
+ "{player}", "Players in the matchmaking queue by playlist");
+ s_meter.CreateObservableGauge("genonline.background_job.last_success.time",
+ () => s_backgroundJobLastSuccess.Select(entry => new Measurement(entry.Value, new KeyValuePair("job", entry.Key))),
+ "s", "Unix time of the last successful run by job");
+ s_meter.CreateObservableGauge("genonline.external_publication.backlog.age", () => Volatile.Read(ref s_externalPublicationBacklogAgeSeconds), "s", "Age of the oldest unpublished match");
+ }
+
+ public static void RecordServiceSnapshot(int activeLobbies, int onlinePlayers)
+ {
+ Volatile.Write(ref s_activeLobbies, activeLobbies);
+ Volatile.Write(ref s_onlinePlayers, onlinePlayers);
+ }
+
+ public static void RecordAuthentication(string flow, string source, string outcome)
+ {
+ s_authenticationAttempts.Add(1, new TagList { { "flow", flow }, { "source", source }, { "outcome", outcome } });
+ }
+
+ // records one attempt on dispose, with the outcome known at that point
+ public static IDisposable TrackAuthentication(string flow, string source, Func getOutcome)
+ {
+ return new DeferredRecord(() => RecordAuthentication(flow, source, getOutcome()));
+ }
+
+ public static void RecordWebSocketConnections(EUserSessionType sessionType, int connections)
+ {
+ s_webSocketConnections[sessionType] = connections;
+ }
+
+ public static void RecordWebSocketFailure(string direction, string reason)
+ {
+ s_webSocketFailures.Add(1, new TagList { { "direction", direction }, { "reason", reason } });
+ }
+
+ public static void AdjustWebSocketSendQueueDepth(int delta)
+ {
+ if (Interlocked.Add(ref s_webSocketSendQueueDepth, delta) < 0)
+ {
+ Interlocked.Exchange(ref s_webSocketSendQueueDepth, 0);
+ }
+ }
+
+ public static void RecordMatchmakingQueue(string playlist, int players)
+ {
+ s_matchmakingQueuedPlayers[playlist] = players;
+ }
+
+ public static void RecordMatchmakingQueueExit(string playlist, string outcome, TimeSpan waited)
+ {
+ s_matchmakingWaitDuration.Record(Math.Max(0, waited.TotalSeconds), new TagList { { "playlist", playlist }, { "outcome", outcome } });
+ }
+
+ public static void RecordMatchmakingMatch(string playlist, string outcome)
+ {
+ s_matchmakingMatches.Add(1, new TagList { { "playlist", playlist }, { "outcome", outcome } });
+ }
+
+ public static void RecordBackgroundJob(string job, bool succeeded)
+ {
+ if (succeeded)
+ {
+ s_backgroundJobLastSuccess[job] = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
+ }
+ else
+ {
+ s_backgroundJobFailures.Add(1, new TagList { { "job", job } });
+ }
+ }
+
+ public static void RecordExternalPublicationFailure(string outcome)
+ {
+ s_externalPublicationFailures.Add(1, new TagList { { "outcome", outcome } });
+ }
+
+ public static void RecordExternalPublicationBacklogAge(TimeSpan age)
+ {
+ Volatile.Write(ref s_externalPublicationBacklogAgeSeconds, Math.Max(0, age.TotalSeconds));
+ }
+
+ public static void RecordSecurityAction(string action, string outcome)
+ {
+ s_securityActions.Add(1, new TagList { { "action", action }, { "outcome", outcome } });
+ }
+
+ public static Activity? StartActivity(
+ string name,
+ ActivityKind kind = ActivityKind.Internal,
+ params KeyValuePair[] attributes)
+ {
+ Activity? activity = s_activitySource.StartActivity(name, kind);
+ if (activity != null)
+ {
+ foreach (KeyValuePair attribute in attributes)
+ {
+ activity.SetTag(attribute.Key, attribute.Value);
+ }
+ }
+ return activity;
+ }
+
+ public static void RecordException(Activity? activity, Exception exception)
+ {
+ if (activity == null)
+ {
+ return;
+ }
+
+ activity.SetStatus(ActivityStatusCode.Error, exception.Message);
+ activity.AddEvent(new ActivityEvent(
+ "exception",
+ tags: new ActivityTagsCollection
+ {
+ { "exception.type", exception.GetType().FullName },
+ { "exception.message", exception.Message },
+ { "exception.stacktrace", exception.ToString() }
+ }));
+ }
+
+ private sealed class DeferredRecord : IDisposable
+ {
+ private Action? _record;
+
+ public DeferredRecord(Action record)
+ {
+ _record = record;
+ }
+
+ public void Dispose()
+ {
+ try
+ {
+ Interlocked.Exchange(ref _record, null)?.Invoke();
+ }
+ catch
+ {
+ // Telemetry is fail-open.
+ }
+ }
+ }
+ }
+ // Lower-case enum labels for metrics and spans, cached to avoid per-call allocations.
+ public static class MetricLabels
+ {
+ private static class Cache where T : struct, Enum
+ {
+ public static readonly ConcurrentDictionary Names = new();
+ }
+
+ public static string ToMetricLabel(this T value) where T : struct, Enum
+ {
+ return Cache.Names.GetOrAdd(value, static v => v.ToString().ToLowerInvariant());
+ }
+ }
+}
diff --git a/GenOnlineService/GenOnlineService.csproj b/GenOnlineService/GenOnlineService.csproj
index 62ae699..626d4c9 100644
--- a/GenOnlineService/GenOnlineService.csproj
+++ b/GenOnlineService/GenOnlineService.csproj
@@ -48,9 +48,15 @@
+
+
+
+
+
+
diff --git a/GenOnlineService/SlowDatabaseCommandInterceptor.cs b/GenOnlineService/SlowDatabaseCommandInterceptor.cs
new file mode 100644
index 0000000..f016c23
--- /dev/null
+++ b/GenOnlineService/SlowDatabaseCommandInterceptor.cs
@@ -0,0 +1,84 @@
+/*
+** GeneralsOnline Game Services - Backend Services for Command & Conquer Generals Online: Zero Hour
+** Copyright (C) 2025 GeneralsOnline Development Team
+**
+** This program is free software: you can redistribute it and/or modify
+** it under the terms of the GNU Affero General Public License as
+** published by the Free Software Foundation, either version 3 of the
+** License, or (at your option) any later version.
+*/
+
+using System.Data.Common;
+using Microsoft.EntityFrameworkCore.Diagnostics;
+
+namespace GenOnlineService
+{
+ // Logs slow commands with operation, duration and outcome only, never SQL or parameters.
+ public sealed class SlowDatabaseCommandInterceptor : DbCommandInterceptor
+ {
+ private readonly ILogger _logger;
+ private readonly TimeSpan _threshold;
+
+ public SlowDatabaseCommandInterceptor(IConfiguration configuration, ILogger logger)
+ {
+ _logger = logger;
+ _threshold = TimeSpan.FromMilliseconds(Math.Max(1, configuration.GetValue("OpenTelemetry:SlowDatabaseCommandMilliseconds") ?? 500));
+ }
+
+ public override DbDataReader ReaderExecuted(DbCommand command, CommandExecutedEventData eventData, DbDataReader result)
+ {
+ Check(eventData.ExecuteMethod, "success", eventData.Duration);
+ return result;
+ }
+
+ public override ValueTask ReaderExecutedAsync(DbCommand command, CommandExecutedEventData eventData, DbDataReader result, CancellationToken cancellationToken = default)
+ {
+ Check(eventData.ExecuteMethod, "success", eventData.Duration);
+ return ValueTask.FromResult(result);
+ }
+
+ public override object? ScalarExecuted(DbCommand command, CommandExecutedEventData eventData, object? result)
+ {
+ Check(eventData.ExecuteMethod, "success", eventData.Duration);
+ return result;
+ }
+
+ public override ValueTask