Skip to content

Commit d16169c

Browse files
feat: Implement Agent Telemetry Service and integrate agent status features across controllers and views
1 parent a7a79e2 commit d16169c

10 files changed

Lines changed: 533 additions & 0 deletions

File tree

src/XtremeIdiots.Portal.Web.Tests/Controllers/ServersControllerTests.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,14 @@
88
using System.Security.Claims;
99
using XtremeIdiots.Portal.Repository.Api.Client.V1;
1010
using XtremeIdiots.Portal.Web.Controllers;
11+
using XtremeIdiots.Portal.Web.Services;
1112

1213
namespace XtremeIdiots.Portal.Web.Tests.Controllers;
1314

1415
public class ServersControllerTests
1516
{
1617
private readonly Mock<IRepositoryApiClient> mockRepositoryApiClient = new();
18+
private readonly Mock<IAgentTelemetryService> mockAgentTelemetryService = new();
1719
private readonly TelemetryClient telemetryClient = new(new TelemetryConfiguration());
1820
private readonly Mock<ILogger<ServersController>> mockLogger = new();
1921
private readonly Mock<IConfiguration> mockConfiguration = new();
@@ -22,6 +24,7 @@ private ServersController CreateSut(ClaimsPrincipal? user = null)
2224
{
2325
var controller = new ServersController(
2426
mockRepositoryApiClient.Object,
27+
mockAgentTelemetryService.Object,
2528
telemetryClient,
2629
mockLogger.Object,
2730
mockConfiguration.Object);
@@ -52,6 +55,7 @@ public void Constructor_WithNullTelemetryClient_ThrowsArgumentNullException()
5255
Assert.Throws<ArgumentNullException>(() =>
5356
new ServersController(
5457
mockRepositoryApiClient.Object,
58+
mockAgentTelemetryService.Object,
5559
null!,
5660
mockLogger.Object,
5761
mockConfiguration.Object));
@@ -64,6 +68,7 @@ public void Constructor_WithNullLogger_ThrowsArgumentNullException()
6468
Assert.Throws<ArgumentNullException>(() =>
6569
new ServersController(
6670
mockRepositoryApiClient.Object,
71+
mockAgentTelemetryService.Object,
6772
telemetryClient,
6873
null!,
6974
mockConfiguration.Object));
@@ -76,6 +81,7 @@ public void Constructor_WithNullConfiguration_ThrowsArgumentNullException()
7681
Assert.Throws<ArgumentNullException>(() =>
7782
new ServersController(
7883
mockRepositoryApiClient.Object,
84+
mockAgentTelemetryService.Object,
7985
telemetryClient,
8086
mockLogger.Object,
8187
null!));

src/XtremeIdiots.Portal.Web/Controllers/ServersController.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
using XtremeIdiots.Portal.Web.Auth.Constants;
1010
using XtremeIdiots.Portal.Web.Extensions;
1111
using XtremeIdiots.Portal.Web.Models;
12+
using XtremeIdiots.Portal.Web.Services;
1213
using XtremeIdiots.Portal.Web.ViewModels;
1314

1415
namespace XtremeIdiots.Portal.Web.Controllers;
@@ -20,13 +21,15 @@ namespace XtremeIdiots.Portal.Web.Controllers;
2021
/// Initializes a new instance of the ServersController
2122
/// </remarks>
2223
/// <param name="repositoryApiClient">Client for repository API operations</param>
24+
/// <param name="agentTelemetryService">Service for querying agent telemetry from Application Insights</param>
2325
/// <param name="telemetryClient">Client for application telemetry</param>
2426
/// <param name="logger">Logger instance for this controller</param>
2527
/// <param name="configuration">Application configuration</param>
2628
/// <exception cref="ArgumentNullException">Thrown when required dependencies are null</exception>
2729
[Authorize(Policy = AuthPolicies.AccessServers)]
2830
public class ServersController(
2931
IRepositoryApiClient repositoryApiClient,
32+
IAgentTelemetryService agentTelemetryService,
3033
TelemetryClient telemetryClient,
3134
ILogger<ServersController> logger,
3235
IConfiguration configuration) : BaseController(telemetryClient, logger, configuration)
@@ -192,6 +195,17 @@ public async Task<IActionResult> ServerInfo(Guid id, CancellationToken cancellat
192195
MapTimelineDataPoints = mapTimelineDataPoints
193196
};
194197

198+
// Fetch agent telemetry (non-critical — page renders without it)
199+
try
200+
{
201+
viewModel.AgentStatus = await agentTelemetryService.GetServerStatusAsync(
202+
gameServerData.GameServerId, cancellationToken).ConfigureAwait(false);
203+
}
204+
catch (Exception ex)
205+
{
206+
Logger.LogWarning(ex, "Failed to retrieve agent telemetry for server {ServerId}", id);
207+
}
208+
195209
Logger.LogInformation("User {UserId} successfully retrieved server info for server {ServerId} with {MapCount} maps and {StatCount} statistics",
196210
User.XtremeIdiotsId(), id, maps.Count, gameServerStatDtos.Count);
197211

src/XtremeIdiots.Portal.Web/Controllers/StatusController.cs

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
using XtremeIdiots.Portal.Repository.Api.Client.V1;
88
using XtremeIdiots.Portal.Web.Auth.Constants;
99
using XtremeIdiots.Portal.Web.Extensions;
10+
using XtremeIdiots.Portal.Web.Services;
1011
using XtremeIdiots.Portal.Web.ViewModels;
1112

1213
namespace XtremeIdiots.Portal.Web.Controllers;
@@ -18,13 +19,15 @@ namespace XtremeIdiots.Portal.Web.Controllers;
1819
/// Initializes a new instance of the StatusController
1920
/// </remarks>
2021
/// <param name="repositoryApiClient">Client for repository API operations</param>
22+
/// <param name="agentTelemetryService">Service for querying agent telemetry from Application Insights</param>
2123
/// <param name="telemetryClient">Client for application telemetry</param>
2224
/// <param name="logger">Logger instance for this controller</param>
2325
/// <param name="configuration">Application configuration</param>
2426
/// <exception cref="ArgumentNullException">Thrown when required dependencies are null</exception>
2527
[Authorize(Policy = AuthPolicies.AccessStatus)]
2628
public class StatusController(
2729
IRepositoryApiClient repositoryApiClient,
30+
IAgentTelemetryService agentTelemetryService,
2831
TelemetryClient telemetryClient,
2932
ILogger<StatusController> logger,
3033
IConfiguration configuration) : BaseController(telemetryClient, logger, configuration)
@@ -93,6 +96,66 @@ public async Task<IActionResult> BanFileStatus(CancellationToken cancellationTok
9396
}, nameof(BanFileStatus)).ConfigureAwait(false);
9497
}
9598

99+
/// <summary>
100+
/// Displays the agent status page showing telemetry for all agent-enabled game servers
101+
/// </summary>
102+
/// <param name="cancellationToken">Cancellation token for the async operation</param>
103+
/// <returns>View with agent status information for all servers</returns>
104+
[HttpGet]
105+
public async Task<IActionResult> AgentStatus(CancellationToken cancellationToken = default)
106+
{
107+
return await ExecuteWithErrorHandlingAsync(async () =>
108+
{
109+
var gameServersApiResponse = await repositoryApiClient.GameServers.V1.GetGameServers(
110+
null, null, GameServerFilter.AgentEnabled, 0, 100,
111+
GameServerOrder.BannerServerListPosition, cancellationToken).ConfigureAwait(false);
112+
113+
var servers = gameServersApiResponse.IsSuccess && gameServersApiResponse.Result?.Data?.Items is not null
114+
? [.. gameServersApiResponse.Result.Data.Items]
115+
: new List<GameServerDto>();
116+
117+
IReadOnlyList<AgentServerSummary> telemetry = Array.Empty<AgentServerSummary>();
118+
try
119+
{
120+
telemetry = await agentTelemetryService.GetAllServersStatusAsync(cancellationToken).ConfigureAwait(false);
121+
}
122+
catch (Exception ex)
123+
{
124+
Logger.LogWarning(ex, "Failed to retrieve agent telemetry for status page");
125+
}
126+
127+
var telemetryByServer = telemetry.ToDictionary(t => t.ServerId);
128+
129+
var models = servers.Select(gs =>
130+
{
131+
telemetryByServer.TryGetValue(gs.GameServerId, out var summary);
132+
133+
return new AgentServerSummary
134+
{
135+
ServerId = gs.GameServerId,
136+
ServerTitle = string.IsNullOrWhiteSpace(gs.LiveTitle) ? gs.Title : gs.LiveTitle,
137+
GameType = gs.GameType.ToString(),
138+
LastEventReceived = summary?.LastEventReceived,
139+
EventsLastHour = summary?.EventsLastHour ?? 0,
140+
PlayerCount = summary?.PlayerCount ?? 0,
141+
CurrentMap = summary?.CurrentMap ?? gs.LiveMap,
142+
IsAgentActive = summary?.IsAgentActive ?? false
143+
};
144+
}).ToList();
145+
146+
TrackSuccessTelemetry("AgentStatusRetrieved", nameof(AgentStatus), new Dictionary<string, string>
147+
{
148+
{ "ServerCount", models.Count.ToString() },
149+
{ "ActiveCount", models.Count(m => m.IsAgentActive).ToString() }
150+
});
151+
152+
Logger.LogInformation("User {UserId} retrieved agent status for {ServerCount} servers",
153+
User.XtremeIdiotsId(), models.Count);
154+
155+
return View(models);
156+
}, nameof(AgentStatus)).ConfigureAwait(false);
157+
}
158+
96159
/// <summary>
97160
/// Retrieves game server data for a specific ban file monitor
98161
/// </summary>

src/XtremeIdiots.Portal.Web/Program.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@
102102

103103
builder.Services.AddSingleton(_ => new LogsQueryClient(new DefaultAzureCredential()));
104104
builder.Services.AddScoped<IActivityLogService, ActivityLogService>();
105+
builder.Services.AddScoped<IAgentTelemetryService, AgentTelemetryService>();
105106

106107
builder.Services.AddRepositoryApiClient(options => options
107108
.WithBaseUrl(GetConfigValue(builder.Configuration, "RepositoryApi:BaseUrl", "RepositoryApi:BaseUrl configuration is required"))
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
using System.Text;
2+
3+
using Azure.Core;
4+
using Azure.Monitor.Query;
5+
using Azure.Monitor.Query.Models;
6+
7+
namespace XtremeIdiots.Portal.Web.Services;
8+
9+
public class AgentTelemetryService(
10+
LogsQueryClient logsQueryClient,
11+
IConfiguration configuration,
12+
ILogger<AgentTelemetryService> logger) : IAgentTelemetryService
13+
{
14+
private const int AgentActiveThresholdMinutes = 5;
15+
16+
public async Task<AgentServerStatus> GetServerStatusAsync(Guid serverId, CancellationToken ct = default)
17+
{
18+
var resourceId = GetAppInsightsResourceId();
19+
var serverIdStr = serverId.ToString();
20+
21+
var summaryQuery = new StringBuilder();
22+
summaryQuery.Append("customEvents");
23+
summaryQuery.Append(" | where timestamp > ago(24h)");
24+
summaryQuery.Append($" | where tostring(customDimensions.ServerId) == '{EscapeKql(serverIdStr)}'");
25+
summaryQuery.Append(" | summarize lastEvent=max(timestamp),");
26+
summaryQuery.Append(" totalEvents=countif(timestamp > ago(1h)),");
27+
summaryQuery.Append(" playerConnects=countif(name == 'PlayerConnected' and timestamp > ago(1h)),");
28+
summaryQuery.Append(" chatMessages=countif(name == 'ChatMessagePersisted' and timestamp > ago(1h)),");
29+
summaryQuery.Append(" bansDetected=countif(name == 'BanImported'),");
30+
summaryQuery.Append(" moderationTriggers=countif(name == 'ChatModerationTriggered')");
31+
32+
var mapQuery = new StringBuilder();
33+
mapQuery.Append("customEvents");
34+
mapQuery.Append(" | where timestamp > ago(24h)");
35+
mapQuery.Append($" | where tostring(customDimensions.ServerId) == '{EscapeKql(serverIdStr)}'");
36+
mapQuery.Append(" | where name == 'MapChange'");
37+
mapQuery.Append(" | top 1 by timestamp desc");
38+
mapQuery.Append(" | project timestamp, mapName=tostring(customDimensions.MapName)");
39+
40+
var summaryTask = ExecuteQueryAsync(resourceId, summaryQuery.ToString(), TimeSpan.FromHours(24), ct);
41+
var mapTask = ExecuteQueryAsync(resourceId, mapQuery.ToString(), TimeSpan.FromHours(24), ct);
42+
43+
await Task.WhenAll(summaryTask, mapTask).ConfigureAwait(false);
44+
45+
var summaryResponse = await summaryTask.ConfigureAwait(false);
46+
var mapResponse = await mapTask.ConfigureAwait(false);
47+
48+
var status = new AgentServerStatus();
49+
50+
if (summaryResponse.Table.Rows.Count > 0)
51+
{
52+
var row = summaryResponse.Table.Rows[0];
53+
var columns = summaryResponse.Table.Columns;
54+
55+
var lastEvent = GetDateTimeValue(row, columns, "lastEvent");
56+
var totalEvents = GetIntValue(row, columns, "totalEvents");
57+
var playerConnects = GetIntValue(row, columns, "playerConnects");
58+
var chatMessages = GetIntValue(row, columns, "chatMessages");
59+
var bansDetected = GetIntValue(row, columns, "bansDetected");
60+
var moderationTriggers = GetIntValue(row, columns, "moderationTriggers");
61+
62+
var isActive = lastEvent.HasValue &&
63+
(DateTime.UtcNow - lastEvent.Value).TotalMinutes <= AgentActiveThresholdMinutes;
64+
65+
status = status with
66+
{
67+
LastEventReceived = lastEvent,
68+
EventsLastHour = totalEvents,
69+
PlayersConnectedLastHour = playerConnects,
70+
ChatMessagesLastHour = chatMessages,
71+
BansDetectedLast24h = bansDetected,
72+
ModerationTriggersLast24h = moderationTriggers,
73+
IsAgentActive = isActive
74+
};
75+
}
76+
77+
if (mapResponse.Table.Rows.Count > 0)
78+
{
79+
var row = mapResponse.Table.Rows[0];
80+
var columns = mapResponse.Table.Columns;
81+
82+
status = status with
83+
{
84+
LastMapChange = GetDateTimeValue(row, columns, "timestamp"),
85+
LastMapName = GetStringValue(row, columns, "mapName")
86+
};
87+
}
88+
89+
return status;
90+
}
91+
92+
public async Task<IReadOnlyList<AgentServerSummary>> GetAllServersStatusAsync(CancellationToken ct = default)
93+
{
94+
var resourceId = GetAppInsightsResourceId();
95+
96+
var query = new StringBuilder();
97+
query.Append("customEvents");
98+
query.Append(" | where timestamp > ago(1h)");
99+
query.Append(" | extend serverId = tostring(customDimensions.ServerId)");
100+
query.Append(" | where isnotempty(serverId)");
101+
query.Append(" | summarize lastEvent=max(timestamp), eventCount=count(),");
102+
query.Append(" playerConnects=countif(name == 'PlayerConnected'),");
103+
query.Append(" lastMap=take_any(tostring(customDimensions.MapName))");
104+
query.Append(" by serverId");
105+
query.Append(" | order by lastEvent desc");
106+
107+
var response = await ExecuteQueryAsync(resourceId, query.ToString(), TimeSpan.FromHours(1), ct).ConfigureAwait(false);
108+
109+
var results = new List<AgentServerSummary>();
110+
111+
foreach (var row in response.Table.Rows)
112+
{
113+
var columns = response.Table.Columns;
114+
var serverIdStr = GetStringValue(row, columns, "serverId");
115+
116+
if (!Guid.TryParse(serverIdStr, out var serverId))
117+
continue;
118+
119+
var lastEvent = GetDateTimeValue(row, columns, "lastEvent");
120+
var isActive = lastEvent.HasValue &&
121+
(DateTime.UtcNow - lastEvent.Value).TotalMinutes <= AgentActiveThresholdMinutes;
122+
123+
results.Add(new AgentServerSummary
124+
{
125+
ServerId = serverId,
126+
LastEventReceived = lastEvent,
127+
EventsLastHour = GetIntValue(row, columns, "eventCount"),
128+
PlayerCount = GetIntValue(row, columns, "playerConnects"),
129+
CurrentMap = GetStringValue(row, columns, "lastMap"),
130+
IsAgentActive = isActive
131+
});
132+
}
133+
134+
return results;
135+
}
136+
137+
private async Task<LogsQueryResult> ExecuteQueryAsync(
138+
string resourceId, string query, TimeSpan timeRange, CancellationToken ct)
139+
{
140+
logger.LogDebug("Executing KQL query against App Insights: {Query}", query);
141+
142+
var response = await logsQueryClient.QueryResourceAsync(
143+
new ResourceIdentifier(resourceId),
144+
query,
145+
new QueryTimeRange(timeRange),
146+
cancellationToken: ct).ConfigureAwait(false);
147+
148+
return response.Value;
149+
}
150+
151+
private string GetAppInsightsResourceId()
152+
{
153+
var resourceId = configuration["ApplicationInsights:ResourceId"];
154+
155+
if (string.IsNullOrWhiteSpace(resourceId))
156+
{
157+
logger.LogError("ApplicationInsights:ResourceId is not configured");
158+
throw new InvalidOperationException(
159+
"ApplicationInsights:ResourceId must be configured with the full Azure resource ID");
160+
}
161+
162+
return resourceId;
163+
}
164+
165+
private static DateTime? GetDateTimeValue(LogsTableRow row, IReadOnlyList<LogsTableColumn> columns, string columnName)
166+
{
167+
var index = FindColumnIndex(columns, columnName);
168+
if (index < 0) return null;
169+
170+
var value = row[index];
171+
if (value is DateTimeOffset dto) return dto.UtcDateTime;
172+
if (value is DateTime dt) return dt;
173+
if (DateTime.TryParse(value?.ToString(), out var parsed)) return parsed;
174+
return null;
175+
}
176+
177+
private static int GetIntValue(LogsTableRow row, IReadOnlyList<LogsTableColumn> columns, string columnName)
178+
{
179+
var index = FindColumnIndex(columns, columnName);
180+
if (index < 0) return 0;
181+
182+
var value = row[index];
183+
if (value is int i) return i;
184+
if (value is long l) return (int)l;
185+
if (int.TryParse(value?.ToString(), out var parsed)) return parsed;
186+
return 0;
187+
}
188+
189+
private static string? GetStringValue(LogsTableRow row, IReadOnlyList<LogsTableColumn> columns, string columnName)
190+
{
191+
var index = FindColumnIndex(columns, columnName);
192+
if (index < 0) return null;
193+
return row[index]?.ToString();
194+
}
195+
196+
private static int FindColumnIndex(IReadOnlyList<LogsTableColumn> columns, string columnName)
197+
{
198+
for (var i = 0; i < columns.Count; i++)
199+
{
200+
if (columns[i].Name.Equals(columnName, StringComparison.OrdinalIgnoreCase))
201+
return i;
202+
}
203+
204+
return -1;
205+
}
206+
207+
private static string EscapeKql(string value)
208+
{
209+
return value.Replace("\\", "\\\\").Replace("'", "\\'");
210+
}
211+
}

0 commit comments

Comments
 (0)