Mert Özen Further With Every Line
Backend Logging in .NET — #08

HTTP Logging in ASP.NET Core: AddHttpLogging, the W3C Logger and the Cost of Request/Response Bodies

Mert Özen Nov 8, 2026 9 min 3 views
HTTP Logging in ASP.NET Core: AddHttpLogging, the W3C Logger and the Cost of Request/Response Bodies

Use HTTP logging in production with one event per request, bodies off, and an interceptor that logs the body only on errors. The real defaults of AddHttpLogging, the header allow list, CombineLogs, [HttpLogging] and WithHttpLogging, an IHttpLoggingInterceptor that logs bodies on 4xx/5xx, the W3C logger file and the measured cost of body logging: 85 KB of allocations per request instead of 8 KB.

Version note: The code in this post was built and run with .NET 10; the middleware used here is part of ASP.NET Core 10 itself, no extra package is needed.

Guess First

You turned on HTTP (HyperText Transfer Protocol) logging with the default settings: AddHttpLogging(o => { }) and UseHttpLogging(). A client sends a POST with an Authorization: Bearer ... header and a JSON (JavaScript Object Notation) body. Do you see the token in the log? The body? And one more: if you turn on body logging and use an interceptor to drop the body for successful requests, does the memory cost go back to what it was with body logging off?

AddHttpLogging and the Defaults

HTTP logging is a middleware that logs the incoming request and the outgoing response. The configuration in the Bookstore catalog service:

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Logging.AddFilter("Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware", LogLevel.Information);

builder.Services.AddHttpLogging(logging =>
{
    logging.LoggingFields = mode == "headers"
        ? HttpLoggingFields.RequestPropertiesAndHeaders | HttpLoggingFields.ResponsePropertiesAndHeaders | HttpLoggingFields.Duration
        : HttpLoggingFields.All;
    logging.RequestHeaders.Add("X-Correlation-ID");
    logging.MediaTypeOptions.AddText("application/problem+json");
    logging.RequestBodyLogLimit = 4096;
    logging.ResponseBodyLogLimit = 4096;
    logging.CombineLogs = true;
});
if (mode == "interceptor")
{
    builder.Services.AddHttpLoggingInterceptor<BodyOnErrorInterceptor>();
}

The mode variable comes from the command line; shortly we'll measure the cost of the different modes. The middleware's category is Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware and it writes at the Information level. The "Microsoft.AspNetCore": "Warning" filter in the project templates therefore silently turns HTTP logging off; that's the reason behind the classic "I turned it on but it writes nothing" problem. The filter on the first line fixes it.

Instead of writing the defaults from memory, I created new HttpLoggingOptions() and new W3CLoggerOptions() inside the app and read them:

{"loggingFields":253,"requestBodyLogLimit":32768,"responseBodyLogLimit":32768,"combineLogs":false,"w3cFields":"Date, Time, ServerName, ConnectionInfoFields, ProtocolStatus, TimeTaken, Request","fileSizeLimit":10485760,"retainedFileCountLimit":4,"fileName":"w3clog-","flushInterval":"00:00:01"}

253 is the numeric value of RequestPropertiesAndHeaders | ResponsePropertiesAndHeaders: no body, no duration. The body limits are 32 KB and CombineLogs is off. So the answer to the first half of the opening question: with the default settings, the body doesn't enter the log.

HttpLoggingFields and CombineLogs

Without CombineLogs = true, the middleware writes separate events for one request: request, response, request body, response body, duration. In a log tool they show up as independent lines, and you can tell they belong to the same request only through the TraceId. CombineLogs gathers all of them into a single event at the end of the request. The real output of a GET /books/1 request:

info: Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware[9]
      Request and Response:
      Protocol: HTTP/1.1
      Method: GET
      Scheme: http
      PathBase:
      Path: /books/1
      Accept: */*
      Host: localhost:17440
      User-Agent: curl/8.7.1
      Authorization: [Redacted]
      X-Correlation-ID: web-1a2b3c4d
      StatusCode: 200
      Content-Type: application/json; charset=utf-8
      Duration: 2,4692

The client sent Authorization: Bearer secret-token; the log says [Redacted]. The middleware writes values only for headers in the RequestHeaders and ResponseHeaders lists; for the others it writes the name and hides the value. Because I added X-Correlation-ID to the list, its value is visible. This allow-list approach is a safe default: a newly added header doesn't enter the log on its own.

One more detail: Duration: 2,4692. The duration is in milliseconds and the decimal separator is a comma, because the machine's culture is Turkish. If you try to parse this line as a number in a log tool, the culture difference will hurt; remember that the middleware's event also carries structured properties, and the text is only the formatted form.

Per-Endpoint Rules: [HttpLogging] and WithHttpLogging

The global setting may not be right for every endpoint. There are two ways at the endpoint level:

app.MapGet("/ping", [HttpLogging(HttpLoggingFields.Duration)] () => "pong");

app.MapGet("/allocated", () => GC.GetTotalAllocatedBytes(precise: true)).WithHttpLogging(HttpLoggingFields.None);

The [HttpLogging] attribute works both on controller actions and on minimal API (Application Programming Interface) lambdas; WithHttpLogging adds endpoint metadata for minimal APIs. The only thing logged for /ping:

info: Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware[9]
      Request and Response:
      Duration: 0,0765

The order of precedence: first the global setting in AddHttpLogging, then endpoint metadata overrides it, and finally the interceptor can change it once more per request.

IHttpLoggingInterceptor: Logging the Body Only on Errors

The real value of body logging is at the moment of failure. The answer to "why did this order get a 400?" is usually in the body; the body of a successful order is megabytes nobody reads. We write that rule with an interceptor:

using Microsoft.AspNetCore.HttpLogging;

namespace Bookstore.Catalog;

public sealed class BodyOnErrorInterceptor : IHttpLoggingInterceptor
{
    public ValueTask OnRequestAsync(HttpLoggingInterceptorContext logContext)
    {
        if (logContext.HttpContext.Request.Path.StartsWithSegments("/health"))
        {
            // health checks arrive every 10 seconds, we don't log them at all
            logContext.LoggingFields = HttpLoggingFields.None;
        }
        return default;
    }

    public ValueTask OnResponseAsync(HttpLoggingInterceptorContext logContext)
    {
        if (logContext.HttpContext.Response.StatusCode < 400)
        {
            // drop the body for successful requests, keep it only for 4xx/5xx
            logContext.TryDisable(HttpLoggingFields.RequestBody | HttpLoggingFields.ResponseBody);
        }
        else
        {
            logContext.AddParameter("ErrorStatus", logContext.HttpContext.Response.StatusCode);
        }
        return default;
    }
}

OnResponseAsync is called right before the response starts; the status code is known at that point. TryDisable turns a field off if it's on and returns true. AddParameter adds your own field to the log. An invalid order with a quantity of 50:

info: Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware[9]
      Request and Response:
      Protocol: HTTP/1.1
      Method: POST
      Scheme: http
      PathBase:
      Path: /orders
      Accept: */*
      Host: localhost:17440
      User-Agent: curl/8.7.1
      Content-Type: application/json
      Content-Length: 74
      ErrorStatus: 400
      StatusCode: 400
      Content-Type: application/problem+json
      RequestBody: {"customerId":42,"isbn":"978-0135957059","quantity":50,"note":"gift wrap"}
      RequestBodyStatus: [Completed]
      ResponseBody: {"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"Quantity":["Quantity must be between 1 and 10."]},"traceId":"00-24ef6f4b429ff0c6f314b1f21c09be59-b16788b3297a2b43-00"}
      Duration: 7,5746

Both the incoming body and the ProblemDetails response are in the log; what went wrong is clear at a glance. I had to add application/problem+json with MediaTypeOptions.AddText, otherwise the middleware doesn't recognize that media type as text. The valid version of the same order (quantity 2) comes without a body:

info: Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware[9]
      Request and Response:
      Protocol: HTTP/1.1
      Method: POST
      Scheme: http
      PathBase:
      Path: /orders
      Accept: */*
      Host: localhost:17440
      User-Agent: curl/8.7.1
      Content-Type: application/json
      Content-Length: 73
      StatusCode: 201
      Content-Type: application/json; charset=utf-8
      Location: /orders/01a1b8f2-b5ad-7c28-ae98-cba6cb3526e7
      Duration: 0,4351

GET /health doesn't show up in the log at all. You can have more than one interceptor; they run in the order they were registered.

The Cost of Logging Bodies

The documentation says plainly that HTTP logging can reduce performance, especially when logging bodies. By how much? I measured it. A small client, after a warm-up of 300 requests, sends 3000 POSTs with a roughly 2 KB body one after another; the server returns GC.GetTotalAllocatedBytes before and after the measurement:

using HttpClient http = new() { BaseAddress = new Uri("http://localhost:17440") };
string note = new('x', 2000);
object body = new { customerId = 42, isbn = "978-0135957059", quantity = 2, note };

async Task SendAsync(int count)
{
    for (int i = 0; i < count; i++)
    {
        using HttpResponseMessage response = await http.PostAsJsonAsync("/orders", body);
        response.EnsureSuccessStatusCode();
    }
}

await SendAsync(300);
long before = await http.GetFromJsonAsync<long>("/allocated");
Stopwatch stopwatch = Stopwatch.StartNew();
const int requests = 3000;
await SendAsync(requests);
stopwatch.Stop();
long after = await http.GetFromJsonAsync<long>("/allocated");

Console.WriteLine(string.Create(CultureInfo.InvariantCulture,
    $"{args[0],-12} {(after - before) / requests,8:N0} bytes/request {stopwatch.Elapsed.TotalMilliseconds / requests,7:N3} ms/request"));

I ran the server in four modes: HTTP logging off (none), headers and duration only (headers), everything including the body (body), and body on but dropped for successful requests by the interceptor above (interceptor). Console output was redirected to a file. The results:

none            8,296 bytes/request   0.700 ms/request
headers        12,259 bytes/request   0.711 ms/request
body           85,502 bytes/request   0.720 ms/request
interceptor    21,521 bytes/request   0.727 ms/request

The size of the log files for the same 3300 requests: none 650 bytes, headers 1,848,244 bytes, body 15,407,894 bytes, interceptor 1,528,136 bytes.

Three findings. First, body logging increased allocations per request about tenfold (from 8 KB to 85 KB) and the log volume eightfold. The body itself is 2 KB, but the text written to the log, the buffers and the string conversions leave a much larger memory footprint. Second, with a single sequential client the latency difference was too small to measure; the console logger writes logs in the background, so the request path doesn't wait. That doesn't mean the cost is gone: under load, extra allocations mean more frequent garbage collection, and that cost doesn't show up in this single-client measurement. Third, and this is the answer to the second opening question: the interceptor brought the log volume down to the header level, but allocations dropped to 21 KB, not 12 KB. The middleware has to buffer the body while the request is being read, and the status code is known only when the response starts. Even a body that will be dropped has to be copied once.

These numbers hold for this machine and this body size. Run the same measurement for your own service with your own typical body; especially on endpoints with large bodies, such as file uploads, turning body logging off with WithHttpLogging is almost always the right call.

When to Use the W3C Logger

The W3C (World Wide Web Consortium) logger writes to a file in the W3C Extended Log File Format used by IIS (Internet Information Services) and other web servers. It doesn't go through the ILogger pipeline; it writes straight to its own file:

builder.Services.AddW3CLogging(w3c =>
{
    w3c.LoggingFields = W3CLoggingFields.Date | W3CLoggingFields.Time | W3CLoggingFields.Method |
        W3CLoggingFields.UriStem | W3CLoggingFields.ProtocolStatus | W3CLoggingFields.TimeTaken;
    w3c.LogDirectory = Path.Combine(builder.Environment.ContentRootPath, "w3c");
    w3c.FlushInterval = TimeSpan.FromSeconds(1);
});

After adding the middleware with app.UseW3CLogging(), the content of w3c/w3clog-20261108.0000.txt:

#Version: 1.0
#Start-Date: 2026-11-08 02:17:56
#Fields: date time cs-method cs-uri-stem sc-status time-taken
2026-11-08 02:17:56 GET /defaults 200 2.9069
2026-11-08 02:17:56 GET /books/1 200 2.1645
2026-11-08 02:17:56 GET /books/99 404 0.1062
2026-11-08 02:17:56 POST /orders 400 7.101
2026-11-08 02:17:56 POST /orders 201 0.4159
2026-11-08 02:17:56 GET /health 200 0.1659
2026-11-08 02:17:56 GET /ping 200 0.0693

One line per request, fixed columns, culture-independent numbers. /health is here too, because the interceptor affects only the HTTP logging middleware. The defaults roll the file at 10 MB and keep the last 4 files. If you have an older analysis tool that reads this format, or you want an access log on a server running outside containers, on disk, the W3C logger makes sense. In the container world, structured logs going to stdout are usually the better choice.

Compared with UseSerilogRequestLogging

UseSerilogRequestLogging from this series' "Serilog in Depth" post also writes a log per request. Which one should you use when?

AspectAddHttpLoggingUseSerilogRequestLoggingAddW3CLogging
Number of eventsSeveral by default, one with CombineLogsOne per requestOne line per request
Headers and bodyHeaders via allow list, optional bodyNone; method, path, status and durationSelected W3C fields, no body
EnrichmentAddParameter in an interceptorEnrichDiagnosticContextLimited extra headers
DestinationThe ILogger pipelineSerilog sinksIts own file
WhenDebugging, bodies on specific endpointsRequest summary in productionWhere an access-log format is required

My default: in production, a single summary event per request (UseSerilogRequestLogging if you use Serilog, otherwise HTTP logging with CombineLogs on and bodies off), and bodies only on errors via an interceptor, and if needed only on specific endpoints.

In Production

Middleware order matters. Put UseHttpLogging near the start of the pipeline; the work of middleware that comes before it (for example static files) isn't logged and isn't included in the duration. Whether it sits before or after the exception handler also determines whether the body of 500 responses enters the log; try it once in your own pipeline.

A body can contain personal data or a password. Headers are protected by the allow list, but there's no such protection for the body: if RequestBody is on, the password sent to the login endpoint enters the log. On endpoints such as login, registration and payment, always turn body logging off with WithHttpLogging or an interceptor. To mask headers and route parameters by classifying them, there's AddHttpLoggingRedaction in the Microsoft.AspNetCore.Diagnostics.Middleware package; the classification logic is the same as in this series' "Sensitive Data in Logs" post.

Choose the limits deliberately. The default 32 KB body limit means up to 64 KB of log text per request. I lowered it to 4 KB; for debugging, the first few KB are usually enough.

With .NET 11 it was announced that the HTTP logging middleware pools its response buffering streams, reducing per-request allocations when response body logging or interceptors are on; the measurement above is on .NET 10.

Common Pitfalls

First, turning HTTP logging on and forgetting the "Microsoft.AspNetCore": "Warning" filter. Nothing gets written and nobody notices; raise the middleware's category to Information explicitly.

Second, leaving HttpLoggingFields.All on in production. In the measurement above the log volume grew eightfold; if your log tool charges by ingestion, so does the bill.

Third, leaving CombineLogs off and trying to match request and response lines in the log tool. One event, one query.

Fourth, assuming the interceptor eliminates the cost of the body completely. It lowers the log volume, but the buffering cost remains; on endpoints where the body is never needed, turn the field off at the endpoint level.

A Small Experiment

★☆☆ Turn CombineLogs off and send the GET /books/1 request again. How many separate events come out, and what is the EventId of each?

★★☆ Add a rule to the interceptor: for paths starting with /auth, never log RequestBody, whatever the status code. Measure whether writing the rule in OnRequestAsync or in OnResponseAsync is cheaper.

★★★ Repeat the measurement with a 20 KB body, running it twice with RequestBodyLogLimit set to 4 KB and to 32 KB. Find out whether the allocation difference scales with the body size or with the limit.

We've seen the volume and cost of HTTP logging. In the next post we look at the total cost of logs: measuring volume per level, sampling and buffering with Microsoft.Extensions.Telemetry, flushing the preceding Debug logs when an error occurs, and changing the log level in production without a restart.