diff --git a/Catalogger.Backend/Bot/Commands/MetaCommands.cs b/Catalogger.Backend/Bot/Commands/MetaCommands.cs index 4a35132..11ec957 100644 --- a/Catalogger.Backend/Bot/Commands/MetaCommands.cs +++ b/Catalogger.Backend/Bot/Commands/MetaCommands.cs @@ -1,12 +1,12 @@ using System.ComponentModel; using System.Diagnostics; using System.Runtime.InteropServices; +using App.Metrics; using Catalogger.Backend.Cache; using Catalogger.Backend.Database; using Catalogger.Backend.Extensions; using Humanizer; using Microsoft.EntityFrameworkCore; -using NodaTime; using Remora.Commands.Attributes; using Remora.Commands.Groups; using Remora.Discord.API.Abstractions.Objects; @@ -15,6 +15,7 @@ using Remora.Discord.Commands.Feedback.Services; using Remora.Discord.Extensions.Embeds; using Remora.Discord.Gateway; using Remora.Results; +using IClock = NodaTime.IClock; using IResult = Remora.Results.IResult; namespace Catalogger.Backend.Bot.Commands; @@ -22,11 +23,11 @@ namespace Catalogger.Backend.Bot.Commands; [Group("catalogger")] public class MetaCommands( IClock clock, - DatabaseContext db, + IMetrics metrics, DiscordGatewayClient client, + IFeedbackService feedbackService, GuildCacheService guildCache, ChannelCacheService channelCache, - IFeedbackService feedbackService, IDiscordRestChannelAPI channelApi) : CommandGroup { [Command("ping")] @@ -37,7 +38,6 @@ public class MetaCommands( var msg = await feedbackService.SendContextualAsync("...").GetOrThrow(); var elapsed = clock.GetCurrentInstant() - t1; - var messageCount = await db.Messages.CountAsync(); var process = Process.GetCurrentProcess(); var memoryUsage = process.WorkingSet64; @@ -49,11 +49,19 @@ public class MetaCommands( inline: true); embed.AddField("Memory usage", memoryUsage.Bytes().Humanize(), inline: true); + var messagesReceived = metrics.Snapshot.GetForContext("Bot").Meters + .FirstOrDefault(m => m.MultidimensionalName == CataloggerMetrics.MessagesReceived.Name)?.Value; + if (messagesReceived != null) + embed.AddField("Messages received", $"{messagesReceived.OneMinuteRate * 60:F1}/m", true); + + var messageCount = metrics.Snapshot.GetForContext("Bot").Gauges + .FirstOrDefault(m => m.MultidimensionalName == CataloggerMetrics.MessagesStored.Name)?.Value ?? 0; + embed.AddField("Numbers", $"{messageCount:N0} messages from {guildCache.Size:N0} servers\nCached {channelCache.Size:N0} channels", inline: false); - List embeds = [embed.Build().GetOrThrow()]; + IEmbed[] embeds = [embed.Build().GetOrThrow()]; return (Result)await channelApi.EditMessageAsync(msg.ChannelID, msg.ID, content: "", embeds: embeds); } diff --git a/Catalogger.Backend/Bot/Responders/MessageCreateResponder.cs b/Catalogger.Backend/Bot/Responders/MessageCreateResponder.cs index c7cfdec..5d94fb2 100644 --- a/Catalogger.Backend/Bot/Responders/MessageCreateResponder.cs +++ b/Catalogger.Backend/Bot/Responders/MessageCreateResponder.cs @@ -1,4 +1,5 @@ using System.Text.RegularExpressions; +using App.Metrics; using Catalogger.Backend.Cache; using Catalogger.Backend.Database; using Catalogger.Backend.Database.Models; @@ -20,7 +21,8 @@ public class MessageCreateResponder( DatabaseContext db, MessageRepository messageRepository, UserCacheService userCache, - PkMessageHandler pkMessageHandler) + PkMessageHandler pkMessageHandler, + IMetrics metrics) : IResponder { private readonly ILogger _logger = logger.ForContext(); @@ -29,6 +31,7 @@ public class MessageCreateResponder( public async Task RespondAsync(IMessageCreate msg, CancellationToken ct = default) { userCache.UpdateUser(msg.Author); + metrics.Measure.Meter.Mark(CataloggerMetrics.MessagesReceived); if (!msg.GuildID.IsDefined()) { diff --git a/Catalogger.Backend/Cache/UserCacheService.cs b/Catalogger.Backend/Cache/UserCacheService.cs index 7053e66..0737878 100644 --- a/Catalogger.Backend/Cache/UserCacheService.cs +++ b/Catalogger.Backend/Cache/UserCacheService.cs @@ -8,6 +8,8 @@ namespace Catalogger.Backend.Cache; public class UserCacheService(IDiscordRestUserAPI userApi) { private readonly ConcurrentDictionary _cache = new(); + + public int Size => _cache.Count; public async Task GetUserAsync(Snowflake userId) { diff --git a/Catalogger.Backend/Catalogger.Backend.csproj b/Catalogger.Backend/Catalogger.Backend.csproj index 8967295..ef8a6b0 100644 --- a/Catalogger.Backend/Catalogger.Backend.csproj +++ b/Catalogger.Backend/Catalogger.Backend.csproj @@ -7,6 +7,7 @@ + @@ -27,6 +28,8 @@ + + diff --git a/Catalogger.Backend/CataloggerMetrics.cs b/Catalogger.Backend/CataloggerMetrics.cs new file mode 100644 index 0000000..4ef57b6 --- /dev/null +++ b/Catalogger.Backend/CataloggerMetrics.cs @@ -0,0 +1,88 @@ +using App.Metrics; +using App.Metrics.Gauge; +using App.Metrics.Meter; +using App.Metrics.Timer; + +namespace Catalogger.Backend; + +public static class CataloggerMetrics +{ + public static MeterOptions MessagesReceived => new() + { + Name = "Messages received", + MeasurementUnit = Unit.Events, + RateUnit = TimeUnit.Seconds, + Context = "Bot" + }; + + public static GaugeOptions GuildsCached => new() + { + Name = "Guilds cached", + MeasurementUnit = Unit.Items, + Context = "Bot" + }; + + public static GaugeOptions ChannelsCached => new() + { + Name = "Channels cached", + MeasurementUnit = Unit.Items, + Context = "Bot" + }; + + public static GaugeOptions UsersCached => new() + { + Name = "Users cached", + MeasurementUnit = Unit.Items, + Context = "Bot" + }; + + public static GaugeOptions MessagesStored => new() + { + Name = "Messages stored", + MeasurementUnit = Unit.Items, + Context = "Bot" + }; + + public static TimerOptions MetricsCollectionTime => new() + { + Name = "Metrics collection time", + MeasurementUnit = Unit.Events, + DurationUnit = TimeUnit.Milliseconds, + Context = "Bot" + }; + + public static GaugeOptions ProcessPhysicalMemory => new() + { + Name = "Process physical memory", + MeasurementUnit = Unit.Bytes, + Context = "Process" + }; + + public static GaugeOptions ProcessVirtualMemory => new() + { + Name = "Process virtual memory", + MeasurementUnit = Unit.Bytes, + Context = "Process" + }; + + public static GaugeOptions ProcessPrivateMemory => new() + { + Name = "Process private memory", + MeasurementUnit = Unit.Bytes, + Context = "Process" + }; + + public static GaugeOptions ProcessThreads => new() + { + Name = "Process thread count", + MeasurementUnit = Unit.Threads, + Context = "Process" + }; + + public static GaugeOptions ProcessHandles => new() + { + Name = "Process handle count", + MeasurementUnit = Unit.Items, + Context = "Process" + }; +} \ No newline at end of file diff --git a/Catalogger.Backend/Config.cs b/Catalogger.Backend/Config.cs index 5217cc1..0235d49 100644 --- a/Catalogger.Backend/Config.cs +++ b/Catalogger.Backend/Config.cs @@ -8,13 +8,13 @@ public class Config public DatabaseConfig Database { get; init; } = new(); public DiscordConfig Discord { get; init; } = new(); public WebConfig Web { get; init; } = new(); - + public class LoggingConfig { public LogEventLevel LogEventLevel { get; init; } = LogEventLevel.Debug; public bool LogQueries { get; init; } = false; } - + public class DatabaseConfig { public string Url { get; init; } = string.Empty; @@ -37,7 +37,9 @@ public class Config { public string Host { get; init; } = "localhost"; public int Port { get; init; } = 5000; + public int? MetricsPort { get; init; } public string BaseUrl { get; init; } = null!; public string Address => $"http://{Host}:{Port}"; + public string MetricsAddress => $"http://{Host}:{MetricsPort ?? Port}"; } } \ No newline at end of file diff --git a/Catalogger.Backend/Extensions/StartupExtensions.cs b/Catalogger.Backend/Extensions/StartupExtensions.cs index 16363dc..33eb9bb 100644 --- a/Catalogger.Backend/Extensions/StartupExtensions.cs +++ b/Catalogger.Backend/Extensions/StartupExtensions.cs @@ -75,7 +75,8 @@ public static class StartupExtensions .AddScoped() .AddSingleton() .AddSingleton() - .AddSingleton(InMemoryDataService.Instance); + .AddSingleton(InMemoryDataService.Instance) + .AddHostedService(); public static async Task Initialize(this WebApplication app) { diff --git a/Catalogger.Backend/Program.cs b/Catalogger.Backend/Program.cs index 1cce447..d5a4a55 100644 --- a/Catalogger.Backend/Program.cs +++ b/Catalogger.Backend/Program.cs @@ -1,3 +1,4 @@ +using App.Metrics; using Catalogger.Backend.Bot.Commands; using Catalogger.Backend.Database; using Catalogger.Backend.Extensions; @@ -49,6 +50,11 @@ builder.Host .AddInteractionGroup() ); +// Add metrics +// TODO: add actual reporter +var metricsBuilder = AppMetrics.CreateDefaultBuilder(); +builder.Services.AddSingleton(metricsBuilder.Build()); + builder.Services .AddDbContext() .AddCustomServices() diff --git a/Catalogger.Backend/Services/MetricsCollectionService.cs b/Catalogger.Backend/Services/MetricsCollectionService.cs new file mode 100644 index 0000000..b6d6040 --- /dev/null +++ b/Catalogger.Backend/Services/MetricsCollectionService.cs @@ -0,0 +1,61 @@ +using System.Diagnostics; +using App.Metrics; +using Catalogger.Backend.Cache; +using Catalogger.Backend.Database; +using Humanizer; +using Microsoft.EntityFrameworkCore; +using NodaTime.Extensions; + +namespace Catalogger.Backend.Services; + +public class MetricsCollectionService( + ILogger logger, + GuildCacheService guildCache, + ChannelCacheService channelCache, + UserCacheService userCache, + IMetrics metrics, + IServiceProvider services) : BackgroundService +{ + private readonly ILogger _logger = logger.ForContext(); + + private async Task CollectMetricsAsync() + { + var stopwatch = new Stopwatch(); + stopwatch.Start(); + + await using var scope = services.CreateAsyncScope(); + await using var db = scope.ServiceProvider.GetRequiredService(); + + var messageCount = await db.Messages.CountAsync(); + + metrics.Measure.Gauge.SetValue(CataloggerMetrics.GuildsCached, guildCache.Size); + metrics.Measure.Gauge.SetValue(CataloggerMetrics.ChannelsCached, channelCache.Size); + metrics.Measure.Gauge.SetValue(CataloggerMetrics.UsersCached, userCache.Size); + metrics.Measure.Gauge.SetValue(CataloggerMetrics.MessagesStored, messageCount); + + var process = Process.GetCurrentProcess(); + metrics.Measure.Gauge.SetValue(CataloggerMetrics.ProcessPhysicalMemory, process.WorkingSet64); + metrics.Measure.Gauge.SetValue(CataloggerMetrics.ProcessVirtualMemory, process.VirtualMemorySize64); + metrics.Measure.Gauge.SetValue(CataloggerMetrics.ProcessPrivateMemory, process.PrivateMemorySize64); + metrics.Measure.Gauge.SetValue(CataloggerMetrics.ProcessThreads, process.Threads.Count); + metrics.Measure.Gauge.SetValue(CataloggerMetrics.ProcessHandles, process.HandleCount); + + stopwatch.Stop(); + + metrics.Measure.Timer.Time(CataloggerMetrics.MetricsCollectionTime, stopwatch.ElapsedMilliseconds); + _logger.Information("Collected metrics in {Duration}", stopwatch.ElapsedDuration()); + + await Task.WhenAll(((IMetricsRoot)metrics).ReportRunner.RunAllAsync()); + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + using var timer = new PeriodicTimer(1.Minutes()); + while (await timer.WaitForNextTickAsync(stoppingToken)) + { + _logger.Debug("Collecting periodic metrics"); + await CollectMetricsAsync(); + _logger.Debug("Reported metrics to backend"); + } + } +} \ No newline at end of file