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 diesem Artikel:

pg
Pawel Gerr ist Architekt und Consultant bei Thinktecture. Er hat sich auf .NET Core Backends spezialisiert und kennt Entity Framework von vorne bis hinten.

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.

Kostenloser
Newsletter

Aktuelle Artikel, Screencasts, Webinare und Interviews unserer Experten für Sie

Verpassen Sie keine Inhalte zu Angular, .NET Core, Blazor, Azure und Kubernetes und melden Sie sich zu unserem kostenlosen monatlichen Dev-Newsletter an.

Newsletter Anmeldung
Diese Artikel könnten Sie interessieren
.NET
pg

Smart Enums in .NET: Integration with Frameworks and Libraries

Learn how to seamlessly integrate Smart Enums with essential .NET frameworks and libraries. This article covers practical solutions for JSON serialization, ASP.NET Core model binding for both Minimal APIs and MVC controllers, and Entity Framework Core persistence using value converters. Discover how Thinktecture.Runtime.Extensions provides dedicated packages to eliminate integration friction and maintain type safety across your application stack.
21.09.2025
.NET
pg

Value Objects in .NET: Enhancing Business Semantics

Value objects are fundamental building blocks in Domain-Driven Design, serving far more than simple data wrappers. This article explores their strategic importance in bridging technical code and business concepts, enforcing domain rules, and fostering clearer communication with domain experts. Learn how to build robust aggregates, cultivate ubiquitous language, and encapsulate domain-specific behavior using Thinktecture.Runtime.Extensions in .NET applications.
16.09.2025
.NET
pg

Pattern Matching with Discriminated Unions in .NET

Traditional C# pattern matching with switch statements and if/else chains is error-prone and doesn't guarantee exhaustive handling of all cases. When you add new types or states, it's easy to miss updating conditional logic, leading to runtime bugs. The library Thinktecture.Runtime.Extensions solves this with built-in Switch and Map methods for discriminated unions that enforce compile-time exhaustiveness checking.
26.08.2025
.NET
pg

Value Objects in .NET: Integration with Frameworks and Libraries

Value Objects in .NET provide a structured way to improve consistency and maintainability in domain modeling. This article examines their integration with popular frameworks and libraries, highlighting best practices for seamless implementation. From working with Entity Framework to leveraging their advantages in ASP.NET, we explore how Value Objects can be effectively incorporated into various architectures. By understanding their role in framework integration, developers can optimize data handling and enhance code clarity without unnecessary complexity.
12.08.2025
.NET
pg

Smart Enums: Adding Domain Logic to Enumerations in .NET

This article builds upon the introduction of Smart Enums by exploring their powerful capability to encapsulate behavior, a significant limitation of traditional C# enums. We delve into how Thinktecture.Runtime.Extensions enables embedding domain-specific logic directly within Smart Enum definitions. This co-location of data and behavior promotes more cohesive, object-oriented, and maintainable code, moving beyond scattered switch statements and extension methods. Discover techniques to make your enumerations truly "smart" by integrating behavior directly where it belongs.
29.07.2025
.NET
pg

Discriminated Unions: Representation of Alternative Types in .NET

Representing values that may take on multiple distinct types or states is a common challenge in C#. Traditional approaches—like tuples, generics, or exceptions—often lead to clumsy and error-prone code. Discriminated unions address these issues by enabling clear, type-safe modeling of “one-of” alternatives. This article examines pitfalls of conventional patterns and introduces discriminated unions with the Thinktecture.Runtime.Extensions library, demonstrating how they enhance code safety, prevent invalid states, and improve maintainability—unlocking powerful domain modeling in .NET with minimal boilerplate.
15.07.2025