diff --git a/code/Executable/DependencyInjection/LoggingDI.cs b/code/Executable/DependencyInjection/LoggingDI.cs index f7b4dc06..e5274257 100644 --- a/code/Executable/DependencyInjection/LoggingDI.cs +++ b/code/Executable/DependencyInjection/LoggingDI.cs @@ -1,15 +1,9 @@ -using Common.Configuration.General; -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.Extensions.DependencyInjection.Extensions; -using Microsoft.AspNetCore.SignalR; using Serilog; -using Serilog.Core; using Serilog.Events; using Serilog.Templates; using Serilog.Templates.Themes; @@ -18,40 +12,15 @@ namespace Executable.DependencyInjection; public static class LoggingDI { - public static ILoggingBuilder AddLogging(this ILoggingBuilder builder, IServiceProvider serviceProvider) + public static ILoggingBuilder AddLogging(this ILoggingBuilder builder) { - // Register LoggingConfigManager as a singleton - serviceProvider.GetRequiredService() - .TryAddSingleton(); + Log.Logger = GetDefaultLoggerConfiguration().CreateLogger(); - // Get LoggingConfigManager (will be created if not already registered) - var configManager = serviceProvider.GetRequiredService(); - - - // Get the dynamic level switch for controlling log levels - var levelSwitch = configManager.GetLevelSwitch(); - - // Get the configuration path provider - var pathProvider = serviceProvider.GetRequiredService(); - - // Get logging config from the config manager - var config = serviceProvider.GetRequiredService() - .GetConfiguration().Logging; - - // Create the logs directory - string logsPath = Path.Combine(pathProvider.GetConfigPath(), "logs"); - if (!Directory.Exists(logsPath)) - { - try - { - Directory.CreateDirectory(logsPath); - } - catch (Exception exception) - { - throw new Exception($"Failed to create log directory | {logsPath}", exception); - } - } + return builder.ClearProviders().AddSerilog(); + } + public static LoggerConfiguration GetDefaultLoggerConfiguration() + { 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}"; @@ -87,9 +56,24 @@ public static class LoggingDI // Configure base logger with dynamic level control logConfig - .MinimumLevel.ControlledBy(levelSwitch) + // .MinimumLevel.ControlledBy(levelSwitch) + .MinimumLevel.Is(LogEventLevel.Information) .Enrich.FromLogContext() .WriteTo.Console(new ExpressionTemplate(consoleTemplate, theme: TemplateTheme.Literate)); + + // Create the logs directory + string logsPath = Path.Combine(ConfigurationPathProvider.GetConfigPath(), "logs"); + if (!Directory.Exists(logsPath)) + { + try + { + Directory.CreateDirectory(logsPath); + } + catch (Exception exception) + { + throw new Exception($"Failed to create log directory | {logsPath}", exception); + } + } // Add main log file logConfig.WriteTo.File( @@ -100,47 +84,14 @@ public static class LoggingDI 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: 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 + logConfig .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) - .Enrich.WithProperty("ApplicationName", "cleanuperr") - .CreateLogger(); - - return builder - .ClearProviders() - .AddSerilog(dispose: true); + .Enrich.WithProperty("ApplicationName", "cleanuperr"); + + return logConfig; } } \ No newline at end of file diff --git a/code/Executable/Program.cs b/code/Executable/Program.cs index 28dc37d3..5e284995 100644 --- a/code/Executable/Program.cs +++ b/code/Executable/Program.cs @@ -1,5 +1,7 @@ using Executable; using Executable.DependencyInjection; +using Infrastructure.Logging; +using Serilog; var builder = WebApplication.CreateBuilder(args); @@ -8,15 +10,36 @@ builder.Services .AddInfrastructure(builder.Configuration) .AddApiServices(); +// Register SignalR - ensure this is before logging initialization +builder.Services.AddSignalR(); + // Register services needed for logging first -builder.Services.AddSingleton(); +builder.Services + .AddSingleton() + .AddSingleton(); // Add logging with proper service provider -var serviceProvider = builder.Services.BuildServiceProvider(); -await builder.Logging.AddLogging(serviceProvider); +builder.Logging.AddLogging(); var app = builder.Build(); +// Get LoggingConfigManager (will be created if not already registered) +var configManager = app.Services.GetRequiredService(); + +// Get the dynamic level switch for controlling log levels +var levelSwitch = configManager.GetLevelSwitch(); + +// Get the SignalRLogSink instance +var signalRSink = app.Services.GetRequiredService(); + +var logConfig = LoggingDI.GetDefaultLoggerConfiguration(); +logConfig.MinimumLevel.ControlledBy(levelSwitch); + +// Add to Serilog pipeline +logConfig.WriteTo.Sink(signalRSink); + +Log.Logger = logConfig.CreateLogger(); + // Configure the HTTP request pipeline app.ConfigureApi(); diff --git a/code/Executable/config/download_client.json b/code/Executable/config/download_client.json deleted file mode 100644 index 2e10d0b5..00000000 --- a/code/Executable/config/download_client.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "clients": [ - { - "enabled": true, - "id": "1ded77f9-199c-4325-9de6-7758b691e5e6", - "name": "qbittorrent", - "host": "http://localhost:8080", - "username": "test", - "password": "testing", - "type": "qbittorrent" - } - ] -} \ No newline at end of file diff --git a/code/Executable/config/content_blocker.json b/code/Executable/config/settings/content_blocker.json similarity index 80% rename from code/Executable/config/content_blocker.json rename to code/Executable/config/settings/content_blocker.json index 795d574b..a39cda82 100644 --- a/code/Executable/config/content_blocker.json +++ b/code/Executable/config/settings/content_blocker.json @@ -6,17 +6,17 @@ "ignored_downloads_path": "", "sonarr": { "enabled": false, - "type": 0, + "type": "Blacklist", "path": null }, "radarr": { "enabled": false, - "type": 0, + "type": "Blacklist", "path": null }, "lidarr": { "enabled": false, - "type": 0, + "type": "Blacklist", "path": null } } \ No newline at end of file diff --git a/code/Executable/config/download_cleaner.json b/code/Executable/config/settings/download_cleaner.json similarity index 100% rename from code/Executable/config/download_cleaner.json rename to code/Executable/config/settings/download_cleaner.json diff --git a/code/Executable/config/settings/download_client.json b/code/Executable/config/settings/download_client.json new file mode 100644 index 00000000..60e9cca6 --- /dev/null +++ b/code/Executable/config/settings/download_client.json @@ -0,0 +1,3 @@ +{ + "clients": [] +} \ No newline at end of file diff --git a/code/Executable/config/general.json b/code/Executable/config/settings/general.json similarity index 72% rename from code/Executable/config/general.json rename to code/Executable/config/settings/general.json index 7f2c2272..b46787bc 100644 --- a/code/Executable/config/general.json +++ b/code/Executable/config/settings/general.json @@ -4,5 +4,6 @@ "http_timeout": 100, "certificate_validation": "Enabled", "search_enabled": true, - "search_delay": 30 + "search_delay": 30, + "log_level": "Information" } \ No newline at end of file diff --git a/code/Executable/config/ignored_downloads.json b/code/Executable/config/settings/ignored_downloads.json similarity index 100% rename from code/Executable/config/ignored_downloads.json rename to code/Executable/config/settings/ignored_downloads.json diff --git a/code/Executable/config/lidarr.json b/code/Executable/config/settings/lidarr.json similarity index 100% rename from code/Executable/config/lidarr.json rename to code/Executable/config/settings/lidarr.json diff --git a/code/Executable/config/settings/notifications.json b/code/Executable/config/settings/notifications.json new file mode 100644 index 00000000..c8aaceda --- /dev/null +++ b/code/Executable/config/settings/notifications.json @@ -0,0 +1,24 @@ +{ + "notifiarr": { + "api_key": null, + "channel_id": null, + "on_failed_import_strike": false, + "on_stalled_strike": false, + "on_slow_strike": false, + "on_queue_item_deleted": false, + "on_download_cleaned": false, + "on_category_changed": false, + "is_enabled": false + }, + "apprise": { + "url": null, + "key": null, + "on_failed_import_strike": false, + "on_stalled_strike": false, + "on_slow_strike": false, + "on_queue_item_deleted": false, + "on_download_cleaned": false, + "on_category_changed": false, + "is_enabled": false + } +} \ No newline at end of file diff --git a/code/Executable/config/queue_cleaner.json b/code/Executable/config/settings/queue_cleaner.json similarity index 68% rename from code/Executable/config/queue_cleaner.json rename to code/Executable/config/settings/queue_cleaner.json index be66e5f7..6c8e469b 100644 --- a/code/Executable/config/queue_cleaner.json +++ b/code/Executable/config/settings/queue_cleaner.json @@ -1,22 +1,22 @@ { - "enabled": true, - "cron_expression": "0/30 * * * * ?", + "enabled": false, + "cron_expression": "0 0/5 * * * ?", "run_sequentially": false, "ignored_downloads_path": "", - "import_failed_max_strikes": 3, + "import_failed_max_strikes": 0, "import_failed_ignore_private": false, "import_failed_delete_private": false, "import_failed_ignore_patterns": [], - "stalled_max_strikes": 3, + "stalled_max_strikes": 0, "stalled_reset_strikes_on_progress": false, "stalled_ignore_private": false, "stalled_delete_private": false, - "downloading_metadata_max_strikes": 3, - "slow_max_strikes": 3, + "downloading_metadata_max_strikes": 0, + "slow_max_strikes": 0, "slow_reset_strikes_on_progress": false, "slow_ignore_private": false, "slow_delete_private": false, - "slow_min_speed": "1KB", + "slow_min_speed": "", "slow_max_time": 0, "slow_ignore_above_size": "" } \ No newline at end of file diff --git a/code/Executable/config/radarr.json b/code/Executable/config/settings/radarr.json similarity index 100% rename from code/Executable/config/radarr.json rename to code/Executable/config/settings/radarr.json diff --git a/code/Executable/config/settings/sonarr.json b/code/Executable/config/settings/sonarr.json new file mode 100644 index 00000000..6202250a --- /dev/null +++ b/code/Executable/config/settings/sonarr.json @@ -0,0 +1,6 @@ +{ + "search_type": "Episode", + "enabled": false, + "import_failed_max_strikes": -1, + "instances": [] +} \ No newline at end of file diff --git a/code/Executable/config/sonarr.json b/code/Executable/config/sonarr.json deleted file mode 100644 index 22189e49..00000000 --- a/code/Executable/config/sonarr.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "enabled": true, - "search_type": "episode", - "import_failed_max_strikes": -1, - "instances": [ - { - "id": "c260fdf2-5f79-4449-b40b-1dce1e8ef2ac", - "name": "sonarr1", - "url": "http://localhost:8989", - "api_key": "425d1e713f0c405cbbf359ac0502c1f4" - } - ] -} \ No newline at end of file diff --git a/code/Infrastructure/Configuration/CachedConfigurationProvider.cs b/code/Infrastructure/Configuration/CachedConfigurationProvider.cs index 154069f2..8a9eac21 100644 --- a/code/Infrastructure/Configuration/CachedConfigurationProvider.cs +++ b/code/Infrastructure/Configuration/CachedConfigurationProvider.cs @@ -18,13 +18,12 @@ public class CachedConfigurationProvider : IConfigurationProvider, IDisposable public CachedConfigurationProvider( ILogger logger, - JsonConfigurationProvider baseProvider, - ConfigurationPathProvider pathProvider + JsonConfigurationProvider baseProvider ) { _logger = logger; _baseProvider = baseProvider; - _configDirectory = pathProvider.GetSettingsPath(); + _configDirectory = ConfigurationPathProvider.GetSettingsPath(); // Ensure directory exists if (!Directory.Exists(_configDirectory)) diff --git a/code/Infrastructure/Configuration/ConfigManager.cs b/code/Infrastructure/Configuration/ConfigManager.cs index 6e514f0d..be59def3 100644 --- a/code/Infrastructure/Configuration/ConfigManager.cs +++ b/code/Infrastructure/Configuration/ConfigManager.cs @@ -32,12 +32,11 @@ public class ConfigManager : IConfigManager public ConfigManager( ILogger logger, - IConfigurationProvider configProvider, - ConfigurationPathProvider pathProvider) + IConfigurationProvider configProvider) { _logger = logger; _configProvider = configProvider; - string settingsPath = pathProvider.GetSettingsPath(); + string settingsPath = ConfigurationPathProvider.GetSettingsPath(); // _generalConfigFile = Path.Combine(settingsPath, "general.json"); // _sonarrConfigFile = Path.Combine(settingsPath, "sonarr.json"); @@ -52,16 +51,16 @@ public class ConfigManager : IConfigManager _settingsPaths = new() { - { typeof(GeneralConfig), Path.Combine(settingsPath, "general.json") }, - { typeof(SonarrConfig), Path.Combine(settingsPath, "sonarr.json") }, - { typeof(RadarrConfig), Path.Combine(settingsPath, "radarr.json") }, - { typeof(LidarrConfig), Path.Combine(settingsPath, "lidarr.json") }, - { typeof(ContentBlockerConfig), Path.Combine(settingsPath, "content_blocker.json") }, - { typeof(QueueCleanerConfig), Path.Combine(settingsPath, "queue_cleaner.json") }, - { typeof(DownloadCleanerConfig), Path.Combine(settingsPath, "download_cleaner.json") }, - { typeof(DownloadClientConfig), Path.Combine(settingsPath, "download_client.json") }, - { typeof(IgnoredDownloadsConfig), Path.Combine(settingsPath, "ignored_downloads.json") }, - { typeof(NotificationsConfig), Path.Combine(settingsPath, "notifications.json") } + { typeof(GeneralConfig), "general.json" }, + { typeof(SonarrConfig), "sonarr.json" }, + { typeof(RadarrConfig), "radarr.json" }, + { typeof(LidarrConfig), "lidarr.json" }, + { typeof(ContentBlockerConfig), "content_blocker.json" }, + { typeof(QueueCleanerConfig), "queue_cleaner.json" }, + { typeof(DownloadCleanerConfig), "download_cleaner.json" }, + { typeof(DownloadClientConfig), "download_client.json" }, + { typeof(IgnoredDownloadsConfig), "ignored_downloads.json" }, + { typeof(NotificationsConfig), "notifications.json" } }; } diff --git a/code/Infrastructure/Configuration/ConfigurationExtensions.cs b/code/Infrastructure/Configuration/ConfigurationExtensions.cs index 7c526a89..112dd7f6 100644 --- a/code/Infrastructure/Configuration/ConfigurationExtensions.cs +++ b/code/Infrastructure/Configuration/ConfigurationExtensions.cs @@ -7,9 +7,6 @@ public static class ConfigurationExtensions { public static IServiceCollection AddConfigurationServices(this IServiceCollection services) { - // Register path provider to handle Docker vs local environment - services.AddSingleton(); - // Register the base JSON provider services.AddSingleton(); diff --git a/code/Infrastructure/Configuration/ConfigurationPathProvider.cs b/code/Infrastructure/Configuration/ConfigurationPathProvider.cs index 0c8daf62..c4d3b228 100644 --- a/code/Infrastructure/Configuration/ConfigurationPathProvider.cs +++ b/code/Infrastructure/Configuration/ConfigurationPathProvider.cs @@ -1,17 +1,39 @@ -using Microsoft.Extensions.Hosting; - namespace Infrastructure.Configuration; /// /// Provides the appropriate configuration path based on the runtime environment. /// Uses '/config' for Docker containers and a relative 'config' path for normal environments. /// -public class ConfigurationPathProvider +public static class ConfigurationPathProvider { - private readonly string _configPath; - private readonly string _settingsPath; + private static string? _configPath; + private static string? _settingsPath; - public ConfigurationPathProvider(IHostEnvironment environment) + static ConfigurationPathProvider() + { + try + { + string configPath = InitializeConfigPath(); + + if (!Directory.Exists(configPath)) + { + Directory.CreateDirectory(configPath); + } + + string settingsPath = InitializeSettingsPath(); + + if (!Directory.Exists(settingsPath)) + { + Directory.CreateDirectory(settingsPath); + } + } + catch (Exception ex) + { + throw new InvalidOperationException($"Failed to create configuration directories: {ex.Message}", ex); + } + } + + private static string InitializeConfigPath() { // Check if running in Docker container bool isInContainer = Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER") == "true"; @@ -24,37 +46,30 @@ public class ConfigurationPathProvider else { // Use path relative to app for normal environment - _configPath = Path.Combine(environment.ContentRootPath, "config"); + _configPath = "config"; } - - // Create settings path as a subdirectory - _settingsPath = Path.Combine(_configPath, "settings"); - - // Ensure directories exist - EnsureDirectoriesExist(); + + return _configPath; + } + + private static string InitializeSettingsPath() + { + if (string.IsNullOrEmpty(_settingsPath)) + { + string configPath = _configPath ?? InitializeConfigPath(); + _settingsPath = Path.Combine(configPath, "settings"); + } + + return _settingsPath; } - public string GetConfigPath() => _configPath; - - public string GetSettingsPath() => _settingsPath; - - private void EnsureDirectoriesExist() + public static string GetConfigPath() { - try - { - if (!Directory.Exists(_configPath)) - { - Directory.CreateDirectory(_configPath); - } - - if (!Directory.Exists(_settingsPath)) - { - Directory.CreateDirectory(_settingsPath); - } - } - catch (Exception ex) - { - throw new InvalidOperationException($"Failed to create configuration directories: {ex.Message}", ex); - } + return _configPath ?? InitializeConfigPath(); + } + + public static string GetSettingsPath() + { + return _settingsPath ?? InitializeConfigPath(); } } diff --git a/code/Infrastructure/Configuration/JsonConfigurationProvider.cs b/code/Infrastructure/Configuration/JsonConfigurationProvider.cs index baa6c925..63a9c319 100644 --- a/code/Infrastructure/Configuration/JsonConfigurationProvider.cs +++ b/code/Infrastructure/Configuration/JsonConfigurationProvider.cs @@ -15,10 +15,10 @@ public class JsonConfigurationProvider : IConfigurationProvider private readonly Dictionary _fileLocks = new(); private readonly JsonSerializerOptions _serializerOptions; - public JsonConfigurationProvider(ILogger logger, ConfigurationPathProvider pathProvider) + public JsonConfigurationProvider(ILogger logger) { _logger = logger; - _configDirectory = pathProvider.GetSettingsPath(); + _configDirectory = ConfigurationPathProvider.GetSettingsPath(); // Create directory if it doesn't exist if (!Directory.Exists(_configDirectory)) diff --git a/code/Infrastructure/Logging/DeferredSignalRSink.cs b/code/Infrastructure/Logging/DeferredSignalRSink.cs deleted file mode 100644 index a27f31da..00000000 --- a/code/Infrastructure/Logging/DeferredSignalRSink.cs +++ /dev/null @@ -1,61 +0,0 @@ -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/LogHub.cs b/code/Infrastructure/Logging/LogHub.cs index 17ef43ee..c0aba30f 100644 --- a/code/Infrastructure/Logging/LogHub.cs +++ b/code/Infrastructure/Logging/LogHub.cs @@ -7,11 +7,11 @@ namespace Infrastructure.Logging; /// public class LogHub : Hub { - private readonly LogBuffer _logBuffer; + private readonly SignalRLogSink _logSink; - public LogHub(LogBuffer logBuffer) + public LogHub(SignalRLogSink logSink) { - _logBuffer = logBuffer; + _logSink = logSink; } /// @@ -19,7 +19,7 @@ public class LogHub : Hub /// public async Task RequestRecentLogs() { - foreach (var logEvent in _logBuffer.GetRecentLogs()) + foreach (var logEvent in _logSink.GetRecentLogs()) { await Clients.Caller.SendAsync("ReceiveLog", logEvent); } diff --git a/code/Infrastructure/Logging/LoggingConfigManager.cs b/code/Infrastructure/Logging/LoggingConfigManager.cs index e73eb27e..8ffa95ca 100644 --- a/code/Infrastructure/Logging/LoggingConfigManager.cs +++ b/code/Infrastructure/Logging/LoggingConfigManager.cs @@ -11,11 +11,11 @@ namespace Infrastructure.Logging; /// public class LoggingConfigManager { - private readonly ConfigManager _configManager; + private readonly IConfigManager _configManager; private readonly LoggingLevelSwitch _levelSwitch; private readonly ILogger _logger; - public LoggingConfigManager(ConfigManager configManager, ILogger logger) + public LoggingConfigManager(IConfigManager configManager, ILogger logger) { _configManager = configManager; _logger = logger; diff --git a/code/Infrastructure/Logging/LoggingInitializer.cs b/code/Infrastructure/Logging/LoggingInitializer.cs index efa7049c..79a06706 100644 --- a/code/Infrastructure/Logging/LoggingInitializer.cs +++ b/code/Infrastructure/Logging/LoggingInitializer.cs @@ -1,5 +1,7 @@ using System.Collections; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; using Serilog; namespace Infrastructure.Logging; @@ -10,31 +12,34 @@ namespace Infrastructure.Logging; public class LoggingInitializer : BackgroundService { private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; - public LoggingInitializer(IServiceProvider serviceProvider) + public LoggingInitializer(IServiceProvider serviceProvider, ILogger logger) { _serviceProvider = serviceProvider; + _logger = logger; } - protected override Task ExecuteAsync(CancellationToken stoppingToken) + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - // Find and initialize any deferred sinks - var deferredSink = Log.Logger; - - if (deferredSink.GetType() - .GetProperty("Sinks", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance) - ?.GetValue(deferredSink) is IEnumerable sinks) + try { - foreach (var sink in sinks) + // Short delay to ensure SignalR is fully initialized + await Task.Delay(1000, stoppingToken); + + // Get the SignalRLogSink and initialize it + _logger.LogDebug("Initializing SignalR logging"); + if (_serviceProvider.GetService() is { } sink) { - if (sink is DeferredSignalRSink deferredSignalRSink) - { - deferredSignalRSink.Initialize(_serviceProvider); - } + sink.Initialize(); + _logger.LogInformation("SignalR logging initialized successfully"); } } - + catch (Exception ex) + { + _logger.LogError(ex, "Failed to initialize SignalR logging"); + } + // We only need to run this once at startup - return Task.CompletedTask; } } diff --git a/code/Infrastructure/Logging/SignalRLogSink.cs b/code/Infrastructure/Logging/SignalRLogSink.cs new file mode 100644 index 00000000..d606e2db --- /dev/null +++ b/code/Infrastructure/Logging/SignalRLogSink.cs @@ -0,0 +1,134 @@ +using System.Collections.Concurrent; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Serilog.Core; +using Serilog.Events; + +namespace Infrastructure.Logging; + +/// +/// A Serilog sink that sends log events to SignalR clients +/// +public class SignalRLogSink : ILogEventSink +{ + private readonly ConcurrentQueue _buffer = new(); + private readonly int _bufferSize; + private readonly ConcurrentQueue _logBuffer; + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + private IHubContext _hubContext; + private volatile bool _isInitialized; + + public SignalRLogSink(IServiceProvider serviceProvider, ILogger logger) + { + _serviceProvider = serviceProvider; + _logger = logger; + _bufferSize = 100; + _logBuffer = new ConcurrentQueue(); + } + + /// + /// Processes and emits a log event to SignalR clients + /// + /// The log event to emit + public void Emit(LogEvent logEvent) + { + if (!_isInitialized) + { + // Buffer the event until we can initialize + _buffer.Enqueue(logEvent); + + // Try to initialize if not already done + TryInitialize(); + return; + } + + try + { + 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") + }; + + // Add to buffer for new clients + AddToBuffer(logData); + + // Send to connected clients + _ = _hubContext.Clients.All.SendAsync("ReceiveLog", logData); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to send log event via SignalR"); + } + } + + /// + /// Gets the buffer of recent logs + /// + public IEnumerable GetRecentLogs() + { + return _logBuffer.ToArray(); + } + + /// + /// Initialize the SignalR hub context + /// + public void Initialize() + { + TryInitialize(); + } + + private void TryInitialize() + { + if (_isInitialized) + return; + + try + { + _hubContext = _serviceProvider.GetRequiredService>(); + _isInitialized = true; + + // Process any buffered events + ProcessBufferedEvents(); + + _logger.LogInformation("SignalR log sink initialized successfully"); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "SignalR log sink initialization deferred - hub context not available yet"); + } + } + + private void ProcessBufferedEvents() + { + while (_buffer.TryDequeue(out var logEvent)) + { + Emit(logEvent); + } + } + + private void AddToBuffer(object logData) + { + _logBuffer.Enqueue(logData); + + // Trim buffer if it exceeds the limit + while (_logBuffer.Count > _bufferSize && _logBuffer.TryDequeue(out _)) { } + } + + private string GetPropertyValue(LogEvent logEvent, string propertyName, string defaultValue = null) + { + if (logEvent.Properties.TryGetValue(propertyName, out var value)) + { + return value.ToString().Trim('\"'); + } + + return defaultValue; + } +}