To keep sensitive data out of AI application logs, project each event into an explicit set of permitted fields before calling the logger. Do not pass raw prompts, nested request objects, response bodies, or exception messages into ordinary operational logs and rely on a final text replacement to catch everything.

The C# example below records an operation identifier, a controlled route label, status, duration, and a fixed error category. It replaces the payload with a constant marker and tests the rendered message, structured fields, and exception surface using synthetic sensitive values.

The aim is useful diagnostic evidence with a small, inspectable logging contract. This example covers one logging boundary; it does not certify an application's complete telemetry system.

Why can structured logs still expose sensitive data?

Structured logging preserves named fields so tools can search and aggregate events. Those fields can contain sensitive values just as easily as a formatted message can.

Masking the visible message does not establish that the underlying state is safe. A sink can serialize structured values separately. An exception can carry sensitive text in its message, inner exception, or data dictionary. A nested payload can acquire a new field after the original redaction code was written.

The OWASP logging guidance identifies data such as credentials and sensitive personal information as requiring exclusion or appropriate handling. The practical implication here is to decide which operational facts are needed before selecting what to record.

What should the event contain?

Start with questions the operations team needs to answer: which route failed, how long the attempt took, and which related events belong to the same operation.

Input

Logged form in this example

Reason

Application-generated operation ID

GUID

Correlate attempts without recording conversation text

Provider route

Controlled label

Group behavior without exposing a credential or URL

HTTP status

Validated number

Inspect response classes

Duration

Nonnegative milliseconds

Diagnose slow attempts

Exception

Fixed category

Identify a broad failure type without raw exception content

Prompt and nested payload

Constant marker

Prevent payload contents entering this event

The operation ID should be generated for the workflow, not copied from arbitrary user input. Correlation identifiers can still reveal activity patterns, so access and retention remain relevant even when content is omitted.

What environment does the example require?

The code was compiled with SDK 8.0.414 and C# 12 and executed on .NET 8.0.20 on Linux. It uses Microsoft.Extensions.Logging from the ASP.NET Core shared framework.

Use a .NET 8 console project with a FrameworkReference to Microsoft.AspNetCore.App. This complete project file supplies that reference:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <LangVersion>12</LangVersion>
  </PropertyGroup>
  <ItemGroup>
    <FrameworkReference Include="Microsoft.AspNetCore.App" />
  </ItemGroup>
</Project>

Replace Program.cs with the C# code below and run the project. The framework reference makes the logging abstractions available; it does not turn this console example into a web server.

All email-like strings, credentials, and request values are synthetic. The example does not call an AI provider or send telemetry to an external logging service.

Create a narrow logging boundary

using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Extensions.Logging;

var sink = new CaptureLogger();
const string email = "[email protected]";
const string secret = "SECRET_47_SYNTHETIC";
var payload = new JsonObject
{
    ["prompt"] = $"Contact {email}",
    ["headers"] = new JsonObject { ["Authorization"] = secret },
    ["messages"] = new JsonArray(new JsonObject { ["content"] = secret })
};
var error = new HttpRequestException($"Failed with {secret}",
    new Exception($"Nested message: {email}"));
error.Data["raw_response"] = secret;
var attempt = new AiAttempt(Guid.NewGuid(), Route.Primary, 503, 120,
                            payload, error);
Audit.Write(sink, attempt);
payload["future_unknown_field"] = new JsonArray(secret, email);
Audit.Write(sink, attempt with { Status = 200, Error = null });
Audit.Write(sink, attempt with { Error = new OperationCanceledException(secret) });

foreach (var captured in sink.Events)
{
    string all = captured.Message + JsonSerializer.Serialize(captured.State)
                 + captured.Exception;
    if (all.Contains(secret) || all.Contains(email))
        throw new Exception("Sensitive sentinel reached a captured log surface");
    if (captured.Exception is not null)
        throw new Exception("Raw exception reached logger");
    string[] keys = captured.State.Keys.OrderBy(x => x).ToArray();
    string[] expected = ["{OriginalFormat}", "DurationMs", "ErrorKind",
        "OperationId", "Payload", "Route", "Status"];
    if (!keys.SequenceEqual(expected.OrderBy(x => x)))
        throw new Exception("Unexpected structured field");
    Console.WriteLine(captured.Message);
}
Console.WriteLine("PASS: 3 events; message, structured state, and exception checked");

enum Route { Primary, Backup }
record AiAttempt(Guid OperationId, Route Route, int Status, long DurationMs,
                 JsonNode Payload, Exception? Error);

static class Audit
{
    public static void Write(ILogger logger, AiAttempt a)
    {
        string route = a.Route switch
        {
            Route.Primary => "primary", Route.Backup => "backup", _ => "unknown"
        };
        string errorKind = a.Error switch
        {
            null => "none", OperationCanceledException => "canceled",
            HttpRequestException => "transport", _ => "internal"
        };
        int status = a.Status is >= 100 and <= 599 ? a.Status : 0;
        long duration = Math.Max(0, a.DurationMs);
        // Deliberately omit Payload and all exception content.
        logger.LogInformation(
            "AI attempt {OperationId} route={Route} status={Status} " +
            "duration_ms={DurationMs} error={ErrorKind} payload={Payload}",
            a.OperationId, route, status, duration, errorKind, "[REDACTED]");
    }
}

record Captured(string Message, Dictionary<string, object?> State,
                string? Exception);

sealed class CaptureLogger : ILogger
{
    public List<Captured> Events { get; } = [];
    public IDisposable? BeginScope<TState>(TState state) where TState : notnull
        => null;
    public bool IsEnabled(LogLevel level) => true;
    public void Log<TState>(LogLevel level, EventId eventId, TState state,
        Exception? exception, Func<TState, Exception?, string> formatter)
    {
        var fields = state is IEnumerable<KeyValuePair<string, object?>> values
            ? values.ToDictionary(x => x.Key, x => x.Value)
            : new Dictionary<string, object?>();
        Events.Add(new Captured(formatter(state, exception), fields,
                                exception?.ToString()));
    }
}

AiAttempt represents the broader application event. Audit.Write selects only the permitted operational fields. It receives the raw payload but never passes it to ILogger; the Payload logging field always contains the same marker.

The exception category comes from a controlled type switch. Neither Message, InnerException, nor Data is serialized. The original exception is also omitted from the logging call, so a sink cannot receive it through that argument.

The custom logger captures both the formatted message and the structured state. This makes the test inspect the event's data, instead of checking only what happens to appear on a console.

Why omit a nested payload instead of walking every key?

A key-based redaction rule must recognize every sensitive location. A newly added messages, attachments, or diagnostic field can bypass an incomplete list. A projection that never includes the raw object has a smaller contract to inspect.

In the test, a new nested field is added after the first event. The later events still contain only the approved operational fields and the constant marker. The test does not depend on recognizing the new field's name.

A technical walkthrough for Ranknod could use this distinction to show why redaction should be evaluated at the logging boundary. A clean-looking sample line alone does not establish that structured data and exception arguments are equally clean.

If selective content logging is necessary, design that as a separate, reviewed path with explicit purpose, access, and retention. Do not gradually expand an operational event into a transcript archive because each extra field is convenient.

What did the checks establish?

The executed sample captured three events: a transport failure, an event without an exception after the payload mutation, and a cancellation-category event. It checked each event for both sensitive sentinel strings, required the expected structured keys, and required the exception field to be null.

The final output was:

PASS: 3 events; message, structured state, and exception checked

The event messages retained status, duration, route, error category, and the redaction marker. The randomly generated operation GUID varies between runs.

These checks are intentionally scoped. They establish the behavior of Audit.Write with the supplied fixtures. They do not test background HTTP diagnostics, logging scopes, tracing spans, crash dumps, SDK diagnostics, or a production sink's enrichment rules.

How does this relate to .NET's redaction APIs?

Microsoft's .NET redaction documentation describes data classification, redactor configuration, and integration with logging. Those facilities can support applications that need classified fields and consistent redaction policies across many event types.

The example here uses explicit projection and a constant payload marker. It does not configure the compliance-redaction packages or claim that installing a package automatically classifies every field.

Choose the approach that fits the data contract, then inspect the actual emitted event. With either approach, an unclassified free-form field or a separate raw-exception call can create a path around the intended protection.

What should you check in the real application?

Exercise success, provider failure, timeout, cancellation, and retry paths with synthetic sentinels. Inspect every enabled sink, including structured exports. Add a new nested payload field and confirm that it does not enter an existing event unnoticed.

Review scopes and automatic enrichers separately. This capture logger does not implement scope storage, so its success says nothing about a real application's scope contents. Also inspect SDK logging that occurs before your application wrapper receives the error.

Keep the fixed template and permitted-field list under review when the event changes. A useful operational log should explain what happened without copying the conversation that happened to trigger it.