Compare commits

4 Commits
Author SHA1 Message Date
Hue 6e57247a45 Merge pull request #64 from HueByte/dev_logs_extended
feat: add periodic server stats reporting and logging
2026-07-17 22:23:13 +02:00
HueByte fd61d9cb9c fix: remove invisible character from migration file 2026-07-17 22:18:49 +02:00
HueByte 79e5a1191f feat: update changelog for v0.2.16 release with new features and improvements 2026-07-17 22:17:21 +02:00
HueByte 7525f8b1d8 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.
2026-07-17 22:14:55 +02:00
19 changed files with 1011 additions and 12 deletions
+16
View File
@@ -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
+1
View File
@@ -4,6 +4,7 @@ Release history for EchoHub.
## Releases
- [v0.2.16](v0.2.16.md) - Periodic Server-Stats Report, Upload & Moderation Logging & Quieter Connection Logs
- [v0.2.15](v0.2.15.md) - Invite Codes, Data Export & Deletion, /me, /banner, Replies, Open Images In Browser & IRC Image Links
- [v0.2.14](v0.2.14.md) - Clipboard Image & Multi-File Paste, E2E Room Unlock Fixes, Encrypted Key Cache & IRC Gateway Polish
- [v0.2.13](v0.2.13.md) - Chat Visual Overhaul, Auto-Join All Channels & Persistent Read Positions
+2
View File
@@ -1,5 +1,7 @@
- name: Overview
href: index.md
- name: v0.2.16
href: v0.2.16.md
- name: v0.2.15
href: v0.2.15.md
- name: v0.2.14
+43
View File
@@ -0,0 +1,43 @@
# v0.2.16
A logging and observability pass built on the new server-logs room. Busy servers stop drowning
in connect/disconnect noise, the events operators actually care about — uploads, moderation
actions — now get logged, and a new periodic report summarizes server activity as pretty-printed
JSON that streams into the logs room and is saved to the database for history.
## New Features
- **Periodic server-stats report** — every 6 hours (configurable), the server logs an aggregate
activity snapshot as pretty-printed JSON and saves it to the database. Each report covers the
window since the previous one: messages sent, files uploaded (and total bytes), new members,
active members (distinct senders), session connections/disconnections, kicks, bans, total
registered members, users online now, and peak concurrent online. Reports also stream into the
live server-logs room, and old ones are pruned past a configurable retention. New `Stats` config
section (`Stats:Enabled`, `Stats:IntervalHours` = 6, `Stats:RetentionDays` = 90).
- **Upload logging** — every file, image, and audio upload is now logged with its resource URL
(`/api/files/{id}`), kind, size, uploader, and channel. In end-to-end encrypted rooms the
filename is client-side ciphertext, so it's logged as `[encrypted]` — the server still never
sees room content.
- **Moderation & user-action logging** — role changes, kicks, bans, unbans, mutes, unmutes,
moderator message removals, and channel nukes are now logged with the actor, target, and reason.
Bans and channel nukes log at Warning level so they stand out.
## Improvements
- **Quieter connection logs** — user connect, disconnect, join-channel, and leave-channel events
(over both SignalR and IRC) dropped from Information to Debug. On a busy server these fired
constantly and buried everything else; the aggregate counts now live in the periodic stats
report instead. They still show in Development, where the default level is Debug.
- **Config examples stay in sync** — `.env.example` gained the `ServerLogs` and `Stats` sections
(the former had been missing), alongside `appsettings.json` / `appsettings.example.json`.
## Notes for server operators
- New config section: `Stats``Enabled` (default true), `IntervalHours` (default 6),
`RetentionDays` (default 90, `0` keeps reports forever). See `appsettings.example.json` and
`.env.example`. A non-positive interval falls back to 6h; positive values are honored down to a
1-second floor.
- One new database migration (`AddServerStatsReports`) applies automatically on startup.
- Connect/disconnect/join/leave now log at Debug — if you relied on those Information lines, lower
`Serilog:MinimumLevel:Default` (or `ServerLogs:MinLevel` for the room) to `Debug`, or read the
periodic stats report for aggregates.
+1 -1
View File
@@ -1,6 +1,6 @@
<Project>
<PropertyGroup>
<Version>0.2.15</Version>
<Version>0.2.16</Version>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
</PropertyGroup>
@@ -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,