BrandGhost
OpenTelemetry Logging .NET: ILogger Integration and Structured Log Export

OpenTelemetry Logging .NET: ILogger Integration and Structured Log Export

If you have been building .NET applications for any meaningful length of time, you have probably hit a wall where plain text log files stopped being enough. OpenTelemetry logging .NET integration changes that picture significantly. Instead of isolated log files, you get structured log records that flow through the same telemetry pipeline as your traces and metrics -- and they can be correlated back to individual requests automatically. That is a meaningful shift in how you can debug production issues.

This article walks through everything you need to wire up OpenTelemetry logging .NET. It covers how ILogger integrates out of the box, how to configure exporters, how trace context ends up in your logs automatically, and how to think about the tradeoffs compared to Serilog. By the end, you will have a clear picture of what the setup looks like and when to reach for each approach.

If you are not yet familiar with the .NET logging ecosystem broadly, the Logging in .NET Complete Guide is a solid foundation before diving into the OpenTelemetry-specific layer.

If you're looking for the full picture, check out the OpenTelemetry in .NET: Complete Observability Guide.

OpenTelemetry Logging .NET: The Third Pillar of Observability

Observability is typically described in terms of three signals: traces, metrics, and logs. Each one answers a different question, and you lose something meaningful when any one of them is missing or disconnected from the others.

Traces tell you what happened -- the sequence of operations that made up a request, with timing information for each step. Metrics tell you at what scale -- how many requests per second, where p99 latency sits, how much heap you are consuming. Logs tell you the specific details -- what the actual values were, what the error message said, what state the system was in at a precise moment in time.

Those three signals become dramatically more powerful when they are correlated. If you can look at a slow trace and jump directly to the log lines emitted during that specific request, you cut debugging time significantly. If your logs carry structured fields that were set by your business logic, you can filter and query them like a database rather than searching through strings.

That is exactly what OpenTelemetry logging .NET enables when configured correctly. Logs automatically pick up the traceId and spanId from the currently active span, so you can pivot between signals in backends like Grafana, Seq, or Azure Monitor. Without this correlation, you are still playing a guessing game about which log line belongs to which request.

The other benefit of routing logs through OpenTelemetry is consistency. Your service name, version, and environment are part of the resource that is shared across traces, metrics, and logs. Backend tools can filter by service.name and get correlated data from all three signal types without any extra configuration.

How ILogger Integrates with OpenTelemetry Logging .NET

One of the genuinely pleasant things about the OpenTelemetry logging approach in .NET is how little you need to change in your existing application code. The ILogger<T> interface is already a first-class abstraction in .NET, and the OpenTelemetry SDK hooks into it at the provider level -- not at the call site.

In practice, this means you keep writing _logger.LogInformation(...) exactly as you do today. The OpenTelemetry logging provider captures those events and converts them into LogRecord objects. Those records are then passed to whatever exporters you have configured -- console, OTLP, or both. There is no new logging API to learn, no special attributes to add to your existing log calls.

The provider model is what makes this work. .NET's logging infrastructure (Microsoft.Extensions.Logging) is built around providers. Each provider receives all log events that pass the configured filters and can export them however it wants. The OpenTelemetry provider is just another entry in that list, alongside the console provider, debug provider, or any Serilog integration you might have.

This is meaningfully different from how Serilog works. Serilog typically replaces the Microsoft.Extensions.Logging pipeline at the sink level or becomes the primary logger. OpenTelemetry's approach sits alongside the standard infrastructure, extending it rather than replacing it. For a detailed comparison of those two approaches and when each makes sense, the Serilog vs Microsoft.Extensions.Logging guide covers the tradeoffs well.

Setting Up OpenTelemetry Logging .NET

The entry point is builder.Logging.AddOpenTelemetry() in your Program.cs. This registers the OpenTelemetry log provider with the .NET logging infrastructure. From there you chain exporter and configuration calls.

using OpenTelemetry.Logs;
using OpenTelemetry.Resources;

var builder = WebApplication.CreateBuilder(args);

builder.Logging.AddOpenTelemetry(logging =>
{
    logging.IncludeFormattedMessage = true;
    logging.IncludeScopes = true;
    logging.ParseStateValues = true;

    logging.AddConsoleExporter(); // good for local dev
    // In production, replace with:
    // logging.AddOtlpExporter();
});

builder.Services.AddOpenTelemetry()
    .ConfigureResource(resource => resource
        .AddService("MyApp", serviceVersion: "1.0.0"))
    .WithTracing(tracing => tracing
        .AddAspNetCoreInstrumentation());
        // Traces and logs share the same resource configuration

A few things worth unpacking here. First, IncludeFormattedMessage = true tells the SDK to store the fully formatted log string in the LogRecord, not just the raw structured properties. By default only the structured properties are captured -- which is usually what you want when querying logs programmatically -- but having the formatted message available is helpful when reading logs in human-friendly viewers.

Second, the resource configuration (ConfigureResource) is shared between tracing and logging. Your service.name and service.version attributes will appear on both trace spans and log records. This is a key design point: you define the service identity once and it flows to all signals automatically.

Third, the console exporter uses SimpleLogRecordExportProcessor under the hood, which exports synchronously per log record. This is fine for development. For production, you will want the OTLP exporter, which defaults to BatchLogRecordExportProcessor -- more on that shortly.

Automatic Trace Context Correlation in Logs

This is where OpenTelemetry logging .NET earns its keep in production debugging. When you emit a log message inside an active Activity (which maps to an OpenTelemetry span), the SDK automatically captures the TraceId and SpanId from Activity.Current and includes them in the log record.

You do not write any extra code for this. It just happens.

using System.Diagnostics;
using Microsoft.Extensions.Logging;

namespace MyApp.Services;

public class PaymentService
{
    private static readonly ActivitySource _activitySource =
        new("MyApp.Payments");
    private readonly ILogger<PaymentService> _logger;
    private readonly IPaymentGateway _paymentGateway;

    public PaymentService(ILogger<PaymentService> logger, IPaymentGateway paymentGateway)
    {
        _logger = logger;
        _paymentGateway = paymentGateway;
    }

    public async Task<PaymentResult> ProcessAsync(PaymentRequest request)
    {
        using var activity = _activitySource.StartActivity("ProcessPayment");
        activity?.SetTag("payment.method", request.Method);

        // This log will automatically include traceId and spanId
        // because Activity.Current is set
        _logger.LogInformation(
            "Processing payment {PaymentId} with method {Method}",
            request.PaymentId,
            request.Method);

        var result = await _paymentGateway.ChargeAsync(request);

        if (!result.Success)
        {
            // Error log also gets trace context automatically
            _logger.LogWarning(
                "Payment {PaymentId} failed: {Reason}",
                request.PaymentId,
                result.FailureReason);

            activity?.SetStatus(ActivityStatusCode.Error, result.FailureReason);
        }

        return result;
    }
}

When you look at the LogRecord produced by the warning log, it will contain TraceId and SpanId fields matching the active ProcessPayment span. In Grafana Loki or Seq, you can use those fields to pivot directly from a log entry to the corresponding trace in Grafana Tempo or Jaeger. This cross-signal navigation is one of the most practically useful capabilities in a properly configured observability setup.

The upside for production debugging is substantial. You are no longer hunting through log files trying to figure out which request produced which error. You find the failing trace, look at the log lines that occurred within that span, and immediately see what values were in play. The feedback loop tightens considerably.

Log Categories, Filtering, and Scopes

OpenTelemetry logging in .NET respects the standard ILogger filtering configuration. You can use the same Logging section in appsettings.json that you use for any other logging provider -- just with "OpenTelemetry" as the provider key.

{
  "Logging": {
    "OpenTelemetry": {
      "LogLevel": {
        "Default": "Information",
        "Microsoft": "Warning",
        "Microsoft.AspNetCore": "Warning",
        "MyApp": "Debug"
      }
    }
  }
}
// Using scopes to add structured context to a batch of log messages
using (_logger.BeginScope(new Dictionary<string, object>
{
    ["OrderId"] = order.Id,
    ["CustomerId"] = order.CustomerId
}))
{
    _logger.LogInformation("Starting order processing");
    await ProcessStepsAsync(order);
    _logger.LogInformation("Order processing complete");
}
// Both log messages will include OrderId and CustomerId as structured fields

The category filtering is particularly valuable for keeping log volume under control in production. Framework-level logs from Microsoft.* namespaces can be extremely noisy. Bumping those to Warning while keeping your own application code at Information or Debug is a straightforward way to reduce noise without losing the signal you actually care about.

Scopes are an underused feature in .NET logging that become significantly more powerful when your backend supports structured fields. When you use BeginScope with a dictionary of properties and IncludeScopes = true is set, those properties are attached to every log message emitted within that scope. For workflows like order processing or payment flows, this gives you automatic correlation without manually adding parameters to every single log call.

If you are using Serilog Enrichers today for this kind of contextual logging, scopes with ParseStateValues = true give you comparable capability in the OpenTelemetry pipeline.

Log Severity Level Mapping in .NET and OpenTelemetry

The .NET ILogger severity levels map to OpenTelemetry severity numbers in a predictable way. Understanding this mapping matters if you are querying logs in an OTLP-native backend or writing alerting rules against severity thresholds.

Microsoft.Extensions.Logging OpenTelemetry Severity Severity Number Range
Trace TRACE 1-4
Debug DEBUG 5-8
Information INFO 9-12
Warning WARN 13-16
Error ERROR 17-20
Critical FATAL 21-24

The mapping is consistent and predictable. If your alerting system triggers on ERROR severity or above, .NET LogError and LogCritical calls will both flow through correctly. The OpenTelemetry specification defines ranges rather than exact numbers for each level, but the .NET SDK uses the midpoint of each range by default.

Exporting Structured Logs to Production with OTLP

For local development, the console exporter is convenient -- you see log output inline with your application output. For production deployments, you will want to export to an OpenTelemetry Collector or directly to a backend using the OTLP exporter.

builder.Logging.AddOpenTelemetry(logging =>
{
    logging.IncludeFormattedMessage = true;
    logging.IncludeScopes = true;

    logging.AddOtlpExporter(otlp =>
    {
        otlp.Endpoint = new Uri("http://otel-collector:4317");
        otlp.Protocol = OpenTelemetry.Exporter.OtlpExportProtocol.Grpc;
    });
});

The OTLP exporter sends log records to any OpenTelemetry-compatible backend. This includes Grafana Loki (via the OTel Collector), Seq, Elastic, Azure Monitor, and many others. The collector itself can apply additional processing, sampling, or routing rules before forwarding to your final destination.

For high-throughput scenarios, gRPC (port 4317) is generally preferred over HTTP/Protobuf (port 4318). gRPC supports streaming, which reduces per-request connection overhead when you are emitting many log records. For lower-traffic services, HTTP/Protobuf works equally well and can be easier to route through some proxies.

If you are already using Serilog Sinks to route logs to multiple destinations, the OTLP exporter gives you a similar capability -- but through a single standardized pipeline rather than managing multiple sink packages.

ParseStateValues and IncludeFormattedMessage in Depth

These three configuration options come up in most OpenTelemetry logging questions. It is worth being precise about what each one actually does.

// With ParseStateValues = true, OpenTelemetry captures the individual
// structured properties from the log state
_logger.LogInformation(
    "Order {OrderId} shipped to {Address} at {Timestamp}",
    orderId,
    shippingAddress,
    DateTimeOffset.UtcNow);

// The resulting log record will have attributes:
// - OrderId: "12345"
// - Address: "123 Main St"
// - Timestamp: "2026-07-27T21:00:00+00:00"
// Rather than just the formatted string

ParseStateValues = true -- Controls whether the SDK unpacks key-value pairs from the log state. When enabled, {OrderId} in your message template becomes a queryable OrderId attribute rather than part of a string. This is generally what you want. Without it, backends cannot index on individual fields from your log messages.

IncludeFormattedMessage = true -- Adds the pre-formatted string (e.g., "Order 12345 shipped to 123 Main St at 2026-07-27...") as an additional attribute in the LogRecord. Useful when reading logs in tools that display the formatted message rather than raw fields. Adds a small data overhead, but the human readability is worth it in most setups.

IncludeScopes = true -- Tells the SDK to capture properties from ILogger.BeginScope calls and include them in log records. If you are using scopes for correlation context, enable this. Without it, scope properties are silently dropped from the exported output.

Batch vs Simple Log Processors

The OpenTelemetry SDK ships two processor types for logs, and the choice between them affects both performance and reliability.

BatchLogRecordExportProcessor collects log records in a memory buffer and flushes them in configurable batches. This is the appropriate choice for production. Batching reduces the overhead of individual export calls, improves throughput, and decouples your application's hot path from the export operation. The tradeoff is a window where logs live in memory before being sent -- if your process crashes during that window, you could lose the last batch. You can tune MaxQueueSize, ScheduledDelayMilliseconds, MaxExportBatchSize, and ExporterTimeoutMilliseconds to balance latency against throughput.

SimpleLogRecordExportProcessor exports each log record immediately and synchronously before returning to the caller. This is the right choice for local development and testing -- you see log output instantly, there is no buffering delay, and you can verify export behavior with deterministic timing. The synchronous export adds latency to each log call, which is acceptable in development but worth avoiding in production code under any meaningful load.

Both processors are configured automatically by the SDK when you call AddConsoleExporter() or AddOtlpExporter(). The console exporter defaults to Simple. The OTLP exporter defaults to Batch. For most production setups, the defaults are reasonable starting points.

OpenTelemetry Logging vs Serilog: Choosing Your Approach

This comparison comes up in nearly every .NET team that is already using Serilog and is evaluating OpenTelemetry. The honest answer is that they solve overlapping problems but are not direct replacements, and the right choice depends more on what you are optimizing for than on the technical capabilities.

Serilog has a mature and extensive sink ecosystem. If you need to write to Elasticsearch, Splunk, various cloud logging services, or highly customized destinations, Serilog likely has a sink for it already. Serilog's enricher model is also well-established -- Serilog Enrichers make it straightforward to add machine name, thread ID, and custom properties to every log entry without changing individual log calls.

OpenTelemetry logging has a different value proposition. If you are already using OpenTelemetry for traces and metrics, routing logs through the same pipeline gives you a unified telemetry story. A single OTLP endpoint receives all three signal types. The resource configuration -- service name, version, environment -- is shared. Trace context correlation happens without any custom enricher code. The operational complexity of managing multiple separate pipelines drops.

These approaches can coexist when needed. You can configure Serilog as your primary logging pipeline (as shown in How to Set Up Serilog in ASP.NET Core) and simultaneously register AddOpenTelemetry() on ILoggingBuilder. Both providers receive the same log events. Serilog also has its own OpenTelemetry sink that can forward Serilog events directly into an OTel collector pipeline if you prefer to keep Serilog as the configuration surface.

The practical guidance: for greenfield projects committing to an OpenTelemetry-first stack, OpenTelemetry logging is the cleaner choice. For existing applications with a mature Serilog investment and established sink pipelines, keeping Serilog for logging while adding OpenTelemetry for traces and metrics is a low-risk incremental path.

Frequently Asked Questions

Does OpenTelemetry logging replace ILogger in .NET?

No. OpenTelemetry logging works through ILogger, not instead of it. You register the OpenTelemetry provider with AddOpenTelemetry(), and from that point your existing ILogger calls are captured by the provider and forwarded to exporters. Your application code does not change at all -- you are adding a new destination for existing log events, not switching to a different logging API.

How does trace context get into logs automatically?

The OpenTelemetry SDK checks Activity.Current when a LogRecord is created. If there is an active Activity (span), it extracts the TraceId and SpanId and includes them as fields in the LogRecord. This happens in the provider layer -- you do not need to do anything in your logging calls to enable it. As long as your code runs inside an active span, the correlation is automatic.

What is the difference between ParseStateValues and IncludeFormattedMessage?

ParseStateValues = true (disabled by default -- you must enable it explicitly) tells the SDK to extract individual key-value pairs from the log message template -- things like OrderId, UserId, and other named parameters -- as separate structured attributes on the LogRecord. IncludeFormattedMessage = true adds the complete formatted string as an additional attribute. The two serve different consumers: ParseStateValues enables querying and filtering on individual fields, while IncludeFormattedMessage is for human-readable display.

Can I use OpenTelemetry logging and Serilog at the same time?

Yes. They operate at different layers of the logging stack. You can configure Serilog as your primary pipeline and also register AddOpenTelemetry() on ILoggingBuilder. Both providers will receive the same log events. Alternatively, Serilog has an official OpenTelemetry sink (Serilog.Sinks.OpenTelemetry) that can forward Serilog log events directly into an OTel pipeline rather than running two separate providers.

What does IncludeScopes do, and should I enable it?

IncludeScopes = true tells the OpenTelemetry logging provider to capture properties from ILogger.BeginScope calls and include them as attributes in log records. If you use scopes to add correlation context -- like attaching OrderId or RequestId to a batch of related log messages -- you should enable this. Without it, scope properties are silently dropped from the exported output, which defeats the purpose of using scopes for structured context.

Which exporter should I use for production?

The console exporter is intended for local development and debugging. For production, use AddOtlpExporter() pointing at an OpenTelemetry Collector or a compatible backend endpoint. The OTLP exporter is vendor-neutral and works with Grafana Loki, Seq, Azure Monitor, Elastic, and many others. The collector approach gives you additional flexibility to route, transform, and sample telemetry before it reaches your final storage backend.

How do .NET log levels map to OpenTelemetry severity?

The mapping is direct: Trace maps to TRACE, Debug to DEBUG, Information to INFO, Warning to WARN, Error to ERROR, and Critical to FATAL. The numeric severity values follow the OpenTelemetry specification, which defines ranges rather than exact numbers for each level. The .NET SDK uses consistent values within those ranges, so severity-based alerting and filtering in OTel-native backends works reliably.

Wrapping Up

OpenTelemetry logging .NET fits cleanly into any observability stack that is already using OpenTelemetry for traces and metrics. The ILogger integration means zero changes to your application logging calls, trace context correlation happens automatically, and the OTLP exporter gives you a vendor-neutral path to most modern log backends.

The key decisions are mostly configuration choices -- whether to use batch or simple processors, whether to include formatted messages, and how to filter log categories. Once those are dialed in, structured logs flow through the same pipeline as your traces and metrics. The cross-signal correlation that makes debugging distributed systems manageable becomes available without instrumenting your logging calls differently.

For teams evaluating their overall approach, the Serilog vs Microsoft.Extensions.Logging guide covers the broader context, and the ASP.NET Core Web API Complete Guide shows how logging fits into the full application setup.


Disclaimer: Yes! This was written by AI based on input and direction from yours truly, Nick Cosentino, for Dev Leader. I have ensured that it represents my thoughts and perspectives on OpenTelemetry logging in .NET and structured log export with ILogger.

OpenTelemetry in .NET: Complete Observability Guide

The complete OpenTelemetry .NET guide for developers: traces, metrics, and logs with C# examples, ASP.NET Core setup, exporters, and distributed tracing.

Logging in .NET: The Complete Developer's Guide

Master logging in .NET with ILogger, structured logging, log levels, Serilog, and OpenTelemetry. Complete guide for .NET 9 and .NET 10 developers.

HttpClient Logging and Observability in .NET 10: ILogger, DelegatingHandler, and OpenTelemetry

How to implement HttpClient logging and observability in .NET 10 -- covering built-in ILogger integration, DelegatingHandler for custom request logging, OpenTelemetry tracing and metrics, and redacting sensitive headers in production.

An error has occurred. This application may no longer respond until reloaded. Reload