Logging & tracing¶
Targets: net10.0, csharp-14 · Last reviewed: 2026-08-18 · Sources: ms-learn, nicholas-blumhardt
One structured pipeline, exported over OTLP. Logs and traces are the same telemetry stream, not two systems.
Conflict of interest: the Serilog-specific guidance below cites Nicholas Blumhardt, who authors Serilog and SerilogTracing and sells Seq (Datalust). The recommendations point at the OSS libraries and the
ActivityAPIs in the BCL; no product is being recommended, and the fallback example deliberately uses an OTLP endpoint rather than Seq.
Opinions¶
- Log through
ILogger/ILogger<T>and declare every event as a source-generated[LoggerMessage]partial method. The generator eliminates boxing, temporary allocations, and thestring.Formatwork thatlogger.LogInformation($"...")incurs even when the level is disabled, and it preserves the message template so structured sinks get named properties instead of a flattened string. It also beats hand-writtenLoggerMessage.Definecalls: no six-parameter ceiling, dynamic level support, and compile-time diagnostics for duplicate event IDs. (Microsoft Learn: Compile-time logging source generation)
public partial class OrderService(ILogger<OrderService> logger)
{
[LoggerMessage(EventId = 100, Level = LogLevel.Information,
Message = "Order {OrderId} accepted for {CustomerId}")]
public partial void OrderAccepted(Guid orderId, string customerId);
}
Never interpolate into a log call: LogInformation($"Order {orderId} accepted") throws away the structure and formats the string whether or not anything is listening.
-
Emit spans with
ActivitySource.StartActivity, not with log lines that record a duration.Activityis the BCL's span type, ASP.NET Core andSystem.Net.Httpalready create and propagate W3Ctraceparentcontext with no code from you, and one instrumented tree gives you both the timing and the parent/child structure that stopwatch logging cannot. (Microsoft Learn: Distributed tracing concepts) For work that crosses a process or time boundary, propagate context explicitly, as aspnet-core.md describes. -
Control tracing cost in the
ActivityListener.Samplecallback, not by filtering at the sink. Sampling is a creation-time decision: the listener can decline the activity outright, create an ID-only activity that still propagates trace context, or populate it fully. A recorded activity costs roughly a microsecond; one rejected at sampling costs under 100ns. Dropping spans at the exporter pays the full construction cost first and breaks trace trees, because children have no way to know their parent was discarded. (Microsoft Learn: Distributed tracing concepts, Blumhardt: .NET'sActivityListenersampling API) -
Export over OTLP to a collector; keep vendor SDKs out of application code.
Microsoft.Extensions.Loggingplus the OpenTelemetry exporter is the default pipeline. It has one wire format, and the backend becomes a deployment concern rather than a code dependency. Serilog is the alternative worth taking when you want message-template logging with its sink ecosystem, andSerilogTracingthen feedsActivityspans through the same Serilog pipeline so traces and logs share enrichers and sinks; it loses the default only because it adds a second configuration surface next to the oneILoggeralready gives you. (Blumhardt: SerilogTracing) -
Give the log pipeline a durable local fallback, and expect it to lag. A network collector will be unavailable at some point, and that is exactly when the logs matter. Chain a rolling file behind the network destination so nothing is lost. Budget for the delay: network sinks retry with backoff before reporting failure, so the fallback typically starts receiving events around ten minutes into an outage, not immediately.
BatchingOptions.RetryTimeLimitis the knob if that window is wrong for you. (Blumhardt: Serilog fallback sinks, Blumhardt: Visualizing the Serilog 4.1 batch retry algorithm)
Log.Logger = new LoggerConfiguration()
.WriteTo.FallbackChain(
wt => wt.OpenTelemetry(endpoint: "https://otlp.example/v1/logs"),
wt => wt.File("logs/app-.txt", rollingInterval: RollingInterval.Day))
.CreateLogger();
Run a collector on localhost and let it own the durable queue: the default pipeline has no equivalent of the chain above. AddOtlpExporter batches in memory and has no on-disk queue to replay, so a failed export or a killed process loses whatever was in flight. Exporting to a collector on the loopback turns the app's export into a call that succeeds even while the backend is down. Retry and disk buffering then belong to the one component built for them, and are configured there, not in the app. Taking Serilog for the logging pipeline and using the chain above is the other way. Doing neither is defensible for a service whose logs are diagnostic rather than an audit trail, as long as that is decided rather than inherited. (Microsoft Learn: .NET observability with OpenTelemetry)
Levels¶
Informationis for events an operator would want in production;Debugis for developers. Set the default minimum level toInformationand raise noisy framework categories such asMicrosoft.AspNetCoretoWarningin configuration rather than deleting the log calls. (Microsoft Learn: Logging in .NET and ASP.NET Core)- Log an exception as the
exceptionargument, never in the message. The source generator treats the firstExceptionparameter specially and structured sinks record the type, message, and stack trace as separate fields. (Microsoft Learn: Compile-time logging source generation) - Redact classified data in the pipeline, not at the call site.
Microsoft.Extensions.Compliance.Redactionselects redactors by data classification, so a parameter annotated with your taxonomy's classification is masked everywhere it is logged, onceEnableRedaction()is on the logging builder. A call-site?? "***"is one refactor away from leaking. (Microsoft Learn: Compile-time logging source generation)