What's New in Logging from .NET 8 to .NET 11: Enrichment, Redaction, FakeLogger, Buffering and Generic LoggerMessage
Logging in .NET moves on two trains: ILogger and the source generator ship with the runtime, while enrichment, redaction, FakeLogger, sampling and buffering come with the monthly Microsoft.Extensions.Telemetry packages. With real output we show which feature arrived when, CA1873 and the obsolete AddServiceLogEnricher in .NET 10, generic LoggerMessage in .NET 11, and how to read old Serilog Startup code.
Version note: The code in this post was built and run with .NET 10, Microsoft.Extensions.Telemetry 10.11, Microsoft.Extensions.Diagnostics.Testing 10.11 and Serilog.AspNetCore 10.0. .NET 11 features were at the RC (Release Candidate) stage at the time of writing, so they are described without code.
Guess First
In an old Bookstore project you've inherited, appsettings.json says "Microsoft.AspNetCore": "Warning". Program has Host.CreateDefaultBuilder(args).UseSerilog() and a Startup class. You move the project to .NET 10 and send a request. Do you see the framework's Information lines such as "Request starting" and "Executing endpoint" in the console? If you do, why?
The second question is about newer code: you want to add service.name to every log with AddApplicationLogEnricher, and you do nothing else. What does the log say for service.name?
This post is the part of the series that looks back. In Block A we covered each topic in depth on .NET 10; here we collect which feature arrived in which version, what you'll run into in old code, and what .NET 11 brings.
A Version Map
A note first: logging in .NET has two separate release trains. ILogger, filters, the console provider and the LoggerMessage source generator ship with the runtime, meaning the yearly .NET release. Enrichment, redaction, FakeLogger, sampling and buffering live in the Microsoft.Extensions.Telemetry family that comes out of the dotnet/extensions repository; those packages get a new minor version almost every month. That's why the correct answer to "which .NET version did sampling arrive with?" is a package version. The map, based on publish dates on NuGet:
| Period | Feature | Where |
|---|---|---|
| .NET 8 (November 2023) | Enrichment, redaction, [LogProperties], FakeLogger | Microsoft.Extensions.Telemetry family 8.0.0 |
| .NET 8 | Constructors without an EventId on LoggerMessage | Runtime |
| .NET 8 | CombineLogs, Duration, IHttpLoggingInterceptor in HTTP (Hypertext Transfer Protocol) logging | ASP.NET Core 8 |
| .NET 9 (November 2024) | Primary constructor support in the source generator | Runtime |
| .NET 9 timeframe (March 2025) | Random and trace-based sampling | Microsoft.Extensions.Telemetry 9.3.0 |
| .NET 9 timeframe (May 2025) | Global and per-request buffering | Microsoft.Extensions.Telemetry 9.5.0, Microsoft.AspNetCore.Diagnostics.Middleware 9.5.0 |
| .NET 10 (November 2025) | The CA1873 analyzer, the 10.x package series | SDK (Software Development Kit), Microsoft.Extensions.Telemetry 10.0.0 → 10.11.0 |
| .NET 11 (RC) | Generic LoggerMessage methods, stream pooling in HTTP logging | Runtime, ASP.NET Core 11 |
.NET 8: The Microsoft.Extensions.Telemetry Family
With .NET 8, Microsoft published a new package family for telemetry and compliance from the dotnet/extensions repository. We saw the framework side in the ASP.NET Core Web API (Application Programming Interface) series post "What's New in ASP.NET Core with .NET 8"; on the logging side there are four pieces.
Enrichment
Enrichment automatically adds environment information to every log record: process id, thread id, service name, environment. It's the Microsoft.Extensions.Logging counterpart of Serilog's enrichers. In the Bookstore shipping service:
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
// service.name and deployment.environment are read from this metadata
builder.Host.UseApplicationMetadata();
builder.Logging.EnableEnrichment();
builder.Services.AddProcessLogEnricher(o => o.ThreadId = true);
builder.Services.AddApplicationLogEnricher(o => o.ApplicationName = true);
builder.Services.AddSingleton<ShipmentService>();
Most .NET 8-era examples on the internet use AddServiceLogEnricher. When I built it with 10.11, this was the real warning:
Program.cs(7,1): warning EXTOBS0002: 'ApplicationEnricherServiceCollectionExtensions.AddServiceLogEnricher(IServiceCollection, Action<ApplicationLogEnricherOptions>)' is obsolete: 'This API is obsolete and will be removed in a future version. Instead of the AddServiceLogEnricher() methods, consider using the respective AddApplicationLogEnricher() methods.' (https://aka.ms/dotnet-extensions-warnings/EXTOBS0002)
The new name is AddApplicationLogEnricher. The answer to the second opening question is in the real output without the UseApplicationMetadata() line: "service.name":"" and "deployment.environment":"". The enricher doesn't find the value itself; it reads it from the ApplicationMetadata filled in by the Microsoft.Extensions.AmbientMetadata.Application package. Without metadata the field is written as an empty string, with no error. The real record in the JSON (JavaScript Object Notation) console after adding the line:
{"EventId":3001,"LogLevel":"Information","Category":"Bookstore.Shipping.ShipmentService","Message":"Shipment created","State":{"Message":"Microsoft.Extensions.Logging.ExtendedLogger+ModernTagJoiner","thread.id":"7","process.pid":"22236","service.name":"Bookstore.Shipping","deployment.environment":"Production","shipment.WeightKg":2.4,"shipment.ParcelCount":2,"shipment.Carrier":"FastCargo","shipment.OrderId":"01a1b8de-8d15-7651-b472-6d4f3064e0f6","{OriginalFormat}":"Shipment created"}}
The field names follow the OpenTelemetry semantic conventions (service.name, process.pid); when you export logs with OTLP (OpenTelemetry Protocol), these names look familiar on the receiving side. The ExtendedLogger+ModernTagJoiner text in State.Message is a side effect the JSON console formatter shows when the Telemetry package's logger is in play; it has nothing to do with the content.
[LogProperties]
The shipment.* fields in the record above come from [LogProperties]:
public sealed record Shipment(Guid OrderId, string Carrier, int ParcelCount, decimal WeightKg);
public sealed partial class ShipmentService(ILogger<ShipmentService> logger)
{
public Shipment Ship(Guid orderId, int parcelCount)
{
Shipment shipment = new(orderId, "FastCargo", parcelCount, parcelCount * 1.2m);
LogShipmentCreated(shipment);
return shipment;
}
// .NET 8: [LogProperties] expands the object into properties
// .NET 9: the source generator uses the logger from the primary constructor directly
[LoggerMessage(3001, LogLevel.Information, "Shipment created")]
private partial void LogShipmentCreated([LogProperties] Shipment shipment);
}
We covered the risky side of this attribute, an email field added to the object tomorrow silently entering the log, and how to prevent it with data classification, in this series' "Sensitive Data in Logs" post. Redaction itself (EnableRedaction, ErasingRedactor, HmacRedactor) comes from the same .NET 8 package family and is covered in detail there.
FakeLogger
Before .NET 8, to write a test like "does this method write that event?" you either mocked ILogger or wrote your own in-memory provider. Mocking was painful, because LogInformation is an extension method and the actual call goes to the generic Log<TState>. FakeLogger in the Microsoft.Extensions.Diagnostics.Testing package ended that:
[Fact]
public void Ship_WritesShipmentCreatedEvent_WithShipmentProperties()
{
FakeLogger<ShipmentService> logger = new();
ShipmentService service = new(logger);
service.Ship(Guid.Parse("01a1b8de-8d15-7651-b472-6d4f3064e0f6"), parcelCount: 3);
FakeLogRecord record = logger.LatestRecord;
Assert.Equal(3001, record.Id.Id);
Assert.Equal(LogLevel.Information, record.Level);
Assert.Equal("3", record.GetStructuredStateValue("shipment.ParcelCount"));
Assert.Equal("FastCargo", record.GetStructuredStateValue("shipment.Carrier"));
Assert.Equal(1, logger.Collector.Count);
}
Passed! - Failed: 0, Passed: 1, Skipped: 0, Total: 1, Duration: 10 ms - Bookstore.Shipping.Tests.dll (net10.0)
Note: GetStructuredStateValue returns the value as a string, so we compare with "3", not 3. Testing the EventId and properties instead of the message text keeps the test from breaking needlessly when the template text changes. We wrote defensive tests like "no email appears in the log" with this logger in this series' seventh post.
HTTP Logging
ASP.NET Core 8 added three things to the HTTP logging middleware: HttpLoggingFields.Duration, which writes the total time of the request; CombineLogs, which merges request and response into a single record at the end of the request; and IHttpLoggingInterceptor, which decides per request what gets logged. WithHttpLogging and [HttpLogging] for per-endpoint settings came in this version too. We ran all of them, together with an interceptor that logs the body only for 4xx/5xx responses, in this series' "HTTP Logging" post.
Finally, a small but handy change on the runtime side: LoggerMessageAttribute got constructor overloads that don't require an EventId (such as LoggerMessage(LogLevel level, string message)). If you don't supply an EventId, the generator produces one. It looks convenient, but with the event catalog approach from this series' "High-Performance Logging" post I recommend supplying the EventId yourself; you don't want to tie a dashboard or an alert to a generated id.
.NET 9: Primary Constructors, Sampling and Buffering
The logging source generator in .NET 9 understands primary constructors. In ShipmentService above, LogShipmentCreated is an instance method, and the generator takes the logger from the primary constructor parameter; we didn't declare a separate _logger field. To write the same code in .NET 8 you had to assign the logger to a field.
.NET 9 also has a behavior change to watch for: the logs that IHttpClientFactory writes at Trace level no longer include header values by default. If you want to see the values of specific headers, you have to allow them explicitly with RedactLoggedHeaders. If you hear the complaint "the Authorization header used to show up in the log and disappeared after the upgrade" in an old project, this is the reason, and it's actually a good change.
Sampling and buffering arrived during the .NET 9 timeframe with minor versions of the Telemetry package: sampling with 9.3.0 (March 2025), buffering with 9.5.0 (May 2025). So even if your project targets .NET 9, these APIs aren't there if the package is 9.2. We measured both with version 10.11 in this series' "Log Levels and Cost" post; that post has version-dependent details such as where the configuration section goes and how buffered records lose scopes and TraceId.
.NET 10: The Current State of the Packages
The .NET 10 LTS (Long Term Support) release didn't bring big news to the logging APIs; the changes are more on the tooling and package side. The most visible one is the new analyzer rule CA1873 ("Avoid potentially expensive logging"). I built the same two lines of code with two SDKs and counted the warnings in the dotnet build output by code; in .editorconfig, CA1848 and CA1873 are set to warning:
int[] data = [1, 2, 3];
app.Logger.LogDebug("Data {Data}", string.Join(",", data));
# SDK 8.0.419, net8.0
2 warning CA1848
# SDK 10.0.402, net10.0
2 warning CA1848: For improved performance, use the LoggerMessage delegates instead of calling 'LoggerExtensions.LogDebug
2 warning CA1873: Evaluation of this argument may be expensive and unnecessary if logging is disabled
SDK 8 doesn't report CA1873 on the same line; the rule came with the .NET 10 SDK and is enabled as a suggestion by default. string.Join runs even when Debug is off; that's exactly what the rule catches.
On the package side, the Microsoft.Extensions.Telemetry family started with 10.0.0 on November 11, 2025 and is at 10.11.0 at the time of writing. API names can change in the monthly releases: AddServiceLogEnricher becoming obsolete is an example. On the Serilog side, Serilog.AspNetCore 10.0.0 was published on November 28, 2025; the package version follows the .NET version you target, so use 10.x in a .NET 10 project. Serilog itself is at 4.4; batching support for sinks moved into the core with Serilog 4.0, released in June 2024. That's why the IBatchedLogEventSink examples in this series' "Serilog Sinks" post don't need an extra package.
What's Coming in .NET 11
.NET 11's GA (General Availability) date is November 10, 2026; on the day I wrote this post it was still at the RC stage, and I didn't have a .NET 11 SDK on my machine. So I'm relaying the following from the ".NET 11 what's new" pages on Microsoft Learn, without code.
The logging source generator now supports generic methods: a method marked with [LoggerMessage] can take a type parameter with constraints such as TCode : struct, Enum, and enum or struct values can be logged without boxing; the allows ref struct anti-constraint isn't supported and produces the SYSLIB1011 error.
The HTTP logging middleware pools its ResponseBufferingStream instances; per-request allocations go down when response body logging or interceptors are enabled.
ASP.NET Core adds OpenTelemetry semantic convention attributes (such as http.request.method, url.path and http.response.status_code) to the HTTP server Activity itself; previously this required the OpenTelemetry.Instrumentation.AspNetCore package. If you don't want them, you can turn them off with the Microsoft.AspNetCore.Hosting.SuppressActivityOpenTelemetryData AppContext switch. It isn't a logging feature as such, but it means a richer span behind the TraceId in your logs; details in the upcoming DevOps › Observability series.
Reading Old Code: Serilog 2/3-Style Startup
In a pre-.NET 6 project, the logging setup most likely looks like this. I built this code with Serilog.AspNetCore 10.0 on .NET 10; not a single warning came up, and it ran:
public class Program
{
public static int Main(string[] args)
{
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Debug()
.MinimumLevel.Override("Microsoft", LogEventLevel.Information)
.Enrich.FromLogContext()
.WriteTo.Console()
.CreateLogger();
try
{
Log.Information("Starting web host");
CreateHostBuilder(args).Build().Run();
return 0;
}
catch (Exception ex)
{
Log.Fatal(ex, "Host terminated unexpectedly");
return 1;
}
finally
{
Log.CloseAndFlush();
}
}
public static IHostBuilder CreateHostBuilder(string[] args) =>
Host.CreateDefaultBuilder(args)
.ConfigureLogging(logging => logging.AddAzureWebAppDiagnostics())
.UseSerilog()
.ConfigureWebHostDefaults(web => web.UseStartup<Startup>());
}
public class Startup
{
public void ConfigureServices(IServiceCollection services)
{
services.AddRouting();
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
app.UseSerilogRequestLogging();
app.UseRouting();
app.UseEndpoints(endpoints =>
{
endpoints.MapGet("/books/{id:int}", (int id, ILogger<Startup> logger) =>
{
logger.LogInformation("Book {BookId} viewed", id);
return Results.Ok(new { id });
});
});
}
}
The console for a single request (I removed the long "Content root path" line):
[05:14:50 INF] Starting web host
[05:14:50 INF] Now listening on: http://localhost:17451
[05:14:50 INF] Application started. Press Ctrl+C to shut down.
[05:14:50 INF] Hosting environment: Production
[05:14:53 INF] Request starting HTTP/1.1 GET http://localhost:17451/books/7 - null null
[05:14:53 INF] Executing endpoint 'HTTP: GET /books/{id:int}'
[05:14:53 INF] Book 7 viewed
[05:14:53 INF] Setting HTTP status code 200.
[05:14:53 INF] Writing value of type '<>f__AnonymousType0`1' as Json.
[05:14:53 INF] Executed endpoint 'HTTP: GET /books/{id:int}'
[05:14:53 INF] HTTP GET /books/7 responded 200 in 28.1874 ms
[05:14:53 INF] Request finished HTTP/1.1 GET http://localhost:17451/books/7 - 200 null application/json; charset=utf-8 31.6995ms
The answer to the first opening question is yes: even though appsettings.json says "Microsoft.AspNetCore": "Warning", all of the framework's Information lines are there. The reason is that in this setup the levels are decided by Serilog's in-code configuration (Override("Microsoft", LogEventLevel.Information)); nobody looks at the Logging section in appsettings.json. When answering "why are there so many logs?" in an old project, the first place to look is the LoggerConfiguration chain, not appsettings.
Other things to watch for while reading:
CreateDefaultBuilderalso adds Microsoft.Extensions.Logging's console provider, yet there isn't a singleinfo:line in the output.UseSerilog()replaces the logger factory and, in its default form, doesn't write to other providers. For the same reason theAddAzureWebAppDiagnostics()line is effectively dead code: once Serilog is set up, not a single record reaches that provider.- The Microsoft.Extensions.Logging.AzureAppServices package isn't dead; it's still published, at version 10.0.14, and exists for Azure App Service's "Diagnostics logs" and "Log stream" features. But if it's used together with Serilog, it does nothing for the reason above; either use a Serilog sink or write to stdout.
Log.Loggeris static and set up once at the start of the app; even on a startup failure,Log.Fatalcan be written. Its modern counterpart is the two-stage initialization withCreateBootstrapLoggerfrom this series' "Serilog in Depth" post.- We covered the steps for moving a
Startupclass toWebApplicationin the ASP.NET Core Web API series post "Reading Old .NET Core 3.1 and .NET 5 Projects"; on the logging side, what you need to do is turnUseSerilog()intobuilder.Services.AddSerilog(...)and move the configuration into appsettings withReadFrom.Configuration.
In Production
Keep all packages of the Microsoft.Extensions.Telemetry family on the same version. I built every example in this post with a single version (10.11.0); because API names can change in the monthly releases, building the app and the test project with different versions means code that compiles cleanly in one can warn in the other. Update these packages as one group with a tool such as Dependabot or Renovate.
Read the build warnings after a package update. Obsolete warnings like EXTOBS0002 are a real signal in this family: the method will be removed in a future major version. In a project with TreatWarningsAsErrors on, the warning breaks the build; with it off, warnings pile up silently. You want neither.
Think of sampling and buffering in terms of the package version. Even if your project targets .NET 10, these features aren't there if Directory.Packages.props pins Telemetry at 9.2. When asking whether a feature is available, look at the package version first.
I don't expect a breaking change on the logging side when moving to .NET 11; generic LoggerMessage is an additive feature. But since the built-in OpenTelemetry attributes are on by default, check once after the upgrade how span attributes look in a project that still uses OpenTelemetry.Instrumentation.AspNetCore.
Common Pitfalls
First, adding the enricher and forgetting the metadata. As you saw, service.name is written as an empty string without any error. If logs from several services land in the same place, records with an empty service name don't match any filter.
Second, editing two logging configurations at once in an old project. A teammate who changes Logging:LogLevel in appsettings.json while Serilog is set up sees no change at all and reaches the wrong conclusion that "configuration reload doesn't work".
Third, trusting the API name in a blog post. Details such as AddServiceLogEnricher in .NET 8-era examples and the location of the sampler configuration on Microsoft Learn have changed with the package version. When you copy an example, build it with your own package version and look at the output once.
Fourth, comparing the formatted message text in a FakeLogger test. Changing one word in the template breaks every test. EventId, level and structured properties are a sturdier contract.
A Small Experiment
★☆☆ Remove the UseApplicationMetadata() line from the shipping service and run the app with ASPNETCORE_ENVIRONMENT=Staging. What does deployment.environment say? What changes when you add the line back?
★★☆ Add a test to ShipmentServiceTests that verifies a new Warning-level event is written when parcelCount is zero. Write the test first, see it go red, then add the event with [LoggerMessage].
★★★ Move the old Startup project to WebApplication.CreateBuilder and builder.Services.AddSerilog, and move the levels into appsettings.json with ReadFrom.Configuration. Compare the number of console lines for the same request before and after the move.
With this post we've finished the "Logging in .NET" series. We started with deciding what to log; we covered writing logs cheaply, Serilog and its sinks, querying in Seq, correlation, sensitive data, HTTP logging, and measured the cost. The natural next step is seeing logs in the same picture as metrics and traces: we'll take on the OpenTelemetry Collector, Grafana and alerting in the upcoming DevOps › Observability series.