Skip to main content

Collect Logs with OpenTelemetry: .NET

Logs are human-readable event records—the foundation of debugging. OpenTelemetry unifies logs with traces and metrics, enabling correlation: when you see a slow trace, you can immediately jump to the logs emitted during that time window and identify the root cause. Integrating Serilog or other .NET loggers with OpenTelemetry creates a complete observability signal.

A support team at a SaaS company once received a customer report: "Our dashboard is loading slowly today." Logs alone showed generic timeout errors. Traces showed the slowdown was in a third-party API call, but the logs didn't mention which customer or account. Only when they unified logs with traces—adding trace context to every log line—could they correlate the slowness to a specific customer's account, find a data anomaly, and resolve it in 10 minutes instead of two hours.

Understanding OpenTelemetry Logs

OpenTelemetry logs are structured records with timestamp, severity, body (message), and optional attributes and trace context. Unlike traditional logging frameworks, OpenTelemetry logs can carry the trace ID and span ID from the current trace, enabling automatic correlation in observability backends:

Timestamp: 2026-06-02T15:30:45.123Z
Severity: Warning
Body: "Payment processing slow for order ORD-12345"
Attributes:
- order.id: ORD-12345
- processing_time_ms: 2450
TraceId: 5c12abe3ef2e8b44
SpanId: 9f6e5d4a2c1b3e8f

The trace ID automatically links this log entry to the trace it originated from, a connection no traditional logging framework provides.

Setting Up OpenTelemetry Logging

Install the OpenTelemetry logger provider:

dotnet add package OpenTelemetry.Logging

Configure it to collect logs from your application:

using OpenTelemetry.Logging;

var loggerFactory = LoggerFactory.Create(builder =>
{
builder
.ClearProviders() // Remove console logger if present
.AddOpenTelemetry(options =>
{
options.IncludeScopes = true;
options.IncludeFormattedMessage = true;

// Add console exporter for development
options.AddConsoleExporter();
});
});

var logger = loggerFactory.CreateLogger<Program>();
logger.LogInformation("Application started");

The IncludeScopes flag captures structured context (baggage, span context). The IncludeFormattedMessage flag ensures the human-readable message is preserved.

Bridging Serilog with OpenTelemetry

Serilog is the most popular .NET structured logger. Integrate it with OpenTelemetry using the Serilog sink:

dotnet add package Serilog
dotnet add package Serilog.Sinks.OpenTelemetry

Configure Serilog to emit to OpenTelemetry:

using Serilog;
using Serilog.Sinks.OpenTelemetry;

Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.WriteTo.OpenTelemetry(options =>
{
options.IncludeEventId = true;
options.IncludeScopes = true;
})
.Enrich.WithProperty("application", "order-service")
.Enrich.WithProperty("environment", "production")
.CreateLogger();

try
{
Log.Information("Starting application");

var order = new Order { Id = "ORD-12345", Total = 99.99m };
Log.Information("Processing order {@Order}", order);

// More app code
}
catch (Exception ex)
{
Log.Fatal(ex, "Application terminated unexpectedly");
}
finally
{
await Log.CloseAndFlushAsync();
}

Serilog's @ syntax serializes objects as structured data, automatically extracted by OpenTelemetry.

Enriching Logs with Trace Context

Automatic trace context enrichment adds the current trace ID and span ID to every log:

dotnet add package OpenTelemetry.Context

Configure enrichment:

using OpenTelemetry.Trace;

Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.WriteTo.OpenTelemetry()
.Enrich.WithTraceContextEnrichment() // Adds TraceId and SpanId
.Enrich.FromLogContext() // Captures baggage
.CreateLogger();

// In your application code
var tracer = tracerProvider.GetTracer("OrderService");

using (var span = tracer.StartSpan("ProcessOrder"))
{
Log.Information("Processing order {OrderId}", "ORD-12345");
// This log now includes the span's trace ID and span ID

await ProcessPaymentAsync();

Log.Information("Order completed successfully");
}

private async Task ProcessPaymentAsync()
{
using (var span = tracer.StartSpan("ProcessPayment"))
{
Log.Warning("Payment processing takes longer than usual");
// This warning is also linked to the same trace
}
}

When exported to a backend like Loki, these log entries are automatically grouped under the same trace ID, creating a unified view of an operation.

Emitting Structured Logs with Attributes

Structured logging captures meaningful data in machine-readable form:

using var scope = new LogContext();

try
{
var order = new Order
{
Id = "ORD-12345",
CustomerId = "CUST-789",
Total = 99.99m
};

Log.Information(
"Order received: {OrderId} from customer {CustomerId} for {Amount}",
order.Id,
order.CustomerId,
order.Total
);

await _inventoryService.ValidateAsync(order);

Log.Information(
"Inventory validated for order {OrderId}. Items in stock: {@Items}",
order.Id,
order.Items
);
}
catch (ValidationException ex)
{
Log.Error(
ex,
"Order validation failed for {OrderId}. Reason: {Reason}",
order.Id,
ex.Message
);
}

Each log line includes named parameters ({OrderId}, {CustomerId}) and optionally complex objects ({@Items}), creating queryable fields.

Exporting Logs to Loki

Loki is Grafana's log aggregation platform, designed to work with Prometheus and Jaeger. Install the exporter:

dotnet add package OpenTelemetry.Exporter.Loki

Configure export:

var loggerFactory = LoggerFactory.Create(builder =>
{
builder.AddOpenTelemetry(options =>
{
options
.SetResourceBuilder(resource)
.AddLokiExporter(config =>
{
config.Endpoint = new Uri("http://localhost:3100");
config.Labels.Add("service", "order-service");
config.Labels.Add("environment", "production");
});
});
});

Loki indexes logs by labels (service, environment, trace ID), making them searchable. The trace ID label automatically correlates logs to traces.

Viewing Logs with Trace Context

In Grafana, when viewing a trace in Jaeger:

  1. Click a span (e.g., "ProcessOrder")
  2. Grafana shows the span's trace ID in the details panel
  3. Click "View logs" → Grafana queries Loki for all logs with trace_id="5c12abe3ef2e8b44"
  4. All logs emitted during that operation appear in chronological order

This is the power of unified observability: you don't manually search for logs; you navigate directly from trace to logs to metrics through automatic linking.

Key Takeaways

  • OpenTelemetry logs carry trace context (trace ID, span ID), enabling automatic correlation with traces.
  • Serilog integration via the OpenTelemetry sink bridges existing .NET logging to the observability stack.
  • Structured logging (named parameters, objects) makes logs queryable.
  • Loki export creates a searchable log archive indexed by service, environment, and trace ID.
  • Trace context enrichment ensures every log is linked to the span that emitted it.

Frequently Asked Questions

Should I replace my existing logging framework or wrap it?

Wrap it. Use Serilog or ILogger with OpenTelemetry sinks. This allows gradual migration and keeps your application code unchanged.

What is the performance cost of adding trace context to every log?

Minimal (< 1 microsecond per log). The trace ID is available from the current activity; serialization is trivial.

Can I collect logs from third-party libraries I don't control?

Yes, if they use the standard .NET ILogger interface (most do). Configure the logger factory to capture their output.

What log level should I use for different events?

Information: normal business events (order received, payment processed). Warning: unexpected but recoverable (retry attempt, slow query). Error: unhandled failures. Debug: detailed diagnostics (only in development).

How do I avoid exporting sensitive data (passwords, credit cards) in logs?

Use redaction middleware: configure Serilog to scan logs for patterns (email, SSN, card numbers) and replace with ***REDACTED*** before export.

Further Reading