From 053bfb0d92c8c75532ade525cc07b9d6c1d0a250 Mon Sep 17 00:00:00 2001 From: Vinzenz Schroeter Date: Tue, 7 May 2024 20:41:06 +0200 Subject: [PATCH] add health check --- tanks-backend/TanksServer/Endpoints.cs | 47 +++++++++++++ .../GameLogic/UpdatesPerSecondCounter.cs | 68 ++++++++++++++++--- tanks-backend/TanksServer/Program.cs | 10 ++- 3 files changed, 115 insertions(+), 10 deletions(-) diff --git a/tanks-backend/TanksServer/Endpoints.cs b/tanks-backend/TanksServer/Endpoints.cs index 0eb0100..e0e0252 100644 --- a/tanks-backend/TanksServer/Endpoints.cs +++ b/tanks-backend/TanksServer/Endpoints.cs @@ -1,7 +1,12 @@ +using System.IO; +using System.Text; +using System.Text.Json; using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Diagnostics.HealthChecks; using TanksServer.GameLogic; using TanksServer.Interactivity; @@ -25,6 +30,11 @@ internal sealed class Endpoints( app.MapGet("/map", () => mapService.MapNames); app.MapPost("/map", PostMap); app.MapGet("/map/{name}", GetMapByName); + + app.MapHealthChecks("/health", new HealthCheckOptions + { + ResponseWriter = WriteJsonHealthCheckResponse + }); } private Results, NotFound, Ok> PostMap([FromQuery] string name) @@ -107,4 +117,41 @@ internal sealed class Endpoints( var mapInfo = new MapInfo(prototype.Name, prototype.GetType().Name, preview.Data); return TypedResults.Ok(mapInfo); } + + private static Task WriteJsonHealthCheckResponse(HttpContext context, HealthReport healthReport) + { + context.Response.ContentType = "application/json; charset=utf-8"; + + var options = new JsonWriterOptions { Indented = true }; + + using var memoryStream = new MemoryStream(); + using (var jsonWriter = new Utf8JsonWriter(memoryStream, options)) + { + jsonWriter.WriteStartObject(); + jsonWriter.WriteString("status", healthReport.Status.ToString()); + jsonWriter.WriteStartObject("results"); + + foreach (var healthReportEntry in healthReport.Entries) + { + jsonWriter.WriteStartObject(healthReportEntry.Key); + jsonWriter.WriteString("status", + healthReportEntry.Value.Status.ToString()); + jsonWriter.WriteString("description", + healthReportEntry.Value.Description); + jsonWriter.WriteStartObject("data"); + + foreach (var item in healthReportEntry.Value.Data) + jsonWriter.WriteString(item.Key, item.Value.ToString()); + + jsonWriter.WriteEndObject(); + jsonWriter.WriteEndObject(); + } + + jsonWriter.WriteEndObject(); + jsonWriter.WriteEndObject(); + } + + return context.Response.WriteAsync( + Encoding.UTF8.GetString(memoryStream.ToArray())); + } } diff --git a/tanks-backend/TanksServer/GameLogic/UpdatesPerSecondCounter.cs b/tanks-backend/TanksServer/GameLogic/UpdatesPerSecondCounter.cs index 71a9818..f2a2613 100644 --- a/tanks-backend/TanksServer/GameLogic/UpdatesPerSecondCounter.cs +++ b/tanks-backend/TanksServer/GameLogic/UpdatesPerSecondCounter.cs @@ -1,13 +1,42 @@ using System.Diagnostics; +using System.Globalization; +using Microsoft.Extensions.Diagnostics.HealthChecks; namespace TanksServer.GameLogic; -internal sealed class UpdatesPerSecondCounter(ILogger logger) : ITickStep +internal sealed class UpdatesPerSecondCounter( + ILogger logger +) : ITickStep, IHealthCheck { private static readonly TimeSpan LongTime = TimeSpan.FromSeconds(5); private static readonly TimeSpan CriticalUpdateTime = TimeSpan.FromMilliseconds(50); private readonly Stopwatch _long = Stopwatch.StartNew(); + + private readonly record struct Statistics( + ulong Updates, + TimeSpan TotalTime, + double AverageUpdatesPerSecond, + TimeSpan MinFrameTime, + TimeSpan AverageFrameTime, + TimeSpan MaxFrameTime) + { + public override string ToString() => + $"{nameof(Updates)}: {Updates}, {nameof(TotalTime)}: {TotalTime}, {nameof(AverageUpdatesPerSecond)}: {AverageUpdatesPerSecond}, {nameof(MinFrameTime)}: {MinFrameTime}, {nameof(AverageFrameTime)}: {AverageFrameTime}, {nameof(MaxFrameTime)}: {MaxFrameTime}"; + + public Dictionary ToDictionary() => new() + { + [nameof(Updates)] = Updates.ToString(), + [nameof(TotalTime)] = TotalTime.ToString(), + [nameof(AverageUpdatesPerSecond)] = AverageUpdatesPerSecond.ToString(CultureInfo.InvariantCulture), + [nameof(MinFrameTime)] = MinFrameTime.ToString(), + [nameof(AverageFrameTime)] = AverageFrameTime.ToString(), + [nameof(MaxFrameTime)] = MaxFrameTime.ToString() + }; + }; + + private Statistics? _currentStatistics = null; + private ulong _updatesSinceLongReset; private TimeSpan _minFrameTime = TimeSpan.MaxValue; private TimeSpan _maxFrameTime = TimeSpan.MinValue; @@ -40,16 +69,20 @@ internal sealed class UpdatesPerSecondCounter(ILogger l private void LogCounters() { + var time = _long.Elapsed; + + _currentStatistics = new Statistics( + _updatesSinceLongReset, + time, + _updatesSinceLongReset / time.TotalSeconds, + _minFrameTime, + time / _updatesSinceLongReset, + _maxFrameTime); + if (!logger.IsEnabled(LogLevel.Debug)) return; - var time = _long.Elapsed; - var averageTime = Math.Round(time.TotalMilliseconds / _updatesSinceLongReset, 2); - var averageUps = Math.Round(_updatesSinceLongReset / time.TotalSeconds, 2); - var min = Math.Round(_minFrameTime.TotalMilliseconds, 2); - var max = Math.Round(_maxFrameTime.TotalMilliseconds, 2); - logger.LogDebug("count={}, time={}, avg={}ms, ups={}, min={}ms, max={}ms", - _updatesSinceLongReset, time, averageTime, averageUps, min, max); + logger.LogDebug("statistics: {}", _currentStatistics); } private void ResetCounters() @@ -59,4 +92,23 @@ internal sealed class UpdatesPerSecondCounter(ILogger l _minFrameTime = TimeSpan.MaxValue; _maxFrameTime = TimeSpan.MinValue; } + + public Task CheckHealthAsync(HealthCheckContext context, + CancellationToken cancellationToken = new()) + { + var stats = _currentStatistics; + if (stats == null) + { + return Task.FromResult( + HealthCheckResult.Degraded("no statistics available yet - this is expected shortly after start")); + } + + if (stats.Value.MaxFrameTime > CriticalUpdateTime) + { + return Task.FromResult(HealthCheckResult.Degraded("max frame time too high", null, + stats.Value.ToDictionary())); + } + + return Task.FromResult(HealthCheckResult.Healthy("", stats.Value.ToDictionary())); + } } diff --git a/tanks-backend/TanksServer/Program.cs b/tanks-backend/TanksServer/Program.cs index 40263f2..91c14fe 100644 --- a/tanks-backend/TanksServer/Program.cs +++ b/tanks-backend/TanksServer/Program.cs @@ -51,6 +51,9 @@ public static class Program builder.Services.AddHttpLogging(_ => { }); + var healthCheckBuilder = builder.Services.AddHealthChecks(); + healthCheckBuilder.AddCheck("updates check"); + builder.Services.Configure(builder.Configuration.GetSection("Host")); var hostConfiguration = builder.Configuration.GetSection("Host").Get(); if (hostConfiguration == null) @@ -66,12 +69,14 @@ public static class Program builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddHostedService(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); builder.Services.AddHostedService(sp => sp.GetRequiredService()); - builder.Services.AddSingleton(sp => sp.GetRequiredService()); + builder.Services.AddSingleton(sp => + sp.GetRequiredService()); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -82,7 +87,8 @@ public static class Program builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => sp.GetRequiredService()); - builder.Services.AddSingleton(); + builder.Services.AddSingleton(sp => + sp.GetRequiredService()); builder.Services.AddSingleton(); builder.Services.AddSingleton();