Configure Serilog for structured .NET application logs

Use Serilog message templates, sinks, scoped context, compact JSON, and ASP.NET Core integration, then verify buffering, shutdown, and collection in Guance.

Best practices
Illustration of logs flowing through a collection pipeline

Serilog records structured properties alongside a message template, so a value such as OrderId can remain a field instead of disappearing into a formatted string. Sinks choose the output destination, enrichers add context, and formatters determine how that event is serialized.

This guide covers the logger configuration, file rotation, request context, JSON output, and ASP.NET Core integration. The final step is to verify what your collector actually receives, including level defaults and exception fields.

Configure a logger and its destinations

For a console application, add the packages used by this first example:

dotnet add package Serilog
dotnet add package Serilog.Sinks.Console
dotnet add package Serilog.Sinks.File

Choose package versions compatible with the application's target framework and lock them through your normal dependency-management process.

using Serilog;

using var log = new LoggerConfiguration()
    .MinimumLevel.Debug()
    .WriteTo.Console()
    .WriteTo.File("logs/app.log")
    .CreateLogger();

log.Verbose("Verbose details"); // Suppressed by the Debug threshold.
log.Debug("Debug details");
log.Information("Application started");
log.Warning("Configuration missing; using default");
log.Error("Database connection failed");
log.Fatal("Application cannot continue");

The levels increase in severity from Verbose, Debug, Information, and Warning to Error and Fatal. The default minimum is Information; this example explicitly lowers it to Debug. Calling Fatal records an event; it does not terminate the application.

The using declaration disposes this locally owned logger when its scope ends. It is distinct from the global Log.Logger, whose lifecycle is managed through the static logging API or the application's host integration.

Preserve values as properties

Use a stable message template with separately supplied values:

log.Information("User {UserId} signed in from {Ip}", user.Id, request.Ip);
log.Information("Order received {@Order}", order);

These are application fragments: user, request, and order are your objects. {UserId} names a property in the event. The @ operator requests structured capture of an object rather than its ordinary string representation. Avoid eager string interpolation when you want these values retained as queryable properties.

Do not destructure an entire user or payment entity by default. Project it into a small object containing approved fields. Exception messages and enrichers can expose sensitive values too; a destructuring policy alone does not sanitize every part of an event.

With a rendered compact JSON formatter, the selected fields from an event can resemble:

{"@t":"2026-08-25T14:50:22.123Z","@m":"User 42 signed in from 10.0.0.8","UserId":42,"Ip":"10.0.0.8"}

This excerpt omits other formatter metadata. UserId stays numeric and separate from @m.

Choose file retention and queue behavior deliberately

This fragment belongs in a LoggerConfiguration chain:

.WriteTo.File(
    "logs/app.log",
    rollingInterval: RollingInterval.Day,
    fileSizeLimitBytes: 100 * 1024 * 1024,
    rollOnFileSizeLimit: true,
    retainedFileCountLimit: 30)

The file sink can roll by time and size. retainedFileCountLimit: 30 retains a count of files, not necessarily 30 days. With several size rollovers in one day, 30 files can cover much less time. Verify the generated names, deletion policy, and collector behavior at rollover. See the Serilog file sink.

To move a sink's work to a background thread, install Serilog.Sinks.Async and wrap it:

.WriteTo.Async(a => a.File("logs/async.log"))

This adds an in-memory queue; it does not create unlimited capacity or durable delivery. The async sink's default bounded buffer drops new events when full. Its blockWhenFull option changes that trade-off by blocking the caller. Monitor dropped events and test sustained overload, rather than measuring only short bursts. See the async sink's buffering behavior.

Add stable and scoped context

Enrichers can attach machine, process, or thread identifiers:

.Enrich.WithMachineName()
.Enrich.WithProcessId()
.Enrich.WithThreadId()
.Enrich.FromLogContext()

The first three require their corresponding packages: Serilog.Enrichers.Environment, Serilog.Enrichers.Process, and Serilog.Enrichers.Thread. Include only identifiers that help your queries; a thread ID is not a request ID in asynchronous code.

FromLogContext() enables properties pushed into Serilog's contextual scope:

using Serilog.Context;

using (LogContext.PushProperty("RequestId", requestId))
{
    log.Information("Order processing started");
    // Perform the operation within this scope.
}

Here requestId is supplied by the application. Dispose nested context scopes in reverse order, and make sure work that needs the context executes within the intended scope. Context propagation and logger configuration should be tested across the asynchronous boundaries used by the application.

Pick a JSON formatter and inspect its output

Serilog includes JsonFormatter. The compact formatters require Serilog.Formatting.Compact:

.WriteTo.Console(new Serilog.Formatting.Json.JsonFormatter())

Or, as an alternative console sink:

.WriteTo.Console(
    new Serilog.Formatting.Compact.RenderedCompactJsonFormatter())

Choose one output path for each intended destination; adding both writes two copies of each event to the same console. RenderedCompactJsonFormatter emits the rendered message as @m; CompactJsonFormatter preserves the message template as @mt instead. They have different downstream processing requirements.

In the compact format, @t is the timestamp, @l carries a non-default level, and @x carries exception information when present. An omitted @l means Information, not an unknown severity. Configure the collector accordingly. The compact-format specification describes the fields and their defaults.

Integrate with ASP.NET Core explicitly

Install Serilog.AspNetCore and Serilog.Formatting.Compact. Use an integration package major version appropriate for your ASP.NET Core target, and check the package's compatibility guidance.

A modern minimal-host application can register Serilog through services:

using Serilog;
using Serilog.Formatting.Compact;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSerilog((services, configuration) => configuration
    .ReadFrom.Configuration(builder.Configuration)
    .ReadFrom.Services(services)
    .Enrich.FromLogContext()
    .WriteTo.Console(new RenderedCompactJsonFormatter()));

var app = builder.Build();
app.UseSerilogRequestLogging();
app.MapGet("/", () => "Service is running");
app.Run();

ReadFrom.Configuration needs Serilog.Settings.Configuration in the resolved dependencies. If configuration also declares the same console sink under WriteTo, remove one declaration to avoid duplicate output.

Existing applications may use the host-builder integration instead:

builder.Host.UseSerilog((context, configuration) => configuration
    .ReadFrom.Configuration(context.Configuration)
    .Enrich.FromLogContext()
    .WriteTo.Console(new RenderedCompactJsonFormatter()));

Use one registration approach appropriate to your integration version. Host registration does not automatically install request-logging middleware: call app.UseSerilogRequestLogging() explicitly. Place it before the handlers you want to time. Work earlier in the middleware pipeline is outside its measurement scope. See Serilog's ASP.NET Core integration.

Application services can continue to receive Microsoft.Extensions.Logging.ILogger<T> through dependency injection. Serilog supplies the underlying implementation; ordinary application code need not depend on the static Log API.

Flush during graceful shutdown

For code that owns the static logger, a typical shutdown pattern is:

try
{
    Log.Information("Application started");
    // Run the application's work here.
}
finally
{
    Log.CloseAndFlush();
}

Initialize Log.Logger before this fragment. For a separately created logger such as the first example, dispose that logger instead. When the host integration owns the logger, use its normal shutdown/disposal lifecycle and check the ownership options you configured.

A finally block helps during graceful exit and ordinary exception unwinding. It cannot run after every process crash, forceful termination, or machine failure. An in-memory queue can still lose events, and flushing a sink is not by itself proof that a remote backend durably stored them. Test shutdown and delivery requirements for your actual sinks.

Validate compact JSON in Guance

Collect stdout or a managed file using the DataKit log collector. Feed Pipeline representative events at Information and Error, plus an event with an exception and request context.

Check the mapping of @t, @m, @l, and business properties. In particular, supply the correct severity when the compact formatter omits the informational level. Confirm that exceptions remain searchable and that numeric properties retain their types.

A RequestId can group one request's logs. For application performance monitoring (APM) correlation, emit the actual active trace identifier in the field expected by your integration and compare it with the stored trace. A generated request identifier should not be relabeled trace_id.

Build an error-count detection rule only after a known error appears under the expected severity and service. Correct JSON syntax is just the first part of that verification.

Common questions

Should I use static Log.Logger or injected ILogger?

In ASP.NET Core services, dependency-injected ILogger<T> keeps the logging dependency visible and fits the host's configuration. Static Log.Logger remains useful in entry-point code and programs without dependency injection. Make logger ownership explicit when using both.

Why do events disappear on exit?

Buffered events may not have reached their destination. Verify graceful disposal and flushing, queue saturation, sink errors, and process termination behavior. No shutdown callback can recover an event that remained in memory after an abrupt failure.

Can appsettings.json change levels without restarting?

ReadFrom.Configuration can react to supported minimum-level settings when the underlying configuration provider reloads changes. This is not arbitrary hot reconfiguration of every sink or setting; adding a new override is different from changing an existing one. Check the configuration package's reload behavior, and verify how your deployed configuration provider handles updates. Changing an environment variable outside a running process does not update that process automatically.

How do I prevent sensitive fields from being logged?

Start with a small allowlisted data object instead of destructuring a full entity. Use a custom IDestructuringPolicy where appropriate, then inspect the serialized output, including context and exception text. Test the paths that actually carry the sensitive values.

Related reading

Get a tailored plan

Contact us

Join the community

Scan with WeChat
to join the community

Try Guance

Start online and pay only for what you use.

Get started

Choose a Guance plan

Code hosting