diff --git a/Directory.Version.props b/Directory.Version.props index e0c7503..84b79be 100644 --- a/Directory.Version.props +++ b/Directory.Version.props @@ -1,6 +1,6 @@ - 10.0.0 + 10.0.1 diff --git a/README.md b/README.md index 68cc3ee..681dd39 100644 --- a/README.md +++ b/README.md @@ -313,6 +313,8 @@ Some Serilog packages require a reference to a logger configuration object. The }, ``` +The same nested pattern applies to wrapping sinks such as `Conditional` (from _[Serilog.Expressions](https://github.com/serilog/serilog-expressions)_), `FallbackChain`, and `Fallible` — see the [sample `appsettings.json`](sample/Sample/appsettings.json) for working examples. + ### Destructuring Destructuring means extracting pieces of information from an object and create properties with values; Serilog offers the `@` [structure-capturing operator](https://github.com/serilog/serilog/wiki/Structured-Data#preserving-object-structure). In case there is a need to customize the way log events are serialized (e.g., hide property values or replace them with something else), one can define several destructuring policies, like this: diff --git a/sample/Sample/AlwaysFailingSink.cs b/sample/Sample/AlwaysFailingSink.cs new file mode 100644 index 0000000..5ee7bdb --- /dev/null +++ b/sample/Sample/AlwaysFailingSink.cs @@ -0,0 +1,13 @@ +using Serilog.Core; +using Serilog.Events; + +namespace Sample; + +public class AlwaysFailingSink : ILogEventSink +{ + public void Emit(LogEvent logEvent) + { + throw new InvalidOperationException( + "AlwaysFailingSink always throws so the sample can demonstrate fallback/failure-listener behavior."); + } +} diff --git a/sample/Sample/SampleFailureListener.cs b/sample/Sample/SampleFailureListener.cs new file mode 100644 index 0000000..0f3df8c --- /dev/null +++ b/sample/Sample/SampleFailureListener.cs @@ -0,0 +1,22 @@ +using Serilog.Core; +using Serilog.Debugging; +using Serilog.Events; + +namespace Sample; + +public class SampleFailureListener : ILoggingFailureListener +{ + public void OnLoggingFailed( + object sender, + LoggingFailureKind kind, + string message, + IReadOnlyCollection? events, + Exception? exception) + { + var exceptionDetail = exception is null + ? string.Empty + : $" — {exception.GetType().Name}: {exception.Message}"; + SelfLog.WriteLine( + $"[SampleFailureListener] {kind} failure from {sender.GetType().Name}: {message} ({events?.Count ?? 0} events){exceptionDetail}"); + } +} diff --git a/sample/Sample/appsettings.json b/sample/Sample/appsettings.json index 98aa712..e0ab488 100644 --- a/sample/Sample/appsettings.json +++ b/sample/Sample/appsettings.json @@ -4,7 +4,7 @@ "LevelSwitches": { "controlSwitch": "Verbose" }, "FilterSwitches": { "$filterSwitch": "Application = 'Sample'" }, "MinimumLevel": { - "Default": "Debug", + "ControlledBy": "$controlSwitch", "Override": { "Microsoft": "Warning", "MyApp.Something.Tricky": "Verbose" @@ -37,7 +37,10 @@ "Name": "File", "Args": { "path": "%TEMP%/Logs/serilog-configuration-sample.txt", - "outputTemplate": "{Timestamp:o} [{Level:u3}] ({Application}/{MachineName}/{ThreadId}/{ThreadName}) {Message}{NewLine}{Exception}" + "formatter": { + "type": "Serilog.Templates.ExpressionTemplate, Serilog.Expressions", + "template": "{@t:o} [{@l:u3}] ({Application}/{MachineName}/{ThreadId}/{ThreadName}) {@m}\n{@x}" + } } } ] @@ -46,7 +49,7 @@ "WriteTo:ConditionalSink": { "Name": "Conditional", "Args": { - "expression": "@Level in ['Error', 'Fatal']", + "expression": "@l in ['Error', 'Fatal']", "configureSink": [ { "Name": "File", @@ -63,6 +66,43 @@ ] } }, + "WriteTo:FallbackChain": { + "Name": "FallbackChain", + "Args": { + "configureSink": [ + { + "Name": "Sink", + "Args": { + "sink": { "type": "Sample.AlwaysFailingSink, Sample" } + } + } + ], + "configureFallback": [ + { + "Name": "Console", + "Args": { + "outputTemplate": "[fallback {Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}" + } + } + ] + } + }, + "WriteTo:Fallible": { + "Name": "Fallible", + "Args": { + "configureSink": [ + { + "Name": "Sink", + "Args": { + "sink": { "type": "Sample.AlwaysFailingSink, Sample" } + } + } + ], + "failureListener": { + "type": "Sample.SampleFailureListener, Sample" + } + } + }, "Enrich": [ "FromLogContext", "WithThreadId", @@ -79,6 +119,13 @@ "expression": "Application = 'Sample'", "configureEnricher": [ "WithMachineName" ] } + }, + { + "Name": "WithComputed", + "Args": { + "name": "ShortContext", + "expression": "coalesce(SourceContext, '')" + } } ], "Properties": { @@ -109,6 +156,12 @@ "switch": "$filterSwitch" } }, + { + "Name": "ByExcluding", + "Args": { + "expression": "Application = 'OtherApp'" + } + }, { "Name": "With", "Args": { @@ -118,6 +171,14 @@ } } } + ], + "AuditTo": [ + { + "Name": "Console", + "Args": { + "outputTemplate": "[AUDIT {Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}" + } + } ] } } diff --git a/src/Serilog.Settings.Configuration/Settings/Configuration/ConfigurationReader.cs b/src/Serilog.Settings.Configuration/Settings/Configuration/ConfigurationReader.cs index 3b86170..19d269d 100644 --- a/src/Serilog.Settings.Configuration/Settings/Configuration/ConfigurationReader.cs +++ b/src/Serilog.Settings.Configuration/Settings/Configuration/ConfigurationReader.cs @@ -403,10 +403,12 @@ static bool HasImplicitValueWhenNotSpecified(ParameterInfo paramInfo) { return paramInfo.HasDefaultValue // parameters of type IConfiguration are implicitly populated with provided Configuration - || paramInfo.ParameterType == typeof(IConfiguration); + || paramInfo.ParameterType == typeof(IConfiguration) + || paramInfo.IsDefined(typeof(ParamArrayAttribute), false) + || paramInfo.CustomAttributes.Any(a => a.AttributeType.FullName == "System.Runtime.CompilerServices.ParamCollectionAttribute"); } - object? GetImplicitValueForNotSpecifiedKey(ParameterInfo parameter, MethodInfo methodToInvoke) + internal object? GetImplicitValueForNotSpecifiedKey(ParameterInfo parameter, MethodInfo methodToInvoke) { if (!HasImplicitValueWhenNotSpecified(parameter)) { @@ -429,7 +431,12 @@ static bool HasImplicitValueWhenNotSpecified(ParameterInfo paramInfo) $"This is not supported when only a `IConfigSection` has been provided. (method '{methodToInvoke}')"); } - return parameter.DefaultValue; + if (parameter.IsDefined(typeof(ParamArrayAttribute), false) && parameter.ParameterType.GetElementType() is { } elementType) + { + return Array.CreateInstance(elementType, 0); + } + + return parameter.HasDefaultValue ? parameter.DefaultValue : null; } internal static MethodInfo? SelectConfigurationMethod(IReadOnlyCollection candidateMethods, string name, IReadOnlyCollection suppliedArgumentNames) @@ -571,8 +578,21 @@ internal static bool IsValidSwitchName(string input) return Regex.IsMatch(input, LevelSwitchNameRegex); } - static LogEventLevel ParseLogEventLevel(string value) - => Enum.TryParse(value, ignoreCase: true, out LogEventLevel parsedLevel) - ? parsedLevel - : throw new InvalidOperationException($"The value {value} is not a valid Serilog level."); + internal static LogEventLevel ParseLogEventLevel(string value) + { + // Try parsing as LevelAlias first (handles "Off", "Minimum", "Maximum") + if (string.Equals(value, "Off", StringComparison.OrdinalIgnoreCase)) + return LevelAlias.Off; + if (string.Equals(value, "Minimum", StringComparison.OrdinalIgnoreCase)) + return LevelAlias.Minimum; + if (string.Equals(value, "Maximum", StringComparison.OrdinalIgnoreCase)) + return LevelAlias.Maximum; + + // Try parsing as LogEventLevel enum + if (Enum.TryParse(value, ignoreCase: true, out LogEventLevel parsedLevel)) + return parsedLevel; + + throw new InvalidOperationException($"The value {value} is not a valid Serilog level."); + } + } diff --git a/src/Serilog.Settings.Configuration/Settings/Configuration/StringArgumentValue.cs b/src/Serilog.Settings.Configuration/Settings/Configuration/StringArgumentValue.cs index 894273c..c6aeda1 100644 --- a/src/Serilog.Settings.Configuration/Settings/Configuration/StringArgumentValue.cs +++ b/src/Serilog.Settings.Configuration/Settings/Configuration/StringArgumentValue.cs @@ -154,6 +154,11 @@ public StringArgumentValue(string providedValue) } } + if (toType.IsArray && string.IsNullOrWhiteSpace(argumentValue)) + { + return Array.CreateInstance(toType.GetElementType()!, 0); + } + return Convert.ChangeType(argumentValue, toType, resolutionContext.ReaderOptions.FormatProvider); } diff --git a/src/Serilog.Settings.Configuration/Settings/Configuration/SurrogateConfigurationMethods.cs b/src/Serilog.Settings.Configuration/Settings/Configuration/SurrogateConfigurationMethods.cs index b758d7c..92d8a29 100644 --- a/src/Serilog.Settings.Configuration/Settings/Configuration/SurrogateConfigurationMethods.cs +++ b/src/Serilog.Settings.Configuration/Settings/Configuration/SurrogateConfigurationMethods.cs @@ -55,6 +55,19 @@ static LoggerConfiguration Logger( LoggingLevelSwitch? levelSwitch = null) => loggerSinkConfiguration.Logger(configureLogger, restrictedToMinimumLevel, levelSwitch); + static LoggerConfiguration FallbackChain( + LoggerSinkConfiguration loggerSinkConfiguration, + Action configureSink, + Action configureFallback, + Action[]? configureSubsequentFallbacks = null) + => loggerSinkConfiguration.FallbackChain(configureSink, configureFallback, configureSubsequentFallbacks ?? []); + + static LoggerConfiguration Fallible( + LoggerSinkConfiguration loggerSinkConfiguration, + Action configureSink, + ILoggingFailureListener failureListener) + => loggerSinkConfiguration.Fallible(configureSink, failureListener); + // .AuditTo... // ======== static LoggerConfiguration Sink( diff --git a/test/Serilog.Settings.Configuration.Tests/ConfigurationReaderTests.cs b/test/Serilog.Settings.Configuration.Tests/ConfigurationReaderTests.cs index da01316..7f47c14 100644 --- a/test/Serilog.Settings.Configuration.Tests/ConfigurationReaderTests.cs +++ b/test/Serilog.Settings.Configuration.Tests/ConfigurationReaderTests.cs @@ -294,4 +294,102 @@ public void NoConfigurationRootUsedStillValid() AssertLogEventLevels(loggerConfig, LogEventLevel.Error); } + + [Theory] + // Standard LogEventLevel enum values + [InlineData("Verbose", LogEventLevel.Verbose)] + [InlineData("Debug", LogEventLevel.Debug)] + [InlineData("Information", LogEventLevel.Information)] + [InlineData("Warning", LogEventLevel.Warning)] + [InlineData("Error", LogEventLevel.Error)] + [InlineData("Fatal", LogEventLevel.Fatal)] + // Case insensitivity + [InlineData("verbose", LogEventLevel.Verbose)] + [InlineData("INFORMATION", LogEventLevel.Information)] + [InlineData("warning", LogEventLevel.Warning)] + // LevelAlias values + [InlineData("Off", LevelAlias.Off)] + [InlineData("off", LevelAlias.Off)] + [InlineData("OFF", LevelAlias.Off)] + [InlineData("Minimum", LevelAlias.Minimum)] + [InlineData("minimum", LevelAlias.Minimum)] + [InlineData("Maximum", LevelAlias.Maximum)] + [InlineData("maximum", LevelAlias.Maximum)] + public void ParseLogEventLevelHandlesAllLevelValues(string value, LogEventLevel expected) + { + var result = ConfigurationReader.ParseLogEventLevel(value); + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("InvalidLevel")] + [InlineData("")] + [InlineData("None")] + public void ParseLogEventLevelThrowsForInvalidValues(string value) + { + Assert.Throws(() => ConfigurationReader.ParseLogEventLevel(value)); + } + + [Fact] + public void ParamsStringArrayParameter_WithNoArgsSupplied_IsMatchedAsOptional() + { + var candidateMethods = typeof(DummyLoggerConfigurationExtensions) + .GetTypeInfo() + .DeclaredMethods + .ToList(); + + var selected = ConfigurationReader.SelectConfigurationMethod( + candidateMethods, "DummyParamsArray", Array.Empty()); + + Assert.NotNull(selected); + } + + [Fact] + public void ParamsStringArrayParameter_ImplicitValueIsEmptyArray() + { + var reader = new ConfigurationReader( + JsonStringConfigSource.LoadSection("{}", "Serilog"), + AssemblyFinder.ForSource(ConfigurationAssemblySource.UseLoadedAssemblies), + new ConfigurationReaderOptions()); + + var method = typeof(DummyLoggerConfigurationExtensions).GetMethod("DummyParamsArray")!; + var param = method.GetParameters().Last(); // params string[] values + + var result = reader.GetImplicitValueForNotSpecifiedKey(param, method); + + var array = Assert.IsType(result); + Assert.Empty(array); + } + + [Fact] + public void ParamsEnumerableParameter_GracefullyReturnsDefaultValue() + { + var reader = new ConfigurationReader( + JsonStringConfigSource.LoadSection("{}", "Serilog"), + AssemblyFinder.ForSource(ConfigurationAssemblySource.UseLoadedAssemblies), + new ConfigurationReaderOptions()); + + var method = typeof(DummyLoggerConfigurationExtensions).GetMethod("DummyParamsEnumerable")!; + var param = method.GetParameters().Last(); // params IEnumerable + + var result = reader.GetImplicitValueForNotSpecifiedKey(param, method); + + Assert.Null(result); + } + + [Fact] + public void ParamsSpanParameter_GracefullyReturnsDefaultValue() + { + var reader = new ConfigurationReader( + JsonStringConfigSource.LoadSection("{}", "Serilog"), + AssemblyFinder.ForSource(ConfigurationAssemblySource.UseLoadedAssemblies), + new ConfigurationReaderOptions()); + + var method = typeof(DummyLoggerConfigurationExtensions).GetMethod("DummyParamsSpan")!; + var param = method.GetParameters().Last(); // params ReadOnlySpan + + var result = reader.GetImplicitValueForNotSpecifiedKey(param, method); + + Assert.Null(result); + } } diff --git a/test/Serilog.Settings.Configuration.Tests/ConfigurationSettingsTests.cs b/test/Serilog.Settings.Configuration.Tests/ConfigurationSettingsTests.cs index 5aeec1f..6ced053 100644 --- a/test/Serilog.Settings.Configuration.Tests/ConfigurationSettingsTests.cs +++ b/test/Serilog.Settings.Configuration.Tests/ConfigurationSettingsTests.cs @@ -1575,4 +1575,130 @@ public void TestLogFilterSwitchesCallback() var switch2 = Assert.Contains("$switch2", switches); Assert.Equal("Prop = 2", switch2.Expression); } + + [Fact] + public void WriteToFallbackChainRoutesFailuresToFallbackSink() + { + // language=json + var json = """ + { + "Serilog": { + "Using": ["TestDummies"], + "WriteTo": [{ + "Name": "FallbackChain", + "Args": { + "configureSink": [{ + "Name": "Sink", + "Args": { + "sink": { + "type": "TestDummies.DummyFailingSink, TestDummies" + } + } + }], + "configureFallback": [{ + "Name": "DummyRollingFile", + "Args": { "pathFormat": "C:\\" } + }] + } + }] + } + } + """; + + var log = ConfigFromJson(json) + .CreateLogger(); + + DummyFailingSink.Reset(); + DummyRollingFileSink.Reset(); + + log.Write(Some.InformationEvent()); + + Assert.Equal(1, DummyFailingSink.EmitAttempts); + Assert.Single(DummyRollingFileSink.Emitted); + } + + [Fact] + public void WriteToFallbackChainSupportsSubsequentFallbacks() + { + // language=json + var json = """ + { + "Serilog": { + "Using": ["TestDummies"], + "WriteTo": [{ + "Name": "FallbackChain", + "Args": { + "configureSink": [{ + "Name": "Sink", + "Args": { + "sink": { "type": "TestDummies.DummyFailingSink, TestDummies" } + } + }], + "configureFallback": [{ + "Name": "Sink", + "Args": { + "sink": { "type": "TestDummies.DummyFailingSink, TestDummies" } + } + }], + "configureSubsequentFallbacks": [ + [{ + "Name": "DummyRollingFile", + "Args": { "pathFormat": "C:\\" } + }] + ] + } + }] + } + } + """; + + var log = ConfigFromJson(json) + .CreateLogger(); + + DummyFailingSink.Reset(); + DummyRollingFileSink.Reset(); + + log.Write(Some.InformationEvent()); + + Assert.Equal(2, DummyFailingSink.EmitAttempts); + Assert.Single(DummyRollingFileSink.Emitted); + } + + [Fact] + public void WriteToFallibleReportsFailuresToListener() + { + // language=json + var json = """ + { + "Serilog": { + "Using": ["TestDummies"], + "WriteTo": [{ + "Name": "Fallible", + "Args": { + "configureSink": [{ + "Name": "Sink", + "Args": { + "sink": { "type": "TestDummies.DummyFailingSink, TestDummies" } + } + }], + "failureListener": { + "type": "TestDummies.DummyFailureListener, TestDummies" + } + } + }] + } + } + """; + + var log = ConfigFromJson(json) + .CreateLogger(); + + DummyFailingSink.Reset(); + DummyFailureListener.Reset(); + + log.Write(Some.InformationEvent()); + + Assert.Equal(1, DummyFailingSink.EmitAttempts); + Assert.Equal(1, DummyFailureListener.FailureCount); + } } diff --git a/test/Serilog.Settings.Configuration.Tests/DummyLoggerConfigurationExtensions.cs b/test/Serilog.Settings.Configuration.Tests/DummyLoggerConfigurationExtensions.cs index c8a00d6..6dcd8d2 100644 --- a/test/Serilog.Settings.Configuration.Tests/DummyLoggerConfigurationExtensions.cs +++ b/test/Serilog.Settings.Configuration.Tests/DummyLoggerConfigurationExtensions.cs @@ -1,6 +1,7 @@ using Serilog.Configuration; using Serilog.Events; using Serilog.Formatting; +using TestDummies; namespace Serilog.Settings.Configuration.Tests; @@ -24,4 +25,25 @@ static class DummyLoggerConfigurationExtensions { return null; } + + public static LoggerConfiguration DummyParamsArray( + this LoggerSinkConfiguration loggerSinkConfiguration, + params string[] values) + { + return loggerSinkConfiguration.Sink(new DummyParamsSink(values)); + } + + public static LoggerConfiguration DummyParamsEnumerable( + this LoggerSinkConfiguration loggerSinkConfiguration, + params IEnumerable values) + { + return loggerSinkConfiguration.Sink(new DummyParamsSink(values.ToArray())); + } + + public static LoggerConfiguration DummyParamsSpan( + this LoggerSinkConfiguration loggerSinkConfiguration, + params ReadOnlySpan values) + { + return loggerSinkConfiguration.Sink(new DummyParamsSink(values.ToArray())); + } } diff --git a/test/Serilog.Settings.Configuration.Tests/DummyParamsSink.cs b/test/Serilog.Settings.Configuration.Tests/DummyParamsSink.cs new file mode 100644 index 0000000..267fd13 --- /dev/null +++ b/test/Serilog.Settings.Configuration.Tests/DummyParamsSink.cs @@ -0,0 +1,21 @@ +using Serilog.Core; +using Serilog.Events; + +namespace TestDummies; + +public class DummyParamsSink : ILogEventSink +{ + public static string[]? LastValues { get; private set; } + + public DummyParamsSink(params string[] values) + { + LastValues = values; + } + + public void Emit(LogEvent logEvent) { } + + public static void Reset() + { + LastValues = null; + } +} diff --git a/test/Serilog.Settings.Configuration.Tests/StringArgumentValueTests.cs b/test/Serilog.Settings.Configuration.Tests/StringArgumentValueTests.cs index be172cd..7ad3ba2 100644 --- a/test/Serilog.Settings.Configuration.Tests/StringArgumentValueTests.cs +++ b/test/Serilog.Settings.Configuration.Tests/StringArgumentValueTests.cs @@ -374,4 +374,24 @@ public void StringValuesConvertToString(string expected) Assert.Equal(expected, actual); } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void EmptyOrWhitespaceStringConvertsToEmptyArrayForArrayType(string input) + { + var value = new StringArgumentValue(input); + var actual = value.ConvertTo(typeof(string[]), new ResolutionContext()); + + var array = Assert.IsType(actual); + Assert.Empty(array); + } + + [Fact] + public void NonEmptyStringDoesNotConvertToArray() + { + var value = new StringArgumentValue("Information"); + Assert.Throws(() => + value.ConvertTo(typeof(string[]), new ResolutionContext())); + } } diff --git a/test/TestDummies/DummyFailingSink.cs b/test/TestDummies/DummyFailingSink.cs new file mode 100644 index 0000000..935827b --- /dev/null +++ b/test/TestDummies/DummyFailingSink.cs @@ -0,0 +1,23 @@ +using Serilog.Core; +using Serilog.Events; + +namespace TestDummies; + +public class DummyFailingSink : ILogEventSink +{ + [ThreadStatic] + static int _emitAttempts; + + public static int EmitAttempts => _emitAttempts; + + public void Emit(LogEvent logEvent) + { + _emitAttempts++; + throw new InvalidOperationException("DummyFailingSink always fails."); + } + + public static void Reset() + { + _emitAttempts = 0; + } +} diff --git a/test/TestDummies/DummyFailureListener.cs b/test/TestDummies/DummyFailureListener.cs new file mode 100644 index 0000000..6dfd872 --- /dev/null +++ b/test/TestDummies/DummyFailureListener.cs @@ -0,0 +1,27 @@ +using Serilog.Core; +using Serilog.Events; + +namespace TestDummies; + +public class DummyFailureListener : ILoggingFailureListener +{ + [ThreadStatic] + static int _failureCount; + + public static int FailureCount => _failureCount; + + public void OnLoggingFailed( + object sender, + LoggingFailureKind kind, + string message, + IReadOnlyCollection? events, + Exception? exception) + { + _failureCount++; + } + + public static void Reset() + { + _failureCount = 0; + } +}