Skip to content

Logging & Monitoring Interview Questions & Answers

15 questions Updated 2026-06-23 Share:

Logging and monitoring interview questions — ILogger, structured logging, Serilog, log scopes, health checks, OpenTelemetry metrics, and distributed tracing.

Read the in-depth guideStructured Logging and Monitoring in ASP.NET Core(opens in new tab)
15 of 15

ILogger<T> is the standard logging abstraction in ASP.NET Core. It is registered automatically and injected like any other service. The generic parameter T becomes the category name that appears in every log entry, letting you filter by class or namespace.

public class OrderService
{
    private readonly ILogger<OrderService> _logger;

    public OrderService(ILogger<OrderService> logger) => _logger = logger;

    public async Task<Order> PlaceOrderAsync(Order order)
    {
        _logger.LogInformation("Placing order {OrderId} for customer {CustomerId}",
            order.Id, order.CustomerId);

        try
        {
            var result = await _repository.SaveAsync(order);
            _logger.LogInformation("Order {OrderId} saved successfully", result.Id);
            return result;
        }
        catch (Exception ex)
        {
            // Exception is the first parameter when logging errors:
            _logger.LogError(ex, "Failed to place order {OrderId}", order.Id);
            throw;
        }
    }
}

// Configuration in appsettings.json — control minimum levels per namespace:
// {
// "Logging": {
// "LogLevel": {
// "Default":             "Information",
// "Microsoft.AspNetCore": "Warning",
// "MyApp.Data":          "Debug"
// }
// }
// }

Use message templates with named placeholders ({OrderId}) rather than string interpolation ($"Order {order.Id}"). Structured logging providers capture placeholders as separate fields, enabling rich querying in log aggregators.

Rule of thumb: Always use message templates, never string interpolation. LogInformation($"Order {id}") stores one string; LogInformation("Order {Id}", id) stores a structured document with a searchable Id field.

.NET defines six log levels in ascending severity. The configured minimum level filters out everything below it, so lower-severity messages cost nothing at runtime when they are below the threshold.

// Levels in order — Trace (0) through Critical (5):
_logger.LogTrace("Entering GetProductAsync(id={Id})", id);       // 0 — per-step traces
_logger.LogDebug("Cache miss for product {Id}", id);             // 1 — developer diagnostics
_logger.LogInformation("Order {Id} placed successfully", id);    // 2 — normal business events
_logger.LogWarning("Retry {Attempt} for order {Id}", attempt, id); // 3 — unexpected but handled
_logger.LogError(ex, "Failed to save order {Id}", id);           // 4 — failure, action needed
_logger.LogCritical(ex, "Database connection lost");             // 5 — system down

// Guard against expensive computation when level is disabled:
if (_logger.IsEnabled(LogLevel.Debug))
    _logger.LogDebug("Full order payload: {Payload}", JsonSerializer.Serialize(order));

// Common level strategy by environment:
// Development: Debug (verbose, includes framework internals)
// Staging:     Information (business events, warnings)
// Production:  Warning (only unexpected or error events)
Level Use case
Trace Step-by-step tracing for deep debugging
Debug Developer diagnostics (loop values, cache state)
Information Normal business events (order placed, user logged in)
Warning Handled unexpected conditions (retry, degraded mode)
Error Unhandled failures that need investigation
Critical System-wide failures requiring immediate action

Rule of thumb: Log Information for every significant business event — these are the facts you want in an audit trail. Log Warning when you handle an error gracefully but something unexpected happened. Log Error only for failures that actually matter.

Structured logging captures log entries as key-value documents rather than flat strings. This allows log aggregators (Seq, Elasticsearch, Splunk) to query, filter, and aggregate on individual fields — something impossible with plain text.

// Plain text — one unsearchable string:
_logger.LogInformation($"Order {order.Id} for customer {order.CustomerId} total {order.Total}");
// Log: "Order 42 for customer 99 total 150.00"

// Structured — document with searchable fields:
_logger.LogInformation(
    "Order {OrderId} placed for {CustomerId}, total {Total:C}",
    order.Id, order.CustomerId, order.Total);
// Log document: { "OrderId": 42, "CustomerId": 99, "Total": 150.00,
// "@t": "...", "@l": "Information", ... }

// Querying structured logs in Seq or Kibana:
// OrderId = 42
// Total > 100 AND @l = 'Error'
// CustomerId = 99 AND @t > '2026-06-01'

// Destructure objects — capture all properties with @:
_logger.LogInformation("Processing {@Order}", order);
// Captures: Order.Id, Order.Sku, Order.Total, Order.CustomerId as separate fields

// Avoid destructuring large objects — it serializes the whole graph:
// _logger.LogInformation("Context {@DbContext}", dbContext); // thousands of fields!
// _logger.LogInformation("DB query completed for {EntityType}", typeof(Order).Name);

Structured logging transforms log files from write-only archives into queryable datasets. You can answer "how many orders over $100 failed last Thursday?" without parsing strings.

Rule of thumb: Every significant log entry should capture the entity ID and the relevant business context as named fields. Never log by concatenating strings — you will want to query those fields later.

Serilog is the most popular third-party logging library for .NET. It integrates with ILogger<T> so existing code needs no changes, but adds rich sink support (files, Seq, Elasticsearch, Application Insights) and output templates.

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

// Program.cs — configure Serilog before building the host:
Log.Logger = new LoggerConfiguration()
    .MinimumLevel.Information()
    .MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Warning)
    .Enrich.FromLogContext()                // adds scope properties to every event
    .Enrich.WithMachineName()
    .WriteTo.Console(new JsonFormatter())   // structured JSON to stdout
    .WriteTo.File(
        path: "logs/app-.log",
        rollingInterval: RollingInterval.Day,
        outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}")
    .WriteTo.Seq("http://seq:5341")         // central log server
    .CreateLogger();

builder.Host.UseSerilog();                  // replaces the built-in providers

// appsettings.json-driven config (recommended for production flexibility):
Log.Logger = new LoggerConfiguration()
    .ReadFrom.Configuration(builder.Configuration)
    .CreateLogger();

// Request logging middleware — one log line per HTTP request with timing:
app.UseSerilogRequestLogging(opts =>
{
    opts.MessageTemplate =
        "{RequestMethod} {RequestPath} responded {StatusCode} in {Elapsed:0.0000} ms";
    opts.EnrichDiagnosticContext = (diagCtx, httpCtx) =>
        diagCtx.Set("UserId", httpCtx.User.FindFirst("sub")?.Value ?? "anon");
});

Rule of thumb: Use UseSerilogRequestLogging() to replace ASP.NET Core's verbose default request log (which emits two events per request) with one structured event per request that includes timing, status code, and custom fields.

Log scopes add ambient context properties to every log entry emitted within a using block. This is essential for correlating all logs from one request or operation without passing extra parameters through every method call.

// ILogger.BeginScope — add fields to all logs within the using block:
public async Task<Order> PlaceOrderAsync(Order order)
{
    using (_logger.BeginScope(new Dictionary<string, object>
    {
        ["OrderId"]    = order.Id,
        ["CustomerId"] = order.CustomerId,
        ["CorrelationId"] = Activity.Current?.Id ?? Guid.NewGuid().ToString(),
    }))
    {
        // Every log inside this using block carries OrderId + CustomerId:
        _logger.LogInformation("Validating order");         // has OrderId
        await ValidateAsync(order);
        _logger.LogInformation("Saving order to database"); // has OrderId
        await _repository.SaveAsync(order);
        _logger.LogInformation("Sending confirmation email"); // has OrderId
        await _emailService.SendAsync(order);
    }
    // Outside the using — scope properties are gone
    _logger.LogInformation("Done"); // no OrderId here
}

// Scopes nest — inner scopes add to outer:
using (_logger.BeginScope("RequestId: {RequestId}", requestId))
using (_logger.BeginScope("TenantId: {TenantId}", tenantId))
{
    _logger.LogInformation("Processing"); // has both RequestId and TenantId
}

// In Serilog, Enrich.FromLogContext() is required to pick up scope properties.
// In the built-in provider, IncludeScopes must be true (default):
// "Logging": { "Console": { "IncludeScopes": true } }

Rule of thumb: Use scopes to attach a correlation ID, request ID, or transaction ID at the top of an operation. This makes it trivial to filter all logs for one request in any log aggregator.

ASP.NET Core's health checks expose a /health endpoint that orchestrators (Kubernetes, load balancers) probe to decide whether to route traffic to an instance. Checks can verify databases, message brokers, and external services.

// Program.cs:
builder.Services.AddHealthChecks()
    .AddDbContextCheck<AppDbContext>()       // checks EF Core can query
    .AddRedis("localhost:6379")              // checks Redis connectivity
    .AddUrlGroup(new Uri("https://api.partner.com/health"), "partner-api")
    .AddCheck("custom", () =>               // arbitrary custom check
    {
        var queueDepth = _queue.GetDepth();
        return queueDepth < 1000
            ? HealthCheckResult.Healthy($"Queue depth: {queueDepth}")
            : HealthCheckResult.Degraded($"Queue depth high: {queueDepth}");
    }, tags: ["readiness"]);

// Two endpoints — liveness (is it running?) and readiness (can it take traffic?):
app.MapHealthChecks("/health/live",  new HealthCheckOptions
{
    Predicate = _ => false,  // no checks — just returns 200 if the process is up
});

app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
    Predicate = check => check.Tags.Contains("readiness"),
    ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse, // rich JSON
});

// Response shape (healthy):
// { "status": "Healthy", "checks": [{ "name": "...", "status": "Healthy" }] }

Health check packages:

  • AspNetCore.HealthChecks.SqlServer / Npgsql
  • AspNetCore.HealthChecks.Redis
  • AspNetCore.HealthChecks.UI for a dashboard

Rule of thumb: Always expose separate liveness and readiness probes. Liveness (is the process alive?) should never check external dependencies — a failing database should not kill the process, just take it out of rotation.

.NET 8+ has a built-in metrics API in System.Diagnostics.Metrics. Metrics are counters, histograms, and gauges that can be exported to Prometheus, OpenTelemetry, or Application Insights without changing application code.

// Define a Meter and instruments once (usually a static field):
public static class AppMetrics
{
    private static readonly Meter _meter = new("MyApp.Orders", "1.0.0");

    public static readonly Counter<long>   OrdersPlaced =
        _meter.CreateCounter<long>("orders.placed.total",
            unit: "{orders}", description: "Total orders placed");

    public static readonly Histogram<double> OrderTotal =
        _meter.CreateHistogram<double>("orders.total.amount",
            unit: "USD", description: "Distribution of order totals");

    public static readonly ObservableGauge<int> QueueDepth =
        _meter.CreateObservableGauge("orders.queue.depth",
            () => OrderQueue.CurrentDepth, unit: "{orders}");
}

// Emit metrics in application code:
public async Task<Order> PlaceOrderAsync(Order order)
{
    var result = await _repo.SaveAsync(order);

    AppMetrics.OrdersPlaced.Add(1, new TagList
    {
        { "region",  order.Region },
        { "channel", order.Channel },
    });
    AppMetrics.OrderTotal.Record(order.Total, new TagList
    {
        { "currency", order.Currency },
    });

    return result;
}

// Export to Prometheus (dotnet add package OpenTelemetry.Exporter.Prometheus.AspNetCore):
builder.Services.AddOpenTelemetry()
    .WithMetrics(metrics => metrics
        .AddMeter("MyApp.Orders")
        .AddPrometheusExporter());

app.MapPrometheusScrapingEndpoint("/metrics");

Rule of thumb: Define meters and instruments as static fields — creating them per-request is expensive and causes duplicate registration errors. Use tags (dimensions) to slice metrics by region, tenant, or error type rather than creating separate instruments per value.

Distributed tracing tracks a request as it flows through multiple services. Each service adds a span to a shared trace, creating a full timeline that shows where time was spent and where errors occurred.

// dotnet add package OpenTelemetry.Extensions.Hosting
// dotnet add package OpenTelemetry.Instrumentation.AspNetCore
// dotnet add package OpenTelemetry.Instrumentation.Http
// dotnet add package OpenTelemetry.Instrumentation.EntityFrameworkCore
// dotnet add package OpenTelemetry.Exporter.Jaeger

builder.Services.AddOpenTelemetry()
    .WithTracing(tracing => tracing
        .AddSource("MyApp.Orders")            // custom spans from this source
        .AddAspNetCoreInstrumentation()        // HTTP requests auto-instrumented
        .AddHttpClientInstrumentation()        // outbound HTTP calls
        .AddEntityFrameworkCoreInstrumentation() // EF Core queries
        .AddJaegerExporter(j =>
            j.AgentHost = "jaeger"));          // export to Jaeger

// Create custom spans for significant operations:
private static readonly ActivitySource _source = new("MyApp.Orders");

public async Task<Order> PlaceOrderAsync(Order order)
{
    using var activity = _source.StartActivity("PlaceOrder");
    activity?.SetTag("order.id",       order.Id);
    activity?.SetTag("customer.id",    order.CustomerId);
    activity?.SetTag("order.total",    order.Total);

    try
    {
        var result = await _repo.SaveAsync(order);
        activity?.SetStatus(ActivityStatusCode.Ok);
        return result;
    }
    catch (Exception ex)
    {
        activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
        activity?.RecordException(ex);
        throw;
    }
}

The W3C traceparent header propagates the trace ID across service boundaries. ASP.NET Core reads and forwards this header automatically when AddAspNetCoreInstrumentation() is configured.

Rule of thumb: Add custom spans (ActivitySource.StartActivity) for your business operations, not just for infrastructure. A Jaeger or Zipkin trace that shows "HTTP → EF Core → HTTP" is less useful than one that shows "HTTP → PlaceOrder → ValidateInventory → ChargePayment."

ASP.NET Core provides two standard mechanisms for global exception handling: UseExceptionHandler (for non-API responses) and IExceptionHandler (introduced in .NET 8, interface-based, testable).

// .NET 8+ — IExceptionHandler (preferred):
public class GlobalExceptionHandler : IExceptionHandler
{
    private readonly ILogger<GlobalExceptionHandler> _logger;

    public GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger)
        => _logger = logger;

    public async ValueTask<bool> TryHandleAsync(
        HttpContext httpContext,
        Exception   exception,
        CancellationToken ct)
    {
        _logger.LogError(exception,
            "Unhandled exception on {Method} {Path}: {Message}",
            httpContext.Request.Method,
            httpContext.Request.Path,
            exception.Message);

        httpContext.Response.StatusCode  = StatusCodes.Status500InternalServerError;
        httpContext.Response.ContentType = "application/problem+json";

        await httpContext.Response.WriteAsJsonAsync(new ProblemDetails
        {
            Status   = 500,
            Title    = "An unexpected error occurred",
            Instance = httpContext.Request.Path,
        }, ct);

        return true; // handled — stop propagation
    }
}

// Registration:
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddProblemDetails();
app.UseExceptionHandler();

// Older approach — UseExceptionHandler with a lambda:
app.UseExceptionHandler(appError => appError.Run(async context =>
{
    var feature = context.Features.Get<IExceptionHandlerFeature>();
    var ex      = feature?.Error;
    if (ex is not null)
        logger.LogError(ex, "Unhandled exception");
    context.Response.StatusCode = 500;
    await context.Response.WriteAsync("An error occurred.");
}));

Rule of thumb: Use IExceptionHandler in .NET 8+ for global exception logging — it's DI-friendly, testable, and can be chained (multiple handlers tried in registration order). Always return a ProblemDetails JSON response rather than a plain string so API clients can parse the error.

Logging is never truly free. Even when a message is below the minimum level, the arguments may be evaluated before the logger can discard them. High-volume paths need additional guards.

// Problem — string interpolation always allocates:
_logger.LogDebug($"Processing item {item.Id} with payload {JsonSerializer.Serialize(item)}");
// JsonSerializer.Serialize runs on every call, even if Debug is disabled!

// Fix 1 — IsEnabled guard:
if (_logger.IsEnabled(LogLevel.Debug))
    _logger.LogDebug("Processing item {Id} with payload {Payload}",
        item.Id, JsonSerializer.Serialize(item));

// Fix 2 — LoggerMessage.Define (zero-allocation for hot paths):
private static readonly Action<ILogger, int, string, Exception?> _logProcessing =
    LoggerMessage.Define<int, string>(
        LogLevel.Debug,
        new EventId(1001, "ProcessingItem"),
        "Processing item {Id} with payload {Payload}");

// Call site — no allocation if Debug is disabled:
_logProcessing(_logger, item.Id, item.Payload, null);

// Fix 3 — [LoggerMessage] source generator (.NET 6+, preferred):
public partial class ItemProcessor
{
    private readonly ILogger<ItemProcessor> _logger;

    [LoggerMessage(Level = LogLevel.Debug,
                   Message = "Processing item {Id} with payload {Payload}")]
    partial void LogProcessing(int id, string payload);

    public void Process(Item item)
    {
        LogProcessing(item.Id, item.Payload); // zero-alloc if Debug is off
        // ...
    }
}

[LoggerMessage] source generators produce the same zero-allocation code as LoggerMessage.Define but with a clean, readable call site and compile-time validation of the message template.

Rule of thumb: Use [LoggerMessage] source generators in any method called more than ~1000 times per second. For normal business logic paths, the built-in ILogger.LogXxx with message templates is fast enough.

Application Insights is Azure's application performance monitoring (APM) service. It collects logs, metrics, traces, and exceptions with minimal configuration and provides a rich analytics dashboard.

// dotnet add package Microsoft.ApplicationInsights.AspNetCore

// Program.cs:
builder.Services.AddApplicationInsightsTelemetry(opts =>
    opts.ConnectionString = builder.Configuration["ApplicationInsights:ConnectionString"]);

// appsettings.json:
// {
// "ApplicationInsights": {
// "ConnectionString": "InstrumentationKey=...;IngestionEndpoint=..."
// }
// }

// Custom telemetry — track business events:
public class OrderService
{
    private readonly TelemetryClient _telemetry;

    public async Task<Order> PlaceOrderAsync(Order order)
    {
        var result = await _repo.SaveAsync(order);

        // Track a custom event with properties:
        _telemetry.TrackEvent("OrderPlaced", new Dictionary<string, string>
        {
            ["OrderId"]    = result.Id.ToString(),
            ["CustomerId"] = order.CustomerId.ToString(),
            ["Channel"]    = order.Channel,
        }, new Dictionary<string, double>
        {
            ["Total"]    = (double)order.Total,
            ["ItemCount"] = order.Items.Count,
        });

        return result;
    }
}

// Custom dependency tracking (for non-HTTP dependencies):
using var operation = _telemetry.StartOperation<DependencyTelemetry>("PaymentGateway");
operation.Telemetry.Type   = "HTTP";
operation.Telemetry.Target = "payment.example.com";
try { await _gateway.ChargeAsync(order); operation.Telemetry.Success = true; }
catch { operation.Telemetry.Success = false; throw; }

Rule of thumb: Let Application Insights auto-collect HTTP, SQL, and exception telemetry. Add TrackEvent only for domain-significant events (order placed, subscription cancelled) that are not captured automatically.

The logging framework applies a minimum level filter per category (namespace or class name) and per provider (Console, File, Application Insights). Filters are configured in appsettings.json or in code without touching log call sites.

// appsettings.json — granular filtering by category and provider:
{
  "Logging": {
    "LogLevel": {
      "Default":                        "Information",
      "Microsoft":                      "Warning",
      "Microsoft.AspNetCore":           "Warning",
      "Microsoft.EntityFrameworkCore":  "Warning",
      "Microsoft.EntityFrameworkCore.Database.Command": "Information",
      "MyApp.Services.OrderService":    "Debug"
    },
    "Console": {
      "LogLevel": {
        "Default": "Warning"
      }
    },
    "ApplicationInsights": {
      "LogLevel": {
        "Default": "Information"
      }
    }
  }
}
// Equivalent in code — useful for dynamic configuration:
builder.Logging.AddFilter("Microsoft.EntityFrameworkCore", LogLevel.Warning);
builder.Logging.AddFilter<ConsoleLoggerProvider>("Default", LogLevel.Warning);

// Filter by predicate — suppress specific event IDs:
builder.Logging.AddFilter((provider, category, level) =>
{
    // Suppress EF Core command logging in Application Insights to reduce cost:
    if (provider.Contains("ApplicationInsights") &&
        category.StartsWith("Microsoft.EntityFrameworkCore"))
        return false;
    return level >= LogLevel.Information;
});

Category rules are matched by prefix: Microsoft.AspNetCore matches Microsoft.AspNetCore.Routing, Microsoft.AspNetCore.Mvc, etc. The most specific matching rule wins.

Rule of thumb: In production, set Microsoft and System namespaces to Warning and your own application namespaces to Information. This cuts log volume by 80% and eliminates noise from framework internals while keeping all business events.

Log enrichers add properties to every log entry automatically, without passing extra parameters through every method call. ASP.NET Core's ILogger scopes and Serilog's LogContext are the two main mechanisms.

// Middleware — push ambient context into the log scope for every request:
public class LogEnrichmentMiddleware
{
    private readonly RequestDelegate _next;

    public LogEnrichmentMiddleware(RequestDelegate next) => _next = next;

    public async Task InvokeAsync(HttpContext ctx, ILogger<LogEnrichmentMiddleware> logger)
    {
        var userId   = ctx.User.FindFirst("sub")?.Value   ?? "anon";
        var tenantId = ctx.User.FindFirst("tenant")?.Value ?? "none";
        var traceId  = Activity.Current?.TraceId.ToString()
                       ?? ctx.TraceIdentifier;

        // ILogger scope — carried by all loggers in this request:
        using (logger.BeginScope(new Dictionary<string, object>
        {
            ["UserId"]    = userId,
            ["TenantId"]  = tenantId,
            ["TraceId"]   = traceId,
        }))
        {
            await _next(ctx);
        }
    }
}

// Register the middleware early in the pipeline:
app.UseMiddleware<LogEnrichmentMiddleware>();

// Serilog LogContext — same concept, uses Serilog's enrichment mechanism:
// (requires Enrich.FromLogContext() in LoggerConfiguration)
using (LogContext.PushProperty("UserId",   userId))
using (LogContext.PushProperty("TenantId", tenantId))
{
    await _next(ctx);
}

// Result — every log entry inside the request automatically includes:
// { "UserId": "u-123", "TenantId": "acme", "TraceId": "4bf92f3577b34da6..." }

Rule of thumb: Push user ID, tenant ID, and correlation/trace ID into the log scope in a single middleware. This makes filtering logs by user or tenant in Seq or Kibana a one-field query instead of a grep across unstructured strings.

A correlation ID links all log entries for one logical operation across multiple services. The originating service generates the ID and passes it in an HTTP header; downstream services read it, log it, and forward it to their own dependencies.

// Middleware — read or generate a correlation ID for every request:
public class CorrelationIdMiddleware
{
    private const string HeaderName = "X-Correlation-Id";
    private readonly RequestDelegate _next;

    public CorrelationIdMiddleware(RequestDelegate next) => _next = next;

    public async Task InvokeAsync(HttpContext ctx, ILogger<CorrelationIdMiddleware> logger)
    {
        // Accept an inbound ID (from upstream caller) or generate a new one:
        var correlationId = ctx.Request.Headers[HeaderName].FirstOrDefault()
                            ?? Activity.Current?.TraceId.ToString()
                            ?? Guid.NewGuid().ToString("N");

        // Echo the ID back in the response header:
        ctx.Response.Headers[HeaderName] = correlationId;

        // Push into log scope so every log in this request carries it:
        using (logger.BeginScope(new Dictionary<string, object>
                   { ["CorrelationId"] = correlationId }))
        {
            await _next(ctx);
        }
    }
}

// Forward the correlation ID on outbound HTTP calls (HttpClientFactory):
builder.Services.AddHttpClient("downstream")
    .AddHttpMessageHandler<CorrelationIdDelegatingHandler>();

public class CorrelationIdDelegatingHandler : DelegatingHandler
{
    private const string HeaderName = "X-Correlation-Id";

    protected override Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request, CancellationToken ct)
    {
        // Propagate the current trace ID as the correlation header:
        var traceId = Activity.Current?.TraceId.ToString();
        if (traceId is not null)
            request.Headers.TryAddWithoutValidation(HeaderName, traceId);

        return base.SendAsync(request, ct);
    }
}

Note: if OpenTelemetry is configured, the W3C traceparent header propagates the trace ID automatically via AddHttpClientInstrumentation(). A manual X-Correlation-Id header is still useful for non-OpenTelemetry consumers (front-end apps, third-party APIs) that do not understand traceparent.

Rule of thumb: Use the W3C traceparent header as your correlation ID when all services use OpenTelemetry. Add X-Correlation-Id as an alias for clients and logs that need a human-readable identifier in dashboards.

dotnet-monitor is a sidecar tool that exposes diagnostic APIs (traces, dumps, metrics, logs) over HTTP without modifying the application or attaching a debugger. It is the recommended way to capture diagnostics from containerized .NET workloads in production.

# Run dotnet-monitor as a sidecar (Docker Compose example):
# dotnet-monitor listens on localhost:52323 (diagnostic) and 52325 (metrics)
# and connects to the application via the .NET diagnostic pipe.

# docker-compose.yml snippet:
# services:
#   app:
#     image: myapp:latest
#   monitor:
#     image: mcr.microsoft.com/dotnet/monitor:8
#     environment:
#       - DOTNETMONITOR_DiagnosticPort__ConnectionMode=Listen
#       - DOTNETMONITOR_Storage__DumpTempFolder=/tmp
#     volumes:
#       - /tmp:/tmp
#     command: ["collect", "--no-auth"]  # Note: add auth in production

# Capture a CPU trace via the REST API:
curl -X POST http://localhost:52323/trace \
    -H "Content-Type: application/json" \
    -d '{"profile": "CpuSampling", "durationSeconds": 30}' \
    --output trace.nettrace

# Capture a memory dump:
curl -X POST http://localhost:52323/dump?type=Full \
    --output app.dmp

# Live metrics stream (Prometheus-compatible scrape):
# GET http://localhost:52325/metrics
// Trigger-based collection — automatically capture a dump when CPU exceeds 80%:
// (collectionrules.json mounted into the monitor container)
{
  "CollectionRules": {
    "HighCpuDump": {
      "Trigger": { "Type": "EventCounter",
        "Settings": { "ProviderName": "System.Runtime",
          "CounterName": "cpu-usage", "GreaterThan": 80 } },
      "Actions": [
        { "Type": "CollectDump", "Settings": { "Type": "Heap",
          "Egress": "AzureBlobStorage" } }
      ],
      "Limits": { "ActionCount": 2, "ActionCountSlidingWindowDuration": "01:00:00" }
    }
  }
}

Rule of thumb: Deploy dotnet-monitor as a sidecar in every Kubernetes pod running a .NET service. It gives you on-demand traces and dumps without redeploying the application or breaching the container boundary.

More ways to practice

The self-quiz is live. Get notified when mock interviews and new question packs drop.

or
Join our WhatsApp Channel