Why logging and monitoring matter in .NET interviews
Production systems that cannot be observed cannot be debugged. Interviewers probe
logging to check whether a candidate understands structured logging (not just
Console.WriteLine), knows how to correlate logs across a request with scopes,
and can configure health checks that make sense for a Kubernetes deployment.
This article walks through the full stack — from ILogger<T> basics to
OpenTelemetry distributed tracing.
ILogger: the built-in abstraction
ILogger<T> is the standard .NET logging interface. The generic parameter T
becomes the category name — the source identifier that appears in every log
entry and lets 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 {CustomerId}",
order.Id, order.CustomerId);
try
{
var result = await _repo.SaveAsync(order);
_logger.LogInformation("Order {OrderId} saved", result.Id);
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to place order {OrderId}", order.Id);
throw;
}
}
}
Use message templates ({OrderId}), never string interpolation ($"{order.Id}").
Templates let structured logging providers store each placeholder as a separate
queryable field — the difference between a plain string and a searchable document.
Log levels and when to use them
.NET defines six levels in ascending severity. The configured minimum level filters out everything below it, so disabled levels cost almost nothing at runtime.
| Level | When to log |
|---|---|
| Trace | Step-by-step traces for deep debugging only |
| Debug | Developer diagnostics (cache state, loop variables) |
| Information | Normal business events (order placed, user logged in) |
| Warning | Handled unexpected conditions (retry, degraded mode) |
| Error | Unhandled failures requiring investigation |
| Critical | System-wide failures requiring immediate action |
// Configuration in appsettings.json:
// {
// "Logging": {
// "LogLevel": {
// "Default": "Information",
// "Microsoft.AspNetCore": "Warning", // suppress noisy framework logs
// "MyApp.Data": "Debug" // verbose for one namespace
// }
// }
// }
Log Information for every significant business event — these form the audit trail.
Log Warning when you handle an error gracefully. Log Error for failures that
a human needs to investigate.
Structured logging: why it matters
Structured logging stores log entries as key-value documents. This transforms logs from a write-only archive into a queryable dataset.
// Plain text — unsearchable:
_logger.LogInformation($"Order {order.Id} placed for customer {order.CustomerId}");
// Stored as: "Order 42 placed for customer 99"
// Structured — each placeholder is a separate field:
_logger.LogInformation("Order {OrderId} placed for {CustomerId}, total {Total:C}",
order.Id, order.CustomerId, order.Total);
// Stored as: { "OrderId": 42, "CustomerId": 99, "Total": 150.00, "@l": "Information", ... }
// Now queryable in Seq or Kibana:
// OrderId = 42
// Total > 100 AND @l = "Error"
// CustomerId = 99 AND @t > "2026-06-01T00:00:00"
Structured logging answers "how many orders over $100 failed last Thursday?" without parsing strings — something impossible with plain text logs.
Serilog: the most popular .NET logging library
Serilog adds rich sink support (files, Seq, Elasticsearch, Application Insights)
while integrating transparently with ILogger<T> so no application code changes.
// dotnet add package Serilog.AspNetCore Serilog.Sinks.Console Serilog.Sinks.File
// Program.cs:
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Warning)
.Enrich.FromLogContext() // picks up log scope properties
.Enrich.WithMachineName()
.WriteTo.Console(new JsonFormatter())
.WriteTo.File("logs/app-.log", rollingInterval: RollingInterval.Day)
.WriteTo.Seq("http://seq:5341")
.CreateLogger();
builder.Host.UseSerilog();
// One structured log line per HTTP request (replaces two noisy default events):
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");
});
UseSerilogRequestLogging() replaces ASP.NET Core's default request logging
(which emits two events per request) with one structured event that includes
timing and the authenticated user ID.
Log scopes: ambient context without parameter passing
Log scopes add properties to every log entry within a using block. This is
the cleanest way to attach a request ID or correlation ID without threading it
through every method signature.
public async Task<Order> PlaceOrderAsync(Order order)
{
using (_logger.BeginScope(new Dictionary<string, object>
{
["OrderId"] = order.Id,
["CorrelationId"] = Activity.Current?.Id ?? Guid.NewGuid().ToString(),
}))
{
// Every log in this block carries OrderId + CorrelationId:
_logger.LogInformation("Validating");
await ValidateAsync(order);
_logger.LogInformation("Saving");
await _repo.SaveAsync(order);
}
}
In Serilog, .Enrich.FromLogContext() is required to pick up scope properties.
In the built-in provider, IncludeScopes: true must be set in the logging config.
Health checks: liveness and readiness
ASP.NET Core's health checks expose /health endpoints that orchestrators probe to
decide whether to route traffic to an instance. The two probe types serve different
purposes.
builder.Services.AddHealthChecks()
.AddDbContextCheck<AppDbContext>(tags: ["readiness"])
.AddRedis("localhost:6379", tags: ["readiness"]);
// Liveness — is the process running? No dependency checks:
app.MapHealthChecks("/health/live", new HealthCheckOptions
{
Predicate = _ => false, // always 200 if the process is alive
});
// Readiness — can this instance handle traffic?
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = c => c.Tags.Contains("readiness"),
});
Liveness probe failure causes Kubernetes to restart the pod. Readiness probe failure removes it from the load balancer without restarting it. A database being down should make the pod unready (not unalive) — the pod can recover without a restart once the database comes back.
Custom metrics with System.Diagnostics.Metrics
.NET 8+ has a built-in metrics API that exports to Prometheus, OpenTelemetry, or Application Insights without changing application code.
// Define metrics once as static fields:
private static readonly Meter _meter = new("MyApp.Orders", "1.0.0");
private static readonly Counter<long> _placed = _meter.CreateCounter<long>("orders.placed");
private static readonly Histogram<double> _total = _meter.CreateHistogram<double>("orders.total.usd");
// Emit in application code:
public async Task<Order> PlaceOrderAsync(Order order)
{
var result = await _repo.SaveAsync(order);
_placed.Add(1, new TagList { { "channel", order.Channel } });
_total.Record((double)order.Total, new TagList { { "region", order.Region } });
return result;
}
// Export to Prometheus:
builder.Services.AddOpenTelemetry()
.WithMetrics(m => m.AddMeter("MyApp.Orders").AddPrometheusExporter());
app.MapPrometheusScrapingEndpoint("/metrics");
Define meters and instruments as static fields — creating them per-request causes duplicate registration errors and is expensive.
Distributed tracing with OpenTelemetry
Distributed tracing tracks a request as it flows through multiple services, creating a timeline that shows exactly where time was spent and where errors occurred.
builder.Services.AddOpenTelemetry()
.WithTracing(t => t
.AddSource("MyApp.Orders")
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddEntityFrameworkCoreInstrumentation()
.AddJaegerExporter(j => j.AgentHost = "jaeger"));
// Custom span for a business operation:
private static readonly ActivitySource _src = new("MyApp.Orders");
public async Task<Order> PlaceOrderAsync(Order order)
{
using var span = _src.StartActivity("PlaceOrder");
span?.SetTag("order.id", order.Id);
span?.SetTag("customer.id", order.CustomerId);
try
{
var result = await _repo.SaveAsync(order);
span?.SetStatus(ActivityStatusCode.Ok);
return result;
}
catch (Exception ex)
{
span?.SetStatus(ActivityStatusCode.Error, ex.Message);
span?.RecordException(ex);
throw;
}
}
Performance-safe logging
Even disabled log levels have a cost if arguments are evaluated before the level
check. Use [LoggerMessage] source generators for hot paths:
public partial class ItemProcessor
{
[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 allocation if Debug is off
}
}
The source generator produces the same zero-allocation code as the manual
LoggerMessage.Define<T> pattern but with a readable, type-safe call site.
Rule of thumb: Use [LoggerMessage] for any logging in a method called more
than ~1000 times per second. For normal business logic paths, the standard
ILogger.LogXxx with message templates is fast enough.