diff --git a/Foxnouns.Backend/Config.cs b/Foxnouns.Backend/Config.cs index 140214d..132a28c 100644 --- a/Foxnouns.Backend/Config.cs +++ b/Foxnouns.Backend/Config.cs @@ -10,7 +10,7 @@ public class Config public string MediaBaseUrl { get; set; } = null!; public string Address => $"http://{Host}:{Port}"; - public string? MetricsAddress => Logging.MetricsPort != null ? $"http://{Host}:{Logging.MetricsPort}" : null; + public string MetricsAddress => $"http://{Host}:{Logging.MetricsPort}"; public LoggingConfig Logging { get; init; } = new(); public DatabaseConfig Database { get; init; } = new(); @@ -27,7 +27,8 @@ public class Config public bool SentryTracing { get; init; } = false; public double SentryTracesSampleRate { get; init; } = 0.0; public bool LogQueries { get; init; } = false; - public int? MetricsPort { get; init; } + public bool EnableMetrics { get; init; } = false; + public ushort MetricsPort { get; init; } = 5001; } public class DatabaseConfig diff --git a/Foxnouns.Backend/Controllers/MetaController.cs b/Foxnouns.Backend/Controllers/MetaController.cs index 6f6b4e1..31c505e 100644 --- a/Foxnouns.Backend/Controllers/MetaController.cs +++ b/Foxnouns.Backend/Controllers/MetaController.cs @@ -1,30 +1,23 @@ -using Foxnouns.Backend.Database; -using Microsoft.EntityFrameworkCore; using Microsoft.AspNetCore.Mvc; -using NodaTime; namespace Foxnouns.Backend.Controllers; [Route("/api/v2/meta")] -public class MetaController(DatabaseContext db, IClock clock) : ApiControllerBase +public class MetaController : ApiControllerBase { private const string Repository = "https://codeberg.org/pronounscc/pronouns.cc"; [HttpGet] [ProducesResponseType(StatusCodes.Status200OK)] - public async Task GetMeta() + public IActionResult GetMeta() { - var now = clock.GetCurrentInstant(); - var users = await db.Users.Select(u => u.LastActive).ToListAsync(); - var memberCount = await db.Members.CountAsync(); - return Ok(new MetaResponse( - Repository, BuildInfo.Version, BuildInfo.Hash, memberCount, + Repository, BuildInfo.Version, BuildInfo.Hash, (int)FoxnounsMetrics.MemberCount.Value, new UserInfo( - users.Count, - users.Count(i => i > now - Duration.FromDays(30)), - users.Count(i => i > now - Duration.FromDays(7)), - users.Count(i => i > now - Duration.FromDays(1)) + (int)FoxnounsMetrics.UsersCount.Value, + (int)FoxnounsMetrics.UsersActiveMonthCount.Value, + (int)FoxnounsMetrics.UsersActiveWeekCount.Value, + (int)FoxnounsMetrics.UsersActiveDayCount.Value )) ); } diff --git a/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs b/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs index 1915eae..a76928c 100644 --- a/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs +++ b/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs @@ -1,6 +1,3 @@ -using App.Metrics; -using App.Metrics.AspNetCore; -using App.Metrics.Formatters.Prometheus; using Coravel; using Coravel.Queuing.Interfaces; using Foxnouns.Backend.Database; @@ -9,6 +6,7 @@ using Foxnouns.Backend.Middleware; using Foxnouns.Backend.Services; using Microsoft.EntityFrameworkCore; using NodaTime; +using Prometheus; using Serilog; using Serilog.Events; using IClock = NodaTime.IClock; @@ -59,32 +57,12 @@ public static class WebApplicationExtensions return config; } - public static WebApplicationBuilder AddMetrics(this WebApplicationBuilder builder) + public static WebApplicationBuilder AddMetrics(this WebApplicationBuilder builder, Config config) { - var config = builder.Configuration.Get() ?? new(); - var metrics = AppMetrics.CreateDefaultBuilder() - .OutputMetrics.AsPrometheusPlainText() - .Build(); - - builder.Services.AddSingleton(metrics); - builder.Services.AddSingleton(metrics); - - builder.WebHost - .ConfigureMetrics(metrics) - .UseMetrics(opts => - { - opts.EndpointOptions = options => - { - // Metrics must listen on a separate port for security reasons. If no metrics port is set, disable the endpoint entirely. - options.MetricsEndpointEnabled = config.Logging.MetricsPort != null; - options.EnvironmentInfoEndpointEnabled = config.Logging.MetricsPort != null; - options.MetricsTextEndpointEnabled = false; - options.MetricsEndpointOutputFormatter = metrics.OutputMetricsFormatters - .OfType().First(); - }; - }) - .UseMetricsWebTracking() - .ConfigureAppMetricsHostingConfiguration(opts => { opts.AllEndpointsPort = config.Logging.MetricsPort; }); + builder.Services.AddMetricServer(o => o.Port = config.Logging.MetricsPort) + .AddSingleton(); + if (!config.Logging.EnableMetrics) + builder.Services.AddHostedService(); return builder; } diff --git a/Foxnouns.Backend/Foxnouns.Backend.csproj b/Foxnouns.Backend/Foxnouns.Backend.csproj index 711e620..82ccf80 100644 --- a/Foxnouns.Backend/Foxnouns.Backend.csproj +++ b/Foxnouns.Backend/Foxnouns.Backend.csproj @@ -6,9 +6,6 @@ - - - @@ -25,6 +22,8 @@ + + diff --git a/Foxnouns.Backend/FoxnounsMetrics.cs b/Foxnouns.Backend/FoxnounsMetrics.cs new file mode 100644 index 0000000..b5cc1ac --- /dev/null +++ b/Foxnouns.Backend/FoxnounsMetrics.cs @@ -0,0 +1,37 @@ +using Prometheus; + +namespace Foxnouns.Backend; + +public static class FoxnounsMetrics +{ + public static readonly Gauge UsersCount = + Metrics.CreateGauge("foxnouns_user_count", "Number of total users"); + + public static readonly Gauge UsersActiveMonthCount = + Metrics.CreateGauge("foxnouns_user_count_active_month", "Number of users active in the last month"); + + public static readonly Gauge UsersActiveWeekCount = + Metrics.CreateGauge("foxnouns_user_count_active_week", "Number of users active in the last week"); + + public static readonly Gauge UsersActiveDayCount = + Metrics.CreateGauge("foxnouns_user_count_active_day", "Number of users active in the last day"); + + public static readonly Gauge MemberCount = + Metrics.CreateGauge("foxnouns_member_count", "Number of total members"); + + public static readonly Summary MetricsCollectionTime = + Metrics.CreateSummary("foxnouns_time_metrics", "Time it took to collect metrics"); + + public static Gauge ProcessPhysicalMemory => + Metrics.CreateGauge("foxnouns_process_physical_memory", "Process physical memory"); + + public static Gauge ProcessVirtualMemory => + Metrics.CreateGauge("foxnouns_process_virtual_memory", "Process virtual memory"); + + public static Gauge ProcessPrivateMemory => + Metrics.CreateGauge("foxnouns_process_private_memory", "Process private memory"); + + public static Gauge ProcessThreads => Metrics.CreateGauge("foxnouns_process_threads", "Process thread count"); + + public static Gauge ProcessHandles => Metrics.CreateGauge("foxnouns_process_handles", "Process handle count"); +} \ No newline at end of file diff --git a/Foxnouns.Backend/Metrics.cs b/Foxnouns.Backend/Metrics.cs deleted file mode 100644 index a830ab8..0000000 --- a/Foxnouns.Backend/Metrics.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Foxnouns.Backend; - -public static class Metrics -{ - -} \ No newline at end of file diff --git a/Foxnouns.Backend/Program.cs b/Foxnouns.Backend/Program.cs index e1f201e..e849bd1 100644 --- a/Foxnouns.Backend/Program.cs +++ b/Foxnouns.Backend/Program.cs @@ -9,6 +9,7 @@ using Microsoft.AspNetCore.Mvc; using Minio; using Newtonsoft.Json; using Newtonsoft.Json.Serialization; +using Prometheus; using Sentry.Extensibility; // Read version information from .version in the repository root @@ -18,7 +19,9 @@ var builder = WebApplication.CreateBuilder(args); var config = builder.AddConfiguration(); -builder.AddSerilog().AddMetrics(); +builder + .AddSerilog() + .AddMetrics(config); builder.WebHost .UseSentry(opts => @@ -89,7 +92,10 @@ app.MapControllers(); app.Urls.Clear(); app.Urls.Add(config.Address); -if (config.MetricsAddress != null) app.Urls.Add(config.MetricsAddress); + +// Make sure metrics are updated whenever Prometheus scrapes them +Metrics.DefaultRegistry.AddBeforeCollectCallback(async ct => + await app.Services.GetRequiredService().CollectMetricsAsync(ct)); // Fire off the periodic tasks loop in the background _ = new Timer(_ => diff --git a/Foxnouns.Backend/Services/MetricsCollectionService.cs b/Foxnouns.Backend/Services/MetricsCollectionService.cs new file mode 100644 index 0000000..f860650 --- /dev/null +++ b/Foxnouns.Backend/Services/MetricsCollectionService.cs @@ -0,0 +1,66 @@ +using System.Diagnostics; +using Foxnouns.Backend.Database; +using Microsoft.EntityFrameworkCore; +using NodaTime; +using Prometheus; + +namespace Foxnouns.Backend.Services; + +public class MetricsCollectionService( + ILogger logger, + IServiceProvider services, + IClock clock) +{ + private readonly ILogger _logger = logger.ForContext(); + + private static readonly Duration Month = Duration.FromDays(30); + private static readonly Duration Week = Duration.FromDays(7); + private static readonly Duration Day = Duration.FromDays(1); + + public async Task CollectMetricsAsync(CancellationToken ct = default) + { + var timer = FoxnounsMetrics.MetricsCollectionTime.NewTimer(); + var now = clock.GetCurrentInstant(); + + await using var scope = services.CreateAsyncScope(); + await using var db = scope.ServiceProvider.GetRequiredService(); + + var users = await db.Users.Where(u => !u.Deleted).Select(u => u.LastActive).ToListAsync(ct); + FoxnounsMetrics.UsersCount.Set(users.Count); + FoxnounsMetrics.UsersActiveMonthCount.Set(users.Count(i => i > now - Month)); + FoxnounsMetrics.UsersActiveWeekCount.Set(users.Count(i => i > now - Week)); + FoxnounsMetrics.UsersActiveDayCount.Set(users.Count(i => i > now - Day)); + + var memberCount = await db.Members.Include(m => m.User) + .Where(m => !m.Unlisted && !m.User.ListHidden && !m.User.Deleted).CountAsync(ct); + FoxnounsMetrics.MemberCount.Set(memberCount); + + var process = Process.GetCurrentProcess(); + FoxnounsMetrics.ProcessPhysicalMemory.Set(process.WorkingSet64); + FoxnounsMetrics.ProcessVirtualMemory.Set(process.VirtualMemorySize64); + FoxnounsMetrics.ProcessPrivateMemory.Set(process.PrivateMemorySize64); + FoxnounsMetrics.ProcessThreads.Set(process.Threads.Count); + FoxnounsMetrics.ProcessHandles.Set(process.HandleCount); + + _logger.Information("Collected metrics in {DurationMilliseconds} ms", + timer.ObserveDuration().TotalMilliseconds); + } +} + +public class BackgroundMetricsCollectionService(ILogger logger, MetricsCollectionService innerService) + : BackgroundService +{ + private readonly ILogger _logger = logger.ForContext(); + + protected override async Task ExecuteAsync(CancellationToken ct) + { + _logger.Information("Metrics are disabled, periodically collecting metrics manually"); + + using var timer = new PeriodicTimer(TimeSpan.FromMinutes(1)); + while (await timer.WaitForNextTickAsync(ct)) + { + _logger.Debug("Collecting metrics"); + await innerService.CollectMetricsAsync(ct); + } + } +} \ No newline at end of file diff --git a/Foxnouns.Backend/config.example.ini b/Foxnouns.Backend/config.example.ini index 0b80a7a..27e5cbf 100644 --- a/Foxnouns.Backend/config.example.ini +++ b/Foxnouns.Backend/config.example.ini @@ -20,7 +20,11 @@ SentryTracing = true SentryTracesSampleRate = 1.0 ; Whether to log SQL queries. Note that this is very verbose. Defaults to false. LogQueries = false -; The port the /metrics endpoint will listen on. If not set, metrics will be disabled. +; Whether metrics are enabled. If this is set to true, Foxnouns.NET will rely on Prometheus scraping metrics to update stats. +; If set to false, a background service will be used instead. Does not actually disable the /metrics endpoint. +; Defaults to false. +EnableMetrics = true +; The port the /metrics endpoint will listen on. Defaults to 5001. MetricsPort = 5001 [Database]