Behind [LogProperties] and the new telemetry logging source generator

.NET 8 shipped a small looking logging attribute called [LogProperties] that lets you attach an entire object’s properties to a log entry, instead of pulling each field into the message template by hand. It looks like a minor convenience addition on the surface. Once you look at the code the compiler actually generates for it, you find out Microsoft replaced the whole logging source generator to make it work.

This article walks through how the original [LoggerMessage] source generator behaves, what changes the moment you add the Microsoft.Extensions.Telemetry.Abstractions package, and how [LogProperties] works once you start using it. There are a couple of gotchas around collections and property exclusion in here that are easy to miss if you only read the documentation.

How the built-in [LoggerMessage] source generator works

Source generators became a major part of .NET starting with .NET 5, and logging was one of the first areas to get one. The [LoggerMessage] attribute tells the compiler to generate a strongly typed, allocation friendly logging method behind a partial method declaration. If you are writing a library or a high throughput service where every allocation on the hot path matters, this is worth using. For ordinary application code, a plain ILogger.LogDebug or LogInformation call usually reads better and adds less ceremony to the class, so I would not reach for the source generator everywhere by default.

Consider a minimal API endpoint that generates a batch of weather forecasts and logs how many it created. To use the source generator, the handler needs to be pulled into its own partial class, and the log call is defined through [LoggerMessage] rather than calling the logger directly.

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
 
// Minimal API using the static handler pattern
app.MapGet("/weatherforecast", Handler.GetForecasts);
 
app.Run();
 
// Note: the handler class must be partial when using the source generator
internal partial class Handler
{
    public static WeatherForecast[] GetForecasts(ILogger<Handler> logger)
    {
        var entriesToGenerate = Random.Shared.Next(5);
        GeneratingForecasts(logger, entriesToGenerate); // logs the count
 
        var entries = new WeatherForecast[entriesToGenerate];
        for (int i = 0; i < entriesToGenerate; i++)
        {
            entries[i] = new WeatherForecast(
                Date: DateOnly.FromDateTime(DateTime.Now.AddDays(i)),
                TemperatureC: Random.Shared.Next(-20, 55));
        }
 
        return entries;
    }
 
    [LoggerMessage(
        Level = LogLevel.Debug,
        Message = "Generating {ForecastCount} forecasts")]
    private static partial void GeneratingForecasts(ILogger logger, int forecastCount);
}
 
internal record WeatherForecast(DateOnly Date, int TemperatureC)
{
    public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
}

The [LoggerMessage] attribute on GeneratingForecasts is what triggers the generator. Both Visual Studio and Rider let you navigate to the generated implementation, and it looks roughly like this once expanded.

partial class Handler
{
    // Cached delegate, template parsed only once
    private static readonly Action<ILogger, int, Exception?> __GeneratingForecastsCallback =
        LoggerMessage.Define<int>(
            LogLevel.Debug,
            new EventId(1759284123, nameof(GeneratingForecasts)),
            "Generating {ForecastCount} forecasts",
            new LogDefineOptions { SkipEnabledCheck = true });
 
    private static partial void GeneratingForecasts(ILogger logger, int forecastCount)
    {
        if (logger.IsEnabled(LogLevel.Debug))
        {
            __GeneratingForecastsCallback(logger, forecastCount, null);
        }
    }
}

The generator caches the message template through LoggerMessage.Define<int>, so the template is parsed once and reused on every call instead of being re-parsed each time. It also checks logger.IsEnabled before doing any work, so when the log level is switched off, nothing gets boxed or allocated. This is really the whole point of using the source generator on a hot path, you get structured logging without paying the parsing and boxing cost on every invocation. All of this ships as part of Microsoft.Extensions.Logging.Abstractions, which is already present in every ASP.NET Core project, so there is nothing extra to install for this part.

Logging an entire object with [LogProperties]

At .NET Conf 2023, Microsoft introduced a set of telemetry, observability and resilience packages alongside .NET 8, and [LogProperties] came in as a smaller addition within that set. Decorate a parameter with [LogProperties] and every public property on that object gets attached to the log entry automatically, so you no longer have to manually pull each field into the message template.

[LoggerMessage(
    Level = LogLevel.Debug,
    Message = "Generated Forecast")] // template has no parameters
private static partial void GeneratedForecast(
    ILogger logger,
    [LogProperties] WeatherForecast forecast);

Notice the message template itself, “Generated Forecast”, does not reference any property of the forecast object. Even so, every public property on WeatherForecast, that is Date, TemperatureC and TemperatureF, ends up in the structured log output once this method runs. To use the attribute you need one extra NuGet package, Microsoft.Extensions.Telemetry.Abstractions, which is not part of the default ASP.NET Core template.

Installing Microsoft.Extensions.Telemetry.Abstractions

You can add the package from the CLI:

dotnet add package Microsoft.Extensions.Telemetry.Abstractions

Or reference it directly in the csproj file:

<Project Sdk="Microsoft.NET.Sdk.Web">
 
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <InvariantGlobalization>true</InvariantGlobalization>
  </PropertyGroup>
 
  <ItemGroup>
    <PackageReference Include="Microsoft.Extensions.Telemetry.Abstractions" Version="8.0.0" />
  </ItemGroup>
 
</Project>

One point worth calling out, this package is not tied to .NET 8. It also supports .NET Framework and .NET 6 and above, so you can bring in [LogProperties] even on projects that have not moved to .NET 8 yet. What is easy to miss, and what surprised me while digging into this, is that installing the package changes the generated code for every [LoggerMessage] method in the project, including ones that never touch [LogProperties].

Why one package reference changes all your existing log methods

The natural question is whether this package hooks into the existing source generator or replaces it outright. Looking inside the NuGet package makes the answer clear.

Contents of the Microsoft.Extensions.Telemetry.Abstractions NuGet package
Contents of the Microsoft.Extensions.Telemetry.Abstractions NuGet package

The package ships its own logging source generator, plus .props and .targets files that explicitly disable the original Microsoft.Extensions.Logging.Abstractions generator, so the two never run side by side. The .props file sets a property, and the .targets file uses that property to remove the old analyzer from the compilation.

<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
  <!-- Removes the Microsoft.Extensions.Logging.Abstractions source generator -->
  <PropertyGroup>
    <DisableMicrosoftExtensionsLoggingSourceGenerator>true</DisableMicrosoftExtensionsLoggingSourceGenerator>
  </PropertyGroup>
</Project>

And the corresponding .targets file:

<Project>
  <Target Name="_Microsoft_Extensions_Logging_AbstractionsRemoveAnalyzers"
          Condition="'$(DisableMicrosoftExtensionsLoggingSourceGenerator)' == 'true'"
          AfterTargets="ResolveReferences">
    <ItemGroup>
      <_Microsoft_Extensions_Logging_AbstractionsAnalyzer Include="@(Analyzer)"
        Condition="'%(Analyzer.AssemblyName)' == 'Microsoft.Extensions.Logging.Generators'
                Or '%(Analyzer.NuGetPackageId)' == 'Microsoft.Extensions.Logging.Abstractions'" />
    </ItemGroup>
    <ItemGroup>
      <Analyzer Remove="@(_Microsoft_Extensions_Logging_AbstractionsAnalyzer)" />
    </ItemGroup>
  </Target>
</Project>

In short, this is not an extension of the existing generator, it is a full replacement. Once the package is referenced, every [LoggerMessage] method in the project gets its implementation from the new generator, whether that method uses [LogProperties] or not.

How the new generated code is structured

Take the same GeneratingForecasts method from earlier, unchanged, and just add the Telemetry.Abstractions package. The generated implementation looks completely different from the LoggerMessage.Define version we saw before.

partial class Handler
{
    private static partial void GeneratingForecasts(ILogger logger, int forecastCount)
    {
        if (!logger.IsEnabled(LogLevel.Debug))
        {
            return;
        }
 
        // LoggerMessageState wraps a pooled KeyValuePair<string, object>[]
        LoggerMessageState state = LoggerMessageHelper.ThreadLocalState;
        _ = state.ReserveTagSpace(2);
        state.TagArray[1] = new("ForecastCount", forecastCount);
        state.TagArray[0] = new("{OriginalFormat}", "Generating {ForecastCount} forecasts");
 
        logger.Log(
            LogLevel.Debug,
            new EventId(0, nameof(GeneratingForecasts)),
            state,
            exception: null,
            formatter: static (s, _) =>
            {
                var ForecastCount = s.TagArray[1].Value;
                return FormattableString.Invariant($"Generating {ForecastCount} forecasts");
            });
 
        state.Clear(); // returns state to the thread local pool
    }
}

A few things stand out here. LoggerMessageState acts as a wrapper around an array of key value pairs, and a thread local pool of these state objects avoids allocating a fresh array on every log call. The formatter uses FormattableString to build the final message text lazily, only when something actually needs to read it, rather than eagerly building a string on every log call regardless of whether a sink consumes it.

LoggerMessageState and LoggerMessageHelper both live inside Microsoft.Extensions.Telemetry.Abstractions, and their source is on GitHub if you want to go deeper. For the most part they are not doing anything exotic, they mostly reuse arrays through pooling to keep the allocation count down.

This change is visible even in the raw JSON output. Without the package, the JSON console logger writes something like this:

{
  "EventId": 823449399,
  "LogLevel": "Debug",
  "Category": "Handler",
  "Message": "Generating 4 forecasts",
  "State": {
    "Message": "Generating 4 forecasts",
    "ForecastCount": 4,
    "{OriginalFormat}": "Generating {ForecastCount} forecasts"
  }
}

And with the Telemetry.Abstractions package installed, the same log call produces this instead:

{
  "EventId": 0,
  "LogLevel": "Debug",
  "Category": "Handler",
  "Message": "Generating 4 forecasts",
  "State": {
    "Message": "{OriginalFormat}=Generating {ForecastCount} forecasts,ForecastCount=4",
    "{OriginalFormat}": "Generating {ForecastCount} forecasts",
    "ForecastCount": 4
  }
}

The State.Message field is different because the JSON console logger calls ToString() on the state object, and the state is now a LoggerMessageState instance instead of the older LoggerMessage.LogValues<int>, and the two types format themselves differently in ToString(). The top level Message property that most log viewers actually read stays correct in both cases, so this is mostly a curiosity, but it is worth knowing about if you have log parsing code that inspects State.Message specifically. Other logging providers, such as Serilog sinks, may not surface this difference at all since they usually read the tag array directly instead of calling ToString() on the state.

What [LogProperties] adds to the generated code

With the background out of the way, go back to the GeneratedForecast method that uses [LogProperties] on the forecast parameter, and look at what the generator produces for it.

partial class Handler
{
    private static partial void GeneratedForecast(ILogger logger, WeatherForecast forecast)
    {
        if (!logger.IsEnabled(LogLevel.Debug))
        {
            return;
        }
 
        LoggerMessageState state = LoggerMessageHelper.ThreadLocalState;
        _ = state.ReserveTagSpace(4);
 
        state.TagArray[3] = new("forecast.Date", forecast?.Date);
        state.TagArray[2] = new("forecast.TemperatureC", forecast?.TemperatureC);
        state.TagArray[1] = new("forecast.TemperatureF", forecast?.TemperatureF);
        state.TagArray[0] = new("{OriginalFormat}", "Generated Forecast");
 
        logger.Log(
            LogLevel.Debug,
            new EventId(0, nameof(GeneratingForecasts)),
            state,
            exception: null,
            formatter: static (s, _) => "Generated Forecast");
 
        state.Clear();
    }
}

The only real difference from the plain GeneratingForecasts method is that each public property of WeatherForecast gets its own entry in the tag array, prefixed with the parameter name. The resulting JSON output confirms this, every property shows up as its own field alongside the original message.

{
  "EventId": 0,
  "LogLevel": "Debug",
  "Category": "Handler",
  "Message": "Generated Forecast",
  "State": {
    "Message": "{OriginalFormat}=Generated Forecast,forecast.TemperatureF=125,forecast.TemperatureC=52,forecast.Date=11/27/2023",
    "{OriginalFormat}": "Generated Forecast",
    "forecast.TemperatureF": 125,
    "forecast.TemperatureC": 52,
    "forecast.Date": "11/27/2023"
  }
}

Customizing the prefix and null handling

By default, [LogProperties] prefixes every key with the parameter name and writes a property whether its value is null or not. Both behaviours are configurable through named arguments on the attribute.

[LoggerMessage(Level = LogLevel.Debug, Message = "Generated Forecast")]
private static partial void GeneratedForecast(
    ILogger logger,
    [LogProperties(OmitReferenceName = true, SkipNullProperties = true)] WeatherForecast forecast);

With this change, the generated code switches from a fixed size array to a variable one, using AddTag instead of directly indexing TagArray.

LoggerMessageState state = LoggerMessageHelper.ThreadLocalState;
_ = state.ReserveTagSpace(1);
 
state.TagArray[0] = new("{OriginalFormat}", "Generated Forecast");
state.AddTag("Date", forecast?.Date);
state.AddTag("TemperatureC", forecast?.TemperatureC);
state.AddTag("TemperatureF", forecast?.TemperatureF);

Notice the keys no longer carry the forecast. prefix, and only one slot is reserved upfront instead of four. AddTag checks each value for null before appending it to the array, expanding the array by one entry only when the value is worth logging. This is a reasonable default to reach for once you are logging several optional or nullable properties on a class and do not want a wall of null entries cluttering your log sink.

Excluding a property with [LogPropertyIgnore]

Sometimes a property is not worth logging at all, either because it is derived from other fields or because it is sensitive. TemperatureF here is just a calculation from TemperatureC, so it makes sense to drop it from the log entirely rather than log a redundant value.

internal record WeatherForecast(DateOnly Date, int TemperatureC)
{
    [LogPropertyIgnore]
    public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
}

The generated code respects this immediately, only reserving space for the properties that remain.

LoggerMessageState state = LoggerMessageHelper.ThreadLocalState;
_ = state.ReserveTagSpace(3);
 
state.TagArray[2] = new("forecast.Date", forecast?.Date);
state.TagArray[1] = new("forecast.TemperatureC", forecast?.TemperatureC);
state.TagArray[0] = new("{OriginalFormat}", "Generated Forecast");
// forecast.TemperatureF is no longer written

The gotcha with collections of objects

A natural next question is whether [LogProperties] can expand a collection, logging each item’s properties individually, say with keys like forecasts[0].Date. Try applying it to an array parameter and see what actually happens.

[LoggerMessage(Level = LogLevel.Debug, Message = "Generating {ForecastCount} forecasts")]
private static partial void GeneratingForecasts(
    ILogger logger,
    int forecastCount,
    [LogProperties] WeatherForecast[] forecasts);

This does not expand each item into its own set of keys. Instead, the generator emits a compiler warning, LOGGEN018, telling you the value cannot be logged in the expanded form, and falls back to stringifying the whole array into a single property.

LoggerMessageState state = LoggerMessageHelper.ThreadLocalState;
_ = state.ReserveTagSpace(3);
 
state.TagArray[2] = new("ForecastCount", forecastCount);
var forecastsToLog = forecasts != null
    ? LoggerMessageHelper.Stringify(forecasts)
    : null;
state.TagArray[1] = new("forecasts", forecastsToLog);
state.TagArray[0] = new("{OriginalFormat}", "Generating {ForecastCount} forecasts");

Which produces a log entry where the entire collection has been turned into one long string value:

{
  "EventId": 0,
  "LogLevel": "Debug",
  "Category": "Handler",
  "Message": "Generating 4 forecasts",
  "State": {
    "forecasts": "[\"WeatherForecast { Date = 11/25/2023, TemperatureC = 16, TemperatureF = 60 }\", ...]",
    "ForecastCount": 4
  }
}

This design keeps the tag array a bounded, predictable size regardless of how many items the collection holds, which is sensible from a pooling and allocation point of view. But there is a sharper gotcha hiding here: this stringify path calls ToString() on every item directly, which means [LogPropertyIgnore] is silently bypassed. TemperatureF shows up in the string above even though it carries the ignore attribute on the property, because the ignore attribute only affects the expanded, per property logging path, not the fallback ToString() path used for collections.

This is worth remembering if you have sensitive fields on a class and rely on [LogPropertyIgnore] to keep them out of logs. The moment that class ends up inside a collection passed through [LogProperties], the exclusion stops applying, and whatever the record’s default ToString() prints, including the supposedly ignored property, lands in your log sink. The safer pattern for collections is to either log a summary, such as just the count, or write your own explicit ToString() override or a dedicated log record type that only contains what you actually want to expose.

When this is worth using

The source generator swap is a reasonable trade for teams that already lean on structured logging and query logs through something like Application Insights, Seq or an ELK stack, since the flattened property keys make filtering and aggregation easier without you writing manual scope objects everywhere. For smaller applications with a handful of log statements, the added package reference and the behavioural quirks around collections and ToString() are probably not worth it, plain LogInformation calls with a couple of named parameters will get you most of the value with far less to remember.

If you do adopt it, treat [LogProperties] as an opt in decision per log call rather than a blanket replacement for every logging statement in the codebase, and be deliberate about which objects you decorate with it, especially anything that might carry personal or sensitive data down the line.

Summary

Installing Microsoft.Extensions.Telemetry.Abstractions does not simply add a new attribute on top of the existing logging source generator, it replaces the generator wholesale through .props and .targets files that disable the original one. Every [LoggerMessage] method in the project starts using LoggerMessageState and a thread local pool instead of LoggerMessage.Define, whether or not it touches [LogProperties]. The [LogProperties] attribute itself is a genuine convenience for flattening an object’s properties into a log entry, but the collection handling and the way [LogPropertyIgnore] gets bypassed for arrays are details worth testing before you rely on them in production.

Leave a Reply

Discover more from Behind the Stack

Subscribe now to keep reading and get access to the full archive.

Continue reading