refactor: Configurator design pattern for log configuration

Primary motivation for this change is to create separation of concerns
for log sink configuration. The LoggerFactory was becoming too large and
had too many responsibilities.

Later on this will help facilitate extension of log functionality while
respecting the Open Closed Principle.
pull/336/head
Robert Dailey 5 months ago
parent d6783093ce
commit 06b68772bd

@ -14,6 +14,7 @@
<PackageVersion Include="AutoMapper.Contrib.Autofac.DependencyInjection" Version="8.0.0" />
<PackageVersion Include="CliWrap" Version="3.6.6" />
<PackageVersion Include="FluentValidation" Version="11.10.0" />
<PackageVersion Include="Flurl" Version="4.0.0" />
<PackageVersion Include="Flurl.Http" Version="4.0.2" />
<PackageVersion Include="GitVersion.MsBuild" Version="6.0.2" PrivateAssets="All" />
<PackageVersion Include="MudBlazor" Version="7.8.0" />
@ -77,4 +78,4 @@
<!-- Cannot use the official Jetbrains.Annotations package because it doesn't work with GlobalPackageReference -->
<GlobalPackageReference Include="Rocket.Surgery.MSBuild.JetBrains.Annotations" Version="1.2.1" />
</ItemGroup>
</Project>
</Project>

@ -61,6 +61,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recyclarr.Cache", "src\Recy
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recyclarr.Cli.TestLibrary", "tests\Recyclarr.Cli.TestLibrary\Recyclarr.Cli.TestLibrary.csproj", "{7B730BDB-1519-4847-BA23-998AB3750E31}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recyclarr.Logging", "src\Recyclarr.Logging\Recyclarr.Logging.csproj", "{0CE6D5F2-A7DC-4C03-B367-ECF8D06FC51E}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -155,6 +157,10 @@ Global
{7B730BDB-1519-4847-BA23-998AB3750E31}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7B730BDB-1519-4847-BA23-998AB3750E31}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7B730BDB-1519-4847-BA23-998AB3750E31}.Release|Any CPU.Build.0 = Release|Any CPU
{0CE6D5F2-A7DC-4C03-B367-ECF8D06FC51E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0CE6D5F2-A7DC-4C03-B367-ECF8D06FC51E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0CE6D5F2-A7DC-4C03-B367-ECF8D06FC51E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0CE6D5F2-A7DC-4C03-B367-ECF8D06FC51E}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE

@ -21,6 +21,7 @@ using Recyclarr.Compatibility;
using Recyclarr.Config;
using Recyclarr.Http;
using Recyclarr.Json;
using Recyclarr.Logging;
using Recyclarr.Platform;
using Recyclarr.Repo;
using Recyclarr.ServarrApi;
@ -28,7 +29,6 @@ using Recyclarr.Settings;
using Recyclarr.TrashGuide;
using Recyclarr.VersionControl;
using Recyclarr.Yaml;
using Serilog.Core;
using Spectre.Console.Cli;
namespace Recyclarr.Cli;
@ -42,7 +42,7 @@ public static class CompositionRoot
// Needed for Autofac.Extras.Ordering
builder.RegisterSource<OrderedRegistrationSource>();
RegisterLogger(builder);
RegisterLogger(builder, thisAssembly);
builder.RegisterModule<MigrationAutofacModule>();
builder.RegisterModule<ConfigAutofacModule>();
@ -89,12 +89,14 @@ public static class CompositionRoot
.OrderByRegistration();
}
private static void RegisterLogger(ContainerBuilder builder)
private static void RegisterLogger(ContainerBuilder builder, Assembly thisAssembly)
{
builder.RegisterAssemblyTypes(thisAssembly)
.AssignableTo<ILogConfigurator>()
.As<ILogConfigurator>();
builder.RegisterModule<LoggingAutofacModule>();
builder.RegisterType<LogJanitor>();
builder.RegisterType<LoggingLevelSwitch>().SingleInstance();
builder.RegisterType<LoggerFactory>();
builder.Register(c => c.Resolve<LoggerFactory>().Create()).As<ILogger>().SingleInstance();
}
private static void CliRegistrations(ContainerBuilder builder)

@ -0,0 +1,23 @@
using Recyclarr.Logging;
using Recyclarr.Platform;
using Serilog.Core;
using Serilog.Templates;
using Serilog.Templates.Themes;
namespace Recyclarr.Cli.Logging;
internal class ConsoleLogSinkConfigurator(LoggingLevelSwitch levelSwitch, IEnvironment env) : ILogConfigurator
{
public void Configure(LoggerConfiguration config)
{
config.WriteTo.Console(BuildExpressionTemplate(), levelSwitch: levelSwitch);
}
private ExpressionTemplate BuildExpressionTemplate()
{
var template = "[{@l:u3}] " + LogTemplates.Base;
var raw = !string.IsNullOrEmpty(env.GetEnvironmentVariable("NO_COLOR"));
return new ExpressionTemplate(template, theme: raw ? null : TemplateTheme.Code);
}
}

@ -0,0 +1,40 @@
using System.IO.Abstractions;
using Recyclarr.Logging;
using Recyclarr.Platform;
using Serilog.Events;
using Serilog.Templates;
namespace Recyclarr.Cli.Logging;
internal class FileLogSinkConfigurator(IAppPaths paths) : ILogConfigurator
{
public void Configure(LoggerConfiguration config)
{
var logFilePrefix = $"recyclarr_{DateTime.Now:yyyy-MM-dd_HH-mm-ss}";
var logDir = paths.LogDirectory;
var template = BuildExpressionTemplate();
config
.WriteTo.Logger(c => c
.MinimumLevel.Debug()
.WriteTo.File(template, LogFilePath("debug")))
.WriteTo.Logger(c => c
.Filter.ByIncludingOnly(e => e.Level == LogEventLevel.Verbose)
.WriteTo.File(template, LogFilePath("verbose")));
return;
string LogFilePath(string type)
{
return logDir.File($"{logFilePrefix}.{type}.log").FullName;
}
}
private static ExpressionTemplate BuildExpressionTemplate()
{
var template = "[{@t:HH:mm:ss} {@l:u3}] " + LogTemplates.Base +
"{Inspect(@x).StackTrace}";
return new ExpressionTemplate(template);
}
}

@ -1,72 +0,0 @@
using System.IO.Abstractions;
using Recyclarr.Platform;
using Serilog.Core;
using Serilog.Events;
using Serilog.Templates;
using Serilog.Templates.Themes;
namespace Recyclarr.Cli.Logging;
public class LoggerFactory(
IAppPaths paths,
LoggingLevelSwitch levelSwitch,
IEnvironment env,
IEnumerable<ILogEventSink> sinks)
{
private static string GetBaseTemplateString()
{
var scope = LogProperty.Scope;
return
$"{{#if {scope} is not null}}{{{scope}}}: {{#end}}" +
"{@m}";
}
private ExpressionTemplate GetConsoleTemplate()
{
var template = "[{@l:u3}] " + GetBaseTemplateString() +
"{#if SanitizedExceptionMessage is not null}: {SanitizedExceptionMessage}{#end}\n";
var raw = !string.IsNullOrEmpty(env.GetEnvironmentVariable("NO_COLOR"));
return new ExpressionTemplate(template, theme: raw ? null : TemplateTheme.Code);
}
private static ExpressionTemplate GetFileTemplate()
{
var template = "[{@t:HH:mm:ss} {@l:u3}] " + GetBaseTemplateString() +
"{#if SanitizedExceptionMessage is not null}: {SanitizedExceptionMessage}{#end}\n" +
"{Inspect(@x).StackTrace}";
return new ExpressionTemplate(template);
}
public ILogger Create()
{
var logFilePrefix = $"recyclarr_{DateTime.Now:yyyy-MM-dd_HH-mm-ss}";
var logDir = paths.LogDirectory;
var config = new LoggerConfiguration()
.MinimumLevel.Is(LogEventLevel.Verbose)
.Enrich.FromLogContext()
.Enrich.With<FlurlExceptionSanitizingEnricher>()
.WriteTo.Console(GetConsoleTemplate(), levelSwitch: levelSwitch)
.WriteTo.Logger(c => c
.MinimumLevel.Debug()
.WriteTo.File(GetFileTemplate(), LogFilePath("debug")))
.WriteTo.Logger(c => c
.Filter.ByIncludingOnly(e => e.Level == LogEventLevel.Verbose)
.WriteTo.File(GetFileTemplate(), LogFilePath("verbose")));
foreach (var sink in sinks)
{
config.WriteTo.Sink(sink, levelSwitch: levelSwitch);
}
return config.CreateLogger();
string LogFilePath(string type)
{
return logDir.File($"{logFilePrefix}.{type}.log").FullName;
}
}
}

@ -30,6 +30,7 @@
<ProjectReference Include="..\Recyclarr.Common\Recyclarr.Common.csproj" />
<ProjectReference Include="..\Recyclarr.Compatibility\Recyclarr.Compatibility.csproj" />
<ProjectReference Include="..\Recyclarr.Config\Recyclarr.Config.csproj" />
<ProjectReference Include="..\Recyclarr.Logging\Recyclarr.Logging.csproj" />
<ProjectReference Include="..\Recyclarr.TrashGuide\Recyclarr.TrashGuide.csproj" />
</ItemGroup>

@ -0,0 +1,23 @@
using Recyclarr.Common.Extensions;
namespace Recyclarr.Logging;
public static class ExceptionExtensions
{
public static string FullMessage(this Exception ex)
{
if (ex is AggregateException aex)
{
return aex.InnerExceptions.Aggregate("[ ", (total, next) => $"{total}[{next.FullMessage()}] ") + "]";
}
var msg = ex.Message.Replace(", see inner exception.", "").Trim();
var innerMsg = ex.InnerException?.FullMessage();
if (innerMsg != null && !innerMsg.ContainsIgnoreCase(msg) && !msg.ContainsIgnoreCase(innerMsg))
{
msg = $"{msg} [ {innerMsg} ]";
}
return msg;
}
}

@ -1,8 +1,7 @@
using Recyclarr.Http;
using Serilog.Core;
using Serilog.Events;
namespace Recyclarr.Cli.Logging;
namespace Recyclarr.Logging;
internal class FlurlExceptionSanitizingEnricher : ILogEventEnricher
{

@ -0,0 +1,8 @@
using Serilog;
namespace Recyclarr.Logging;
public interface ILogConfigurator
{
void Configure(LoggerConfiguration config);
}

@ -0,0 +1,6 @@
namespace Recyclarr.Logging;
public static class LogProperty
{
public static string Scope => nameof(Scope);
}

@ -0,0 +1,16 @@
namespace Recyclarr.Logging;
public static class LogTemplates
{
public static string Base { get; } = GetBaseTemplateString();
private static string GetBaseTemplateString()
{
var scope = LogProperty.Scope;
return
$"{{#if {scope} is not null}}{{{scope}}}: {{#end}}" +
"{@m}" +
"{#if SanitizedExceptionMessage is not null}: {SanitizedExceptionMessage}{#end}\n";
}
}

@ -0,0 +1,22 @@
using Serilog;
using Serilog.Events;
namespace Recyclarr.Logging;
public class LoggerFactory(IEnumerable<ILogConfigurator> configurators)
{
public ILogger Create()
{
var config = new LoggerConfiguration()
.MinimumLevel.Is(LogEventLevel.Verbose)
.Enrich.FromLogContext()
.Enrich.With<FlurlExceptionSanitizingEnricher>();
foreach (var configurator in configurators)
{
configurator.Configure(config);
}
return config.CreateLogger();
}
}

@ -0,0 +1,18 @@
using Autofac;
using Serilog;
using Serilog.Core;
using Module = Autofac.Module;
namespace Recyclarr.Logging;
public class LoggingAutofacModule : Module
{
protected override void Load(ContainerBuilder builder)
{
base.Load(builder);
builder.RegisterType<LoggingLevelSwitch>().SingleInstance();
builder.RegisterType<LoggerFactory>();
builder.Register(c => c.Resolve<LoggerFactory>().Create()).As<ILogger>().SingleInstance();
}
}

@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<PackageReference Include="Flurl" />
<PackageReference Include="Serilog" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Recyclarr.Common\Recyclarr.Common.csproj" />
</ItemGroup>
</Project>

@ -0,0 +1,40 @@
using System.Text.RegularExpressions;
using Flurl;
namespace Recyclarr.Logging;
public static partial class Sanitize
{
public static string Message(string message)
{
// Replace full URLs
var result = UrlRegex().Replace(message, SanitizeMatch);
// There are sometimes parenthetical parts of the message that contain the host but are not
// detected as true URLs. Just strip those out completely.
return HostRegex().Replace(result, "");
}
public static string ExceptionMessage(Exception exception)
{
return Message(exception.FullMessage());
}
public static Url Url(Url url)
{
// Replace hostname for user privacy
url.Host = "REDACTED";
return url;
}
private static string SanitizeMatch(Match match)
{
return Url(match.Value).ToString() ?? match.Value;
}
[GeneratedRegex(@"\([-a-zA-Z0-9@:%._+~#=]{1,256}(?::[0-9]+)?\)")]
private static partial Regex HostRegex();
[GeneratedRegex(@"https?://(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}(:[0-9]+)?\b([-a-zA-Z0-9()@:%_+.~#?&/=]*)")]
private static partial Regex UrlRegex();
}
Loading…
Cancel
Save