Article series
- Code sharing of the future
- Better experience through Roslyn Analyzers and Code Fixes
- Testing Source Generators, Roslyn Analyzers and Code Fixes
- Increasing Performance through Harnessing of the Memoization
- Adapt Code Generation Based on Project Dependencies
- Using 3rd-Party Libraries
- Using Additional Files
- High-Level API β ForAttributeWithMetadataName
- Reduction of resource consumption in IDE
- Configuration
- Logging β¬
More information about the Smart Enums and the source code can be found on GitHub:
- Smart Enums
- Source Code (see commits starting with message “Part 11”)
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
LogLevel
, ILogger
, Logger
, NullLogger
, and a SelfLog
-logger in case the Logger
throws an exception. IDisposable
. Without Dispose
, we cannot start always-running (background) tasks because we don’t get an event when to stop them. 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());
}
}
}
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 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
.
...
enable
c:/temp/DemoSourceGenerator_logs.txt
debug
DemoLibrary.props
for the NuGet package.
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 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.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.