Logging

By the Syncfusion DevTutorials team · Last updated

Record what your app does: ILogger, log levels, structured logging, providers, console and file logging, exception logging, diagnostics, debugging, tracing and metrics.

Introduction to Logging

Think about the black box on an aeroplane.

During a normal flight, nobody looks at it. But if something goes wrong, the black box tells investigators exactly what happened, in what order, and why.

Logging is the black box for your program. While it runs, it writes short messages about what it is doing:

09:14:02 info  Order ORD-2201 placed by Asha, total Rs. 1,299
09:14:03 warn  Payment gateway slow: 2,450 ms
09:14:05 error Email to asha@example.com failed: SMTP timeout

On your own machine you can use a debugger. On a server at 3 a.m., with real users, logs are all you have.

In simple words: logging is your program keeping a diary, so you can find out later what happened.

Easy Way to Remember: if it is not logged, it did not happen (as far as anyone can prove).

What You Will Learn

In this lesson, you will learn:

  1. How ILogger and ILogger<T> work.
  2. The six log levels and when to use each.
  3. Structured logging with message templates.
  4. What logging providers are, including console and file logging.
  5. How to log exceptions properly.
  6. Diagnostic tools and debugging techniques.
  7. Tracing with Activity and performance monitoring with metrics.
  8. How to add diagnostics to a real application.

The examples run in your browser with Microsoft.Extensions.Logging, the same logging library ASP.NET Core uses. The ready-made console and file provider packages are not part of the browser runtime, so the examples include a small provider written by hand. The real package code is shown as plain text next to it.

Start With a Working Example

Press Run.

Your first logger

using System;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;

var services = new ServiceCollection();
services.AddLogging(logging =>
{
    logging.AddProvider(new SimpleConsoleProvider());   // in a real app: logging.AddConsole();
    logging.SetMinimumLevel(LogLevel.Debug);
});
services.AddTransient<OrderService>();

var provider = services.BuildServiceProvider();
provider.GetRequiredService<OrderService>().Place("ORD-2201", "Asha", 1299m);

class OrderService
{
    private readonly ILogger<OrderService> _logger;
    public OrderService(ILogger<OrderService> logger) => _logger = logger;

    public void Place(string orderId, string customer, decimal total)
    {
        _logger.LogDebug("Validating order {OrderId}", orderId);
        _logger.LogInformation("Order {OrderId} placed by {Customer}, total {Total}", orderId, customer, total);
    }
}

// A tiny provider that writes to the console. Real apps use the
// Microsoft.Extensions.Logging.Console package instead.
class SimpleConsoleProvider : ILoggerProvider
{
    public ILogger CreateLogger(string category) => new SimpleConsoleLogger(category);
    public void Dispose() { }
}

class SimpleConsoleLogger : ILogger
{
    private readonly string _category;
    public SimpleConsoleLogger(string category) => _category = category;
    public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
    public bool IsEnabled(LogLevel level) => level != LogLevel.None;

    public void Log<TState>(LogLevel level, EventId id, TState state, Exception? error,
        Func<TState, Exception?, string> format)
    {
        Console.WriteLine($"{level,-11} {_category}: {format(state, error)}");
        if (error != null) Console.WriteLine($"            {error.GetType().Name}: {error.Message}");
    }
}

OrderService asks for an ILogger<OrderService> and writes two messages. It does not know or care where those messages go. That is decided once, at startup.

ILogger

ILogger is the interface you write log messages to. It lives in Microsoft.Extensions.Logging. You almost always use ILogger<T>, where T is your class.

The T sets the logger's category: the full class name, such as OrderService or MyShop.Payments.PaymentService. The category shows up in every message, so you can see which class wrote it, and you can filter by it.

You get a logger through dependency injection, from lesson 17:

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

    public PaymentService(ILogger<PaymentService> logger) => _logger = logger;
}

ILogger has convenient extension methods, one per level:

MethodLevel
LogTrace(...)Trace
LogDebug(...)Debug
LogInformation(...)Information
LogWarning(...)Warning
LogError(...)Error
LogCritical(...)Critical

Outside a dependency injection container, you can build loggers with LoggerFactory.Create:

LoggerFactory without dependency injection

using System;
using Microsoft.Extensions.Logging;

using ILoggerFactory factory = LoggerFactory.Create(builder =>
    builder.AddProvider(new SimpleConsoleProvider()));

ILogger<Program> programLog = factory.CreateLogger<Program>();
ILogger importLog = factory.CreateLogger("Shop.Import");

programLog.LogInformation("App started");
importLog.LogWarning("File {FileName} has {Count} empty rows", "customers.csv", 3);

class SimpleConsoleProvider : ILoggerProvider
{
    public ILogger CreateLogger(string category) => new SimpleConsoleLogger(category);
    public void Dispose() { }
}

class SimpleConsoleLogger : ILogger
{
    private readonly string _category;
    public SimpleConsoleLogger(string category) => _category = category;
    public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
    public bool IsEnabled(LogLevel level) => true;
    public void Log<TState>(LogLevel level, EventId id, TState state, Exception? error, Func<TState, Exception?, string> format)
        => Console.WriteLine($"{level,-11} [{_category}] {format(state, error)}");
}

Log Levels

Not every message is equally important. A log level describes how serious a message is.

LevelNumberUse it forExample
Trace0The tiniest details; off almost always"Entering CalculateTax with 3 items"
Debug1Details useful while developing"Cache miss for product 42"
Information2Normal, important events"Order ORD-2201 placed"
Warning3Something odd that did not stop the work"Payment gateway took 2.4 s"
Error4An operation failed"Could not send email to Asha"
Critical5The whole app is in trouble"Database unreachable; shutting down"

You choose a minimum level. Messages below it are skipped, and cost almost nothing. A typical setup is:

  • Development: Debug, to see more details.
  • Production: Information or Warning, to keep logs smaller.

The minimum level filters messages

using System;
using Microsoft.Extensions.Logging;

foreach (LogLevel minimum in new[] { LogLevel.Debug, LogLevel.Warning })
{
    Console.WriteLine($"--- Minimum level: {minimum} ---");
    using var factory = LoggerFactory.Create(b => b
        .AddProvider(new SimpleConsoleProvider())
        .SetMinimumLevel(minimum));

    var log = factory.CreateLogger("Shop");
    log.LogTrace("Trace: entering checkout");
    log.LogDebug("Debug: cart has 3 items");
    log.LogInformation("Information: order placed");
    log.LogWarning("Warning: stock is low");
    log.LogError("Error: payment failed");
}

class SimpleConsoleProvider : ILoggerProvider
{
    public ILogger CreateLogger(string category) => new SimpleConsoleLogger();
    public void Dispose() { }
}

class SimpleConsoleLogger : ILogger
{
    public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
    public bool IsEnabled(LogLevel level) => true;
    public void Log<TState>(LogLevel level, EventId id, TState state, Exception? error, Func<TState, Exception?, string> format)
        => Console.WriteLine($"  {format(state, error)}");
}

Setting Levels in Configuration

In a real app, levels live in appsettings.json (lesson 18), so you can change them without rebuilding the app. You can set a different level for each category:

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning",
      "MyShop.Payments": "Debug"
    }
  }
}

Structured Logging

Look at these two lines. They print the same text:

_logger.LogInformation($"Order {orderId} placed by {customer}");        // string interpolation
_logger.LogInformation("Order {OrderId} placed by {Customer}", orderId, customer);   // message template

The second one is structured logging. The text in quotes is a message template. {OrderId} and {Customer} are named placeholders, and the values are passed separately.

The logger keeps the values as data, with their names. A log system such as Seq, Elasticsearch or Azure Application Insights can then answer questions like:

  • "Show every log where Customer = Asha."
  • "Count orders per hour where Total > 1000."

With interpolation, you only have one long text string, and searching for specific values becomes difficult.

Structured logging keeps the values as data

using System;
using System.Collections.Generic;
using Microsoft.Extensions.Logging;

using var factory = LoggerFactory.Create(b => b.AddProvider(new InspectingProvider()));
var log = factory.CreateLogger("Orders");

string orderId = "ORD-2201", customer = "Asha";
decimal total = 1299.50m;

log.LogInformation("Order {OrderId} placed by {Customer}, total {Total}", orderId, customer, total);

class InspectingProvider : ILoggerProvider
{
    public ILogger CreateLogger(string category) => new InspectingLogger();
    public void Dispose() { }
}

class InspectingLogger : ILogger
{
    public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
    public bool IsEnabled(LogLevel level) => true;

    public void Log<TState>(LogLevel level, EventId id, TState state, Exception? error, Func<TState, Exception?, string> format)
    {
        Console.WriteLine($"Message: {format(state, error)}");
        Console.WriteLine("Data a log system can search:");
        if (state is IReadOnlyList<KeyValuePair<string, object?>> fields)
            foreach (var (name, value) in fields)
                Console.WriteLine($"  {name,-18} = {value} ({value?.GetType().Name})");
    }
}

The logger received the template, the names and the typed values. Total is still a decimal, not text.

Use PascalCase names for placeholders, such as {OrderId}, and keep the same name for the same thing everywhere. Then one search for OrderId finds every message about that order. Never use $"..." interpolation in a log call: it throws the structure away and builds the string even when the level is turned off.

Log Scopes

A scope attaches extra data to every message logged inside it, such as the current request ID. You do not have to repeat it in each message.

using (_logger.BeginScope("Request {RequestId} for user {UserId}", requestId, userId))
{
    _logger.LogInformation("Loading cart");      // carries RequestId and UserId
    _logger.LogInformation("Applying coupon");   // carries them too
}

ASP.NET Core opens a scope for each web request automatically, with the request ID and path.

Logging Providers

A logging provider decides where log messages go. You can add several, and every message goes to all of them.

ProviderPackageWrites to
ConsoleMicrosoft.Extensions.Logging.ConsoleThe terminal
DebugMicrosoft.Extensions.Logging.DebugThe Visual Studio Output window
EventSourceMicrosoft.Extensions.Logging.EventSource.NET tracing tools such as dotnet-trace
EventLogMicrosoft.Extensions.Logging.EventLogWindows Event Log
Application InsightsMicrosoft.Extensions.Logging.ApplicationInsightsAzure Monitor
Serilog, NLogThird-partyFiles, databases, Seq, Elasticsearch and more

Your classes only depend on ILogger<T>. Switching from the console to a cloud service is a one-line change at startup. That is dependency inversion again (lesson 17).

A provider has two parts:

  • ILoggerProvider creates a logger for each category.
  • ILogger writes the messages.

That is exactly what the small SimpleConsoleProvider in the examples implements.

Console Logging

Console logging shows what your program writes while you develop, and in containers, where the platform collects everything the app writes to the console.

With the real package installed, setup is one line. This cannot run in the browser, so it is plain code:

var builder = WebApplication.CreateBuilder(args);
builder.Logging.ClearProviders();
builder.Logging.AddConsole();                 // human-readable text
// builder.Logging.AddJsonConsole();          // one JSON object per line, for log collectors
// builder.Logging.AddSimpleConsole(o => { o.SingleLine = true; o.TimestampFormat = "HH:mm:ss "; });

The console formatter prints the level, the category and the message, with colours:

info: MyShop.OrderService[0]
      Order ORD-2201 placed by Asha, total 1299.50
warn: MyShop.PaymentService[0]
      Payment gateway slow: 2450 ms

AddJsonConsole() is popular in containers and Kubernetes, because tools like Fluent Bit and Azure Monitor read JSON lines easily.

File Logging

Microsoft does not provide a built-in file provider. Most teams use Serilog or NLog for files, which handle log file rotation, size limits and retention:

// dotnet add package Serilog.AspNetCore
// dotnet add package Serilog.Sinks.File
Log.Logger = new LoggerConfiguration()
    .WriteTo.File("logs/shop-.log", rollingInterval: RollingInterval.Day, retainedFileCountLimit: 14)
    .CreateLogger();

builder.Host.UseSerilog();

To understand how file logging works, here is a small file provider. The browser has an in-memory file system, so it runs here:

A simple file logging provider

using System;
using System.IO;
using Microsoft.Extensions.Logging;

using (var fileProvider = new FileLoggerProvider("shop.log"))
using (var factory = LoggerFactory.Create(b => b.AddProvider(fileProvider)))
{
    var log = factory.CreateLogger("Checkout");
    log.LogInformation("Cart opened by {Customer}", "Ravi");
    log.LogWarning("Coupon {Code} has expired", "DIWALI10");
    log.LogInformation("Order {OrderId} placed", "ORD-3310");
}   // disposing the provider flushes and closes the file

Console.WriteLine("Contents of shop.log:");
Console.Write(File.ReadAllText("shop.log"));

class FileLoggerProvider : ILoggerProvider
{
    private readonly StreamWriter _writer;
    public FileLoggerProvider(string path) => _writer = new StreamWriter(path, append: false);
    public ILogger CreateLogger(string category) => new FileLogger(category, _writer);
    public void Dispose() => _writer.Dispose();
}

class FileLogger : ILogger
{
    private readonly string _category;
    private readonly StreamWriter _writer;
    public FileLogger(string category, StreamWriter writer) { _category = category; _writer = writer; }
    public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
    public bool IsEnabled(LogLevel level) => level >= LogLevel.Information;

    public void Log<TState>(LogLevel level, EventId id, TState state, Exception? error, Func<TState, Exception?, string> format)
    {
        if (!IsEnabled(level)) return;
        lock (_writer)       // many threads may log at once
            _writer.WriteLine($"{level.ToString().ToUpper()[..4]} {_category}: {format(state, error)}");
    }
}

Notice the lock. Many threads log at the same time in a real app, and a file can only be written by one thread at a time. Real file loggers also write on a background thread, so logging never slows down a request.

Exception Logging

When you log an error, pass the exception object itself as the first argument. Do not just log ex.Message.

catch (Exception ex)
{
    _logger.LogError(ex, "Payment failed for order {OrderId}", orderId);   // good
    // _logger.LogError("Payment failed: " + ex.Message);                 // loses the stack trace
}

Passing ex keeps the exception type, the stack trace and every inner exception. Those tell you exactly which line failed and why.

Log the exception, not just its message

using System;
using Microsoft.Extensions.Logging;

using var factory = LoggerFactory.Create(b => b.AddProvider(new ErrorDetailsProvider()));
var log = factory.CreateLogger("Payments");

try
{
    ChargeCard("ORD-4410", 2499m);
}
catch (Exception ex)
{
    log.LogError(ex, "Payment failed for order {OrderId}", "ORD-4410");
}

static void ChargeCard(string orderId, decimal amount)
{
    try
    {
        throw new TimeoutException("Gateway did not answer in 30 seconds");
    }
    catch (TimeoutException inner)
    {
        throw new InvalidOperationException($"Could not charge Rs. {amount}", inner);
    }
}

class ErrorDetailsProvider : ILoggerProvider
{
    public ILogger CreateLogger(string category) => new ErrorDetailsLogger();
    public void Dispose() { }
}

class ErrorDetailsLogger : ILogger
{
    public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
    public bool IsEnabled(LogLevel level) => true;

    public void Log<TState>(LogLevel level, EventId id, TState state, Exception? error, Func<TState, Exception?, string> format)
    {
        Console.WriteLine($"{level}: {format(state, error)}");
        for (Exception? e = error; e != null; e = e.InnerException)
            Console.WriteLine($"  {e.GetType().Name}: {e.Message}");
    }
}

The log shows the outer error and its cause, the timeout. With only ex.Message, you would see "Could not charge Rs. 2499" and have to guess why.

Logs are read by many people and kept for a long time. Never write passwords, tokens, card numbers or full personal details into a log message, even at Debug level. Log an ID instead, such as {CustomerId}, and look the details up when you need them.

Exception Logging Rules

  • Log an exception once, where you handle it. Logging and re-throwing at every level fills the logs with copies.
  • Do not log and swallow without a good reason. If the operation failed, the caller usually needs to know.
  • Never log secrets: passwords, card numbers, tokens, or full personal details.
  • In ASP.NET Core, unhandled exceptions are logged automatically by the framework.

Diagnostic Tools

When logs are not enough, .NET has tools that look inside a running app. These tools run on your machine or server, not in a browser:

ToolWhat it shows
Visual Studio Diagnostic ToolsCPU, memory and events while debugging
dotnet-countersLive numbers: CPU, garbage collection, requests per second, exceptions
dotnet-traceA detailed recording of what the app did, for performance analysis
dotnet-dumpA snapshot of the app's memory, to analyse a hang or crash
dotnet-gcdumpA snapshot of the managed heap, for memory leaks (lesson 19)
Application Insights, Grafana, SeqLogs, traces and metrics from production, searchable in one place
dotnet tool install -g dotnet-counters
dotnet-counters monitor --name MyShop.Api

dotnet tool install -g dotnet-trace
dotnet-trace collect --name MyShop.Api --duration 00:00:30

Debugging Techniques

Logging is one tool. Here are the others every developer uses:

Breakpoints and Stepping

In Visual Studio or VS Code, click in the margin to set a breakpoint. Run with F5. The app stops on that line, and you can inspect every variable.

  • F10 steps over a line; F11 steps into a method.
  • Conditional breakpoints stop only when a condition is true, such as orderId == "ORD-4410".
  • Tracepoints print a message instead of stopping.

Debug and Trace Output

System.Diagnostics.Debug.WriteLine writes to the debugger's Output window. It is removed entirely from Release builds, because its methods are marked [Conditional("DEBUG")] (lesson 14).

Debug.WriteLine($"Cart total before discount: {total}");
Debug.Assert(total >= 0, "Total should never be negative");   // stops the debugger if false

Assertions and Guard Clauses

Check your assumptions in code, so a bug shows up where it starts, not three methods later:

Fail early with clear messages

using System;

try
{
    ApplyDiscount(price: 500m, percent: 120);
}
catch (ArgumentOutOfRangeException ex)
{
    Console.WriteLine($"Caught early: {ex.ParamName} = {ex.ActualValue}");
    Console.WriteLine(ex.Message.Split('\n')[0]);
}

static decimal ApplyDiscount(decimal price, int percent)
{
    ArgumentOutOfRangeException.ThrowIfNegative(price);
    ArgumentOutOfRangeException.ThrowIfGreaterThan(percent, 100);
    return price * (100 - percent) / 100;
}

Reproduce, Then Fix

The most reliable debugging method is simple:

  1. Reproduce the bug with the smallest possible input.
  2. Write a failing test that shows it.
  3. Fix the code until the test passes.
  4. Keep the test, so the bug can never come back silently.

Tracing

In modern apps, one user action often passes through several services: a web API, a payment service, a database, a message queue. A log line from each service is not enough. You need to see one request's whole journey.

Distributed tracing gives each request a trace ID, and each step a span. In .NET, a span is an Activity, and you create activities from an ActivitySource.

Trace the steps of a checkout

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading.Tasks;

var source = new ActivitySource("MyShop.Checkout");

var traceIds = new HashSet<ActivityTraceId>();

// A listener collects finished activities. OpenTelemetry does this in a real app.
using var listener = new ActivityListener
{
    ShouldListenTo = s => s.Name == "MyShop.Checkout",
    Sample = (ref ActivityCreationOptions<ActivityContext> _) => ActivitySamplingResult.AllData,
    ActivityStopped = a =>
    {
        traceIds.Add(a.TraceId);
        Console.WriteLine($"{a.DisplayName,-12} parent: {a.Parent?.DisplayName ?? "-",-9} order: {a.GetTagItem("order.id")}");
    },
};
ActivitySource.AddActivityListener(listener);

using (var checkout = source.StartActivity("Checkout"))
{
    checkout?.SetTag("order.id", "ORD-5120");

    using (var pay = source.StartActivity("ChargeCard"))
    {
        pay?.SetTag("order.id", "ORD-5120");
        await Task.Delay(30);
    }

    using (var mail = source.StartActivity("SendReceipt"))
    {
        mail?.SetTag("order.id", "ORD-5120");
        await Task.Delay(10);
    }
}

Console.WriteLine($"All steps share one trace ID: {traceIds.Count == 1}");

ChargeCard and SendReceipt are children of Checkout, and all three share one trace ID. A tracing tool such as Jaeger, Zipkin or Application Insights draws them as a timeline, so you can see which step was slow.

In a real app you add OpenTelemetry, which listens to activities and sends them to your tracing tool:

builder.Services.AddOpenTelemetry()
    .WithTracing(t => t
        .AddSource("MyShop.Checkout")
        .AddAspNetCoreInstrumentation()
        .AddHttpClientInstrumentation()
        .AddOtlpExporter());

Performance Monitoring

Logs say what happened. Metrics say how much and how fast: requests per second, average response time, orders per minute, error rate.

.NET has two simple tools for this.

Stopwatch for One Operation

Time an operation and warn when it is slow

using System;
using System.Diagnostics;
using System.Threading.Tasks;

foreach (int delay in new[] { 20, 120 })
{
    var watch = Stopwatch.StartNew();
    await Task.Delay(delay);                    // pretend to call a payment gateway
    watch.Stop();

    string level = watch.ElapsedMilliseconds > 100 ? "WARN" : "info";
    Console.WriteLine($"{level}: gateway call took about {(watch.ElapsedMilliseconds >= 100 ? "100+" : "<100")} ms");
}

Meters for Ongoing Numbers

System.Diagnostics.Metrics lets you publish numbers that monitoring tools read continuously. A Meter creates instruments:

  • Counter<T>: a total that only goes up, such as orders placed.
  • Histogram<T>: a spread of values, such as response times.
  • ObservableGauge<T>: a value read on demand, such as queue length.

The browser runtime switches metrics off, so this example is shown as plain code with its desktop .NET output:

using System;
using System.Diagnostics.Metrics;

using var meter = new Meter("MyShop.Orders");
Counter<int> ordersPlaced = meter.CreateCounter<int>("orders_placed");
Histogram<double> orderValue = meter.CreateHistogram<double>("order_value", unit: "INR");

// A listener stands in for dotnet-counters or OpenTelemetry.
using var listener = new MeterListener();
listener.InstrumentPublished = (instrument, l) =>
{
    if (instrument.Meter.Name == "MyShop.Orders") l.EnableMeasurementEvents(instrument);
};
listener.SetMeasurementEventCallback<int>((inst, value, tags, _) =>
    Console.WriteLine($"{inst.Name}: +{value}"));
listener.SetMeasurementEventCallback<double>((inst, value, tags, _) =>
    Console.WriteLine($"{inst.Name}: {value} {inst.Unit}"));
listener.Start();

foreach (double total in new[] { 1299.0, 499.0, 2450.0 })
{
    ordersPlaced.Add(1);
    orderValue.Record(total);
}

// Output on desktop .NET:
// orders_placed: +1
// order_value: 1299 INR
// orders_placed: +1
// order_value: 499 INR
// orders_placed: +1
// order_value: 2450 INR

On a server, you watch these live with dotnet-counters monitor --counters MyShop.Orders, or export them to Prometheus and Grafana with OpenTelemetry.

Easy Way to Remember: logs tell the story, traces show the journey, metrics count the numbers. Together they are called observability.

Application Diagnostics

Putting it all together, a well-observed .NET application has:

  1. Structured logs with ILogger<T>, sensible levels and no secrets.
  2. Configured levels in appsettings.json, more detailed in Development.
  3. Exceptions logged once, with the exception object.
  4. Scopes carrying request and user IDs.
  5. Tracing with ActivitySource and OpenTelemetry across services.
  6. Metrics with Meter for business and performance numbers.
  7. Health checks, so the hosting platform knows if the app is alive:
builder.Services.AddHealthChecks()
    .AddCheck("database", () => HealthCheckResult.Healthy());
app.MapHealthChecks("/health");
  1. High-performance logging in hot paths with source-generated log methods:
public static partial class Log
{
    [LoggerMessage(Level = LogLevel.Information, Message = "Order {OrderId} placed by {Customer}")]
    public static partial void OrderPlaced(ILogger logger, string orderId, string customer);
}

Log.OrderPlaced(_logger, orderId, customer);   // no boxing, no parsing at runtime

The [LoggerMessage] attribute (lesson 14) makes the compiler generate the fastest possible logging code.

Where Logging Is Used

  • Web APIs: every request, error and slow call.
  • Background jobs: start, finish, progress and failures.
  • Payments and orders: an audit trail of what happened and when.
  • Microservices: traces that follow a request through many services.
  • Production support: the first place to look when a user reports a problem.

Common mistakes

MistakeSymptomFix
$"..." interpolation in log callsLogs cannot be searched by field; work done even when the level is offUse a message template with placeholders
LogError("Failed: " + ex.Message)No stack trace or inner exceptionLogError(ex, "Failed for {OrderId}", id)
Logging secrets or personal dataSecurity and privacy problemsLog IDs, not the sensitive values
Everything at InformationLogs too noisy to readUse Debug for details, Warning and Error for problems
Logging the same exception at every layerOne error appears five timesLog once, where it is handled
No provider configuredNothing appears anywhereAdd a provider such as AddConsole()
Minimum level set too highExpected messages are missingLower the level for that category in configuration
Different names for the same field{OrderId} here, {Id} there; searches miss logsUse one name per concept everywhere

What to Remember

  • Logging records what your app does, so you can understand problems later.
  • Inject ILogger<T>; the type sets the category.
  • Six levels: Trace, Debug, Information, Warning, Error, Critical. Set the minimum per environment.
  • Use message templates with named placeholders for structured, searchable logs.
  • Providers decide where logs go: console, debug, files, cloud services.
  • Pass the exception object to LogError, and log each exception once.
  • Debug with breakpoints, guard clauses, assertions and reproducible tests.
  • ActivitySource and Activity trace a request's journey; Meter publishes metrics.
  • Logs, traces and metrics together give you observability.

Next: HttpClient and JSON