P3: Add LoggingHandler with structured logging and sensitive data redaction#1
P3: Add LoggingHandler with structured logging and sensitive data redaction#1
Conversation
…ta redaction - Add AcdcLoggingOptions with configurable slow request threshold (3s default), large payload threshold (1 MiB), and 16 default sensitive field names - Add SensitiveDataRedactor for header, query parameter, and JSON body redaction with case-insensitive field matching via System.Text.Json - Add LoggingHandler DelegatingHandler with request/response logging at Info level, slow request warnings, large payload warnings, error logging with redacted details, AsyncLocal<bool> reentrancy prevention, and per-request SkipLogging opt-out - Add comprehensive unit tests for both SensitiveDataRedactor and LoggingHandler covering all redaction scenarios, threshold boundaries, reentrancy, and error paths Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds structured HTTP request/response logging to the CSharpAcdc HTTP pipeline, including configurable thresholds and a reusable sensitive-data redactor to prevent leaking secrets in logs.
Changes:
- Introduces
AcdcLoggingOptionsfor slow-request / large-payload thresholds and a default sensitive field set. - Adds
SensitiveDataRedactorto redact sensitive headers, query parameters, and JSON body fields. - Adds
LoggingHandler(DelegatingHandler) plus unit tests for both the handler and the redactor.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 13 comments.
Show a summary per file
| File | Description |
|---|---|
| src/CSharpAcdc/Configuration/AcdcLoggingOptions.cs | Adds configuration for logging thresholds and default sensitive fields. |
| src/CSharpAcdc/Logging/SensitiveDataRedactor.cs | Implements header/query/JSON redaction logic. |
| src/CSharpAcdc/Handlers/LoggingHandler.cs | Adds request/response structured logging and warning logic. |
| tests/CSharpAcdc.Tests/Logging/SensitiveDataRedactorTests.cs | Adds unit tests covering redaction behaviors. |
| tests/CSharpAcdc.Tests/Handlers/LoggingHandlerTests.cs | Adds unit tests for logging, redaction, warnings, and reentrancy behavior. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| using FluentAssertions; | ||
| using Microsoft.Extensions.Logging; | ||
| using Microsoft.Extensions.Options; | ||
| using NSubstitute; |
There was a problem hiding this comment.
Unused using NSubstitute; will produce CS8019 (unnecessary using directive) and can fail builds when warnings are treated as errors. Remove this using if it’s not needed.
| using NSubstitute; |
|
|
||
| // Should see request + response logs for outer, but not for inner | ||
| infoLogs.Should().AllSatisfy(e => e.Message.Should().Contain("/outer")); | ||
| infoLogs.Should().NotContain(e => e.Message.Contains("/inner")); |
There was a problem hiding this comment.
This reentrancy test doesn’t currently validate whether the nested request was actually skipped: the inner request logs would go to innerLogger, but the assertions only inspect the outer _logger, so it would pass even if the reentrancy guard failed. Expose innerLogger to the test (e.g., via a field on NestedRequestHandler) and assert it has no entries (or explicitly assert expected counts).
| infoLogs.Should().NotContain(e => e.Message.Contains("/inner")); | |
| infoLogs.Should().NotContain(e => e.Message.Contains("/inner")); | |
| // Additionally ensure the nested handler's own logger did not record any entries | |
| nestedHandler.InnerLogger.Entries.Should().BeEmpty(); |
| // This should be skipped by reentrancy guard | ||
| _ = innerClient.GetAsync("/inner", cancellationToken).GetAwaiter().GetResult(); | ||
|
|
||
| return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)); |
There was a problem hiding this comment.
Calling GetAsync(...).GetAwaiter().GetResult() from within a handler can deadlock under certain synchronization contexts and can also block threadpool threads unnecessarily. Make this override async and await the nested call instead.
| redactedParts.Add(IsSensitive(name) | ||
| ? $"{Uri.EscapeDataString(name)}={Redacted}" | ||
| : $"{Uri.EscapeDataString(name)}={Uri.EscapeDataString(query[name] ?? string.Empty)}"); |
There was a problem hiding this comment.
HttpUtility.ParseQueryString collapses repeated query keys (e.g., a=1&a=2) into a single comma-separated string via query[name], and this implementation will log it as a single value (and after escaping, it becomes 1%2C2). If preserving query shape matters for debugging, iterate query.GetValues(name) and emit one name=value pair per value while still redacting sensitive keys.
| redactedParts.Add(IsSensitive(name) | |
| ? $"{Uri.EscapeDataString(name)}={Redacted}" | |
| : $"{Uri.EscapeDataString(name)}={Uri.EscapeDataString(query[name] ?? string.Empty)}"); | |
| var values = query.GetValues(name); | |
| var escapedName = Uri.EscapeDataString(name); | |
| if (IsSensitive(name)) | |
| { | |
| if (values is null || values.Length == 0) | |
| { | |
| redactedParts.Add($"{escapedName}={Redacted}"); | |
| } | |
| else | |
| { | |
| foreach (var _ in values) | |
| { | |
| redactedParts.Add($"{escapedName}={Redacted}"); | |
| } | |
| } | |
| } | |
| else | |
| { | |
| if (values is null || values.Length == 0) | |
| { | |
| redactedParts.Add($"{escapedName}="); | |
| } | |
| else | |
| { | |
| foreach (var value in values) | |
| { | |
| redactedParts.Add($"{escapedName}={Uri.EscapeDataString(value ?? string.Empty)}"); | |
| } | |
| } | |
| } |
| { | ||
| private static readonly AsyncLocal<bool> IsLogging = new(); | ||
|
|
There was a problem hiding this comment.
Private field naming deviates from the repository’s naming rules (private fields should be prefixed with '_' and camelCase). Rename this static field (e.g., to '_isLogging') to satisfy the analyzer rules in .editorconfig and avoid style warnings becoming build breaks under TreatWarningsAsErrors.
| mockHandler.When("*").Respond(HttpStatusCode.OK); | ||
|
|
||
| using var client = CreateClient(mockHandler); | ||
| var request = new HttpRequestMessage(HttpMethod.Get, "/test"); |
There was a problem hiding this comment.
Disposable 'HttpRequestMessage' is created but not disposed.
| var request = new HttpRequestMessage(HttpMethod.Get, "/test"); | |
| using var request = new HttpRequestMessage(HttpMethod.Get, "/test"); |
| mockHandler.When("*").Respond(HttpStatusCode.OK); | ||
|
|
||
| using var client = CreateClient(mockHandler, options); | ||
| var request = new HttpRequestMessage(HttpMethod.Get, "/test"); |
There was a problem hiding this comment.
Disposable 'HttpRequestMessage' is created but not disposed.
| var request = new HttpRequestMessage(HttpMethod.Get, "/test"); | |
| using var request = new HttpRequestMessage(HttpMethod.Get, "/test"); |
| mockHandler.When("*").Respond(HttpStatusCode.OK); | ||
|
|
||
| using var client = CreateClient(mockHandler, options); | ||
| var content = new StringContent(new string('x', 200)); |
There was a problem hiding this comment.
Disposable 'StringContent' is created but not disposed.
| var content = new StringContent(new string('x', 200)); | |
| using var content = new StringContent(new string('x', 200)); |
| mockHandler.When("*").Respond(HttpStatusCode.OK); | ||
|
|
||
| using var client = CreateClient(mockHandler); | ||
| var request = new HttpRequestMessage(HttpMethod.Get, "/silent"); |
There was a problem hiding this comment.
Disposable 'HttpRequestMessage' is created but not disposed.
| var request = new HttpRequestMessage(HttpMethod.Get, "/silent"); | |
| using var request = new HttpRequestMessage(HttpMethod.Get, "/silent"); |
| // This should be skipped by reentrancy guard | ||
| _ = innerClient.GetAsync("/inner", cancellationToken).GetAwaiter().GetResult(); | ||
|
|
||
| return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)); |
There was a problem hiding this comment.
Disposable 'HttpResponseMessage' is created but not disposed.
Code reviewNo issues found. Checked for bugs and CLAUDE.md compliance. 🤖 Generated with Claude Code |
Comprehensive PR Review -- P3: LoggingHandlerFive specialized review agents independently analyzed this PR. Below is the deduplicated, aggregated summary. Critical Issues1. CLAUDE.md states: "Use CSharp-ACDC/src/CSharpAcdc/Configuration/AcdcLoggingOptions.cs Lines 3 to 10 in f837f5f Type design scores: Encapsulation 2/10, Invariant Expression 2/10, Enforcement 1/10. Fix: Convert to 2. Unprotected redaction/logging can crash the HTTP pipeline (2 agents)
CSharp-ACDC/src/CSharpAcdc/Handlers/LoggingHandler.cs Lines 47 to 54 in f837f5f Fix: Wrap pre-request and post-response logging blocks in try/catch that degrades gracefully. 3. Invalid JSON silently returned unredacted -- sensitive data leak risk (2 agents) When CSharp-ACDC/src/CSharpAcdc/Logging/SensitiveDataRedactor.cs Lines 70 to 74 in f837f5f Fix: Either return Important Issues4. No defensive copy of
CSharp-ACDC/src/CSharpAcdc/Logging/SensitiveDataRedactor.cs Lines 9 to 12 in f837f5f Fix: Defensively copy in constructor: 5. Missing constructor null guards (2 agents) Neither Fix: Add 6. The catch block in CSharp-ACDC/src/CSharpAcdc/Handlers/LoggingHandler.cs Lines 64 to 74 in f837f5f Fix: Add a separate 7.
CSharp-ACDC/tests/CSharpAcdc.Tests/Handlers/LoggingHandlerTests.cs Lines 367 to 370 in f837f5f 8. Contradictory comment block in Lines 343-350 contain a paragraph describing an abandoned approach, followed by "But for a proper test, we actually construct an inner pipeline:" -- contradicting the preceding text. This is a draft-then-revise artifact. CSharp-ACDC/tests/CSharpAcdc.Tests/Handlers/LoggingHandlerTests.cs Lines 343 to 350 in f837f5f Test Coverage Gaps (Severity 7+)
Strengths
Type Design Summary
Recommended Action Plan
Generated by 5 specialized review agents (code-reviewer, test-analyzer, silent-failure-hunter, type-design-analyzer, comment-analyzer). 🤖 Generated with Claude Code If this review was useful, please react with 👍. Otherwise, react with 👎. |
- Convert AcdcLoggingOptions to record with FrozenSet defaults - Protect HTTP pipeline from logging/redaction failures (try/catch) - Defensive copy of SensitiveFields with forced OrdinalIgnoreCase - Add ArgumentNullException.ThrowIfNull in constructors - Log OperationCanceledException at Information, not Error - Return placeholder for non-JSON bodies instead of leaking raw content - Fix repeated query param handling via GetValues() - Add null-check for response.Content - Rename IsLogging to _isLogging (naming convention) - Fix GetAwaiter().GetResult() to async/await in test - Remove unused usings, add using var for disposables - Add tests: AsyncLocal error recovery, concurrent isolation, cancellation - Improve reentrancy test with InnerLogger assertion - Enhance response header redaction test with sensitive header assertion Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Summary
AcdcLoggingOptionsconfiguration (now arecordwithFrozenSetdefaults) with configurable thresholds and 16 default sensitive field namesSensitiveDataRedactorfor redacting headers, query parameters, and JSON body fields (case-insensitive matching viaSystem.Text.Json)LoggingHandler(DelegatingHandler) with structured request/response logging, slow request and large payload warnings,AsyncLocalreentrancy prevention, and per-requestSkipLoggingopt-outSensitiveDataRedactor(18 tests) andLoggingHandler(18 tests)Review feedback addressed
AcdcLoggingOptionstorecordwithFrozenSetfor immutable defaultsSensitiveFieldsinSensitiveDataRedactorwith forcedOrdinalIgnoreCaseArgumentNullException.ThrowIfNull()null guards in constructorsOperationCanceledExceptionnow logged at Information level (not Error)[non-JSON body, redaction skipped]instead of leaking raw contentGetValues()response.Content?.Headers.ContentLengthIsLogging→_isLogging(naming convention).GetAwaiter().GetResult()→ properawaitin reentrancy testusing varfor disposablesInnerLoggerassertionTest plan
TreatWarningsAsErrorsenabled (0 warnings)🤖 Generated with Claude Code