diff --git a/README.md b/README.md index f7ea0d5..ac35983 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,7 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) - `Environments`: Comma-separated list of environments where logging is active - `LogRequests`: Write one line per HTTP request with its duration, status and a `Route` property holding the route template (`/api/orders/{id}`), so requests can be grouped per endpoint, plus `User` (the caller's name identifier, when signed in) and `UserAgent` (cut to 200 characters), so requests can be counted per caller. Kept even when `LoggingLevel` is above Information. Default `true`. - `SlowQueryMilliseconds`: EF Core commands taking at least this long are logged ("Executed DbCommand (Nms)"), so a slow request can be traced to its query; faster ones are not logged. Failed commands always are. `0` disables. Default `500`. +- `Properties`: added to every log line, such as which deployment wrote it. Set in code (`o.Properties["Deployment"] = "acme"`) or under `SwLogger:Properties`; configuration wins for the same name. `Environment`, `Application` and `ApplicationVersion` keep the logger's values, and an empty value is left out. - `QuietRequestPaths`: Comma-separated path prefixes whose successful requests are dropped (default `/health,/healthz,/metrics`). Failures are still logged. > **Upgrading:** before this change `LoggingLevel` defaulted to 1, which is Debug, not Information as this README claimed. Apps that never set it now log at Information, and framework chatter from ASP.NET Core and EF Core is Warning-only. diff --git a/SW.Logger.Console/IServiceCollectionExtensions.cs b/SW.Logger.Console/IServiceCollectionExtensions.cs index 1fe33d8..17d370a 100644 --- a/SW.Logger.Console/IServiceCollectionExtensions.cs +++ b/SW.Logger.Console/IServiceCollectionExtensions.cs @@ -11,6 +11,9 @@ namespace SW.Logger.Console; public static class IServiceCollectionExtensions { + static readonly HashSet ReservedProperties = new(StringComparer.OrdinalIgnoreCase) + { "Environment", "Application", "ApplicationVersion" }; + public static IServiceCollection AddSWConsoleLogger(this IServiceCollection services, Action configure = null) { @@ -45,6 +48,9 @@ public static IServiceCollection AddSWConsoleLogger(this IServiceCollection serv .Enrich.WithProperty("Environment", hostEnvironment.EnvironmentName) .Enrich.WithProperty("ApplicationVersion", loggerOptions.ApplicationVersion) .Enrich.WithProperty("Application", loggerOptions.ApplicationName); + foreach (var (name, value) in loggerOptions.Properties) + if (!string.IsNullOrEmpty(value) && !ReservedProperties.Contains(name)) + loggerConfiguration = loggerConfiguration.Enrich.WithProperty(name, value); loggerConfiguration = Debugger.IsAttached ? loggerConfiguration.WriteTo.Console() diff --git a/SW.Logger.Console/LoggerOptions.cs b/SW.Logger.Console/LoggerOptions.cs index ee9ca4a..0d44a94 100644 --- a/SW.Logger.Console/LoggerOptions.cs +++ b/SW.Logger.Console/LoggerOptions.cs @@ -41,4 +41,11 @@ public LoggerOptions() /// volume down. 0 or less disables slow-query logging. Default 500. /// public int SlowQueryMilliseconds { get; set; } + + /// + /// Properties added to every log line, such as which deployment wrote it. Set in code, or under + /// SwLogger:Properties in configuration (configuration wins for the same name). A name the logger sets + /// itself (Environment, Application, ApplicationVersion) keeps its value, and an empty value is left out. + /// + public IDictionary Properties { get; } = new Dictionary(StringComparer.OrdinalIgnoreCase); } diff --git a/SW.Logger.UnitTests/EndToEndTests.cs b/SW.Logger.UnitTests/EndToEndTests.cs index 8dbdc0f..08c2770 100644 --- a/SW.Logger.UnitTests/EndToEndTests.cs +++ b/SW.Logger.UnitTests/EndToEndTests.cs @@ -19,8 +19,12 @@ private static async Task Run(int loggingLevel, params string[] paths) = private static async Task Run(int loggingLevel, Action act, params string[] paths) => await Run(loggingLevel, act, null, paths); + private static Task Run(int loggingLevel, Action act, + Action configureClient, params string[] paths) => + Run(loggingLevel, act, configureClient, null, paths); + private static async Task Run(int loggingLevel, Action act, - Action configureClient, params string[] paths) + Action configureClient, Action configureLogger, params string[] paths) { var original = System.Console.Out; var captured = new StringWriter(); @@ -33,6 +37,7 @@ private static async Task Run(int loggingLevel, Action a { o.ApplicationName = "test"; o.LoggingLevel = loggingLevel; + configureLogger?.Invoke(o); }); var app = builder.Build(); // Stands in for authentication: a request with an X-Test-User header is signed in as that user. @@ -61,6 +66,21 @@ private static async Task Run(int loggingLevel, Action a return captured.ToString(); } + [Fact] + public async Task Every_line_carries_the_configured_properties() + { + var output = await Run(3, null, null, o => + { + o.Properties["Deployment"] = "acme"; + o.Properties["Application"] = "not-the-app"; + o.Properties["Empty"] = ""; + }, "/api/orders/1"); + Assert.Contains("\"Deployment\":\"acme\"", output); + Assert.Contains("\"Application\":\"test\"", output); + Assert.DoesNotContain("not-the-app", output); + Assert.DoesNotContain("\"Empty\"", output); + } + [Theory] [InlineData(2)] [InlineData(3)] diff --git a/SW.Logger.UnitTests/LoggerOptionsBindingTests.cs b/SW.Logger.UnitTests/LoggerOptionsBindingTests.cs new file mode 100644 index 0000000..7f50208 --- /dev/null +++ b/SW.Logger.UnitTests/LoggerOptionsBindingTests.cs @@ -0,0 +1,23 @@ +using Microsoft.Extensions.Configuration; +using SW.Logger.Console; +using Xunit; + +namespace SW.Logger.UnitTests; + +public class LoggerOptionsBindingTests +{ + [Fact] + public void Properties_bind_from_configuration_over_code() + { + var options = new LoggerOptions(); + options.Properties["Deployment"] = "from-code"; + options.Properties["Region"] = "eu"; + new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { ["SwLogger:Properties:Deployment"] = "acme" }) + .Build() + .GetSection(LoggerOptions.ConfigurationSection).Bind(options); + + Assert.Equal("acme", options.Properties["Deployment"]); + Assert.Equal("eu", options.Properties["Region"]); + } +}