Skip to content

Instantly share code, notes, and snippets.

@boarnoah
Created October 28, 2025 17:56
Show Gist options
  • Select an option

  • Save boarnoah/7413168d37e8d8558ccf6a4979e7463e to your computer and use it in GitHub Desktop.

Select an option

Save boarnoah/7413168d37e8d8558ccf6a4979e7463e to your computer and use it in GitHub Desktop.
Experimenting with using Log
#:sdk Microsoft.NET.Sdk.Worker
#:package Microsoft.Extensions.Hosting@9.0.10
#:package Microsoft.Extensions.Telemetry@9.10.0
// https://github.com/dotnet/runtime/issues/35995
// https://learn.microsoft.com/en-us/dotnet/core/enrichment/custom-enricher
using System.Text.Json;
using Microsoft.Extensions.Diagnostics.Enrichment;
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddScoped<MyScopedService>();
// Adds a service, very similar to HttpContextAccessor, in this case to hold tenant context info
builder.Services.AddSingleton<TenantContextAccessor>();
builder.Services.AddLogEnricher<TenantLogEnricher>();
builder.Logging.AddJsonConsole(
options => options.JsonWriterOptions = new JsonWriterOptions()
{
Indented = true
}
);
builder.Logging.EnableEnrichment();
builder.Services.AddHostedService<Worker>();
var host = builder.Build();
host.Run();
public class Worker(
ILogger<Worker> logger,
IHostApplicationLifetime appLifeTime,
TenantContextAccessor tenantContextAccessor,
IServiceScopeFactory scopeFactory
) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var tenants = new List<string>{ "TenantA", "TenantB"};
var i = 0;
await Parallel.ForEachAsync(tenants, stoppingToken, async (tenant, token) =>
{
var tenantLetter = tenant.Last().ToString();
logger.LogInformation("Starting processing for a tenant {TenantLetter} in separate thread", tenantLetter);
// ILogEnrichers have to be registered as singletons to work -> which in turn restricts us to only inject
// other singletons to them, hence the need for this HttpContextAccessor-like pattern
tenantContextAccessor.TenantContext = new TenantContextObject()
{
Tenant = tenant
};
using var scope = scopeFactory.CreateScope();
var myService = scope.ServiceProvider.GetRequiredService<MyScopedService>();
myService.DoTheThing(tenantLetter);
});
logger.LogInformation("All done");
appLifeTime.StopApplication();
}
}
public class MyScopedService(
ILogger<MyScopedService> logger
) {
public void DoTheThing(string i){
logger.LogInformation("Done the thing: {thing}", i);
}
}
internal class TenantLogEnricher(TenantContextAccessor tenantContext) : ILogEnricher
{
public void Enrich(IEnrichmentTagCollector collector)
{
if (!string.IsNullOrEmpty(tenantContext.TenantContext?.Tenant))
{
collector.Add("Tenant", tenantContext.TenantContext.Tenant);
}
}
}
// Ripped from https://github.com/dotnet/aspnetcore/blob/5dde6e4691f73763cc31ce3934e6a37fd9707188/src/Http/Http/src/HttpContextAccessor.cs
public class TenantContextAccessor
{
private static readonly AsyncLocal<TenantContextHolder> _tenantContextCurrent = new();
public TenantContextObject? TenantContext
{
get
{
return _tenantContextCurrent.Value?.Context;
}
set
{
if (_tenantContextCurrent.Value is not null)
{
_tenantContextCurrent.Value.Context = null;
}
if (value != null)
{
// Use an object indirection to hold the HttpContext in the AsyncLocal,
// so it can be cleared in all ExecutionContexts when its cleared.
_tenantContextCurrent.Value = new TenantContextHolder { Context = value };
}
}
}
private sealed class TenantContextHolder
{
public TenantContextObject? Context;
}
}
public class TenantContextObject
{
public string Tenant { get; set; }
}
@boarnoah
Copy link
Author

You get nice structured logs with your properties like:

{
  "EventId": 0,
  "LogLevel": "Information",
  "Category": "ILoggerEnrichmentTest.MyScopedService",
  "Message": "Done the thing: C",
  "State": {
    "Tenant": "TenantC",
    "thing": "C",
    "{OriginalFormat}": "Done the thing: {thing}"
  }
}
{
  "EventId": 0,
  "LogLevel": "Information",
  "Category": "ILoggerEnrichmentTest.Worker",
  "Message": "All done",
  "State": {
    "{OriginalFormat}": "All done"
  }
}

A much better state than how something similar with scopes look:

{
  "EventId": 0,
  "LogLevel": "Information",
  "Category": "ILoggerScopeTest.MyScopedService",
  "Message": "Done the thing: C",
  "State": {
    "Message": "Done the thing: C",
    "thing": "C",
    "{OriginalFormat}": "Done the thing: {thing}"
  },
  "Scopes": [
    {
      "Message": "System.Collections.Generic.Dictionary\u00602[System.String,System.Object]",
      "TenantId": "TenantC"
    }
  ]
}
{
  "EventId": 0,
  "LogLevel": "Information",
  "Category": "ILoggerScopeTest.Worker",
  "Message": "All done",
  "State": {
    "Message": "All done",
    "{OriginalFormat}": "All done"
  },
  "Scopes": []
}

@boarnoah
Copy link
Author

As mentioned here: dotnet/runtime#35995 (comment) turns out its not a good idea after all.

Log enrichment is tied to telemetry sampling decisions, so you can't use it to enrich all your log entries in application if you are doing any kind of telemetry sampling.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment