├── .gitignore ├── CustomFormatter.cs ├── Extensions ├── ConsoleLoggerExtensions.cs ├── HttpResponseExtensions.cs └── TextWriterExtensions.cs ├── Log.cs ├── LoggingWithMinimalApi.csproj ├── NuGet.config ├── Program.cs ├── Properties └── launchSettings.json ├── README.md ├── appsettings.Development.json └── appsettings.json /.gitignore: -------------------------------------------------------------------------------- 1 | bin/* 2 | bin\* 3 | obj/* 4 | obj\* 5 | .suo -------------------------------------------------------------------------------- /CustomFormatter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using Microsoft.Extensions.Logging; 4 | using Microsoft.Extensions.Logging.Console; 5 | using Microsoft.Extensions.Logging.Abstractions; 6 | using Microsoft.Extensions.Options; 7 | using Console.ExampleFormatters.Custom; 8 | 9 | namespace Console.ExampleFormatters.Custom 10 | { 11 | public class CustomColorOptions : SimpleConsoleFormatterOptions 12 | { 13 | public string? CustomPrefix { get; set; } 14 | } 15 | 16 | public sealed class CustomColorFormatter : ConsoleFormatter, IDisposable 17 | { 18 | private readonly IDisposable _optionsReloadToken; 19 | private CustomColorOptions _formatterOptions; 20 | 21 | private bool ConsoleColorFormattingEnabled => 22 | _formatterOptions.ColorBehavior == LoggerColorBehavior.Enabled || 23 | _formatterOptions.ColorBehavior == LoggerColorBehavior.Default && 24 | System.Console.IsOutputRedirected == false; 25 | 26 | public CustomColorFormatter(IOptionsMonitor options) 27 | // Case insensitive 28 | : base("myCustomFormatter") => 29 | (_optionsReloadToken, _formatterOptions) = 30 | (options.OnChange(ReloadLoggerOptions), options.CurrentValue); 31 | 32 | private void ReloadLoggerOptions(CustomColorOptions options) => 33 | _formatterOptions = options; 34 | 35 | public override void Write( 36 | in LogEntry logEntry, 37 | IExternalScopeProvider scopeProvider, 38 | TextWriter textWriter) 39 | { 40 | if (logEntry.Exception is null) 41 | { 42 | // return; 43 | } 44 | 45 | string message = 46 | logEntry.Formatter( 47 | logEntry.State, logEntry.Exception); 48 | 49 | if (message == null) 50 | { 51 | return; 52 | } 53 | 54 | CustomLogicGoesHere(textWriter); 55 | textWriter.Write(message); 56 | } 57 | 58 | private void CustomLogicGoesHere(TextWriter textWriter) 59 | { 60 | if (ConsoleColorFormattingEnabled) 61 | { 62 | textWriter.WriteWithColor( 63 | _formatterOptions.CustomPrefix ?? string.Empty, 64 | ConsoleColor.Black, 65 | ConsoleColor.Green); 66 | } 67 | else 68 | { 69 | textWriter.Write(_formatterOptions.CustomPrefix); 70 | } 71 | } 72 | 73 | public void Dispose() => _optionsReloadToken?.Dispose(); 74 | } 75 | } -------------------------------------------------------------------------------- /Extensions/ConsoleLoggerExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using Microsoft.Extensions.Logging; 4 | using Microsoft.Extensions.Logging.Console; 5 | using Microsoft.Extensions.Logging.Abstractions; 6 | using Microsoft.Extensions.Options; 7 | using Console.ExampleFormatters.Custom; 8 | 9 | namespace Demo 10 | { 11 | public static class ConsoleLoggerExtensions 12 | { 13 | public static ILoggingBuilder AddCustomFormatter(this ILoggingBuilder builder, Action configure) 14 | { 15 | return builder.AddConsole(o => { o.FormatterName = "myCustomFormatter"; }) 16 | .AddConsoleFormatter(configure); 17 | } 18 | } 19 | } -------------------------------------------------------------------------------- /Extensions/HttpResponseExtensions.cs: -------------------------------------------------------------------------------- 1 | 2 | 3 | internal static partial class HttpResponseExtensions 4 | { 5 | public static async Task WriteJsonAsync(this HttpResponse response, string json, string? contentType = null) 6 | { 7 | response.ContentType = (contentType ?? "application/json; charset=UTF-8"); 8 | await response.WriteAsync(json); 9 | await response.Body.FlushAsync(); 10 | } 11 | } -------------------------------------------------------------------------------- /Extensions/TextWriterExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using Microsoft.Extensions.Logging; 4 | using Microsoft.Extensions.Logging.Console; 5 | using Microsoft.Extensions.Logging.Abstractions; 6 | using Microsoft.Extensions.Options; 7 | using Console.ExampleFormatters.Custom; 8 | 9 | namespace Console.ExampleFormatters.Custom 10 | { 11 | public static class TextWriterExtensions 12 | { 13 | const string DefaultForegroundColor = "\x1B[39m\x1B[22m"; 14 | const string DefaultBackgroundColor = "\x1B[49m"; 15 | 16 | public static void WriteWithColor( 17 | this TextWriter textWriter, 18 | string message, 19 | ConsoleColor? background, 20 | ConsoleColor? foreground) 21 | { 22 | // Order: 23 | // 1. background color 24 | // 2. foreground color 25 | // 3. message 26 | // 4. reset foreground color 27 | // 5. reset background color 28 | 29 | var backgroundColor = background.HasValue ? GetBackgroundColorEscapeCode(background.Value) : null; 30 | var foregroundColor = foreground.HasValue ? GetForegroundColorEscapeCode(foreground.Value) : null; 31 | 32 | if (backgroundColor != null) 33 | { 34 | textWriter.Write(backgroundColor); 35 | } 36 | if (foregroundColor != null) 37 | { 38 | textWriter.Write(foregroundColor); 39 | } 40 | 41 | textWriter.Write(message); 42 | 43 | if (foregroundColor != null) 44 | { 45 | textWriter.Write(DefaultForegroundColor); 46 | } 47 | if (backgroundColor != null) 48 | { 49 | textWriter.Write(DefaultBackgroundColor); 50 | } 51 | } 52 | 53 | static string GetForegroundColorEscapeCode(ConsoleColor color) => 54 | color switch 55 | { 56 | ConsoleColor.Black => "\x1B[30m", 57 | ConsoleColor.DarkRed => "\x1B[31m", 58 | ConsoleColor.DarkGreen => "\x1B[32m", 59 | ConsoleColor.DarkYellow => "\x1B[33m", 60 | ConsoleColor.DarkBlue => "\x1B[34m", 61 | ConsoleColor.DarkMagenta => "\x1B[35m", 62 | ConsoleColor.DarkCyan => "\x1B[36m", 63 | ConsoleColor.Gray => "\x1B[37m", 64 | ConsoleColor.Red => "\x1B[1m\x1B[31m", 65 | ConsoleColor.Green => "\x1B[1m\x1B[32m", 66 | ConsoleColor.Yellow => "\x1B[1m\x1B[33m", 67 | ConsoleColor.Blue => "\x1B[1m\x1B[34m", 68 | ConsoleColor.Magenta => "\x1B[1m\x1B[35m", 69 | ConsoleColor.Cyan => "\x1B[1m\x1B[36m", 70 | ConsoleColor.White => "\x1B[1m\x1B[37m", 71 | 72 | _ => DefaultForegroundColor 73 | }; 74 | 75 | static string GetBackgroundColorEscapeCode(ConsoleColor color) => 76 | color switch 77 | { 78 | ConsoleColor.Black => "\x1B[40m", 79 | ConsoleColor.DarkRed => "\x1B[41m", 80 | ConsoleColor.DarkGreen => "\x1B[42m", 81 | ConsoleColor.DarkYellow => "\x1B[43m", 82 | ConsoleColor.DarkBlue => "\x1B[44m", 83 | ConsoleColor.DarkMagenta => "\x1B[45m", 84 | ConsoleColor.DarkCyan => "\x1B[46m", 85 | ConsoleColor.Gray => "\x1B[47m", 86 | 87 | _ => DefaultBackgroundColor 88 | }; 89 | } 90 | } -------------------------------------------------------------------------------- /Log.cs: -------------------------------------------------------------------------------- 1 | internal static partial class Log 2 | { 3 | [LoggerMessage(EventId = 22, Level = LogLevel.Information, Message = "Hello World!")] 4 | public static partial void HelloWorld(ILogger logger); 5 | 6 | #region Sample2 7 | [LoggerMessage(EventId = 1010, Level = LogLevel.Information, Message = "Resident {Name} aged {Age} years old from {Hometown} moved here {YearsSince} years with {NumSuitcases} suitcases!")] 8 | public static partial void NewResidentInformationGenerated(ILogger logger, 9 | string name, int age, string hometown, int yearsSince, long numSuitcases); 10 | 11 | #endregion 12 | 13 | #region SkippedBoilerplateCode 14 | // skip all boilerplate code below by using source generated logs via LoggerMessage attribute 15 | public static void NewResidentInformationUserDefined(ILogger logger, string name, int age, string hometown, int yearsSince, long numSuitcases) => 16 | s_userDefinedPerformantLog(logger, name, age, hometown, yearsSince, numSuitcases, null); 17 | 18 | private static readonly Action s_userDefinedPerformantLog = 19 | LoggerMessage.Define( 20 | LogLevel.Information, 21 | new EventId(1012), 22 | "Resident {Name} aged {Age} years old from {Hometown} moved here {YearsSince} years with {NumSuitcases} suitcases!"); 23 | #endregion 24 | 25 | #region MoreLoggerMessageSamples 26 | [LoggerMessage(EventId = 111, Level = LogLevel.Information, Message = "Logged in #{ElapsedTime}")] 27 | public static partial void LoggedInElapsedTime(ILogger logger, string elapsedTime); 28 | 29 | [LoggerMessage(EventId = 1023, Level = LogLevel.Critical, Message = "log #{LogNumber}")] 30 | public static partial void LogCritical_1_Arg_Generated(ILogger logger, long logNumber); 31 | 32 | [LoggerMessage(EventId = 10, Level = LogLevel.Information, Message = "Got weather forecast!")] 33 | public static partial void GotWeatherForecast(ILogger logger); 34 | 35 | [LoggerMessage(EventId = 0, EventName = "GotWeatherForecastWithSummary", Level = LogLevel.Information, Message = "Got weather forecast, and it's {Summary}!")] 36 | public static partial void GotWeatherForecastWithSummary(ILogger logger, string summary); 37 | #endregion 38 | 39 | #region SkipEnabledCheckFeature 40 | public static void SkipEnabledCheckFalse(ILogger logger) => 41 | s_SkipEnabledCheckFalse(logger, null); 42 | 43 | private static readonly Action s_SkipEnabledCheckFalse = 44 | LoggerMessage.Define( 45 | LogLevel.Information, 46 | new EventId(1011), 47 | "Hello World", 48 | s_LogDefineOptionsSkipFalse); 49 | 50 | public static void SkipEnabledCheckTrue(ILogger logger) => 51 | s_SkipEnabledCheckTrue(logger, null); 52 | 53 | private static readonly Action s_SkipEnabledCheckTrue = 54 | LoggerMessage.Define( 55 | LogLevel.Information, 56 | new EventId(1010), 57 | "Hello World", 58 | s_LogDefineOptions); 59 | 60 | private static LogDefineOptions s_LogDefineOptions = new LogDefineOptions() { SkipEnabledCheck = true }; 61 | private static LogDefineOptions s_LogDefineOptionsSkipFalse = new LogDefineOptions() { SkipEnabledCheck = false }; 62 | #endregion 63 | } -------------------------------------------------------------------------------- /LoggingWithMinimalApi.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net6.0 5 | enable 6 | enable 7 | LoggingWithMinimalApi 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /NuGet.config: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | 9 | 10 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /Program.cs: -------------------------------------------------------------------------------- 1 | using Demo; 2 | using Microsoft.Extensions.Logging.Console; 3 | using System.Diagnostics; 4 | using OpenTelemetry.Trace; 5 | using OpenTelemetry.Logs; 6 | using Microsoft.Extensions.Logging.Configuration; 7 | using Microsoft.Extensions.DependencyInjection.Extensions; 8 | 9 | var builder = WebApplication.CreateBuilder(args); 10 | 11 | // Add services to the container. 12 | builder.Services.AddEndpointsApiExplorer(); 13 | builder.Services.AddSwaggerGen(); 14 | builder.Logging.ClearProviders() 15 | .AddConsole() 16 | .AddOpenTelemetry(options => options.AddConsoleExporter()) 17 | .AddCustomFormatter(o => 18 | { 19 | o.CustomPrefix = Environment.NewLine + " >>> "; 20 | o.ColorBehavior = LoggerColorBehavior.Default; 21 | }); 22 | 23 | var app = builder.Build(); 24 | 25 | // Configure the HTTP request pipeline. 26 | if (app.Environment.IsDevelopment()) 27 | { 28 | app.UseSwagger(); 29 | app.UseSwaggerUI(); 30 | } 31 | 32 | app.UseHttpsRedirection(); 33 | 34 | var summaries = new[] 35 | { 36 | "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" 37 | }; 38 | var residents = new[] 39 | { 40 | "Sam", "Tina", "Chester", "Brian" 41 | }; 42 | 43 | app.MapGet("/", () => 44 | { 45 | Log.HelloWorld(app.Logger); 46 | }) 47 | .WithName("Index"); 48 | 49 | app.MapGet("/weatherforecast", () => 50 | { 51 | var forecasts = Enumerable.Range(1, 5).Select(index => 52 | new WeatherForecast 53 | ( 54 | DateTime.Now.AddDays(index), 55 | Random.Shared.Next(-20, 55), 56 | summaries[Random.Shared.Next(summaries.Length)] 57 | )) 58 | .ToArray(); 59 | foreach (WeatherForecast forecastOnDay in forecasts) 60 | { 61 | Log.GotWeatherForecastWithSummary(app.Logger, forecastOnDay.Summary!); 62 | } 63 | 64 | return forecasts; 65 | }) 66 | .WithName("GetWeatherForecast"); 67 | 68 | app.MapGet("/residentinfo", () => 69 | { 70 | var resident = new Resident 71 | ( 72 | residents[Random.Shared.Next(residents.Length)], 73 | Random.Shared.Next(19, 55), 74 | "Vancouver", 75 | Random.Shared.Next(1, 11), 76 | 2 77 | ); 78 | 79 | // fast to write, slow to execute 80 | app.Logger.LogInformation(1010, 81 | "Resident {Name} aged {Age} years old from {Hometown} moved here {YearsSince} years with {NumSuitcases} suitcases!", 82 | resident.Name, resident.Age, resident.Hometown, resident.YearsSince, resident.NumSuitcases); 83 | 84 | // slow to write, faster to execute 85 | Log.NewResidentInformationUserDefined(app.Logger, resident.Name, resident.Age, resident.Hometown, resident.YearsSince, resident.NumSuitcases); 86 | 87 | // NEW: fast to write and to execute 88 | Log.NewResidentInformationGenerated(app.Logger, resident.Name, resident.Age, resident.Hometown, resident.YearsSince, resident.NumSuitcases); 89 | }) 90 | .WithName("GetResidentInformantion"); 91 | 92 | #region SkipEnabledCheckFeature 93 | app.MapGet("/skipenabledcheckfalse", () => 94 | { 95 | CheckElapsedTime(() => 96 | { 97 | for (int i = 0; i < 100_000; i++) 98 | Log.SkipEnabledCheckFalse(app.Logger); 99 | }); 100 | }) 101 | .WithName("SkipEnabledCheckFalse"); 102 | 103 | app.MapGet("/skipenabledchecktrue", () => 104 | { 105 | CheckElapsedTime(() => 106 | { 107 | for (int i = 0; i < 100_000; i++) 108 | Log.SkipEnabledCheckTrue(app.Logger); 109 | }); 110 | }) 111 | .WithName("SkipEnabledCheckTrue"); 112 | #endregion 113 | 114 | app.Run(); 115 | 116 | void CheckElapsedTime(Action action) 117 | { 118 | Stopwatch stopWatch = new Stopwatch(); 119 | stopWatch.Start(); 120 | action(); 121 | stopWatch.Stop(); 122 | // Get the elapsed time as a TimeSpan value. 123 | TimeSpan ts = stopWatch.Elapsed; 124 | 125 | // Format and display the TimeSpan value. 126 | string elapsedTime = String.Format("{0:00}:{1:00}:{2:00}.{3:00}", 127 | ts.Hours, ts.Minutes, ts.Seconds, 128 | ts.Milliseconds / 10); 129 | 130 | Log.LoggedInElapsedTime(app.Logger, elapsedTime); 131 | } 132 | 133 | record WeatherForecast(DateTime Date, int TemperatureC, string? Summary) 134 | { 135 | public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); 136 | } 137 | 138 | record Resident(string Name, int Age, string Hometown, int YearsSince, long NumSuitcases) { } 139 | 140 | public static class OpenTelemetryLoggingExtensions 141 | { 142 | public static ILoggingBuilder AddOpenTelemetry(this ILoggingBuilder builder, Action configure = null) 143 | { 144 | builder.AddConfiguration(); 145 | builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton()); 146 | 147 | if (configure != null) 148 | { 149 | builder.Services.Configure(configure); 150 | } 151 | 152 | return builder; 153 | } 154 | } -------------------------------------------------------------------------------- /Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/launchsettings.json", 3 | "iisSettings": { 4 | "windowsAuthentication": false, 5 | "anonymousAuthentication": true, 6 | "iisExpress": { 7 | "applicationUrl": "http://localhost:8618", 8 | "sslPort": 44398 9 | } 10 | }, 11 | "profiles": { 12 | "LoggingWithMinimalApi": { 13 | "commandName": "Project", 14 | "dotnetRunMessages": true, 15 | "launchBrowser": true, 16 | "launchUrl": "swagger", 17 | "applicationUrl": "https://localhost:7001;http://localhost:5204", 18 | "environmentVariables": { 19 | "ASPNETCORE_ENVIRONMENT": "Development" 20 | } 21 | }, 22 | "IIS Express": { 23 | "commandName": "IISExpress", 24 | "launchBrowser": true, 25 | "launchUrl": "swagger", 26 | "environmentVariables": { 27 | "ASPNETCORE_ENVIRONMENT": "Development" 28 | } 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Source generated logging with Minimal APIs in ASP.NET Core 2 | 3 | Using .NET 6.0 RC2 and with the minimal API template: 4 | 5 | ``` 6 | > dotnet new webapi -minimal 7 | ``` 8 | 9 | This sample uses a custom console log formatter and also uses source generated logging methods partially defined in the static `Log` class. 10 | 11 | ### Links to documentation: 12 | 13 | - .NET 6.0 feature: [Compile-time logging source generation](https://docs.microsoft.com/en-us/dotnet/core/extensions/logger-message-generator) 14 | - .NET 5.0 feature: [Console Log Formatting](https://docs.microsoft.com/en-us/dotnet/core/extensions/console-log-formatter) 15 | 16 | ### Sample usage in dotnet/aspnetcore 17 | 18 | Sample usage of logging source generator in aspnetcore repo: 19 | https://github.com/dotnet/aspnetcore/blob/main/src/Servers/Kestrel/Transport.Quic/src/Internal/QuicLog.cs#L33-L42 20 | 21 | The aspnetcore sample above shows that we could set `[LoggerMessage(..., SkipEnabledCheck = true)]` to guard expensive custom logic from running for cases where logging is disabled for that level. This flag tells source generator to skip adding an `IsEnabled` check during code generation by assuming the developer itself will add that flag around the log method themselves (as shown in the aspnet sample). 22 | 23 |
24 | Advanced Topics 25 | 26 | ### `SkipEnabledCheck` (6.0) feature: 27 | 28 | Refer to source code [here](https://github.com/dotnet/runtime/blob/e5dd7150e6ced783159a8ae3adb77b899b1204db/src/libraries/Microsoft.Extensions.Logging.Abstractions/src/LoggerMessage.cs#L150-L161). This logic suggests that on every log call we are making sure that logging is enabled for that log level. We should be able to skip this flag check if the user code is already guarding the log call with an `IsEnabled` check (as seen in the aspnet code sample above). 29 | 30 | To allow for deferring the `IsEnabled` check to user code, in .NET 6.0 we added `LogDefineOptions` for `LoggerMessage.Define` APIs, accepting a `SkipEnabledCheck` flag. By default this flag is `false` and the logging code will always do a `logger.IsEnabled(logLevel)` check before each log call, because there is no guarantee that log calls would be guarded by user code already. 31 | 32 | But with source generated code, since the source generator does this `IsEnabled` check then the `LogDefineOptions` flag is by default set to true. This can be skipped further by using `SkipEnabledCheck` flag on the `LoggerMessageAttribute` as seen in the aspnetcore sample. 33 | 34 | ### Benchmarks on Logging: 35 | 36 | The benchmark report below explains more background into performance investigations done with logging during the 6.0 timeframe: 37 | https://gist.github.com/maryamariyan/0bad4136655f344bf203274e5b7431b4#file-report-md 38 | 39 | 40 | -------------------------------------------------------------------------------- /appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | }, 8 | "AllowedHosts": "*" 9 | } 10 | --------------------------------------------------------------------------------