mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 08:36:11 +02:00
feat: add periodic server stats reporting and logging
- Introduced ServerStatsReport and ServerStatsCollector for tracking server activity. - Implemented ServerStatsReportService to generate and persist stats reports periodically. - Added configuration options for stats reporting in appsettings and .env.example. - Enhanced logging in ChannelsController and ModerationController to include stats-related actions. - Updated database context and migrations to support new ServerStatsReport entity. - Adjusted logging levels in IrcGatewayService and ChatService for better performance.
This commit is contained in:
@@ -75,3 +75,19 @@ Irc__Enabled=false
|
||||
|
||||
# ── Logging ──────────────────────────────────────────────────────────
|
||||
# Serilog__MinimumLevel__Default=Information
|
||||
|
||||
# ── Server logs room ─────────────────────────────────────────────────
|
||||
# Read-only system channel that live-streams Serilog events to Mod+ users.
|
||||
# ServerLogs__Enabled=true
|
||||
# ServerLogs__RoomName=server-logs
|
||||
# ServerLogs__MinRole=Mod
|
||||
# ServerLogs__MinLevel=Information
|
||||
# ServerLogs__BacklogLines=100
|
||||
# ServerLogs__LogDirectory=logs
|
||||
# ServerLogs__LogFilePattern=echohub-server-*.log
|
||||
|
||||
# ── Periodic stats report ────────────────────────────────────────────
|
||||
# Aggregate activity snapshot logged as JSON and persisted to the DB.
|
||||
# Stats__Enabled=true
|
||||
# Stats__IntervalHours=6
|
||||
# Stats__RetentionDays=90
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
namespace EchoHub.Core.Models;
|
||||
|
||||
/// <summary>
|
||||
/// A snapshot of server activity over one reporting window, produced periodically by the
|
||||
/// stats-report background job. Each report is logged as pretty-printed JSON and persisted
|
||||
/// for historical trend analysis. The window is "since the previous report" (or since startup
|
||||
/// for the first report of a run).
|
||||
/// </summary>
|
||||
public class ServerStatsReport
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>When this report was generated (equals <see cref="PeriodEnd"/>).</summary>
|
||||
public DateTimeOffset GeneratedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
|
||||
/// <summary>Start of the reporting window.</summary>
|
||||
public DateTimeOffset PeriodStart { get; set; }
|
||||
|
||||
/// <summary>End of the reporting window.</summary>
|
||||
public DateTimeOffset PeriodEnd { get; set; }
|
||||
|
||||
/// <summary>Length of the reporting window in hours.</summary>
|
||||
public double WindowHours { get; set; }
|
||||
|
||||
// ── Activity during the window ──────────────────────────────────────────
|
||||
/// <summary>Messages sent during the window.</summary>
|
||||
public int MessagesSent { get; set; }
|
||||
|
||||
/// <summary>Attachments (files/images/audio) uploaded during the window.</summary>
|
||||
public int FilesUploaded { get; set; }
|
||||
|
||||
/// <summary>Total bytes across all attachments uploaded during the window.</summary>
|
||||
public long BytesUploaded { get; set; }
|
||||
|
||||
/// <summary>Accounts registered during the window ("new members joined").</summary>
|
||||
public int NewMembers { get; set; }
|
||||
|
||||
/// <summary>Distinct users who sent at least one message during the window.</summary>
|
||||
public int ActiveMembers { get; set; }
|
||||
|
||||
/// <summary>Session connects during the window (per-connection, across SignalR + IRC).</summary>
|
||||
public int Connections { get; set; }
|
||||
|
||||
/// <summary>Session disconnects during the window ("members left" sessions).</summary>
|
||||
public int Disconnections { get; set; }
|
||||
|
||||
/// <summary>Users kicked during the window.</summary>
|
||||
public int Kicks { get; set; }
|
||||
|
||||
/// <summary>Users banned during the window.</summary>
|
||||
public int Bans { get; set; }
|
||||
|
||||
// ── Point-in-time totals at window end ──────────────────────────────────
|
||||
/// <summary>Total registered (unique) members at window end.</summary>
|
||||
public int TotalMembers { get; set; }
|
||||
|
||||
/// <summary>Distinct users online at window end.</summary>
|
||||
public int OnlineNow { get; set; }
|
||||
|
||||
/// <summary>Peak distinct users online observed during the window.</summary>
|
||||
public int PeakOnline { get; set; }
|
||||
}
|
||||
@@ -113,7 +113,7 @@ public sealed class IrcGatewayService : BackgroundService
|
||||
var connection = new IrcClientConnection(tcpClient, stream);
|
||||
_connections[connection.ConnectionId] = connection;
|
||||
|
||||
_logger.LogInformation("IRC client connected: {Id}", connection.ConnectionId);
|
||||
_logger.LogDebug("IRC client connected: {Id}", connection.ConnectionId);
|
||||
|
||||
IChatService? chatService = null;
|
||||
|
||||
@@ -148,7 +148,7 @@ public sealed class IrcGatewayService : BackgroundService
|
||||
|
||||
_connections.TryRemove(connection.ConnectionId, out _);
|
||||
await connection.DisposeAsync();
|
||||
_logger.LogInformation("IRC client {Id} ({Nick}) disconnected",
|
||||
_logger.LogDebug("IRC client {Id} ({Nick}) disconnected",
|
||||
connection.ConnectionId, connection.Nickname ?? "unregistered");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace EchoHub.Server.Config;
|
||||
|
||||
/// <summary>
|
||||
/// Periodic server-stats report settings, bound from the "Stats" config section (env override:
|
||||
/// <c>Stats__Enabled</c> etc.). When enabled, a background job periodically snapshots server
|
||||
/// activity, logs it as pretty-printed JSON, and persists it to the database.
|
||||
/// </summary>
|
||||
public sealed class StatsOptions
|
||||
{
|
||||
/// <summary>Master switch for the periodic stats report job.</summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>How often a report is generated, in hours. Default: every 6 hours.</summary>
|
||||
public double IntervalHours { get; set; } = 6;
|
||||
|
||||
/// <summary>
|
||||
/// How long persisted reports are kept before being pruned, in days. Set to 0 to keep
|
||||
/// reports indefinitely. Default: 90 days.
|
||||
/// </summary>
|
||||
public int RetentionDays { get; set; } = 90;
|
||||
}
|
||||
@@ -29,6 +29,7 @@ public class ChannelsController : ControllerBase
|
||||
private readonly IChatService _chatService;
|
||||
private readonly IMessageEncryptionService _encryption;
|
||||
private readonly UploadLimits _uploadLimits;
|
||||
private readonly ILogger<ChannelsController> _logger;
|
||||
|
||||
public ChannelsController(
|
||||
IChannelService channelService,
|
||||
@@ -38,7 +39,8 @@ public class ChannelsController : ControllerBase
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IChatService chatService,
|
||||
IMessageEncryptionService encryption,
|
||||
UploadLimits uploadLimits)
|
||||
UploadLimits uploadLimits,
|
||||
ILogger<ChannelsController> logger)
|
||||
{
|
||||
_channelService = channelService;
|
||||
_db = db;
|
||||
@@ -48,6 +50,7 @@ public class ChannelsController : ControllerBase
|
||||
_chatService = chatService;
|
||||
_encryption = encryption;
|
||||
_uploadLimits = uploadLimits;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
@@ -289,6 +292,12 @@ public class ChannelsController : ControllerBase
|
||||
});
|
||||
attachmentDtos.Add(new AttachmentDto(kind, url, file.FileName, file.Length,
|
||||
_encryption.EncryptNullable(previewPlain)));
|
||||
|
||||
// Filename is client-encrypted ciphertext in E2E rooms — never log it there.
|
||||
var loggedName = channelDto.IsEncrypted ? "[encrypted]" : file.FileName;
|
||||
_logger.LogInformation(
|
||||
"{User} uploaded {Kind} '{FileName}' ({Size} bytes) to '{Channel}': {Url}",
|
||||
usernameClaim, kind, loggedName, file.Length, channelName, url);
|
||||
}
|
||||
|
||||
var dbContent = _encryption.EncryptDatabaseEnabled ? _encryption.Encrypt(content) : content;
|
||||
@@ -446,6 +455,10 @@ public class ChannelsController : ControllerBase
|
||||
_db.Messages.Add(message);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation(
|
||||
"{User} uploaded Image '{FileName}' ({Size} bytes) from URL to '{Channel}': {Url} (source: {Source})",
|
||||
usernameClaim, fileName, imageBytes.Length, channelName, attachmentUrl, request.Url);
|
||||
|
||||
var messageDto = new MessageDto(
|
||||
message.Id,
|
||||
_encryption.Encrypt(string.Empty),
|
||||
|
||||
@@ -4,6 +4,7 @@ using EchoHub.Core.DTOs;
|
||||
using EchoHub.Core.Models;
|
||||
using EchoHub.Server.Data;
|
||||
using EchoHub.Server.Services;
|
||||
using EchoHub.Server.Services.Stats;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
@@ -22,19 +23,25 @@ public class ModerationController : ControllerBase
|
||||
private readonly PresenceTracker _presenceTracker;
|
||||
private readonly FileStorageService _fileStorage;
|
||||
private readonly IEnumerable<IChatBroadcaster> _broadcasters;
|
||||
private readonly ServerStatsCollector _statsCollector;
|
||||
private readonly ILogger<ModerationController> _logger;
|
||||
|
||||
public ModerationController(
|
||||
EchoHubDbContext db,
|
||||
IChatService chatService,
|
||||
PresenceTracker presenceTracker,
|
||||
FileStorageService fileStorage,
|
||||
IEnumerable<IChatBroadcaster> broadcasters)
|
||||
IEnumerable<IChatBroadcaster> broadcasters,
|
||||
ServerStatsCollector statsCollector,
|
||||
ILogger<ModerationController> logger)
|
||||
{
|
||||
_db = db;
|
||||
_chatService = chatService;
|
||||
_presenceTracker = presenceTracker;
|
||||
_fileStorage = fileStorage;
|
||||
_broadcasters = broadcasters;
|
||||
_statsCollector = statsCollector;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpPost("role")]
|
||||
@@ -56,9 +63,14 @@ public class ModerationController : ControllerBase
|
||||
if (request.Role >= caller!.Role)
|
||||
return BadRequest(new ErrorResponse("Cannot assign a role equal to or above your own."));
|
||||
|
||||
var previousRole = target.Role;
|
||||
target.Role = request.Role;
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Role change: {Actor} set {Target} from {OldRole} to {NewRole}",
|
||||
caller!.Username, target.Username, previousRole, request.Role);
|
||||
|
||||
return Ok(new { Message = $"{target.Username} is now {request.Role}." });
|
||||
}
|
||||
|
||||
@@ -86,6 +98,11 @@ public class ModerationController : ControllerBase
|
||||
var reason = request?.Reason ?? "You have been kicked from the server.";
|
||||
await ForceDisconnectAndCleanupAsync(target.Username, reason);
|
||||
|
||||
_statsCollector.RecordKick();
|
||||
_logger.LogInformation(
|
||||
"Kick: {Actor} kicked {Target} (reason: {Reason})",
|
||||
caller!.Username, target.Username, request?.Reason ?? "none");
|
||||
|
||||
return Ok(new { Message = $"{target.Username} has been kicked." });
|
||||
}
|
||||
|
||||
@@ -111,13 +128,18 @@ public class ModerationController : ControllerBase
|
||||
var reason = request?.Reason ?? "You have been banned from this server.";
|
||||
await ForceDisconnectAndCleanupAsync(target.Username, reason);
|
||||
|
||||
_statsCollector.RecordBan();
|
||||
_logger.LogWarning(
|
||||
"Ban: {Actor} banned {Target} (reason: {Reason})",
|
||||
caller!.Username, target.Username, request?.Reason ?? "none");
|
||||
|
||||
return Ok(new { Message = $"{target.Username} has been banned." });
|
||||
}
|
||||
|
||||
[HttpPost("unban/{username}")]
|
||||
public async Task<IActionResult> UnbanUser(string username)
|
||||
{
|
||||
var (_, error) = await GetCallerAsync(ServerRole.Admin);
|
||||
var (caller, error) = await GetCallerAsync(ServerRole.Admin);
|
||||
if (error is not null) return error;
|
||||
|
||||
var target = await _db.Users.FirstOrDefaultAsync(u => u.Username == username.ToLowerInvariant());
|
||||
@@ -127,6 +149,8 @@ public class ModerationController : ControllerBase
|
||||
target.IsBanned = false;
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Unban: {Actor} unbanned {Target}", caller!.Username, target.Username);
|
||||
|
||||
return Ok(new { Message = $"{target.Username} has been unbanned." });
|
||||
}
|
||||
|
||||
@@ -149,6 +173,12 @@ public class ModerationController : ControllerBase
|
||||
: null;
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Mute: {Actor} muted {Target} ({Duration}, reason: {Reason})",
|
||||
caller!.Username, target.Username,
|
||||
request?.DurationMinutes is > 0 ? $"{request.DurationMinutes}m" : "indefinite",
|
||||
request?.Reason ?? "none");
|
||||
|
||||
var durationText = request?.DurationMinutes is > 0 ? $" for {request.DurationMinutes} minutes" : "";
|
||||
return Ok(new { Message = $"{target.Username} has been muted{durationText}." });
|
||||
}
|
||||
@@ -156,7 +186,7 @@ public class ModerationController : ControllerBase
|
||||
[HttpPost("unmute/{username}")]
|
||||
public async Task<IActionResult> UnmuteUser(string username)
|
||||
{
|
||||
var (_, error) = await GetCallerAsync(ServerRole.Mod);
|
||||
var (caller, error) = await GetCallerAsync(ServerRole.Mod);
|
||||
if (error is not null) return error;
|
||||
|
||||
var target = await _db.Users.FirstOrDefaultAsync(u => u.Username == username.ToLowerInvariant());
|
||||
@@ -167,6 +197,8 @@ public class ModerationController : ControllerBase
|
||||
target.MutedUntil = null;
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Unmute: {Actor} unmuted {Target}", caller!.Username, target.Username);
|
||||
|
||||
return Ok(new { Message = $"{target.Username} has been unmuted." });
|
||||
}
|
||||
|
||||
@@ -219,13 +251,19 @@ public class ModerationController : ControllerBase
|
||||
|
||||
await BroadcastToAllAsync(b => b.SendMessageDeletedAsync(channelName, messageId));
|
||||
|
||||
// Only moderator removals of another user's message are noteworthy; self-deletes are routine.
|
||||
if (!isOwnMessage)
|
||||
_logger.LogInformation(
|
||||
"Message removed: {Actor} deleted {Author}'s message {MessageId} in '{Channel}'",
|
||||
caller.Username, message.SenderUsername, messageId, channelName);
|
||||
|
||||
return Ok(new { Message = "Message deleted." });
|
||||
}
|
||||
|
||||
[HttpDelete("channels/{channel}/nuke")]
|
||||
public async Task<IActionResult> NukeChannel(string channel)
|
||||
{
|
||||
var (_, error) = await GetCallerAsync(ServerRole.Mod);
|
||||
var (caller, error) = await GetCallerAsync(ServerRole.Mod);
|
||||
if (error is not null) return error;
|
||||
|
||||
var channelName = channel.ToLowerInvariant().Trim();
|
||||
@@ -251,6 +289,10 @@ public class ModerationController : ControllerBase
|
||||
|
||||
await BroadcastToAllAsync(b => b.SendChannelNukedAsync(channelName));
|
||||
|
||||
_logger.LogWarning(
|
||||
"Channel nuked: {Actor} cleared {Count} messages from '{Channel}'",
|
||||
caller!.Username, messages.Count, channelName);
|
||||
|
||||
return Ok(new { Message = $"All messages in #{channelName} have been cleared." });
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ public class EchoHubDbContext : DbContext
|
||||
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
|
||||
public DbSet<ChannelMembership> ChannelMemberships => Set<ChannelMembership>();
|
||||
public DbSet<InviteCode> InviteCodes => Set<InviteCode>();
|
||||
public DbSet<ServerStatsReport> ServerStatsReports => Set<ServerStatsReport>();
|
||||
|
||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||
{
|
||||
@@ -107,6 +108,12 @@ public class EchoHubDbContext : DbContext
|
||||
entity.Property(i => i.CreatedByUsername).IsRequired().HasMaxLength(50);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<ServerStatsReport>(entity =>
|
||||
{
|
||||
entity.HasKey(r => r.Id);
|
||||
entity.HasIndex(r => r.GeneratedAt);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<RefreshToken>(entity =>
|
||||
{
|
||||
entity.HasKey(r => r.Id);
|
||||
|
||||
+437
@@ -0,0 +1,437 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using EchoHub.Server.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace EchoHub.Server.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(EchoHubDbContext))]
|
||||
[Migration("20260717200332_AddServerStatsReports")]
|
||||
partial class AddServerStatsReports
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.3");
|
||||
|
||||
modelBuilder.Entity("EchoHub.Core.Models.Attachment", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("AsciiPreview")
|
||||
.HasMaxLength(64000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("FileName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("FileSize")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Kind")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("MessageId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Url")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("MessageId");
|
||||
|
||||
b.ToTable("Attachments");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("EchoHub.Core.Models.Channel", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("CreatedByUserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("EncryptionSalt")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsPublic")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsSystem")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Topic")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("WrappedRoomKey")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Channels");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b =>
|
||||
{
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("ChannelId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("JoinedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("UserId", "ChannelId");
|
||||
|
||||
b.HasIndex("ChannelId");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("ChannelMemberships");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("EchoHub.Core.Models.InviteCode", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Code")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("CreatedByUserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CreatedByUsername")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("ExpiresAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("MaxUses")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("UseCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Code")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("InviteCodes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("AttachmentFileName")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("AttachmentFileSize")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AttachmentUrl")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("ChannelId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Content")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("EmbedJson")
|
||||
.HasMaxLength(32000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("ReplyToMessageId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("SenderUserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("SenderUsername")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("SentAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ChannelId");
|
||||
|
||||
b.HasIndex("SentAt");
|
||||
|
||||
b.ToTable("Messages");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("ExpiresAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long?>("RevokedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("TokenHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TokenHash");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("RefreshTokens");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("EchoHub.Core.Models.ServerStatsReport", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("ActiveMembers")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Bans")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("BytesUploaded")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Connections")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Disconnections")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("FilesUploaded")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("GeneratedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Kicks")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("MessagesSent")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("NewMembers")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("OnlineNow")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("PeakOnline")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PeriodEnd")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PeriodStart")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("TotalMembers")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<double>("WindowHours")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("GeneratedAt");
|
||||
|
||||
b.ToTable("ServerStatsReports");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("EchoHub.Core.Models.User", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("AvatarAscii")
|
||||
.HasMaxLength(10000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Bio")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsBanned")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsMuted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("LastSeenAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long?>("MutedUntil")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("NicknameColor")
|
||||
.HasMaxLength(7)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Role")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("StatusMessage")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Username")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Users");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("EchoHub.Core.Models.Attachment", b =>
|
||||
{
|
||||
b.HasOne("EchoHub.Core.Models.Message", "Message")
|
||||
.WithMany("Attachments")
|
||||
.HasForeignKey("MessageId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Message");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b =>
|
||||
{
|
||||
b.HasOne("EchoHub.Core.Models.Channel", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("ChannelId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("EchoHub.Core.Models.User", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
|
||||
{
|
||||
b.HasOne("EchoHub.Core.Models.Channel", "Channel")
|
||||
.WithMany("Messages")
|
||||
.HasForeignKey("ChannelId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Channel");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b =>
|
||||
{
|
||||
b.HasOne("EchoHub.Core.Models.User", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("EchoHub.Core.Models.Channel", b =>
|
||||
{
|
||||
b.Navigation("Messages");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
|
||||
{
|
||||
b.Navigation("Attachments");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace EchoHub.Server.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddServerStatsReports : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ServerStatsReports",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
GeneratedAt = table.Column<long>(type: "INTEGER", nullable: false),
|
||||
PeriodStart = table.Column<long>(type: "INTEGER", nullable: false),
|
||||
PeriodEnd = table.Column<long>(type: "INTEGER", nullable: false),
|
||||
WindowHours = table.Column<double>(type: "REAL", nullable: false),
|
||||
MessagesSent = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
FilesUploaded = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
BytesUploaded = table.Column<long>(type: "INTEGER", nullable: false),
|
||||
NewMembers = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
ActiveMembers = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Connections = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Disconnections = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Kicks = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Bans = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
TotalMembers = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
OnlineNow = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
PeakOnline = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ServerStatsReports", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ServerStatsReports_GeneratedAt",
|
||||
table: "ServerStatsReports",
|
||||
column: "GeneratedAt");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "ServerStatsReports");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -246,6 +246,67 @@ namespace EchoHub.Server.Data.Migrations
|
||||
b.ToTable("RefreshTokens");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("EchoHub.Core.Models.ServerStatsReport", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("ActiveMembers")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Bans")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("BytesUploaded")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Connections")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Disconnections")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("FilesUploaded")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("GeneratedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Kicks")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("MessagesSent")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("NewMembers")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("OnlineNow")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("PeakOnline")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PeriodEnd")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PeriodStart")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("TotalMembers")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<double>("WindowHours")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("GeneratedAt");
|
||||
|
||||
b.ToTable("ServerStatsReports");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("EchoHub.Core.Models.User", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
|
||||
@@ -11,6 +11,7 @@ using EchoHub.Server.Hubs;
|
||||
using EchoHub.Server.Irc;
|
||||
using EchoHub.Server.Services;
|
||||
using EchoHub.Server.Services.ServerLogs;
|
||||
using EchoHub.Server.Services.Stats;
|
||||
using EchoHub.Server.Setup;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
@@ -142,6 +143,13 @@ while (true)
|
||||
builder.Services.AddHostedService<FileCleanupService>();
|
||||
builder.Services.AddHostedService<MuteExpirationService>();
|
||||
|
||||
// ── Periodic stats report (admin-configurable via the "Stats" section) ─
|
||||
var statsOptions = builder.Configuration.GetSection("Stats").Get<StatsOptions>() ?? new StatsOptions();
|
||||
builder.Services.AddSingleton(statsOptions);
|
||||
builder.Services.AddSingleton<ServerStatsCollector>();
|
||||
if (statsOptions.Enabled)
|
||||
builder.Services.AddHostedService<ServerStatsReportService>();
|
||||
|
||||
// Live server-log streaming (only when the sink is active)
|
||||
if (serverLogsSink is not null)
|
||||
builder.Services.AddHostedService<ServerLogsStreamService>();
|
||||
|
||||
@@ -6,6 +6,7 @@ using EchoHub.Core.Models;
|
||||
using EchoHub.Core.Security;
|
||||
using EchoHub.Server.Data;
|
||||
using EchoHub.Server.Services.ServerLogs;
|
||||
using EchoHub.Server.Services.Stats;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -23,6 +24,7 @@ public class ChatService : IChatService
|
||||
private readonly FileStorageService _fileStorage;
|
||||
private readonly SpamGuard _spamGuard;
|
||||
private readonly ServerLogsService _serverLogs;
|
||||
private readonly ServerStatsCollector _statsCollector;
|
||||
private readonly ILogger<ChatService> _logger;
|
||||
|
||||
public ChatService(
|
||||
@@ -35,6 +37,7 @@ public class ChatService : IChatService
|
||||
FileStorageService fileStorage,
|
||||
SpamGuard spamGuard,
|
||||
ServerLogsService serverLogs,
|
||||
ServerStatsCollector statsCollector,
|
||||
ILogger<ChatService> logger)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
@@ -46,12 +49,14 @@ public class ChatService : IChatService
|
||||
_fileStorage = fileStorage;
|
||||
_spamGuard = spamGuard;
|
||||
_serverLogs = serverLogs;
|
||||
_statsCollector = statsCollector;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task UserConnectedAsync(string connectionId, Guid userId, string username)
|
||||
{
|
||||
_presenceTracker.UserConnected(connectionId, userId, username);
|
||||
_statsCollector.RecordConnection(_presenceTracker.GetOnlineUserCount());
|
||||
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||
@@ -64,7 +69,9 @@ public class ChatService : IChatService
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
_logger.LogInformation("{User} connected (ConnectionId: {ConnectionId})", username, connectionId);
|
||||
// Debug-level: connect/disconnect churn is high-volume on a busy server. Aggregate
|
||||
// counts land in the periodic stats report instead.
|
||||
_logger.LogDebug("{User} connected (ConnectionId: {ConnectionId})", username, connectionId);
|
||||
}
|
||||
|
||||
public async Task<string?> UserDisconnectedAsync(string connectionId)
|
||||
@@ -75,6 +82,7 @@ public class ChatService : IChatService
|
||||
: [];
|
||||
|
||||
var username = _presenceTracker.UserDisconnected(connectionId);
|
||||
_statsCollector.RecordDisconnection(_presenceTracker.GetOnlineUserCount());
|
||||
|
||||
if (username is not null && !_presenceTracker.IsOnline(username))
|
||||
{
|
||||
@@ -100,7 +108,7 @@ public class ChatService : IChatService
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("{User} disconnected (ConnectionId: {ConnectionId})", username ?? "Unknown", connectionId);
|
||||
_logger.LogDebug("{User} disconnected (ConnectionId: {ConnectionId})", username ?? "Unknown", connectionId);
|
||||
return username;
|
||||
}
|
||||
|
||||
@@ -165,7 +173,7 @@ public class ChatService : IChatService
|
||||
await BroadcastToAllAsync(b => b.SendUserJoinedAsync(channelName, username, presence, connectionId));
|
||||
}
|
||||
|
||||
_logger.LogInformation("{User} joined channel '{Channel}'", username, channelName);
|
||||
_logger.LogDebug("{User} joined channel '{Channel}'", username, channelName);
|
||||
}
|
||||
|
||||
var history = await GetChannelHistoryAsync(channelName, HubConstants.DefaultHistoryCount);
|
||||
@@ -177,7 +185,7 @@ public class ChatService : IChatService
|
||||
channelName = channelName.ToLowerInvariant().Trim();
|
||||
_presenceTracker.LeaveChannel(username, channelName);
|
||||
await BroadcastToAllAsync(b => b.SendUserLeftAsync(channelName, username));
|
||||
_logger.LogInformation("{User} left channel '{Channel}'", username, channelName);
|
||||
_logger.LogDebug("{User} left channel '{Channel}'", username, channelName);
|
||||
}
|
||||
|
||||
public async Task<string?> SendMessageAsync(Guid userId, string username, string channelName, string content, string? originConnectionId = null, Guid? replyToMessageId = null)
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
namespace EchoHub.Server.Services.Stats;
|
||||
|
||||
/// <summary>
|
||||
/// Thread-safe, in-memory accumulator for server-activity counters that have no natural
|
||||
/// database timestamp to query after the fact — session connects/disconnects, moderation
|
||||
/// actions, and peak concurrency. The periodic stats-report job snapshots and resets these
|
||||
/// once per reporting window. Registered as a singleton; every increment is lock-free so it
|
||||
/// is safe to call from hot paths (connect/disconnect).
|
||||
/// </summary>
|
||||
public sealed class ServerStatsCollector
|
||||
{
|
||||
private long _connections;
|
||||
private long _disconnections;
|
||||
private long _kicks;
|
||||
private long _bans;
|
||||
private int _peakOnline;
|
||||
|
||||
/// <summary>Record a session coming online, updating the running peak.</summary>
|
||||
public void RecordConnection(int onlineNow)
|
||||
{
|
||||
Interlocked.Increment(ref _connections);
|
||||
RecordOnline(onlineNow);
|
||||
}
|
||||
|
||||
/// <summary>Record a session going offline, updating the running peak.</summary>
|
||||
public void RecordDisconnection(int onlineNow)
|
||||
{
|
||||
Interlocked.Increment(ref _disconnections);
|
||||
RecordOnline(onlineNow);
|
||||
}
|
||||
|
||||
/// <summary>Record a kick action.</summary>
|
||||
public void RecordKick() => Interlocked.Increment(ref _kicks);
|
||||
|
||||
/// <summary>Record a ban action.</summary>
|
||||
public void RecordBan() => Interlocked.Increment(ref _bans);
|
||||
|
||||
/// <summary>Update the running maximum of concurrent online users (lock-free).</summary>
|
||||
public void RecordOnline(int onlineNow)
|
||||
{
|
||||
int current;
|
||||
while (onlineNow > (current = Volatile.Read(ref _peakOnline)))
|
||||
{
|
||||
if (Interlocked.CompareExchange(ref _peakOnline, onlineNow, current) == current)
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Atomically read all counters and reset them for the next reporting window. The peak is
|
||||
/// reset to <paramref name="onlineNow"/> so the next window's peak starts from the current
|
||||
/// concurrency rather than zero.
|
||||
/// </summary>
|
||||
public StatsCounters SnapshotAndReset(int onlineNow) => new(
|
||||
Connections: Interlocked.Exchange(ref _connections, 0),
|
||||
Disconnections: Interlocked.Exchange(ref _disconnections, 0),
|
||||
Kicks: Interlocked.Exchange(ref _kicks, 0),
|
||||
Bans: Interlocked.Exchange(ref _bans, 0),
|
||||
PeakOnline: Interlocked.Exchange(ref _peakOnline, onlineNow));
|
||||
}
|
||||
|
||||
/// <summary>Immutable snapshot of the counters held by <see cref="ServerStatsCollector"/>.</summary>
|
||||
public readonly record struct StatsCounters(
|
||||
long Connections,
|
||||
long Disconnections,
|
||||
long Kicks,
|
||||
long Bans,
|
||||
int PeakOnline);
|
||||
@@ -0,0 +1,151 @@
|
||||
using System.Text.Json;
|
||||
using EchoHub.Core.Models;
|
||||
using EchoHub.Server.Config;
|
||||
using EchoHub.Server.Data;
|
||||
using EchoHub.Server.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace EchoHub.Server.Services.Stats;
|
||||
|
||||
/// <summary>
|
||||
/// Background job that periodically snapshots server activity over a window, logs it as
|
||||
/// pretty-printed JSON (which also surfaces in the live server-logs room), and persists it to
|
||||
/// the database for historical trends. Interval and retention are configurable via the "Stats"
|
||||
/// section; the default cadence is every 6 hours.
|
||||
/// </summary>
|
||||
public sealed class ServerStatsReportService : BackgroundService
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
|
||||
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly PresenceTracker _presence;
|
||||
private readonly ServerStatsCollector _collector;
|
||||
private readonly StatsOptions _options;
|
||||
private readonly ILogger<ServerStatsReportService> _logger;
|
||||
|
||||
// Start of the current reporting window. Advances to PeriodEnd after each report so that
|
||||
// the DB-derived counts and the in-memory collector counters cover the same span.
|
||||
private DateTimeOffset _periodStart;
|
||||
|
||||
public ServerStatsReportService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
PresenceTracker presence,
|
||||
ServerStatsCollector collector,
|
||||
StatsOptions options,
|
||||
ILogger<ServerStatsReportService> logger)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_presence = presence;
|
||||
_collector = collector;
|
||||
_options = options;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
if (!_options.Enabled)
|
||||
return;
|
||||
|
||||
// A non-positive interval falls back to the 6h default; anything positive is honoured
|
||||
// but floored at 1s so a mis-set tiny value can't spin the loop.
|
||||
var interval = _options.IntervalHours > 0
|
||||
? TimeSpan.FromHours(_options.IntervalHours)
|
||||
: TimeSpan.FromHours(6);
|
||||
if (interval < TimeSpan.FromSeconds(1))
|
||||
interval = TimeSpan.FromSeconds(1);
|
||||
_periodStart = DateTimeOffset.UtcNow;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Server stats report job started — reporting every {Hours}h, retention {Days}d",
|
||||
_options.IntervalHours, _options.RetentionDays);
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(interval, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await GenerateReportAsync(stoppingToken);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to generate periodic server stats report");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task GenerateReportAsync(CancellationToken ct)
|
||||
{
|
||||
var periodStart = _periodStart;
|
||||
var periodEnd = DateTimeOffset.UtcNow;
|
||||
var onlineNow = _presence.GetOnlineUserCount();
|
||||
var counters = _collector.SnapshotAndReset(onlineNow);
|
||||
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||
|
||||
var messagesSent = await db.Messages.CountAsync(m => m.SentAt >= periodStart, ct);
|
||||
|
||||
var activeMembers = await db.Messages
|
||||
.Where(m => m.SentAt >= periodStart)
|
||||
.Select(m => m.SenderUserId)
|
||||
.Distinct()
|
||||
.CountAsync(ct);
|
||||
|
||||
var uploadQuery = db.Attachments
|
||||
.Where(a => db.Messages.Any(m => m.Id == a.MessageId && m.SentAt >= periodStart));
|
||||
var filesUploaded = await uploadQuery.CountAsync(ct);
|
||||
var bytesUploaded = filesUploaded == 0 ? 0L : await uploadQuery.SumAsync(a => a.FileSize, ct);
|
||||
|
||||
var newMembers = await db.Users.CountAsync(u => u.CreatedAt >= periodStart, ct);
|
||||
var totalMembers = await db.Users.CountAsync(ct);
|
||||
|
||||
var report = new ServerStatsReport
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
GeneratedAt = periodEnd,
|
||||
PeriodStart = periodStart,
|
||||
PeriodEnd = periodEnd,
|
||||
WindowHours = Math.Round((periodEnd - periodStart).TotalHours, 2),
|
||||
MessagesSent = messagesSent,
|
||||
FilesUploaded = filesUploaded,
|
||||
BytesUploaded = bytesUploaded,
|
||||
NewMembers = newMembers,
|
||||
ActiveMembers = activeMembers,
|
||||
Connections = (int)counters.Connections,
|
||||
Disconnections = (int)counters.Disconnections,
|
||||
Kicks = (int)counters.Kicks,
|
||||
Bans = (int)counters.Bans,
|
||||
TotalMembers = totalMembers,
|
||||
OnlineNow = onlineNow,
|
||||
PeakOnline = counters.PeakOnline,
|
||||
};
|
||||
|
||||
db.ServerStatsReports.Add(report);
|
||||
|
||||
// Prune reports beyond the retention window (0 = keep forever).
|
||||
if (_options.RetentionDays > 0)
|
||||
{
|
||||
var cutoff = periodEnd.AddDays(-_options.RetentionDays);
|
||||
var stale = await db.ServerStatsReports
|
||||
.Where(r => r.GeneratedAt < cutoff)
|
||||
.ToListAsync(ct);
|
||||
if (stale.Count > 0)
|
||||
db.ServerStatsReports.RemoveRange(stale);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
_periodStart = periodEnd;
|
||||
|
||||
// Pretty-printed JSON so the report is readable both in the log files and the logs room.
|
||||
var json = JsonSerializer.Serialize(report, JsonOptions);
|
||||
_logger.LogInformation("Server stats report ({WindowHours}h window):\n{Report}", report.WindowHours, json);
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,11 @@
|
||||
"LogDirectory": "logs",
|
||||
"LogFilePattern": "echohub-server-*.log"
|
||||
},
|
||||
"Stats": {
|
||||
"Enabled": true,
|
||||
"IntervalHours": 6,
|
||||
"RetentionDays": 90
|
||||
},
|
||||
"Irc": {
|
||||
"Enabled": false,
|
||||
"Port": 6667,
|
||||
|
||||
Reference in New Issue
Block a user