Skip to main content
SEMastery
DevOpsbeginner

Logging Requests and Responses for APIs and HttpClient in ASP.NET Core

Learn to log incoming API requests and outgoing HttpClient calls in ASP.NET Core using built-in HTTP logging and a custom DelegatingHandler, step by step.

12 min readUpdated February 14, 2026

A receipt for every visit to the shop

Think about a busy sweet shop in your town. People walk in all day. Some buy laddoos, some only ask the price, and some come to return a box that was wrong. At the end of the day, the owner wants to know what happened. Who came in? What did they ask for? Did they leave happy or angry?

A smart shop owner keeps two small notebooks. One notebook is for people who walk into the shop — what they wanted and what they got. The second notebook is for trips the owner makes to other shops — like going to the dairy to buy fresh milk. "I asked for 5 litres, they gave me 5 litres, the bill was 250 rupees."

Your web API is exactly like this shop. Other programs walk in and make requests. Your API also walks out to other APIs using something called HttpClient. To run a healthy service, you want both notebooks: one for requests that come in, and one for requests that go out.

In this guide we will fill both notebooks using ASP.NET Core on .NET 10. The good news is that most of the work is already built for you.

Two directions of traffic your app must log

Why log at all?

When something breaks at 2 in the morning, you cannot ask the user "what exactly did you click?" The logs are your only witness. Good request and response logs help you:

  • See which endpoints are slow or failing.
  • Understand what data a caller actually sent.
  • Prove what a third-party API returned when they say "our side is fine".
  • Trace one user journey across many services using a shared ID.

Let us start with the first notebook: requests that come into your API.

Part 1 — Logging incoming requests with built-in HTTP logging

Since .NET 6, ASP.NET Core ships a middleware called HTTP logging. It lives in the Microsoft.AspNetCore.HttpLogging package, which is already part of the framework. You do not install anything extra. You just switch it on.

There are two steps. First you register the service. Second you add the middleware to the pipeline.

var builder = WebApplication.CreateBuilder(args);
 
// 1. Register HTTP logging and choose what to log
builder.Services.AddHttpLogging(options =>
{
    options.LoggingFields =
        HttpLoggingFields.RequestPropertiesAndHeaders |
        HttpLoggingFields.ResponsePropertiesAndHeaders |
        HttpLoggingFields.Duration;
 
    // Optional: add a header to the log that is normally hidden
    options.RequestHeaders.Add("X-Correlation-Id");
});
 
var app = builder.Build();
 
// 2. Turn the middleware on, early in the pipeline
app.UseHttpLogging();
 
app.MapGet("/hello", () => "Hello from the API");
 
app.Run();

By default the middleware logs common properties: the path, the method, the status code, and the headers. The LoggingFields setting is an enum flag, so you can mix and match the parts you care about with the | symbol.

There is one more step that trips up almost everyone. The logs are written at the Information level under a special category. If your logging level is set higher than that, you will see nothing. Open appsettings.json and add this line.

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware": "Information"
    }
  }
}

Now run the app and call GET /hello. You will see a tidy log entry with the path, the headers, the status code, and how long the request took.

Built-in HTTP logging setup

AddHttpLogging
UseHttpLogging
Set log level

Steps

1

AddHttpLogging

Register and pick fields

2

UseHttpLogging

Add middleware early

3

Set log level

Information in appsettings

The three small steps to switch on request logging

Choosing what to log

The HttpLoggingFields enum lets you control exactly what ends up in the notebook. Here are the common choices.

FieldWhat it logsSafe by default?
RequestPropertiesAndHeadersMethod, path, protocol, request headersYes
ResponsePropertiesAndHeadersStatus code, response headersYes
RequestBodyThe raw request bodyNo, can hold secrets
ResponseBodyThe raw response bodyNo, can be huge
DurationHow long the request tookYes
AllEverything aboveUse with care

A good starting point for most APIs is properties and headers plus duration. Add bodies only when you are chasing a specific bug.

A note on logging bodies

Bodies are tempting but dangerous. A login request body holds a password. A payment body holds card details. A file upload body could be many megabytes. Reading and buffering all of that costs memory and can slow your service under load.

If you must log a body, set a hard size limit so a giant upload does not flood your logs.

builder.Services.AddHttpLogging(options =>
{
    options.LoggingFields = HttpLoggingFields.All;
 
    // Never buffer more than these many bytes
    options.RequestBodyLogLimit = 4096;
    options.ResponseBodyLogLimit = 4096;
 
    // Hide values for sensitive headers
    options.RequestHeaders.Remove("Authorization");
});

Even with limits, redact secrets first. The kindest rule is simple: if you would not write it on a postcard, do not put it in a log.

How an incoming request flows through the logging middleware

Part 2 — Logging outgoing HttpClient calls

Now for the second notebook. Your API often calls other APIs. Maybe a weather service, a payment gateway, or another microservice you own. These calls go through HttpClient, and the best way to make and log them is with IHttpClientFactory.

A little free logging

When you create clients through IHttpClientFactory, you already get some logging for free. The factory adds two helpers to every client. One records a log scope around the whole request, and the other writes a short log line about the request and the response. You can see these by setting this category to Information:

{
  "Logging": {
    "LogLevel": {
      "System.Net.Http.HttpClient": "Information"
    }
  }
}

This gives you the method, the URL, the status code, and the time taken. For many apps that is enough. But when you need richer detail — like specific headers or a trimmed body — you write your own handler.

Writing a DelegatingHandler

A DelegatingHandler is a small class that sits in the middle of every outgoing call. Think of it as a checkpoint on the road. The request stops at the checkpoint, you write a note, you let it pass, the response comes back, and you write another note.

You inherit from DelegatingHandler and override one method called SendAsync. You log before calling base.SendAsync, and you log after.

public sealed class LoggingHandler : DelegatingHandler
{
    private readonly ILogger<LoggingHandler> _logger;
 
    public LoggingHandler(ILogger<LoggingHandler> logger)
    {
        _logger = logger;
    }
 
    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken)
    {
        var watch = Stopwatch.StartNew();
 
        _logger.LogInformation(
            "Outgoing {Method} call to {Url}",
            request.Method,
            request.RequestUri);
 
        var response = await base.SendAsync(request, cancellationToken);
 
        watch.Stop();
 
        _logger.LogInformation(
            "Got {StatusCode} from {Url} in {Elapsed} ms",
            (int)response.StatusCode,
            request.RequestUri,
            watch.ElapsedMilliseconds);
 
        return response;
    }
}

Notice we use message templates with named holes like {Method} and {Url}. This keeps each value as a real searchable field, not just a glued string. That habit pays off when you later search your logs.

Registering the handler

Handlers must be registered as transient services. Then you attach the handler to a named or typed client with AddHttpMessageHandler.

builder.Services.AddTransient<LoggingHandler>();
 
builder.Services
    .AddHttpClient("weather", client =>
    {
        client.BaseAddress = new Uri("https://api.weather.example/");
    })
    .AddHttpMessageHandler<LoggingHandler>();

Now every call made through the weather client passes through your checkpoint and gets logged. You did not have to touch the calling code at all.

DelegatingHandler logging flow

Log request
base.SendAsync
Log response
Return

Steps

1

Log request

Method and URL

2

base.SendAsync

Call the real API

3

Log response

Status and time

4

Return

Pass response back

The checkpoint pattern for outgoing calls

Where your handler sits in the pipeline

The factory builds a chain of handlers, like a row of toll gates. Your LoggingHandler is one gate. If you also add resilience handlers (for retries), the order matters. A handler added first sees the request first.

The outgoing request handler pipeline built by IHttpClientFactory

One thing to watch: if you put logging before a retry handler, you log once per overall call. If you put it after the retry handler, you log once per attempt. Both can be useful. Choose based on whether you want to see every retry or just the final result.

Part 3 — Tying both notebooks together with a correlation ID

Here is where the magic happens. Imagine a request comes into your API, and to answer it your API calls two other services. If something breaks, you want to follow that single journey across all three logs. The trick is a correlation ID: one unique string that travels with the request everywhere.

You read or create it when the request arrives, store it, and then attach it to every outgoing call inside your LoggingHandler.

app.Use(async (context, next) =>
{
    var correlationId = context.Request.Headers["X-Correlation-Id"].FirstOrDefault()
        ?? Guid.NewGuid().ToString();
 
    // Make it available to logs for this request
    using (context.RequestServices
        .GetRequiredService<ILogger<Program>>()
        .BeginScope(new Dictionary<string, object> { ["CorrelationId"] = correlationId }))
    {
        context.Response.Headers["X-Correlation-Id"] = correlationId;
        await next();
    }
});

Now both the incoming log and the outgoing log carry the same CorrelationId. When a customer reports a problem and gives you that ID, you can pull up the entire story in seconds.

One correlation ID flowing through incoming and outgoing logs

Built-in vs custom: which to use?

You do not always have to write code. Often the built-in tools are enough. Here is a simple way to decide.

NeedBest toolWrite code?
Log incoming requests quicklyBuilt-in HTTP logging middlewareNo
Log incoming bodies with limitsHTTP logging with body fieldsSmall config
Basic outgoing call logsIHttpClientFactory default loggingNo
Rich outgoing logs, custom fieldsCustom DelegatingHandlerYes
Trace across servicesCorrelation ID + scopesA little

Start with the built-in options. Add custom handlers only when the built-in logs cannot answer your questions. Less code means fewer bugs.

A few friendly warnings

Logging is helpful, but a careless setup can hurt you. Keep these in mind.

  • Secrets. Strip Authorization, Cookie, passwords, and tokens before they reach a log. The middleware lets you remove sensitive headers from the allowed list.
  • Size. Bodies can be large. Always set RequestBodyLogLimit and ResponseBodyLogLimit when you log bodies.
  • Volume. A busy API can write millions of lines a day. Send logs to a real sink like Seq, Elasticsearch, or a cloud log service, and set sensible levels.
  • Personal data. Names, emails, and addresses may be protected by law. Log only what you truly need.
  • Performance. Reading bodies forces buffering. Measure before turning it on everywhere.

If you also use a structured logging library like Serilog, these logs flow straight into it, and you can search them by field. That pairs beautifully with everything above.

A safe logging checklist

Redact secrets
Limit body size
Pick a fast sink
Add correlation ID

Steps

1

Redact secrets

No passwords in logs

2

Limit body size

Cap the bytes

3

Pick a fast sink

Seq, cloud, etc

4

Add correlation ID

Trace journeys

Run through these before shipping to production

Putting it all in Program.cs

Here is a compact version that brings the incoming and outgoing pieces together, so you can see the whole shape in one place.

var builder = WebApplication.CreateBuilder(args);
 
// Incoming request logging
builder.Services.AddHttpLogging(options =>
{
    options.LoggingFields =
        HttpLoggingFields.RequestPropertiesAndHeaders |
        HttpLoggingFields.ResponsePropertiesAndHeaders |
        HttpLoggingFields.Duration;
});
 
// Outgoing call logging
builder.Services.AddTransient<LoggingHandler>();
builder.Services
    .AddHttpClient("weather", c => c.BaseAddress = new Uri("https://api.weather.example/"))
    .AddHttpMessageHandler<LoggingHandler>();
 
var app = builder.Build();
 
app.UseHttpLogging();
 
app.MapGet("/forecast", async (IHttpClientFactory factory) =>
{
    var client = factory.CreateClient("weather");
    var text = await client.GetStringAsync("today");
    return Results.Ok(text);
});
 
app.Run();

That is a complete, working setup. Incoming requests are logged by the middleware, and the outgoing call to the weather API is logged by your handler.

Quick recap

  • Your API has two kinds of traffic: requests that come in, and calls that go out. Log both.
  • For incoming requests, use the built-in HTTP logging middleware: AddHttpLogging, then UseHttpLogging, then set the log level to Information in appsettings.json.
  • Use HttpLoggingFields to pick exactly what to log. Headers and status are safe; bodies are risky.
  • For outgoing HttpClient calls, IHttpClientFactory gives basic logs for free. For more detail, write a DelegatingHandler that overrides SendAsync, register it as transient, and attach it with AddHttpMessageHandler.
  • Use message templates with named holes so each value stays searchable.
  • Tie everything together with a correlation ID so you can follow one journey across many services.
  • Always redact secrets, limit body sizes, and send logs to a fast sink.

References and further reading

Related Posts