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