From a1bd27865236ba5b7a3648aae20b561a6bfe8077 Mon Sep 17 00:00:00 2001 From: Flaminel Date: Mon, 19 May 2025 13:40:59 +0300 Subject: [PATCH] #21 --- .../Configuration/Logging/LoggingConfig.cs | 7 +- .../Configuration/Logging/SignalRLogConfig.cs | 25 ++++ .../Controllers/LoggingDemoController.cs | 90 +++++++++++++ code/Executable/DependencyInjection/ApiDI.cs | 5 + .../DependencyInjection/LoggingDI.cs | 106 +++++++++++---- code/Executable/Program.cs | 2 +- .../Logging/DeferredSignalRSink.cs | 61 +++++++++ code/Infrastructure/Logging/LogBuffer.cs | 36 +++++ code/Infrastructure/Logging/LogHub.cs | 27 ++++ .../Logging/LoggingCategoryConstants.cs | 42 ++++++ .../Logging/LoggingExtensions.cs | 77 +++++++++++ .../Logging/LoggingInitializer.cs | 46 +++++++ code/Infrastructure/Logging/README.md | 125 ++++++++++++++++++ code/Infrastructure/Logging/SignalRSink.cs | 51 +++++++ 14 files changed, 670 insertions(+), 30 deletions(-) create mode 100644 code/Common/Configuration/Logging/SignalRLogConfig.cs create mode 100644 code/Executable/Controllers/LoggingDemoController.cs create mode 100644 code/Infrastructure/Logging/DeferredSignalRSink.cs create mode 100644 code/Infrastructure/Logging/LogBuffer.cs create mode 100644 code/Infrastructure/Logging/LogHub.cs create mode 100644 code/Infrastructure/Logging/LoggingCategoryConstants.cs create mode 100644 code/Infrastructure/Logging/LoggingExtensions.cs create mode 100644 code/Infrastructure/Logging/LoggingInitializer.cs create mode 100644 code/Infrastructure/Logging/README.md create mode 100644 code/Infrastructure/Logging/SignalRSink.cs diff --git a/code/Common/Configuration/Logging/LoggingConfig.cs b/code/Common/Configuration/Logging/LoggingConfig.cs index 187a3911..38c6a56b 100644 --- a/code/Common/Configuration/Logging/LoggingConfig.cs +++ b/code/Common/Configuration/Logging/LoggingConfig.cs @@ -1,4 +1,5 @@ -using Serilog.Events; +using Microsoft.Extensions.Configuration; +using Serilog.Events; namespace Common.Configuration.Logging; @@ -6,12 +7,14 @@ public class LoggingConfig : IConfig { public const string SectionName = "Logging"; - public LogEventLevel LogLevel { get; set; } + public LogEventLevel LogLevel { get; set; } = LogEventLevel.Information; public bool Enhanced { get; set; } public FileLogConfig? File { get; set; } + public SignalRLogConfig? SignalR { get; set; } + public void Validate() { } diff --git a/code/Common/Configuration/Logging/SignalRLogConfig.cs b/code/Common/Configuration/Logging/SignalRLogConfig.cs new file mode 100644 index 00000000..f5946560 --- /dev/null +++ b/code/Common/Configuration/Logging/SignalRLogConfig.cs @@ -0,0 +1,25 @@ +namespace Common.Configuration.Logging; + +/// +/// Configuration options for SignalR log streaming +/// +public class SignalRLogConfig : IConfig +{ + /// + /// Whether SignalR logging is enabled + /// + public bool Enabled { get; set; } = true; + + /// + /// Number of log entries to buffer for new connections + /// + public int BufferSize { get; set; } = 100; + + public void Validate() + { + if (BufferSize < 0) + { + BufferSize = 100; + } + } +} diff --git a/code/Executable/Controllers/LoggingDemoController.cs b/code/Executable/Controllers/LoggingDemoController.cs new file mode 100644 index 00000000..4e4c8353 --- /dev/null +++ b/code/Executable/Controllers/LoggingDemoController.cs @@ -0,0 +1,90 @@ +using Infrastructure.Logging; +using Microsoft.AspNetCore.Mvc; + +namespace Executable.Controllers; + +/// +/// Sample controller demonstrating the use of the enhanced logging system. +/// This is for demonstration purposes only. +/// +[ApiController] +[Route("api/[controller]")] +public class LoggingDemoController : ControllerBase +{ + private readonly ILogger _logger; + + public LoggingDemoController(ILogger logger) + { + _logger = logger; + } + + [HttpGet("system")] + public IActionResult LogSystemMessage() + { + // Using the Category extension + _logger.WithCategory(LoggingCategoryConstants.System) + .LogInformation("This is a system log message"); + + return Ok("System log sent"); + } + + [HttpGet("api")] + public IActionResult LogApiMessage() + { + _logger.WithCategory(LoggingCategoryConstants.Api) + .LogInformation("This is an API log message"); + + return Ok("API log sent"); + } + + [HttpGet("job")] + public IActionResult LogJobMessage([FromQuery] string jobName = "CleanupJob") + { + _logger.WithCategory(LoggingCategoryConstants.Jobs) + .WithJob(jobName) + .LogInformation("This is a job-related log message"); + + return Ok($"Job log sent for {jobName}"); + } + + [HttpGet("instance")] + public IActionResult LogInstanceMessage([FromQuery] string instance = "Sonarr") + { + _logger.WithCategory(instance.ToUpper()) + .WithInstance(instance) + .LogInformation("This is an instance-related log message"); + + return Ok($"Instance log sent for {instance}"); + } + + [HttpGet("combined")] + public IActionResult LogCombinedMessage( + [FromQuery] string category = "JOBS", + [FromQuery] string jobName = "ContentBlocker", + [FromQuery] string instance = "Sonarr") + { + _logger.WithCategory(category) + .WithJob(jobName) + .WithInstance(instance) + .LogInformation("This log message combines category, job name, and instance"); + + return Ok("Combined log sent"); + } + + [HttpGet("error")] + public IActionResult LogErrorMessage() + { + try + { + // Simulate an error + throw new InvalidOperationException("This is a test exception"); + } + catch (Exception ex) + { + _logger.WithCategory(LoggingCategoryConstants.System) + .LogError(ex, "An error occurred during processing"); + } + + return Ok("Error log sent"); + } +} diff --git a/code/Executable/DependencyInjection/ApiDI.cs b/code/Executable/DependencyInjection/ApiDI.cs index ab447fe0..5ba9216c 100644 --- a/code/Executable/DependencyInjection/ApiDI.cs +++ b/code/Executable/DependencyInjection/ApiDI.cs @@ -1,4 +1,5 @@ using Infrastructure.Health; +using Infrastructure.Logging; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; using Microsoft.OpenApi.Models; @@ -19,6 +20,9 @@ public static class ApiDI // Add health status broadcaster services.AddHostedService(); + // Add logging initializer service + services.AddHostedService(); + services.AddSwaggerGen(options => { options.SwaggerDoc("v1", new OpenApiInfo @@ -56,6 +60,7 @@ public static class ApiDI // Map SignalR hubs app.MapHub("/hubs/health"); + app.MapHub("/hubs/logs"); return app; } diff --git a/code/Executable/DependencyInjection/LoggingDI.cs b/code/Executable/DependencyInjection/LoggingDI.cs index eb1e4ad2..ec586050 100644 --- a/code/Executable/DependencyInjection/LoggingDI.cs +++ b/code/Executable/DependencyInjection/LoggingDI.cs @@ -1,9 +1,13 @@ -using Common.Configuration.Logging; +using Common.Configuration.Logging; using Domain.Enums; +using Infrastructure.Configuration; +using Infrastructure.Logging; using Infrastructure.Verticals.ContentBlocker; using Infrastructure.Verticals.DownloadCleaner; using Infrastructure.Verticals.QueueCleaner; +using Microsoft.AspNetCore.SignalR; using Serilog; +using Serilog.Core; using Serilog.Events; using Serilog.Templates; using Serilog.Templates.Themes; @@ -12,70 +16,118 @@ namespace Executable.DependencyInjection; public static class LoggingDI { - public static ILoggingBuilder AddLogging(this ILoggingBuilder builder, IConfiguration configuration) + public static ILoggingBuilder AddLogging(this ILoggingBuilder builder, IConfiguration configuration, IServiceProvider serviceProvider) { + // Get the logging configuration LoggingConfig? config = configuration.GetSection(LoggingConfig.SectionName).Get(); - if (!string.IsNullOrEmpty(config?.File?.Path) && !Directory.Exists(config.File.Path)) + // Get the configuration path provider + var pathProvider = serviceProvider.GetRequiredService(); + + // Create the logs directory + string logsPath = Path.Combine(pathProvider.GetConfigPath(), "logs"); + if (!Directory.Exists(logsPath)) { try { - Directory.CreateDirectory(config.File.Path); + Directory.CreateDirectory(logsPath); } catch (Exception exception) { - throw new Exception($"log file path is not a valid directory | {config.File.Path}", exception); + throw new Exception($"Failed to create log directory | {logsPath}", exception); } } - + LoggerConfiguration logConfig = new(); + const string categoryTemplate = "{#if Category is not null} {Concat('[',Category,']'),CAT_PAD}{#end}"; const string jobNameTemplate = "{#if JobName is not null} {Concat('[',JobName,']'),JOB_PAD}{#end}"; const string instanceNameTemplate = "{#if InstanceName is not null} {Concat('[',InstanceName,']'),ARR_PAD}{#end}"; - const string consoleOutputTemplate = $"[{{@t:yyyy-MM-dd HH:mm:ss.fff}} {{@l:u3}}]{jobNameTemplate}{instanceNameTemplate} {{@m}}\n{{@x}}"; - const string fileOutputTemplate = $"{{@t:yyyy-MM-dd HH:mm:ss.fff zzz}} [{{@l:u3}}]{jobNameTemplate}{instanceNameTemplate} {{@m:lj}}\n{{@x}}"; - LogEventLevel level = LogEventLevel.Information; - List names = [nameof(ContentBlocker), nameof(QueueCleaner), nameof(DownloadCleaner)]; - int jobPadding = names.Max(x => x.Length) + 2; - names = [InstanceType.Sonarr.ToString(), InstanceType.Radarr.ToString(), InstanceType.Lidarr.ToString()]; - int arrPadding = names.Max(x => x.Length) + 2; + const string consoleOutputTemplate = $"[{{@t:yyyy-MM-dd HH:mm:ss.fff}} {{@l:u3}}]{categoryTemplate}{jobNameTemplate}{instanceNameTemplate} {{@m}}\n{{@x}}"; + const string fileOutputTemplate = $"{{@t:yyyy-MM-dd HH:mm:ss.fff zzz}} [{{@l:u3}}]{categoryTemplate}{jobNameTemplate}{instanceNameTemplate} {{@m:lj}}\n{{@x}}"; + + // Determine categories and padding sizes + List categories = ["SYSTEM", "API", "JOBS", "NOTIFICATIONS"]; + int catPadding = categories.Max(x => x.Length) + 2; + + // Determine job name padding + List jobNames = [nameof(ContentBlocker), nameof(QueueCleaner), nameof(DownloadCleaner)]; + int jobPadding = jobNames.Max(x => x.Length) + 2; + + // Determine instance name padding + List instanceNames = [InstanceType.Sonarr.ToString(), InstanceType.Radarr.ToString(), InstanceType.Lidarr.ToString()]; + int arrPadding = instanceNames.Max(x => x.Length) + 2; + + // Set the minimum log level + LogEventLevel level = config?.LogLevel ?? LogEventLevel.Information; + + // Apply padding values to templates string consoleTemplate = consoleOutputTemplate + .Replace("CAT_PAD", catPadding.ToString()) .Replace("JOB_PAD", jobPadding.ToString()) .Replace("ARR_PAD", arrPadding.ToString()); + string fileTemplate = fileOutputTemplate + .Replace("CAT_PAD", catPadding.ToString()) .Replace("JOB_PAD", jobPadding.ToString()) .Replace("ARR_PAD", arrPadding.ToString()); - if (config is not null) - { - level = config.LogLevel; + // Configure base logger + logConfig + .MinimumLevel.Is(level) + .Enrich.FromLogContext() + .WriteTo.Console(new ExpressionTemplate(consoleTemplate, theme: TemplateTheme.Literate)); - if (config.File?.Enabled is true) - { - logConfig.WriteTo.File( - path: Path.Combine(config.File.Path, "cleanuperr-.txt"), + // Add main log file + logConfig.WriteTo.File( + path: Path.Combine(logsPath, "cleanuperr-.txt"), + formatter: new ExpressionTemplate(fileTemplate), + fileSizeLimitBytes: 10L * 1024 * 1024, + rollingInterval: RollingInterval.Day, + rollOnFileSizeLimit: true + ); + + // Add category-specific log files + foreach (var category in categories) + { + logConfig.WriteTo.Logger(lc => lc + .Filter.ByIncludingOnly(e => + e.Properties.TryGetValue("Category", out var prop) && + prop.ToString().Contains(category, StringComparison.OrdinalIgnoreCase)) + .WriteTo.File( + path: Path.Combine(logsPath, $"{category.ToLower()}-.txt"), formatter: new ExpressionTemplate(fileTemplate), - fileSizeLimitBytes: 10L * 1024 * 1024, + fileSizeLimitBytes: 5L * 1024 * 1024, rollingInterval: RollingInterval.Day, rollOnFileSizeLimit: true - ); - } + ) + ); } + // Configure SignalR log sink if enabled + if (config?.SignalR?.Enabled != false) + { + var bufferSize = config?.SignalR?.BufferSize ?? 100; + + // Create and register LogBuffer + var logBuffer = new LogBuffer(bufferSize); + serviceProvider.GetRequiredService().AddSingleton(logBuffer); + + // Create a log sink for SignalR + logConfig.WriteTo.Sink(new DeferredSignalRSink()); + } + Log.Logger = logConfig - .MinimumLevel.Is(level) .MinimumLevel.Override("MassTransit", LogEventLevel.Warning) .MinimumLevel.Override("Microsoft.Hosting.Lifetime", LogEventLevel.Information) .MinimumLevel.Override("Microsoft.Extensions.Http", LogEventLevel.Warning) .MinimumLevel.Override("Quartz", LogEventLevel.Warning) .MinimumLevel.Override("System.Net.Http.HttpClient", LogEventLevel.Error) - .WriteTo.Console(new ExpressionTemplate(consoleTemplate)) - .Enrich.FromLogContext() .Enrich.WithProperty("ApplicationName", "cleanuperr") .CreateLogger(); return builder .ClearProviders() - .AddSerilog(); + .AddSerilog(dispose: true); } } \ No newline at end of file diff --git a/code/Executable/Program.cs b/code/Executable/Program.cs index 2e7cefde..04c270b1 100644 --- a/code/Executable/Program.cs +++ b/code/Executable/Program.cs @@ -8,7 +8,7 @@ builder.Services .AddInfrastructure(builder.Configuration) .AddApiServices(); -builder.Logging.AddLogging(builder.Configuration); +builder.Logging.AddLogging(builder.Configuration, builder.Services.BuildServiceProvider()); var app = builder.Build(); diff --git a/code/Infrastructure/Logging/DeferredSignalRSink.cs b/code/Infrastructure/Logging/DeferredSignalRSink.cs new file mode 100644 index 00000000..a27f31da --- /dev/null +++ b/code/Infrastructure/Logging/DeferredSignalRSink.cs @@ -0,0 +1,61 @@ +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.DependencyInjection; +using Serilog.Core; +using Serilog.Events; +using System.Collections.Concurrent; + +namespace Infrastructure.Logging; + +/// +/// A Serilog sink that buffers events until the SignalR infrastructure is available +/// +public class DeferredSignalRSink : ILogEventSink +{ + private readonly ConcurrentQueue _buffer = new(); + private volatile bool _isInitialized = false; + private ILogEventSink _signalRSink; + + public void Emit(LogEvent logEvent) + { + if (!_isInitialized) + { + // Buffer the event until we can initialize + _buffer.Enqueue(logEvent.Copy()); + } + else + { + // Pass to the actual sink + _signalRSink?.Emit(logEvent); + } + } + + /// + /// Initialize the actual SignalR sink + /// + /// The DI service provider + public void Initialize(IServiceProvider serviceProvider) + { + if (_isInitialized) + return; + + try + { + // Create the actual sink when the hub context is available + var hubContext = serviceProvider.GetRequiredService>(); + var logBuffer = serviceProvider.GetRequiredService(); + _signalRSink = new SignalRSink(hubContext, logBuffer); + + // Process buffered events + while (_buffer.TryDequeue(out var logEvent)) + { + _signalRSink.Emit(logEvent); + } + + _isInitialized = true; + } + catch + { + // Failed to initialize - will try again later + } + } +} diff --git a/code/Infrastructure/Logging/LogBuffer.cs b/code/Infrastructure/Logging/LogBuffer.cs new file mode 100644 index 00000000..0472248c --- /dev/null +++ b/code/Infrastructure/Logging/LogBuffer.cs @@ -0,0 +1,36 @@ +using System.Collections.Concurrent; + +namespace Infrastructure.Logging; + +/// +/// Maintains a buffer of recent log entries for newly connected clients +/// +public class LogBuffer +{ + private readonly ConcurrentQueue _recentLogs; + private readonly int _bufferSize; + + public LogBuffer(int bufferSize) + { + _bufferSize = Math.Max(10, bufferSize); + _recentLogs = new ConcurrentQueue(); + } + + /// + /// Adds a log entry to the buffer + /// + /// The log event to buffer + public void AddLog(object logEvent) + { + _recentLogs.Enqueue(logEvent); + + // Trim buffer if it exceeds size + while (_recentLogs.Count > _bufferSize && _recentLogs.TryDequeue(out _)) { } + } + + /// + /// Gets all buffered log entries + /// + /// Collection of recent log events + public IEnumerable GetRecentLogs() => _recentLogs.ToArray(); +} diff --git a/code/Infrastructure/Logging/LogHub.cs b/code/Infrastructure/Logging/LogHub.cs new file mode 100644 index 00000000..17ef43ee --- /dev/null +++ b/code/Infrastructure/Logging/LogHub.cs @@ -0,0 +1,27 @@ +using Microsoft.AspNetCore.SignalR; + +namespace Infrastructure.Logging; + +/// +/// SignalR hub for streaming log messages to connected clients +/// +public class LogHub : Hub +{ + private readonly LogBuffer _logBuffer; + + public LogHub(LogBuffer logBuffer) + { + _logBuffer = logBuffer; + } + + /// + /// Allows a client to request all recent logs from the buffer + /// + public async Task RequestRecentLogs() + { + foreach (var logEvent in _logBuffer.GetRecentLogs()) + { + await Clients.Caller.SendAsync("ReceiveLog", logEvent); + } + } +} diff --git a/code/Infrastructure/Logging/LoggingCategoryConstants.cs b/code/Infrastructure/Logging/LoggingCategoryConstants.cs new file mode 100644 index 00000000..b3ac74c8 --- /dev/null +++ b/code/Infrastructure/Logging/LoggingCategoryConstants.cs @@ -0,0 +1,42 @@ +namespace Infrastructure.Logging; + +/// +/// Standard logging categories used throughout the application +/// +public static class LoggingCategoryConstants +{ + /// + /// System-level logs (startup, configuration, etc.) + /// + public const string System = "SYSTEM"; + + /// + /// API-related logs (requests, responses, etc.) + /// + public const string Api = "API"; + + /// + /// Job-related logs (scheduled tasks, background operations) + /// + public const string Jobs = "JOBS"; + + /// + /// Notification-related logs (user alerts, warnings) + /// + public const string Notifications = "NOTIFICATIONS"; + + /// + /// Sonarr-related logs + /// + public const string Sonarr = "SONARR"; + + /// + /// Radarr-related logs + /// + public const string Radarr = "RADARR"; + + /// + /// Lidarr-related logs + /// + public const string Lidarr = "LIDARR"; +} diff --git a/code/Infrastructure/Logging/LoggingExtensions.cs b/code/Infrastructure/Logging/LoggingExtensions.cs new file mode 100644 index 00000000..0e40b0ff --- /dev/null +++ b/code/Infrastructure/Logging/LoggingExtensions.cs @@ -0,0 +1,77 @@ +using Microsoft.Extensions.Logging; +using Serilog; +using ILogger = Microsoft.Extensions.Logging.ILogger; + +namespace Infrastructure.Logging; + +/// +/// Extension methods for contextual logging +/// +public static class LoggingExtensions +{ + /// + /// Adds a category to log messages + /// + /// The logger to enrich + /// The log category + /// Enriched logger instance + public static ILogger WithCategory(this ILogger logger, string category) + { + return logger.BeginScope(new Dictionary { ["Category"] = category }); + } + + /// + /// Adds a job name to log messages + /// + /// The logger to enrich + /// The job name + /// Enriched logger instance + public static ILogger WithJob(this ILogger logger, string jobName) + { + return logger.BeginScope(new Dictionary { ["JobName"] = jobName }); + } + + /// + /// Adds an instance name to log messages + /// + /// The logger to enrich + /// The instance name + /// Enriched logger instance + public static ILogger WithInstance(this ILogger logger, string instanceName) + { + return logger.BeginScope(new Dictionary { ["InstanceName"] = instanceName }); + } + + /// + /// Adds a category to Serilog log messages + /// + /// The Serilog logger + /// The log category + /// Enriched logger instance + public static Serilog.ILogger WithCategory(this Serilog.ILogger logger, string category) + { + return logger.ForContext("Category", category); + } + + /// + /// Adds a job name to Serilog log messages + /// + /// The Serilog logger + /// The job name + /// Enriched logger instance + public static Serilog.ILogger WithJob(this Serilog.ILogger logger, string jobName) + { + return logger.ForContext("JobName", jobName); + } + + /// + /// Adds an instance name to Serilog log messages + /// + /// The Serilog logger + /// The instance name + /// Enriched logger instance + public static Serilog.ILogger WithInstance(this Serilog.ILogger logger, string instanceName) + { + return logger.ForContext("InstanceName", instanceName); + } +} diff --git a/code/Infrastructure/Logging/LoggingInitializer.cs b/code/Infrastructure/Logging/LoggingInitializer.cs new file mode 100644 index 00000000..d2cc70cb --- /dev/null +++ b/code/Infrastructure/Logging/LoggingInitializer.cs @@ -0,0 +1,46 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Serilog; +using Serilog.Core; + +namespace Infrastructure.Logging; + +/// +/// A background service that initializes deferred logging components after startup +/// +public class LoggingInitializer : BackgroundService +{ + private readonly IServiceProvider _serviceProvider; + + public LoggingInitializer(IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider; + } + + protected override Task ExecuteAsync(CancellationToken stoppingToken) + { + // Find and initialize any deferred sinks + var deferredSink = Log.Logger as ILoggerPropertyEnricher; + + if (deferredSink != null) + { + var sinks = deferredSink.GetType() + .GetProperty("Sinks", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance) + ?.GetValue(deferredSink) as System.Collections.IEnumerable; + + if (sinks != null) + { + foreach (var sink in sinks) + { + if (sink is DeferredSignalRSink deferredSignalRSink) + { + deferredSignalRSink.Initialize(_serviceProvider); + } + } + } + } + + // We only need to run this once at startup + return Task.CompletedTask; + } +} diff --git a/code/Infrastructure/Logging/README.md b/code/Infrastructure/Logging/README.md new file mode 100644 index 00000000..c605a72d --- /dev/null +++ b/code/Infrastructure/Logging/README.md @@ -0,0 +1,125 @@ +# Enhanced Logging System + +## Overview + +The enhanced logging system provides a structured approach to logging with the following features: + +- **Category-based logging**: Organize logs by functional areas (SYSTEM, API, JOBS, etc.) +- **Job name context**: Add job name to logs for background operations +- **Instance context**: Add instance names (Sonarr, Radarr, etc.) to relevant logs +- **Multiple output targets**: Console, files, and real-time SignalR streaming +- **Category-specific log files**: Separate log files for different categories + +## Using the Logging System + +### Adding Category to Logs + +```csharp +// Using category constants +logger.WithCategory(LoggingCategoryConstants.System) + .LogInformation("This is a system log"); + +// Using direct category name +logger.WithCategory("API") + .LogInformation("This is an API log"); +``` + +### Adding Job Name Context + +```csharp +logger.WithCategory(LoggingCategoryConstants.Jobs) + .WithJob("ContentBlocker") + .LogInformation("Starting content blocking job"); +``` + +### Adding Instance Name Context + +```csharp +logger.WithCategory(LoggingCategoryConstants.Sonarr) + .WithInstance("Sonarr") + .LogInformation("Processing Sonarr data"); +``` + +### Combined Context Example + +```csharp +logger.WithCategory(LoggingCategoryConstants.Jobs) + .WithJob("QueueCleaner") + .WithInstance("Radarr") + .LogInformation("Cleaning Radarr queue"); +``` + +## Log Storage + +Logs are stored in the following locations: + +- **Main log file**: `{config_path}/logs/cleanuperr-.txt` +- **Category logs**: `{config_path}/logs/{category}-.txt` (e.g., `system-.txt`, `api-.txt`) + +The log files use rolling file behavior: +- Daily rotation +- 10MB size limit for main log files +- 5MB size limit for category-specific logs + +## SignalR Integration + +The logging system includes real-time streaming via SignalR: + +- **Hub URL**: `/hubs/logs` +- **Hub class**: `LogHub` +- **Event name**: `ReceiveLog` + +### Requesting Recent Logs + +When a client connects, it can request recent logs from the buffer: + +```javascript +await connection.invoke("RequestRecentLogs"); +``` + +### Log Message Format + +Each log message contains: +- `timestamp`: The time the log was created +- `level`: Log level (Information, Warning, Error, etc.) +- `message`: The log message text +- `exception`: Exception details (if present) +- `category`: The log category +- `jobName`: The job name (if present) +- `instanceName`: The instance name (if present) + +## How It All Works + +1. The logging system is initialized during application startup +2. Logs are written to the console in real-time +3. Logs are written to files based on their category +4. Logs are buffered and sent to connected SignalR clients +5. New clients can request recent logs from the buffer + +## Configuration Options + +The logging configuration is loaded from the `Logging` section in appsettings.json: + +```json +{ + "Logging": { + "LogLevel": "Information", + "SignalR": { + "Enabled": true, + "BufferSize": 100 + } + } +} +``` + +## Standard Categories + +Use the `LoggingCategoryConstants` class to ensure consistent category naming: + +- `LoggingCategoryConstants.System`: System-level logs +- `LoggingCategoryConstants.Api`: API-related logs +- `LoggingCategoryConstants.Jobs`: Job execution logs +- `LoggingCategoryConstants.Notifications`: User notification logs +- `LoggingCategoryConstants.Sonarr`: Sonarr-related logs +- `LoggingCategoryConstants.Radarr`: Radarr-related logs +- `LoggingCategoryConstants.Lidarr`: Lidarr-related logs diff --git a/code/Infrastructure/Logging/SignalRSink.cs b/code/Infrastructure/Logging/SignalRSink.cs new file mode 100644 index 00000000..1c66a4ed --- /dev/null +++ b/code/Infrastructure/Logging/SignalRSink.cs @@ -0,0 +1,51 @@ +using Microsoft.AspNetCore.SignalR; +using Serilog.Core; +using Serilog.Events; + +namespace Infrastructure.Logging; + +/// +/// Serilog sink that forwards log events to SignalR clients +/// +public class SignalRSink : ILogEventSink +{ + private readonly IHubContext _hubContext; + private readonly LogBuffer _logBuffer; + + public SignalRSink(IHubContext hubContext, LogBuffer logBuffer) + { + _hubContext = hubContext; + _logBuffer = logBuffer; + } + + /// + /// Processes and emits a log event to SignalR clients + /// + /// The log event to emit + public void Emit(LogEvent logEvent) + { + var logData = new + { + Timestamp = logEvent.Timestamp.DateTime, + Level = logEvent.Level.ToString(), + Message = logEvent.RenderMessage(), + Exception = logEvent.Exception?.ToString(), + Category = GetPropertyValue(logEvent, "Category", "SYSTEM"), + JobName = GetPropertyValue(logEvent, "JobName"), + InstanceName = GetPropertyValue(logEvent, "InstanceName") + }; + + _logBuffer.AddLog(logData); + _hubContext.Clients.All.SendAsync("ReceiveLog", logData).GetAwaiter().GetResult(); + } + + private string GetPropertyValue(LogEvent logEvent, string propertyName, string defaultValue = null) + { + if (logEvent.Properties.TryGetValue(propertyName, out var value)) + { + return value.ToString().Trim('\"'); + } + + return defaultValue; + } +}