Apr 25th, 2026

Capturing HTTP Request and Response Bodies in .NET Traces with PHI Redaction

> Standard OTel .NET instrumentation captures headers, status codes, and timing — not request or response bodies. Here's how to add body capture to your traces while keeping PHI out of your observability backend.

Capturing HTTP Request and Response Bodies in .NET Traces with PHI Redaction

Contents

Capturing HTTP Request and Response Bodies in .NET Traces with PHI Redaction

Standard OTel .NET instrumentation captures headers, status codes, and timing — not request or response bodies. Here's how to add body capture to your traces while keeping PHI out of your observability backend.

Your trace shows a 500 Internal Server Error on POST /api/patients/treatment. The span has the URL, the status code, the duration. But the request body — the JSON payload that triggered the failure — isn't there. You're back to correlating logs by timestamp, hoping the application logged enough context.

This is the gap standard OpenTelemetry instrumentation leaves in .NET applications. The OTel spec deliberately excludes HTTP bodies from default instrumentation: bodies are unbounded in size, frequently contain PII, and capturing them at every request can meaningfully affect performance. The decision is correct at the framework level. It's frustrating at 2am during an incident.

This post covers how to fill that gap with a middleware-based approach and centralize PHI redaction in the OTel Collector — so policy lives in one place, not in every service.

Why OTel Doesn't Capture Bodies by Default

The ASP.NET Core OTel instrumentation creates spans with HTTP semantic convention attributes: http.request.method, http.response.status_code, url.path, server.address. Bodies are explicitly out of scope.

Three reasons:

Size. A request body can be a few bytes or several megabytes. Span attributes have size limits. An 8MB file upload as a span attribute would break your collector.

PII exposure. Automatically capturing everything that comes in over HTTP means capturing passwords, tokens, SSNs, credit card numbers — anything a user sends. Most teams don't want their observability backend to be the accidental owner of that data.

Performance. Reading a request body requires buffering it, because HttpRequest.Body is a forward-only stream. Once read, it's consumed. Buffering every request adds memory pressure and latency.

None of these are blockers — they're design considerations that push the decision to the application layer. Which is the right place for it.

Three Approaches, One Winner

Before committing to an implementation, it's worth knowing what the alternatives look like.

Reverse proxy capture — deploy Envoy or an Istio sidecar in front of all services, capture bodies at the proxy layer. Architecturally clean: zero code change per app, centralized capture and redaction. In practice, Envoy's OTel body capture support is limited and not production-ready. You add a proxy hop, HTTPS termination complexity, and a new infrastructure component to manage. Not worth it for most teams.

Auto-instrumentation experimental flags — the .NET auto-instrumentation agent has experimental environment variables for capturing HttpClient (outgoing) request bodies. As of early 2026, these don't cover ASP.NET Core (incoming) request bodies. Partial coverage is worse than no coverage — you get inconsistent data and false confidence.

ASP.NET Core middleware — a lightweight middleware component reads the request body before it reaches your handlers, captures it as a span attribute, and swaps the response stream to read the response body before it's sent. Two lines to register in Program.cs. One middleware class. The PHI redaction problem is solved separately in the collector, which is where it belongs.

The middleware approach wins: minimal app change, works everywhere ASP.NET Core runs, and keeps redaction policy centralized.

The Architecture

┌─────────────────────────────────────┐
│  ASP.NET Core App                   │
│                                     │
│  BodyCaptureMiddleware              │
│  ├─ reads request body (buffered)   │
│  ├─ sets http.request.body span attr│
│  ├─ wraps response stream           │
│  └─ sets http.response.body span attr│
│                                     │
│  OTel SDK → OTLP exporter          │
└──────────────────┬──────────────────┘
                   │ OTLP

┌─────────────────────────────────────┐
│  OTel Collector (Gateway)           │
│                                     │
│  transform/redact-phi               │
│  ├─ SSN: ***-**-****                │
│  ├─ Email: ****@****.***            │
│  ├─ Phone: ***-***-****             │
│  ├─ DOB: ****-**-**                 │
│  └─ Credit card: ****-****-****-****│
└──────────────────┬──────────────────┘
                   │ OTLP

           Observability backend
           (Last9, Datadog, etc.)

PHI enters the collector, gets redacted, and exits clean. Your application captures bodies without knowing what's in them. The redaction rules live in one YAML file, applied to every app that sends traces through the collector.

The Middleware

// BodyCaptureMiddleware.cs
using Microsoft.AspNetCore.Http;
using OpenTelemetry.Trace;
using System.Diagnostics;
using System.Text;

public class BodyCaptureMiddleware
{
    private readonly RequestDelegate _next;
    private readonly BodyCaptureOptions _options;

    public BodyCaptureMiddleware(RequestDelegate next, BodyCaptureOptions options)
    {
        _next = next;
        _options = options;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        // Skip excluded paths (health checks, metrics endpoints, etc.)
        if (_options.ExcludedPaths.Any(p => context.Request.Path.StartsWithSegments(p)))
        {
            await _next(context);
            return;
        }

        string? requestBody = null;
        string? responseBody = null;

        // Capture request body
        if (ShouldCapture(context) && context.Request.ContentLength > 0)
        {
            context.Request.EnableBuffering();
            using var reader = new StreamReader(
                context.Request.Body,
                Encoding.UTF8,
                detectEncodingFromByteOrderMarks: false,
                bufferSize: _options.MaxBodySizeBytes,
                leaveOpen: true
            );

            var body = await reader.ReadToEndAsync();
            context.Request.Body.Position = 0;

            if (body.Length <= _options.MaxBodySizeBytes)
                requestBody = body;
        }

        // Swap response stream to capture response body
        var originalResponseBody = context.Response.Body;
        using var captureStream = new MemoryStream();
        context.Response.Body = captureStream;

        try
        {
            await _next(context);

            captureStream.Position = 0;
            responseBody = await new StreamReader(captureStream).ReadToEndAsync();
            captureStream.Position = 0;
            await captureStream.CopyToAsync(originalResponseBody);
        }
        finally
        {
            context.Response.Body = originalResponseBody;
        }

        // Attach to current span
        var span = Activity.Current;
        if (span != null && ShouldCaptureResponse(context))
        {
            if (requestBody != null)
                span.SetTag("http.request.body", requestBody);

            if (responseBody != null)
                span.SetTag("http.response.body", responseBody.Length <= _options.MaxBodySizeBytes
                    ? responseBody
                    : responseBody[.._options.MaxBodySizeBytes] + "...[truncated]");
        }
    }

    private bool ShouldCapture(HttpContext context)
    {
        if (!_options.CaptureOnErrorOnly) return true;
        // For error-only mode, we still need to buffer — we don't know the status yet
        return true;
    }

    private bool ShouldCaptureResponse(HttpContext context)
    {
        if (_options.CaptureOnErrorOnly)
            return context.Response.StatusCode >= 400;
        return true;
    }
}

public class BodyCaptureOptions
{
    public bool CaptureOnErrorOnly { get; set; } = true;
    public int MaxBodySizeBytes { get; set; } = 8192; // 8KB default
    public List<PathString> ExcludedPaths { get; set; } = new()
    {
        "/health",
        "/metrics",
        "/ready",
    };
}

Register it in Program.cs — two lines:

// Program.cs
var builder = WebApplication.CreateBuilder(args);

// Bind options from appsettings.json
builder.Services.Configure<BodyCaptureOptions>(
    builder.Configuration.GetSection("BodyCapture")
);

// ... your existing services, OTel setup, etc.

var app = builder.Build();

// Add before UseRouting/UseEndpoints
app.UseMiddleware<BodyCaptureMiddleware>(
    builder.Configuration.GetSection("BodyCapture").Get<BodyCaptureOptions>()
    ?? new BodyCaptureOptions()
);

app.MapControllers();
app.Run();

Configure per environment in appsettings.json:

{
  "BodyCapture": {
    "CaptureOnErrorOnly": true,
    "MaxBodySizeBytes": 8192,
    "ExcludedPaths": ["/health", "/metrics", "/ready", "/favicon.ico"]
  }
}

In production, CaptureOnErrorOnly: true captures bodies only on 4xx/5xx responses. This gives you the debugging context when you need it without adding body data to every successful request.

Collector-Side PHI Redaction

The middleware sends raw bodies as span attributes. The collector redacts PHI before the data leaves your infrastructure.

# otel-collector-config.yaml
processors:
  transform/redact-phi:
    trace_statements:
      - context: span
        statements:
          # SSN: 123-45-6789
          - replace_pattern(attributes["http.request.body"],
              "\\b\\d{3}-\\d{2}-\\d{4}\\b", "***-**-****")
          - replace_pattern(attributes["http.response.body"],
              "\\b\\d{3}-\\d{2}-\\d{4}\\b", "***-**-****")

          # Email addresses
          - replace_pattern(attributes["http.request.body"],
              "[a-zA-Z0-9._%+\\-]+@[a-zA-Z0-9.\\-]+\\.[a-zA-Z]{2,}", "****@****.***")
          - replace_pattern(attributes["http.response.body"],
              "[a-zA-Z0-9._%+\\-]+@[a-zA-Z0-9.\\-]+\\.[a-zA-Z]{2,}", "****@****.***")

          # US phone numbers: 555-867-5309, 555.867.5309
          - replace_pattern(attributes["http.request.body"],
              "\\b\\d{3}[-.\\s]\\d{3}[-.\\s]\\d{4}\\b", "***-***-****")
          - replace_pattern(attributes["http.response.body"],
              "\\b\\d{3}[-.\\s]\\d{3}[-.\\s]\\d{4}\\b", "***-***-****")

          # Date of birth ISO: 1985-04-12
          - replace_pattern(attributes["http.request.body"],
              "\\b(19|20)\\d{2}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])\\b", "****-**-**")
          - replace_pattern(attributes["http.response.body"],
              "\\b(19|20)\\d{2}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])\\b", "****-**-**")

          # Date of birth US format: 04/12/1985
          - replace_pattern(attributes["http.request.body"],
              "\\b(0[1-9]|1[0-2])/(0[1-9]|[12]\\d|3[01])/(19|20)\\d{2}\\b", "**/**/****")
          - replace_pattern(attributes["http.response.body"],
              "\\b(0[1-9]|1[0-2])/(0[1-9]|[12]\\d|3[01])/(19|20)\\d{2}\\b", "**/**/****")

          # ZIP+4: 90210-1234
          - replace_pattern(attributes["http.request.body"],
              "\\b\\d{5}-\\d{4}\\b", "*****-****")
          - replace_pattern(attributes["http.response.body"],
              "\\b\\d{5}-\\d{4}\\b", "*****-****")

          # Credit card numbers
          - replace_pattern(attributes["http.request.body"],
              "\\b\\d{4}[\\s-]?\\d{4}[\\s-]?\\d{4}[\\s-]?\\d{4}\\b", "****-****-****-****")
          - replace_pattern(attributes["http.response.body"],
              "\\b\\d{4}[\\s-]?\\d{4}[\\s-]?\\d{4}[\\s-]?\\d{4}\\b", "****-****-****-****")

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [transform/redact-phi, batch]
      exporters: [otlp/last9]

If you prefer a simpler config with less control over replacement strings, the redaction processor is an alternative — it replaces matches with a fixed **** string:

processors:
  redaction:
    allow_all_keys: true
    blocked_values:
      - '\b\d{3}-\d{2}-\d{4}\b'       # SSN
      - '[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}'  # Email

Use transform when you need recognizable redaction patterns (e.g., ***-**-**** makes it obvious an SSN was there). Use redaction when uniform **** replacement is sufficient. For more on the two approaches, see Redacting Sensitive Data in OpenTelemetry Collector.

What This Approach Doesn't Cover

The regex patterns above handle structured PHI with predictable formats: SSNs, email addresses, phone numbers, dates, ZIP codes, and credit card numbers. Several categories require additional work:

Patient names. Free text. No reliable regex exists. Catching names in JSON values requires NLP-based entity recognition — not something the transform processor does. If names appear in predictable JSON fields like "patientName", you can use OTTL's replace_all_patterns against specific keys rather than the full body string.

Medical record numbers and insurance IDs. Format varies by health system and payer. Add custom regex patterns once you know your specific format, or redact by JSON field name.

Street addresses. Street names and city names are free text. The ZIP code pattern catches the ZIP, but not the full address.

The JSON key problem. Even after redaction, "ssn": "***-**-****" reveals that an SSN field was present in the payload. If the existence of a field is itself sensitive, you need JSON-aware field-level redaction, not string replacement. A custom collector processor or a pre-export transformation in the middleware handles this.

For full HIPAA compliance, treat the regex approach as a first pass, not a complete solution. Start in non-production, verify redaction output, and add patterns as you discover gaps.

Performance Considerations

Memory. The middleware buffers each request and response body in memory up to MaxBodySizeBytes. At the default 8KB, this adds 16KB per request (request + response) to working memory. At 1,000 concurrent requests, that's 16MB — negligible for most applications. At high concurrency with large bodies, monitor memory pressure.

Latency. Request body buffering via EnableBuffering() adds ~0.1ms. The response stream swap adds 0.1–0.5ms depending on response size. For APIs targeting sub-10ms p99 latency, measure the impact in your environment.

With CaptureOnErrorOnly: true. The middleware still buffers every request and response (it doesn't know the status code until the handler runs). The difference is that span attributes are only set on 4xx/5xx responses. At typical error rates of 1–5% of traffic, the data volume impact is minimal. The memory and latency impact from buffering applies to all requests regardless of this setting.

Collector CPU. Seven regex patterns applied to two string attributes per span. At 10,000 spans/second, the transform processor adds negligible CPU overhead.

Rollout

Start in non-production with CaptureOnErrorOnly: false to see everything. Verify in your trace backend that PHI appears redacted in the body attributes before any production deployment. Add custom regex patterns for your specific MRN and insurance ID formats based on what you observe. Then switch to CaptureOnErrorOnly: true and roll out to production services one at a time.

Enable the collector's debug exporter during initial rollout to verify redaction output without sending to your production backend. Remove it once you're confident.


If you're sending traces from .NET applications to Last9, the OTLP exporter configuration is standard — endpoint and auth token. The working example including the full middleware implementation and collector config is at last9/opentelemetry-examples. For the collector setup, What is the OpenTelemetry Collector? covers the deployment options, and .NET Logging with Serilog and OpenTelemetry covers the logging side of the same stack.

About the authors
Prathamesh Sonpatki

Prathamesh Sonpatki

Prathamesh works as an evangelist at Last9, runs SRE stories - where SRE and DevOps folks share their stories, and maintains o11y.wiki - a glossary of all terms related to observability.

Last9 keyboard illustration

Start observing for free. No lock-in.

OPENTELEMETRY • PROMETHEUS

Just update your config. Start seeing data on Last9 in seconds.

DATADOG • NEW RELIC • OTHERS

We've got you covered. Bring over your dashboards & alerts in one click.

BUILT ON OPEN STANDARDS

100+ integrations. OTel native, works with your existing stack.

Gartner Cool Vendor 2025 Gartner Cool Vendor 2025
High Performer High Performer
Best Usability Best Usability
Highest User Adoption Highest User Adoption