RO EN

Observability (2) — structured logging with Serilog and Application Insights

Observability (2) — structured logging with Serilog and Application Insights ✨ Imagine generată cu AI
Doru Bulubașa
05 August 2026
54 views

The second part of the series about observability. The first part laid out the three pillars. Now we tackle the first serious pillar: structured logging with Serilog, poured into Application Insights.


Why Serilog over built-in logging

ILogger from Microsoft.Extensions.Logging already supports structured templates — and remains the abstraction you inject everywhere. Serilog goes underneath, as a provider, and brings what is missing: mature sinks (Application Insights, Console, File, Seq), enrichers that automatically add context to every log, and fine configuration per namespace, from appsettings.

dotnet add package Serilog.AspNetCore
dotnet add package Serilog.Sinks.ApplicationInsights
dotnet add package Serilog.Enrichers.Environment

Basic configuration

// Program.cs
var builder = WebApplication.CreateBuilder(args);

builder.Host.UseSerilog((context, services, configuration) => configuration
    .ReadFrom.Configuration(context.Configuration)   // levels from appsettings
    .ReadFrom.Services(services)
    .Enrich.FromLogContext()
    .Enrich.WithMachineName()
    .Enrich.WithProperty("Application", "chat-api")
    .Enrich.WithProperty("Version",
        Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown")
    .WriteTo.Console()
    .WriteTo.ApplicationInsights(
        services.GetRequiredService<TelemetryConfiguration>(),
        TelemetryConverter.Traces));

The important detail: TelemetryConverter.Traces sends logs to the traces table in Application Insights (which, as established in part 1, contains logs, not distributed traces). Each structured property becomes a column in customDimensions — directly queryable in KQL.

Levels from appsettings — per namespace

{
  "Serilog": {
    "MinimumLevel": {
      "Default": "Information",
      "Override": {
        "Microsoft.AspNetCore": "Warning",
        "Microsoft.AspNetCore.Hosting.Diagnostics": "Warning",
        "Azure.Core": "Warning",
        "System.Net.Http.HttpClient": "Warning"
      }
    }
  }
}

The overrides on Microsoft.AspNetCore and Azure.Core cut infrastructure noise that otherwise dominates volume (and cost). Being in appsettings, you can temporarily lower a namespace to Debug in production via an environment variable — without redeploy.


Structured, not interpolated — why it concretely matters

// WRONG -- final string, lost context
_logger.LogInformation($"Cache hit for query {queryHash} of tenant {tenantId}");

// CORRECT -- structured properties, queryable
_logger.LogInformation(
    "Cache hit for query {QueryHash} of tenant {TenantId}",
    queryHash, tenantId);

The difference becomes visible during investigation. With the structured variant, in KQL you can write:

traces
| where customDimensions.TenantId == "tenant-42"
| where message startswith "Cache hit"
| summarize count() by bin(timestamp, 1h)

With the interpolated variant, the same question means string parsing with regex — fragile and slow. The rule is absolute: never interpolate in the log template.

Whole objects with @

// The @ operator serializes the object into customDimensions
_logger.LogInformation("Processed order: {@Order}", new
{
    order.Id,
    order.TenantId,
    ItemCount = order.Items.Count,
    order.Total
});
// Warning: only relevant properties, never the full entity
// (large payloads = cost + risk of sensitive data in logs)

Ambient context with LogContext

The TenantId appears in almost every log in a multi-tenant SaaS. Instead of passing it manually everywhere, a middleware pushes it into LogContext — and all logs in the request get it automatically:

public class TenantLoggingMiddleware
{
    private readonly RequestDelegate _next;
    public TenantLoggingMiddleware(RequestDelegate next) => _next = next;

    public async Task InvokeAsync(HttpContext context)
    {
        var tenantId = context.User.FindFirstValue("tenant_id") ?? "anonymous";

        using (LogContext.PushProperty("TenantId", tenantId))
        using (LogContext.PushProperty("CorrelationId",
            context.TraceIdentifier))
        {
            await _next(context);
        }
    }
}

// Program.cs -- before endpoints
app.UseMiddleware<TenantLoggingMiddleware>();

From now on, any _logger.LogInformation(...) deep inside a service has TenantId in customDimensions, without the service knowing it exists.


Compact request logging

ASP.NET Core logs 3-4 events per request by default. Serilog replaces them with a single, dense one:

// Program.cs -- after build
app.UseSerilogRequestLogging(options =>
{
    options.MessageTemplate =
        "HTTP {RequestMethod} {RequestPath} => {StatusCode} in {Elapsed:0.0}ms";

    options.EnrichDiagnosticContext = (diagnosticContext, httpContext) =>
    {
        diagnosticContext.Set("ClientIp",
            httpContext.Connection.RemoteIpAddress?.ToString());
        diagnosticContext.Set("UserAgent",
            httpContext.Request.Headers.UserAgent.ToString());
    };

    // Dynamic level: errors at Error, health checks at Verbose (cut out)
    options.GetLevel = (httpContext, elapsed, ex) => ex is not null
        ? LogEventLevel.Error
        : httpContext.Request.Path.StartsWithSegments("/health")
            ? LogEventLevel.Verbose
            : LogEventLevel.Information;
});

GetLevel with Verbose on /health is worth highlighting: liveness/readiness probes from Container Apps hit endpoints every few seconds — without this rule, half your logs are health checks.


Cost: logs are paid per GB

Application Insights charges ingestion. Three control mechanisms, in the order you apply them:

  • Correct levels — the per-namespace overrides above; the biggest gain, free
  • Adaptive sampling — at high volume, Application Insights keeps a representative percentage; correlation per operation is preserved (a request is sampled with all its logs)
  • Daily cap as a safety net — daily ingestion limit; alert when you reach it, because beyond it you are blind
# Daily cap on workspace -- safety net, not a strategy
az monitor log-analytics workspace update \
  --resource-group my-rg \
  --workspace-name my-workspace \
  --quota 5

What’s next

Logs now have structure and context. In the third part we link them between services: distributed tracing with OpenTelemetry in ASP.NET Core and Azure Functions — the trace id that travels through HTTP, Service Bus, and Cosmos DB.

Questions? Write me at contact@ludoprogramming.com.