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:
HueByte
2026-07-17 22:14:55 +02:00
parent 0c2e8eae87
commit 7525f8b1d8
15 changed files with 964 additions and 11 deletions
@@ -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; }
}
+2 -2
View File
@@ -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");
}
}
+21
View File
@@ -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);
@@ -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")
+8
View File
@@ -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>();
+12 -4
View File
@@ -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,