Roslyn Source Generators: Logging – Part 11

In previous part we lerned how to pass parameters to a Source Generator. In this article we need this knowledge to pass futher parameters to implement logging.

In this article:

pg
Pawel Gerr is architect consultant at Thinktecture. He focuses on backends with .NET Core and knows Entity Framework inside out.

More information about the Smart Enums and the source code can be found on GitHub:

In the previous part we saw how to pass parameters to a Source Generator. In this article we take care of one of the most important aspects in software development – logging.

Logging Infrastructure

Roslyn doesn’t provide any logging capabilities and referencing a logging framework is not a good idea either. Our Source Generator is probably not the only one running during builds so the number of dependencies should be kept to a bare minimum.Β Minimalistic logging infrastructure will have following components: LogLevel, ILogger, Logger, NullLogger, and a SelfLog-logger in case the Logger throws an exception.
The implementation of the logger in this article will write logs to a file system synchronously. This is not ideal and might slow down the compiler. Feel free to rummage through my logging code to get an idea how to make logging asynchronous. Furthermore, Source Generators doesn’t have a defined lifecycle, e.g. they don’t support IDisposable. Without Dispose, we cannot start always-running (background) tasks because we don’t get an event when to stop them.
The API of the minimalistic logging infrastructure is similar to the one from Microsoft.Extensions.Logging or Serilog.
				
					public enum LogLevel 
{ 
   Trace = 0, 
   Debug = 1, 
   Information = 2, 
   Warning = 3, 
   Error = 4, 
   None = 5 
}

public interface ILogger 
{ 
   bool IsEnabled(LogLevel logLevel);
   void Log(LogLevel logLevel, string message);
}

// does what it supposed to do - nothing
public class NullLogger : ILogger 
{ 
   public static readonly ILogger Instance = new NullLogger(); 
 
   public bool IsEnabled(LogLevel logLevel) => false;
   public void Log(LogLevel logLevel, string message) { } 
} 
				
			

The logger writes the message directly to the file system.Β If the logger throws an exception, then it will be redirected to SelfLog – our last resort to relay the problem to the developer.

				
					public class Logger : ILogger
{
   private readonly LogLevel _logLevel;
   private readonly string _logFilePath;

   public Logger(
      LogLevel logLevel,
      string logFilePath)
   {
      _logLevel = logLevel;
      _logFilePath = logFilePath;
   }

   public bool IsEnabled(LogLevel logLevel)
   {
      return logLevel >= _logLevel;
   }

   public void Log(LogLevel logLevel, string message)
   {
      if (!IsEnabled(logLevel))
         return;

      try
      {
         File.AppendAllText(_logFilePath,
                            $"[{DateTime.Now:O} | {logLevel}] {message}{Environment.NewLine}");
      }
      catch (Exception ex)
      {
         SelfLog.Write(ex.ToString());
      }
   }
}

				
			
The SelfLog writes the error message into a temp file which is (hopefully) accessible by the Source Generator.
				
					public class SelfLog 
{ 
   private const string _FILE_NAME = "DemoSourceGenerator.log"; 
 
   public static void Write(string message) 
   { 
      try 
      { 
         var fullPath = Path.Combine(Path.GetTempPath(), _FILE_NAME); 
         File.AppendAllText(fullPath, $"[{DateTime.Now:O}] {message}{Environment.NewLine}"); 
      } 
      catch (Exception ex) 
      { 
         Debug.WriteLine(ex); 
      } 
   } 
} 

				
			

Fetching Logging Options

The configuration parameters can be fetched using AnalyzerConfigOptionsProvider, as described in previous article. New configuration parameters are DemoSourceGenerator_LogFilePath and DemoSourceGenerator_LogLevel.

				
					   public record struct LoggingOptions(string FilePath, LogLevel Level);

   //-------------------------

   private static IncrementalValueProvider<GeneratorOptions> GetGeneratorOptions(
       IncrementalGeneratorInitializationContext context) 
   { 
      return context.AnalyzerConfigOptionsProvider
                    .Select((options, _) => 
                            { 
                               var counterEnabled = ...; 
 
                               var loggingOptions = GetLoggingOptions(options); 
 
                               return new GeneratorOptions(counterEnabled, loggingOptions); 
                            }); 
   } 
 
   private static LoggingOptions? GetLoggingOptions(AnalyzerConfigOptionsProvider options) 
   { 
      if (!options.GlobalOptions.TryGetValue("build_property.DemoSourceGenerator_LogFilePath",
                                             out var logFilePath)) 
         return null; 
 
      if (String.IsNullOrWhiteSpace(logFilePath)) 
         return null; 
 
      logFilePath = logFilePath.Trim(); 
 
      if (!options.GlobalOptions.TryGetValue("build_property.DemoSourceGenerator_LogLevel",
                                             out var logLevelValue) 
          || !Enum.TryParse(logLevelValue, true, out LogLevel logLevel)) 
      { 
         logLevel = LogLevel.Information; 
      } 
 
      return new LoggingOptions(logFilePath, logLevel); 
   } 
 
				
			

Add new parameters to DemoConsoleApplication.csproj to activate the logging in DemoConsoleApplication.

				
					<Project Sdk="Microsoft.NET.Sdk"> 
 
   <PropertyGroup>
      ...
      <DemoSourceGenerator_Counter>enable</DemoSourceGenerator_Counter> 
      <DemoSourceGenerator_LogFilePath>
          c:/temp/DemoSourceGenerator_logs.txt
      </DemoSourceGenerator_LogFilePath> 
      <DemoSourceGenerator_LogLevel>debug</DemoSourceGenerator_LogLevel> 
   </PropertyGroup> 
 
   <ItemGroup> 
      <CompilerVisibleProperty Include="DemoSourceGenerator_Counter" /> 
      <CompilerVisibleProperty Include="DemoSourceGenerator_LogFilePath" /> 
      <CompilerVisibleProperty Include="DemoSourceGenerator_LogLevel" /> 
   </ItemGroup> 
				
			
And don’t forget to update DemoLibrary.props for the NuGet package.
				
					<Project> 
 
   <ItemGroup> 
      <CompilerVisibleProperty Include="DemoSourceGenerator_Counter" /> 
      <CompilerVisibleProperty Include="DemoSourceGenerator_LogFilePath" /> 
      <CompilerVisibleProperty Include="DemoSourceGenerator_LogLevel" /> 
   </ItemGroup> 
 
</Project>  

				
			

Setup and Usage of the Logger

There are (at least) 2 ways how to setup and to use the logger.

One option is to create a logger like depicted below (lines 26-29) but instead of assigning the variable _logger, the newly created logger is sent down the pipeline, i.e. instead of returning 0. This pipeline can be combined with other pipelines, like enumTypes (line 12), to provide access to logger to subsequent steps. This way the logger is more Source-Generator-like. The downside of this approach is that methods in CreateSyntaxProvider won’t have direct access to the logger.

The other options is to use global variable _logger. In this case the logger must be thread-safe. Furthermore, the logging-pipeline must be registered with RegisterSourceOutput, otherwise it stays inactive.

Mixing both approaches is possible as well, but I don’t see any (practical) reason for doing that. I tried it with Thinktecture.Runtime.Extensions, but having an additional Combine in a dozen of pipelines was quite cumbersome.

				
					[Generator] 
public class DemoSourceGenerator : IIncrementalGenerator 
{ 
   private ILogger _logger = NullLogger.Instance; 
 
   public void Initialize(IncrementalGeneratorInitializationContext context) 
   { 
      var options = GetGeneratorOptions(context); 
 
      SetupLogger(context, options); 
 
      var enumTypes = context.SyntaxProvider 
                             .ForAttributeWithMetadataName("DemoLibrary.EnumGenerationAttribute", 
                                                           CouldBeEnumerationAsync, 
                                                           GetEnumInfoOrNull) 
                             .Collect() 
                             .SelectMany((enumInfos, _) => enumInfos.Distinct()); 
      ...
   } 
   
   private void SetupLogger( 
      IncrementalGeneratorInitializationContext context, 
      IncrementalValueProvider<GeneratorOptions> optionsProvider) 
   { 
      var logging = optionsProvider 
                    .Select((options, _) => options.Logging) 
                    .Select((options, _) => 
                            { 
                               _logger = options is null 
                                            ? NullLogger.Instance 
                                            : new Logger(options.Value.Level,
                                                         options.Value.FilePath); 
 
                               return 0; 
                            }) 
                    .SelectMany((_, _) => ImmutableArray<int>.Empty); // don't emit anything 
 
      // need to register the pipeline so it becomes active
      context.RegisterSourceOutput(logging, static (_, _) => 
                                            { 
                                               // This delegate will never be called 
                                            }); 
   } 
				
			

There is not much to consider when using the logger. Check whether the logger is enabled for specific LogLevel, if yes then log a message. The check is recommended to prevent unnecessary memory allocations due to interpolated string. If logging is disabled then the code will use the NullLogger which is never enabled, so the call is virtually a no-op.

				
					   private DemoEnumInfo? GetEnumInfoOrNull(
       GeneratorSyntaxContext context,
       CancellationToken cancellationToken) 
   { 
      ...
      var enumInfo = ...; 
 
      if (enumInfo is not null && _logger.IsEnabled(LogLevel.Debug)) 
         _logger.Log(LogLevel.Debug, $"Smart Enum found: {enumInfo.Namespace}.{enumInfo.Name}"); 
 
      return enumInfo; 
   } 
				
			

Summary

It doesn’t seem like the Roslyn API for Source Generators were designed with logging in mind. The implementation of the logging infrastructure, the setup and the usage feels slightly akward. Nonetheless, the authors need some means for debugging and support of theirs Source Generators – logging is such a means.

Free
Newsletter

Current articles, screencasts and interviews by our experts

Don’t miss any content on Angular, .NET Core, Blazor, Azure, and Kubernetes and sign up for our free monthly dev newsletter.

EN Newsletter Anmeldung (#7)
Related Articles
Angular
SL-rund
If you previously wanted to integrate view transitions into your Angular application, this was only possible in a very cumbersome way that needed a lot of detailed knowledge about Angular internals. Now, Angular 17 introduced a feature to integrate the View Transition API with the router. In this two-part series, we will look at how to leverage the feature for route transitions and how we could use it for single-page animations.
15.04.2024
.NET
KP-round
.NET 8 brings Native AOT to ASP.NET Core, but many frameworks and libraries rely on unbound reflection internally and thus cannot support this scenario yet. This is true for ORMs, too: EF Core and Dapper will only bring full support for Native AOT in later releases. In this post, we will implement a database access layer with Sessions using the Humble Object pattern to get a similar developer experience. We will use Npgsql as a plain ADO.NET provider targeting PostgreSQL.
15.11.2023
.NET
KP-round
Originally introduced in .NET 7, Native AOT can be used with ASP.NET Core in the upcoming .NET 8 release. In this post, we look at the benefits and drawbacks from a general perspective and perform measurements to quantify the improvements on different platforms.
02.11.2023