We have been using the ILogger interface provided by .NET Core, but do you know how to leverage BeginScope for our benefit? Let’s explore how BeginScope helps us harness log scoping effectively.
Let’s examine a sample transaction API in a minimal API project and see how one would typically write logs as the transaction flows through.
app.MapGet("/transaction/{id}", async (int id, [FromServices] ILogger<Program> logger) =>
{
logger.LogInformation("Fetching transaction with id: {id}", id);
var transaction = await GetTransactionAsync(id);
if (transaction is null)
logger.LogInformation("No transaction found for transactionId: {id}", id);
logger.LogInformation("retrieved the transaction {transactionData}", JsonSerializer.Serialize(transaction));
return transaction;
}).WithName("Transactions By Id").WithOpenApi();
Once you hit the transaction API with an ID of 1, we should see the following logged to the console:
info: Program[0]
Fetching transaction with id: 1
info: Program[0]
retrieved the transaction {"transactionId":1,"productName":"Monitor"}
It works great and looks polished, but only until you realize that the transaction ID isn’t printed on every log statement. This makes it challenging to track down specific entries when sifting through voluminous logs.
To include the transaction ID in each log, we could prepend it manually. Here’s an adjusted version:
app.MapGet("/transaction/{id}", async (int id, [FromServices] ILogger<Program> logger) =>
{
logger.LogInformation("transactionId: {id}, Fetching transaction", id);
var transaction = await GetTransactionAsync(id);
if (transaction is null)
logger.LogInformation("transactionId: {id}, No transaction found", id);
logger.LogInformation("transactionId: {id}, retrieved the transaction {transactionData}", id, JsonSerializer.Serialize(transaction));
return transaction;
}).WithName("Transactions By Id").WithOpenApi();
info: Program[0]
transactionId: 1, Fetching transaction
info: Program[0]
transactionId: 1, retrieved the transaction {"transactionId":1,"productName":"Keyboard"}
The logs look great now! Except we’ve spilled transactionId: {id} across every log line. We could tolerate this (for now), but imagine adding another parameter we want to track alongside the transaction ID. It quickly becomes cumbersome. You could still manually insert it into every statement (a solid exercise if you’re a Vim enthusiast). But there’s a smarter path forward.
Jumping into BeginScope in ILogger
How to Configure BeginScope for Logging
Instead of the plain AddConsole logger setup:
builder.Services.AddConsole();
Substitute it with the following for .NET 5+:
logging.AddSimpleConsole(options => options.IncludeScopes = true);
For older .NET versions:
builder.Logging.AddConsole(options =>
{
options.IncludeScopes = true;
});
BeginScope in Action
Let’s see how BeginScope eliminates the clutter in our ILogger. Here’s the transaction API refactored with BeginScope:
app.MapGet("/transaction/{id}", async (int id, [FromServices] ILogger<Program> logger) =>
{
using var _ = logger.BeginScope("Transaction ID: {id}", id);
logger.LogInformation("Fetching transaction");
var transaction = await GetTransactionAsync(id);
if (transaction is null)
logger.LogInformation("No transaction found");
logger.LogInformation("transaction data: {data}",
JsonSerializer.Serialize(transaction));
return transaction;
}).WithName("Transactions By Id").WithOpenApi();
And the resulting output:
info: Program[0]
=> SpanId:ab61b7bd5164a38a, TraceId:25d595f392edb46bdf8b441024c344a3, ParentId:0000000000000000 => ConnectionId:0HNI3AMCJIJRB => RequestPath:/transaction/1 RequestId:0HNI3AMCJIJRB:00000001 => Transaction ID: 1
Fetching transaction
info: Program[0]
=> SpanId:ab61b7bd5164a38a, TraceId:25d595f392edb46bdf8b441024c344a3, ParentId:0000000000000000 => ConnectionId:0HNI3AMCJIJRB => RequestPath:/transaction/1 RequestId:0HNI3AMCJIJRB:00000001 => Transaction ID: 1
transaction data: {"transactionId":1,"productName":"Mouse"}
We invoked a straightforward .BeginScope on the logger, supplying the scope details we need. Our logging codeโespecially the LogInformation linesโnow appears clean and uncluttered.
The log output is a touch verbose, as BeginScope appends TraceId, SpanId, and other diagnostics. Yet, our Transaction ID: 1 integrates seamlessly into every message, trailing the core information we intended to capture.
One caveat: There’s no built-in way to customize this output directly from BeginScope. If precision demands it, crafting a custom logger provider might be your next stepโthough that’s a deeper dive for another post.
Adding Multiple Parameters to BeginScope
To incorporate multiple parameters into the scope, consider this approach:
logger.BeginScope("Beginning transaction processing {transactionScope}", new Dictionary<string, string?>()
{
{ "id", id.ToString() },
{ "traceId", Activity.Current?.TraceId.ToString() }
});
The BeginScope method accepts params object?[] args for formatting, so it accommodates various typesโlike anonymous objectsโfor describing scope parameters.
Running the above yields something like:
info: Program[0] => SpanId:6ea3200fc733fe67, TraceId:4822d87a4abee6795d7df898f3170cf5, ParentId:0000000000000000 => ConnectionId:0HNI44OQ4G3N7 => RequestPath:/transaction/2 RequestId:0HNI44OQ4G3N7:00000001 => Beginning transaction processing [id, 2], [traceId, 4822d87a4abee6795d7df898f3170cf5] Fetching transaction
info: Program[0] => SpanId:6ea3200fc733fe67, TraceId:4822d87a4abee6795d7df898f3170cf5, ParentId:0000000000000000 => ConnectionId:0HNI44OQ4G3N7 => RequestPath:/transaction/2 RequestId:0HNI44OQ4G3N7:00000001 => Beginning transaction processing [id, 2], [traceId, 4822d87a4abee6795d7df898f3170cf5] transaction data: {"transactionId":2,"productName":"Monitor"}
As evident, it renders the dictionary contents. But notice the Beginning transaction processing prefix echoes everywhere. To streamline, drop it and feed just the dictionary:
logger.BeginScope(new Dictionary<string, object>()
{
{ "id", id.ToString()},
{ "traceId", Activity.Current?.TraceId.ToString()}
});
The output shifts to:
info: Program[0] => SpanId:02c02a66fde1d78d, TraceId:1f8cf2198eec4dc05f37f5020b9c3d4c, ParentId:0000000000000000 => ConnectionId:0HNI44OQ4G3N9 => RequestPath:/transaction/2 RequestId:0HNI44OQ4G3N9:00000001 => System.Collections.Generic.Dictionary`2[System.String,System.Object] Fetching transaction
info: Program[0] => SpanId:02c02a66fde1d78d, TraceId:1f8cf2198eec4dc05f37f5020b9c3d4c, ParentId:0000000000000000 => ConnectionId:0HNI44OQ4G3N9 => RequestPath:/transaction/2 RequestId:0HNI44OQ4G3N9:00000001 => System.Collections.Generic.Dictionary`2[System.String,System.Object] transaction data: {"transactionId":2,"productName":"Keyboard"}
Observe that neither the ID nor TraceId surfaces explicitly. This stems from the console formatter’s handling of the dictionary, which defaults to its type name. It’s standard behavior hereโfunctional, yet not visually revealing.
However, with sinks like Serilog, this flows beautifully into structured outputs.
Testing BeginScope with Serilog File Logger
I’ve validated this using Serilog’s file logger. Here’s the setup in Program.cs:
builder.Host.UseSerilog((ctx, lc) =>
{
lc
// REQUIRED for BeginScope
.Enrich.FromLogContext()
// Optional but useful
.Enrich.WithProperty("Application", "OptionsPatternProj")
// File sink
.WriteTo.File(
path: "logs/logs.json",
rollingInterval: RollingInterval.Day,
formatter: new Serilog.Formatting.Json.JsonFormatter()
)
.MinimumLevel.Information();
});
No alterations to the transaction API codeโBeginScope remains unchanged. The output in the JSON log file appears as:
{"Timestamp":"2025-12-27T07:37:29.8425180+05:30","Level":"Information","MessageTemplate":"Fetching transaction","TraceId":"f122cb7528d44fccac1ea39769736856","SpanId":"0e4f3a038d288e41","Properties":{"SourceContext":"Program","id":"2","traceId":"f122cb7528d44fccac1ea39769736856","RequestId":"0HNI4TILNF711:00000001","RequestPath":"/transaction/2","ConnectionId":"0HNI4TILNF711","Application":"OptionsPatternProj"}}
{"Timestamp":"2025-12-27T07:37:30.8496400+05:30","Level":"Information","MessageTemplate":"transaction data: {data}","TraceId":"f122cb7528d44fccac1ea39769736856","SpanId":"0e4f3a038d288e41","Properties":{"data":"{\"transactionId\":2,\"productName\":\"Keyboard\"}","SourceContext":"Program","id":"2","traceId":"f122cb7528d44fccac1ea39769736856","RequestId":"0HNI4TILNF711:00000001","RequestPath":"/transaction/2","ConnectionId":"0HNI4TILNF711","Application":"OptionsPatternProj"}}
Here, the traceId and id nest neatly within the Properties object, ready for querying and analysis in your logging ecosystem.


Leave a Reply