using System.Globalization;
using System.Text;
using EchoHub.Core.Constants;
using EchoHub.Core.Contracts;
using EchoHub.Core.DTOs;
using EchoHub.Server.Config;
using EchoHub.Server.Hubs;
using Microsoft.AspNetCore.SignalR;
using Serilog.Events;
namespace EchoHub.Server.Services.ServerLogs;
///
/// Streams queued log events to the live log room as ephemeral messages: SignalR only (the
/// IRC gateway never sees them) and no database rows. Ensures the room exists before
/// streaming, recreating it on the fly if it was deleted.
///
/// This class must never log from its streaming path — its namespace is excluded by
/// as a second line of defense, but the primary rule is simply
/// not to log per event, otherwise every streamed line would spawn another.
///
public sealed class ServerLogsStreamService : BackgroundService
{
private static readonly TimeSpan EnsureInterval = TimeSpan.FromSeconds(15);
private readonly ServerLogsSink _sink;
private readonly ServerLogsOptions _options;
private readonly IChannelService _channelService;
private readonly IMessageEncryptionService _encryption;
private readonly IServiceProvider _serviceProvider;
private IHubContext? _hubContext;
private DateTimeOffset _lastEnsure = DateTimeOffset.MinValue;
// Resolved lazily: the hub context isn't available while hosted services are constructed.
private IHubContext HubContext
=> _hubContext ??= _serviceProvider.GetRequiredService>();
public ServerLogsStreamService(
ServerLogsSink sink,
ServerLogsOptions options,
IChannelService channelService,
IMessageEncryptionService encryption,
IServiceProvider serviceProvider)
{
_sink = sink;
_options = options;
_channelService = channelService;
_encryption = encryption;
_serviceProvider = serviceProvider;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// Make the room exist from server start, not only once the first event arrives.
await TryEnsureRoomAsync();
await foreach (var logEvent in _sink.Reader.ReadAllAsync(stoppingToken))
{
try
{
await TryEnsureRoomAsync();
var roomName = _options.NormalizedRoomName;
var message = new MessageDto(
Guid.NewGuid(),
_encryption.Encrypt(Format(logEvent)),
ServerLogsService.SenderName,
null,
roomName,
logEvent.Timestamp);
await HubContext.Clients.Group(roomName).ReceiveMessage(message);
}
catch when (!stoppingToken.IsCancellationRequested)
{
// Swallow: logging here would re-enter the pipeline.
}
}
}
///
/// Recreates the log room if it disappeared, at most once per .
///
private async Task TryEnsureRoomAsync()
{
if (DateTimeOffset.UtcNow - _lastEnsure < EnsureInterval)
return;
_lastEnsure = DateTimeOffset.UtcNow;
try
{
await _channelService.EnsureSystemChannelAsync(_options.NormalizedRoomName, ServerLogsService.RoomTopic);
}
catch
{
// Retried on the next interval; events streamed meanwhile just go to no group members.
}
}
/// Formats an event like the file sink's template, minus the timestamp (clients render their own). Public for tests.
public static string Format(LogEvent logEvent)
{
var builder = new StringBuilder("[").Append(ShortLevel(logEvent.Level)).Append("] ")
.Append(logEvent.RenderMessage(CultureInfo.InvariantCulture));
if (logEvent.Exception is not null)
builder.Append('\n').Append(logEvent.Exception);
if (builder.Length > HubConstants.MaxMessageLength)
{
builder.Length = HubConstants.MaxMessageLength - 1;
builder.Append('…');
}
return builder.ToString();
}
private static string ShortLevel(LogEventLevel level) => level switch
{
LogEventLevel.Verbose => "VRB",
LogEventLevel.Debug => "DBG",
LogEventLevel.Information => "INF",
LogEventLevel.Warning => "WRN",
LogEventLevel.Error => "ERR",
LogEventLevel.Fatal => "FTL",
_ => level.ToString().ToUpperInvariant(),
};
}