Application Insights, structured logging, SQL query plans, ILogger, BenchmarkDotNet, pagination vs full-table fetch, memory dumps
Production bugs and performance problems are different from development bugs.
You cannot attach a debugger. You have limited visibility. The system is under real load. And in many cases, the issue is not an exception — it's slow code, a memory leak, or a database query that works fine with 100 rows but falls apart with 4 million.
Part 1: Debugging Production Issues
Structured Logging — Your First Line of Defence
Production debugging starts and ends with good logs.
// ❌ Bad — unstructured, unsearchable
_logger.LogInformation("User " + userId + " placed order " + orderId);
// ✅ Good — structured, queryable
_logger.LogInformation(
"User {UserId} placed order {OrderId} for {Product}",
userId, orderId, product);
Structured logging lets you query logs by field values in Application Insights, Seq, Datadog, or any log aggregator.
Correlation IDs
Every request should carry a trace ID from entry to exit — across services.
app.Use(async (context, next) =>
{
var correlationId = context.Request.Headers["X-Correlation-Id"]
.FirstOrDefault() ?? Guid.NewGuid().ToString();
context.Response.Headers["X-Correlation-Id"] = correlationId;
using (_logger.BeginScope(new { CorrelationId = correlationId }))
{
await next();
}
});
When a user reports a bug with the correlation ID, you can pull every log entry for that request in seconds.
Application Insights
The Azure-native monitoring solution for .NET apps.
builder.Services.AddApplicationInsightsTelemetry();
Out of the box:
- Request traces with duration and status codes
- Dependency tracking (SQL queries, HTTP calls, queues)
- Exception capture with full stack traces
- Live Metrics for real-time monitoring
- Custom events and metrics
_telemetryClient.TrackEvent("OrderCreated", new Dictionary<string, string>
{
{ "OrderId", orderId.ToString() },
{ "CustomerId", customerId }
});
Health Checks
builder.Services.AddHealthChecks()
.AddDbContextCheck<AppDbContext>()
.AddUrlGroup(new Uri("https://api.external.com/health"), "ExternalApi");
app.MapHealthChecks("/health");
Part 2: Debugging Performance Issues
The Dashboard Problem — Millions of Records
The most common real-world performance issue.
// ❌ The problem — loading all 4 million rows into memory
public async Task<List<OrderDto>> GetDashboardOrders()
{
return await db.Orders
.Select(o => new OrderDto(o.Id, o.Product, o.Total))
.ToListAsync(); // 4,200,000 rows loaded. Memory spikes. Timeout.
}
The fix has multiple layers.
Layer 1: Pagination
public async Task<PagedResult<OrderDto>> GetDashboardOrders(int page, int pageSize = 50)
{
var total = await db.Orders.CountAsync();
var items = await db.Orders
.OrderByDescending(o => o.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(o => new OrderDto(o.Id, o.Product, o.Total))
.AsNoTracking()
.ToListAsync();
return new PagedResult<OrderDto>(items, total, page, pageSize);
}
Layer 2: Projection — select only what you need
// ✅ Only the columns needed
.Select(o => new OrderDto(o.Id, o.Product, o.Total))
// ❌ Loads entire object graph
.Include(o => o.Lines)
Layer 3: AsNoTracking() for read-only queries
.AsNoTracking() // Skips EF change tracking overhead
Layer 4: Compiled Queries for hot paths
private static readonly Func<AppDbContext, string, Task<List<OrderDto>>> GetOrdersByCustomer =
EF.CompileAsyncQuery((AppDbContext db, string customerId) =>
db.Orders
.Where(o => o.CustomerId == customerId)
.Select(o => new OrderDto(o.Id, o.Product, o.Total))
.AsNoTracking());
var orders = await GetOrdersByCustomer(db, customerId);
SQL Query Plans — Finding Slow Queries
Most performance problems are SQL problems, not C# problems.
Step 1: Log EF Core SQL output
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(connectionString)
.LogTo(Console.WriteLine, LogLevel.Information)
.EnableSensitiveDataLogging());
Step 2: Run the query in SSMS
Press Ctrl+M to include the actual execution plan. Look for:
- Full table scans — no index is being used
- Key lookups — covering index needed
-
Sort operators — missing index on
ORDER BYcolumn - Hash joins on large tables — join column not indexed
Step 3: Add missing indexes
CREATE NONCLUSTERED INDEX IX_Orders_CustomerId_CreatedAt
ON Orders (CustomerId, CreatedAt DESC)
INCLUDE (Product, Total, Status);
Or via EF Core migrations:
modelBuilder.Entity<Order>()
.HasIndex(o => new { o.CustomerId, o.CreatedAt })
.IsDescending(false, true);
BenchmarkDotNet — Measuring C# Performance
[MemoryDiagnoser]
public class OrderMappingBenchmark
{
private List<Order> _orders = Enumerable.Range(0, 10_000)
.Select(i => new Order { Id = i, Product = "Widget", Total = 9.99m })
.ToList();
[Benchmark]
public List<OrderDto> ManualMapping()
=> _orders.Select(o => new OrderDto(o.Id, o.Product, o.Total)).ToList();
[Benchmark]
public List<OrderDto> AutoMapperMapping()
=> _mapper.Map<List<OrderDto>>(_orders);
}
dotnet run -c Release
The N+1 Query Problem
The most common EF Core performance issue.
// ❌ N+1 — one query for orders, then one per order for customer
var orders = await db.Orders.ToListAsync();
foreach (var order in orders)
{
var customer = await db.Customers.FindAsync(order.CustomerId); // 1 query per order!
}
// ✅ Single query with Include
var orders = await db.Orders
.Include(o => o.Customer)
.ToListAsync();
Memory Profiling — Diagnosing Leaks
Signs of a memory problem:
- Gen 2 GC collections increasing over time
- Memory usage that never drops after load
-
OutOfMemoryExceptionunder sustained traffic
Tools: dotMemory (JetBrains), Visual Studio Diagnostic Tools, PerfView
Quick in-code check:
_logger.LogInformation(
"GC Gen0={Gen0} Gen1={Gen1} Gen2={Gen2} TotalMemory={TotalMB}MB",
GC.CollectionCount(0),
GC.CollectionCount(1),
GC.CollectionCount(2),
GC.GetTotalMemory(false) / 1024 / 1024);
Interview-Ready Summary
- Structured logging is non-negotiable — use named parameters, not string concatenation
- Correlation IDs link every log entry across a full request lifecycle
- Application Insights provides out-of-the-box tracing, exceptions, and dependencies
- The dashboard performance fix: paginate + project +
AsNoTracking()+ indexes - Never load millions of rows — use
.Skip().Take()or cursor-based pagination - Check SQL execution plans for table scans and missing indexes
- BenchmarkDotNet for C# micro-benchmarks
- N+1 queries are the most common EF Core trap — fix with
Include()or projection
A strong interview answer:
"For production debugging, structured logging with correlation IDs and Application Insights are the foundation — they let you trace any request across the system. For performance issues like the dashboard problem, the fix is almost always: don't load what you don't need. That means pagination with Skip/Take, projection to select only required columns, AsNoTracking for read-only queries, and checking the SQL execution plan for missing indexes. The N+1 query is the most common EF Core trap — fixed with eager loading or projecting into a join."
⭐ Add-On — The Production Debugging Checklist
When something goes wrong in production, follow this order:
- Check logs — is there an exception? What's the correlation ID?
- Check Application Insights — is it a slow dependency (SQL, HTTP)?
- Check resource metrics — is CPU/memory/connection pool exhausted?
- Reproduce in staging — enable detailed EF logging, check SQL plans
- Add targeted logging — deploy a fix that adds more visibility
- Benchmark if needed — BenchmarkDotNet for hot-path issues
- Fix, measure, verify — confirm the fix with before/after metrics
Top comments (0)