From 99a9ba21cc2e01a4153443e6eb2e28db8efd50c0 Mon Sep 17 00:00:00 2001 From: "Marcus Kanon (consultant)" Date: Fri, 17 Jul 2026 11:53:27 +0200 Subject: [PATCH 1/4] Add configurable HTTP status code log levels Add StatusCodeLogLevelRules and ExceptionLogLevel properties to LoggingOptions, allowing users to map HTTP status codes or ranges to specific log levels with first-match-wins evaluation. Fixes #7637 --- .../Logging/HttpStatusCodeLogLevelRule.cs | 46 ++++ .../Logging/Internal/HttpClientLogger.cs | 22 +- .../Logging/Internal/Log.cs | 4 +- .../Logging/LoggingOptions.cs | 25 ++ .../HttpClientLoggerStatusCodeLogLevelTest.cs | 229 ++++++++++++++++++ .../Logging/HttpStatusCodeLogLevelRuleTest.cs | 97 ++++++++ 6 files changed, 418 insertions(+), 5 deletions(-) create mode 100644 src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/HttpStatusCodeLogLevelRule.cs create mode 100644 test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Logging/HttpClientLoggerStatusCodeLogLevelTest.cs create mode 100644 test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Logging/HttpStatusCodeLogLevelRuleTest.cs diff --git a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/HttpStatusCodeLogLevelRule.cs b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/HttpStatusCodeLogLevelRule.cs new file mode 100644 index 00000000000..58d26ae54ab --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/HttpStatusCodeLogLevelRule.cs @@ -0,0 +1,46 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.Logging; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Extensions.Http.Logging; + +/// +/// Maps a status code or range of status codes to a specific log level. +/// +[Experimental(diagnosticId: DiagnosticIds.Experiments.Telemetry, UrlFormat = DiagnosticIds.UrlFormat)] +public class HttpStatusCodeLogLevelRule : IValidatableObject +{ + /// + /// Gets or sets the minimum status code this rule applies to (inclusive). + /// + [Range(100, 599)] + public int FromStatusCode { get; set; } + + /// + /// Gets or sets the maximum status code this rule applies to (inclusive). + /// When , matches only (exact match). + /// + [Range(100, 599)] + public int? ToStatusCode { get; set; } + + /// + /// Gets or sets the log level to use for responses matching this rule. + /// + public LogLevel LogLevel { get; set; } = LogLevel.Information; + + /// + public IEnumerable Validate(ValidationContext validationContext) + { + if (ToStatusCode.HasValue && ToStatusCode.Value < FromStatusCode) + { + yield return new ValidationResult( + $"{nameof(ToStatusCode)} must be greater than or equal to {nameof(FromStatusCode)}.", + [nameof(ToStatusCode)]); + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/HttpClientLogger.cs b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/HttpClientLogger.cs index 536c08eee6d..014205231b8 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/HttpClientLogger.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/HttpClientLogger.cs @@ -31,6 +31,8 @@ internal sealed class HttpClientLogger : IHttpClientAsyncLogger private readonly bool _logResponseHeaders; private readonly bool _logRequestHeaders; private readonly bool _pathParametersRedactionSkipped; + private readonly IReadOnlyList _statusCodeLogLevelRules; + private readonly LogLevel _exceptionLogLevel; private ILogger _logger; private IHttpRequestReader _httpRequestReader; private IHttpClientLogEnricher[] _enrichers; @@ -62,6 +64,8 @@ internal HttpClientLogger( _logResponseHeaders = options.ResponseHeadersDataClasses.Count > 0; _logRequestHeaders = options.RequestHeadersDataClasses.Count > 0; _pathParametersRedactionSkipped = options.RequestPathParameterRedactionMode == HttpRouteParameterRedactionMode.None; + _statusCodeLogLevelRules = options.StatusCodeLogLevelRules as IReadOnlyList ?? options.StatusCodeLogLevelRules.ToArray(); + _exceptionLogLevel = options.ExceptionLogLevel; } public async ValueTask LogRequestStartAsync(HttpRequestMessage request, CancellationToken cancellationToken = default) @@ -133,11 +137,23 @@ public void LogRequestStop(object? context, HttpRequestMessage request, HttpResp public void LogRequestFailed(object? context, HttpRequestMessage request, HttpResponseMessage? response, Exception exception, TimeSpan elapsed) => throw new NotSupportedException(SyncLoggingExceptionMessage); - private static LogLevel GetLogLevel(LogRecord logRecord) + private LogLevel GetLogLevel(LogRecord logRecord) { + int statusCode = logRecord.StatusCode!.Value; + + for (int i = 0; i < _statusCodeLogLevelRules.Count; i++) + { + var rule = _statusCodeLogLevelRules[i]; + int to = rule.ToStatusCode ?? rule.FromStatusCode; + + if (statusCode >= rule.FromStatusCode && statusCode <= to) + { + return rule.LogLevel; + } + } + const int HttpErrorsRangeStart = 400; const int HttpErrorsRangeEnd = 599; - int statusCode = logRecord.StatusCode!.Value; if (statusCode >= HttpErrorsRangeStart && statusCode <= HttpErrorsRangeEnd) { @@ -188,7 +204,7 @@ private async ValueTask LogResponseAsync( } else { - Log.OutgoingRequestError(_logger, logRecord, exception); + Log.OutgoingRequestError(_logger, _exceptionLogLevel, logRecord, exception); } } catch (Exception ex) diff --git a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/Log.cs b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/Log.cs index 6edd27217ec..ac0262bacb8 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/Log.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/Log.cs @@ -44,9 +44,9 @@ public static void OutgoingRequest(ILogger logger, LogLevel level, LogRecord rec OutgoingRequest(logger, level, 1, nameof(OutgoingRequest), record); } - public static void OutgoingRequestError(ILogger logger, LogRecord record, Exception exception) + public static void OutgoingRequestError(ILogger logger, LogLevel level, LogRecord record, Exception exception) { - OutgoingRequest(logger, LogLevel.Error, 2, nameof(OutgoingRequestError), record, exception); + OutgoingRequest(logger, level, 2, nameof(OutgoingRequestError), record, exception); } [LoggerMessage(LogLevel.Error, RequestReadErrorMessage)] diff --git a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/LoggingOptions.cs b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/LoggingOptions.cs index a2e6e41c490..726e601ac5d 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/LoggingOptions.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/LoggingOptions.cs @@ -7,6 +7,8 @@ using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.Compliance.Classification; using Microsoft.Extensions.Http.Diagnostics; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using Microsoft.Shared.Data.Validation; using Microsoft.Shared.DiagnosticIds; @@ -163,4 +165,27 @@ public class LoggingOptions /// [Experimental(diagnosticId: DiagnosticIds.Experiments.Telemetry, UrlFormat = DiagnosticIds.UrlFormat)] public bool LogContentHeaders { get; set; } + + /// + /// Gets or sets a list of rules that map HTTP status codes or ranges to specific log levels. + /// + /// + /// The default value is an empty list. When empty, the built-in behavior applies: 400-599 logs at , + /// all other status codes log at . + /// + /// + /// Rules are evaluated in order; the first matching rule wins. If no rule matches, the built-in default applies. + /// + [Experimental(diagnosticId: DiagnosticIds.Experiments.Telemetry, UrlFormat = DiagnosticIds.UrlFormat)] + [ValidateEnumeratedItems] + public IList StatusCodeLogLevelRules { get; set; } = []; + + /// + /// Gets or sets the log level used when an HTTP request throws an exception. + /// + /// + /// The default value is . + /// + [Experimental(diagnosticId: DiagnosticIds.Experiments.Telemetry, UrlFormat = DiagnosticIds.UrlFormat)] + public LogLevel ExceptionLogLevel { get; set; } = LogLevel.Error; } diff --git a/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Logging/HttpClientLoggerStatusCodeLogLevelTest.cs b/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Logging/HttpClientLoggerStatusCodeLogLevelTest.cs new file mode 100644 index 00000000000..bd6c916c0c0 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Logging/HttpClientLoggerStatusCodeLogLevelTest.cs @@ -0,0 +1,229 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Compliance.Classification; +using Microsoft.Extensions.Compliance.Testing; +using Microsoft.Extensions.Http.Diagnostics; +using Microsoft.Extensions.Http.Diagnostics.Test.Logging.Internal; +using Microsoft.Extensions.Http.Logging.Internal; +using Microsoft.Extensions.Http.Logging.Test.Internal; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Testing; +using Microsoft.Extensions.Telemetry.Internal; +using Moq; +using Xunit; + +namespace Microsoft.Extensions.Http.Logging.Test; + +public class HttpClientLoggerStatusCodeLogLevelTest +{ + [Theory] + [InlineData(HttpStatusCode.NotFound, LogLevel.Warning)] + [InlineData(HttpStatusCode.BadRequest, LogLevel.Warning)] + [InlineData(HttpStatusCode.InternalServerError, LogLevel.Error)] + public async Task StatusCodeLogLevelRules_MatchesConfiguredRule(HttpStatusCode statusCode, LogLevel expectedLevel) + { + var options = new LoggingOptions + { + StatusCodeLogLevelRules = + [ + new HttpStatusCodeLogLevelRule { FromStatusCode = 400, ToStatusCode = 499, LogLevel = LogLevel.Warning }, + new HttpStatusCodeLogLevelRule { FromStatusCode = 500, ToStatusCode = 599, LogLevel = LogLevel.Error }, + ] + }; + + var fakeLogger = new FakeLogger(); + using var httpResponseMessage = new HttpResponseMessage(statusCode); + + using var handler = CreateHandler(fakeLogger, options, httpResponseMessage); + using var client = new HttpClient(handler); + using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "http://example.com/test"); + + await client.SendAsync(httpRequestMessage, CancellationToken.None); + + var logRecords = fakeLogger.Collector.GetSnapshot(); + var logRecord = Assert.Single(logRecords); + Assert.Equal(expectedLevel, logRecord.Level); + } + + [Fact] + public async Task StatusCodeLogLevelRules_FirstMatchWins() + { + var options = new LoggingOptions + { + StatusCodeLogLevelRules = + [ + new HttpStatusCodeLogLevelRule { FromStatusCode = 404, LogLevel = LogLevel.Debug }, + new HttpStatusCodeLogLevelRule { FromStatusCode = 400, ToStatusCode = 499, LogLevel = LogLevel.Warning }, + ] + }; + + var fakeLogger = new FakeLogger(); + using var httpResponseMessage = new HttpResponseMessage(HttpStatusCode.NotFound); + + using var handler = CreateHandler(fakeLogger, options, httpResponseMessage); + using var client = new HttpClient(handler); + using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "http://example.com/test"); + + await client.SendAsync(httpRequestMessage, CancellationToken.None); + + var logRecords = fakeLogger.Collector.GetSnapshot(); + var logRecord = Assert.Single(logRecords); + Assert.Equal(LogLevel.Debug, logRecord.Level); + } + + [Fact] + public async Task StatusCodeLogLevelRules_NoMatch_FallsBackToDefaultBehavior() + { + var options = new LoggingOptions + { + StatusCodeLogLevelRules = + [ + new HttpStatusCodeLogLevelRule { FromStatusCode = 404, LogLevel = LogLevel.Debug }, + ] + }; + + var fakeLogger = new FakeLogger(); + using var httpResponseMessage = new HttpResponseMessage(HttpStatusCode.InternalServerError); + + using var handler = CreateHandler(fakeLogger, options, httpResponseMessage); + using var client = new HttpClient(handler); + using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "http://example.com/test"); + + await client.SendAsync(httpRequestMessage, CancellationToken.None); + + var logRecords = fakeLogger.Collector.GetSnapshot(); + var logRecord = Assert.Single(logRecords); + Assert.Equal(LogLevel.Error, logRecord.Level); + } + + [Fact] + public async Task StatusCodeLogLevelRules_EmptyRules_UsesDefaultBehavior() + { + var options = new LoggingOptions(); + + var fakeLogger = new FakeLogger(); + using var httpResponseMessage = new HttpResponseMessage(HttpStatusCode.OK); + + using var handler = CreateHandler(fakeLogger, options, httpResponseMessage); + using var client = new HttpClient(handler); + using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "http://example.com/test"); + + await client.SendAsync(httpRequestMessage, CancellationToken.None); + + var logRecords = fakeLogger.Collector.GetSnapshot(); + var logRecord = Assert.Single(logRecords); + Assert.Equal(LogLevel.Information, logRecord.Level); + } + + [Fact] + public async Task ExceptionLogLevel_UsesConfiguredLevel() + { + var options = new LoggingOptions + { + ExceptionLogLevel = LogLevel.Warning, + }; + + var exception = new HttpRequestException("test"); + var fakeLogger = new FakeLogger(); + + using var handler = new TestLoggingHandler( + new HttpClientLogger( + fakeLogger, + Mock.Of(), + [], + options), + new TestingHandlerStub((_, _) => throw exception)); + + using var client = new HttpClient(handler); + using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "http://example.com/test"); + + await Assert.ThrowsAsync(() => client.SendAsync(httpRequestMessage, CancellationToken.None)); + + var logRecords = fakeLogger.Collector.GetSnapshot(); + var logRecord = Assert.Single(logRecords); + Assert.Equal(LogLevel.Warning, logRecord.Level); + } + + [Fact] + public async Task ExceptionLogLevel_DefaultIsError() + { + var options = new LoggingOptions(); + + var exception = new HttpRequestException("test"); + var fakeLogger = new FakeLogger(); + + using var handler = new TestLoggingHandler( + new HttpClientLogger( + fakeLogger, + Mock.Of(), + [], + options), + new TestingHandlerStub((_, _) => throw exception)); + + using var client = new HttpClient(handler); + using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "http://example.com/test"); + + await Assert.ThrowsAsync(() => client.SendAsync(httpRequestMessage, CancellationToken.None)); + + var logRecords = fakeLogger.Collector.GetSnapshot(); + var logRecord = Assert.Single(logRecords); + Assert.Equal(LogLevel.Error, logRecord.Level); + } + + [Fact] + public async Task StatusCodeLogLevelRules_ExactMatch_WithNullToStatusCode() + { + var options = new LoggingOptions + { + StatusCodeLogLevelRules = + [ + new HttpStatusCodeLogLevelRule { FromStatusCode = 429, ToStatusCode = null, LogLevel = LogLevel.Warning }, + ] + }; + + var fakeLogger = new FakeLogger(); + using var httpResponseMessage = new HttpResponseMessage((HttpStatusCode)429); + + using var handler = CreateHandler(fakeLogger, options, httpResponseMessage); + using var client = new HttpClient(handler); + using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "http://example.com/test"); + + await client.SendAsync(httpRequestMessage, CancellationToken.None); + + var logRecords = fakeLogger.Collector.GetSnapshot(); + var logRecord = Assert.Single(logRecords); + Assert.Equal(LogLevel.Warning, logRecord.Level); + } + + private static TestLoggingHandler CreateHandler( + FakeLogger fakeLogger, + LoggingOptions options, + HttpResponseMessage response) + { + var mockHeadersRedactor = new Mock(); + mockHeadersRedactor + .Setup(r => r.Redact(It.IsAny>(), It.IsAny())) + .Returns("Redacted"); + + var headersReader = new HttpHeadersReader(options.ToOptionsMonitor(), mockHeadersRedactor.Object); + + return new TestLoggingHandler( + new HttpClientLogger( + fakeLogger, + new HttpRequestReader( + options, + Mock.Of(), + Mock.Of(), + headersReader, + Mock.Of()), + [], + options), + new TestingHandlerStub((_, _) => Task.FromResult(response))); + } +} diff --git a/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Logging/HttpStatusCodeLogLevelRuleTest.cs b/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Logging/HttpStatusCodeLogLevelRuleTest.cs new file mode 100644 index 00000000000..7133a519cec --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Logging/HttpStatusCodeLogLevelRuleTest.cs @@ -0,0 +1,97 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace Microsoft.Extensions.Http.Logging.Test; + +public class HttpStatusCodeLogLevelRuleTest +{ + [Fact] + public void Validate_ValidRange_ReturnsNoErrors() + { + var rule = new HttpStatusCodeLogLevelRule + { + FromStatusCode = 400, + ToStatusCode = 499, + LogLevel = LogLevel.Warning, + }; + + var results = new List(); + var isValid = Validator.TryValidateObject(rule, new ValidationContext(rule), results, true); + + Assert.True(isValid); + Assert.Empty(results); + } + + [Fact] + public void Validate_ToLessThanFrom_ReturnsError() + { + var rule = new HttpStatusCodeLogLevelRule + { + FromStatusCode = 500, + ToStatusCode = 400, + LogLevel = LogLevel.Warning, + }; + + var results = new List(); + var isValid = Validator.TryValidateObject(rule, new ValidationContext(rule), results, true); + + Assert.False(isValid); + Assert.Single(results); + Assert.Contains(nameof(HttpStatusCodeLogLevelRule.ToStatusCode), results[0].MemberNames); + } + + [Fact] + public void Validate_NullToStatusCode_IsValid() + { + var rule = new HttpStatusCodeLogLevelRule + { + FromStatusCode = 404, + ToStatusCode = null, + LogLevel = LogLevel.Debug, + }; + + var results = new List(); + var isValid = Validator.TryValidateObject(rule, new ValidationContext(rule), results, true); + + Assert.True(isValid); + Assert.Empty(results); + } + + [Fact] + public void Validate_FromStatusCodeOutOfRange_ReturnsError() + { + var rule = new HttpStatusCodeLogLevelRule + { + FromStatusCode = 99, + LogLevel = LogLevel.Warning, + }; + + var results = new List(); + var isValid = Validator.TryValidateObject(rule, new ValidationContext(rule), results, true); + + Assert.False(isValid); + Assert.NotEmpty(results); + } + + [Fact] + public void Validate_EqualFromAndTo_IsValid() + { + var rule = new HttpStatusCodeLogLevelRule + { + FromStatusCode = 404, + ToStatusCode = 404, + LogLevel = LogLevel.Warning, + }; + + var results = new List(); + var isValid = Validator.TryValidateObject(rule, new ValidationContext(rule), results, true); + + Assert.True(isValid); + Assert.Empty(results); + } +} From 1a174eec76568f7059a9d02aabf5415bc6a30439 Mon Sep 17 00:00:00 2001 From: "Marcus Kanon (consultant)" Date: Fri, 17 Jul 2026 12:08:06 +0200 Subject: [PATCH 2/4] Handle null StatusCodeLogLevelRules in constructor Treat null as empty rules list to prevent NRE when property is set to null via configuration binding. --- api-proposal-status-code-log-level.md | 206 ++++++++++++++++++ .../Logging/Internal/HttpClientLogger.cs | 4 +- 2 files changed, 209 insertions(+), 1 deletion(-) create mode 100644 api-proposal-status-code-log-level.md diff --git a/api-proposal-status-code-log-level.md b/api-proposal-status-code-log-level.md new file mode 100644 index 00000000000..d5b9041f617 --- /dev/null +++ b/api-proposal-status-code-log-level.md @@ -0,0 +1,206 @@ +# [API Proposal]: Configurable log level for HTTP response status codes in `AddExtendedHttpClientLogging` + +## Background and motivation + +`HttpClientLogger` in `Microsoft.Extensions.Http.Diagnostics` hard-codes the log level based on HTTP status code: + +```csharp +private static LogLevel GetLogLevel(LogRecord logRecord) +{ + const int HttpErrorsRangeStart = 400; + const int HttpErrorsRangeEnd = 599; + int statusCode = logRecord.StatusCode!.Value; + + if (statusCode >= HttpErrorsRangeStart && statusCode <= HttpErrorsRangeEnd) + { + return LogLevel.Error; + } + + return LogLevel.Information; +} +``` + +Similarly, when an exception occurs (no response), it always logs at `LogLevel.Error` via `Log.OutgoingRequestError`. + +This is problematic for real-world applications where certain HTTP error status codes are **expected** as part of normal business logic: + +- **404 Not Found** is used for existence checks, cache lookups, or "get-or-create" patterns. The caller handles the 404 gracefully, yet the logger emits it at `Error`. +- **409 Conflict** is used for optimistic concurrency control. The application retries or merges, but the log says `Error`. +- **429 Too Many Requests** is handled by the resilience pipeline transparently, but every attempt is logged at `Error`. + +### The core problem + +We **still want to see these log entries**. They're useful for debugging and observability. But we need them at `Warning` or `Information`, not `Error`. + +The standard .NET log filtering system (`Logging:LogLevel:CategoryName`) only supports **minimum-level thresholds**. It can suppress logs below a level, but cannot *remap* a log from one level to another. So the only options today are: + +1. **Suppress all logs** from `HttpClientLogger` below `Critical`, which loses visibility entirely. +2. **Leave them at `Error`**, which triggers false alarms in monitoring (Elastic Watchers, PagerDuty, Azure Monitor alert rules, Slack channels) because the alerting systems cannot distinguish "expected 404 during a cache lookup" from "genuine 500 from a broken dependency." + +Neither is acceptable. We need the logs, but at the correct severity. + +### Real-world impact + +In our application, HTTP clients call multiple downstream services where 404/409 responses are part of normal flows. These responses are logged at `Error`, which: +- Spams our alerting channels with non-actionable noise +- Causes on-call engineers to develop alert fatigue +- Makes it harder to spot genuine errors in the noise +- Cannot be fixed by log filtering because we'd lose the diagnostic value of seeing the calls + +## API Proposal + +```csharp +namespace Microsoft.Extensions.Http.Logging; + +/// +/// Maps a status code or range of status codes to a specific log level. +/// +public class HttpStatusCodeLogLevelRule +{ + /// + /// Gets or sets the minimum status code this rule applies to (inclusive). + /// + [Range(100, 599)] + public int FromStatusCode { get; set; } + + /// + /// Gets or sets the maximum status code this rule applies to (inclusive). + /// When null, matches only (exact match). + /// + [Range(100, 599)] + public int? ToStatusCode { get; set; } + + /// + /// Gets or sets the log level to use for responses matching this rule. + /// + public LogLevel LogLevel { get; set; } = LogLevel.Information; +} + +// Additions to the existing LoggingOptions class: +namespace Microsoft.Extensions.Http.Logging; + +public class LoggingOptions +{ + // ... existing properties ... + + /// + /// Gets or sets rules that map HTTP response status codes to log levels. + /// Rules are evaluated in order; the first matching rule wins. + /// If no rule matches, the default behavior applies + /// (Information for 1xx-3xx, Error for 4xx-5xx). + /// + public IList StatusCodeLogLevelRules { get; set; } = []; + + /// + /// Gets or sets the log level used when the request fails with an exception + /// (no HTTP response available). + /// + /// + /// The default value is . + /// + public LogLevel ExceptionLogLevel { get; set; } = LogLevel.Error; +} +``` + +## API Usage + +### Code configuration + +```csharp +builder.Services.AddHttpClient() + .AddExtendedHttpClientLogging(options => + { + options.StatusCodeLogLevelRules = + [ + // Expected "not found" responses during cache lookups + new() { FromStatusCode = 404, LogLevel = LogLevel.Information }, + // Rate limiting is a warning, not an error + new() { FromStatusCode = 429, LogLevel = LogLevel.Warning }, + // All other 4xx are warnings + new() { FromStatusCode = 400, ToStatusCode = 499, LogLevel = LogLevel.Warning }, + // 5xx remain errors (explicit, same as default) + new() { FromStatusCode = 500, ToStatusCode = 599, LogLevel = LogLevel.Error }, + ]; + + // Exceptions (e.g. timeouts, DNS failures) log at Warning instead of Error + options.ExceptionLogLevel = LogLevel.Warning; + }); +``` + +### Configuration from appsettings.json + +```json +{ + "HttpClientLogging": { + "StatusCodeLogLevelRules": [ + { "FromStatusCode": 404, "LogLevel": "Information" }, + { "FromStatusCode": 429, "LogLevel": "Warning" }, + { "FromStatusCode": 400, "ToStatusCode": 499, "LogLevel": "Warning" }, + { "FromStatusCode": 500, "ToStatusCode": 599, "LogLevel": "Error" } + ], + "ExceptionLogLevel": "Warning" + } +} +``` + +### Per-client configuration (via keyed options) + +The existing keyed options mechanism means different clients can have different rules: + +```csharp +builder.Services.AddHttpClient() + .AddExtendedHttpClientLogging(options => + { + // Payment gateway: all errors are critical + options.StatusCodeLogLevelRules = []; // Use defaults (Error for 4xx/5xx) + }); + +builder.Services.AddHttpClient() + .AddExtendedHttpClientLogging(options => + { + // Cache: 404 is normal, 5xx is warning (cache is non-critical) + options.StatusCodeLogLevelRules = + [ + new() { FromStatusCode = 404, LogLevel = LogLevel.Information }, + new() { FromStatusCode = 500, ToStatusCode = 599, LogLevel = LogLevel.Warning }, + ]; + }); +``` + +## Alternative Designs + +### 1. Delegate-based approach + +```csharp +public Func? LogLevelSelector { get; set; } +``` + +Pros: Maximum flexibility. +Cons: Not JSON-serializable, not bindable from configuration, can't be validated, opaque to tooling. + +### 2. Fixed properties per status code class + +```csharp +public LogLevel ClientErrorLogLevel { get; set; } = LogLevel.Error; // 4xx +public LogLevel ServerErrorLogLevel { get; set; } = LogLevel.Error; // 5xx +public IDictionary StatusCodeOverrides { get; set; } // exact codes +``` + +Pros: Simpler for the common case. +Cons: Two different configuration shapes for the same concern. Cannot express arbitrary ranges (e.g. 500-502 = Warning, 503-599 = Error). + +### 3. Dictionary-only approach + +```csharp +public IDictionary StatusCodeLogLevels { get; set; } +``` + +Pros: Very simple. +Cons: Cannot express ranges without listing every code. A 404+429 override needs two entries but "all 4xx = Warning" requires 100 entries. + +## Risks + +- **Backward compatibility:** Fully backward-compatible. Default behavior is preserved when `StatusCodeLogLevelRules` is empty (which is the default). +- **Performance:** Rule evaluation is a linear scan over a small list (typically 2-5 rules). Given that this runs once per HTTP request at logging time, the overhead is negligible. +- **Validation:** `LoggingOptionsValidator` needs a new rule to enforce `FromStatusCode <= ToStatusCode` when both are set. Straightforward addition. +- **Source-generated logging:** `Log.OutgoingRequestError` currently hard-codes `LogLevel.Error` via `[LoggerMessage]`. The exception path would need to call the dynamic `OutgoingRequest(logger, level, ...)` method instead, which already exists for the non-exception path. diff --git a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/HttpClientLogger.cs b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/HttpClientLogger.cs index 014205231b8..a3a247d8186 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/HttpClientLogger.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/HttpClientLogger.cs @@ -64,7 +64,9 @@ internal HttpClientLogger( _logResponseHeaders = options.ResponseHeadersDataClasses.Count > 0; _logRequestHeaders = options.RequestHeadersDataClasses.Count > 0; _pathParametersRedactionSkipped = options.RequestPathParameterRedactionMode == HttpRouteParameterRedactionMode.None; - _statusCodeLogLevelRules = options.StatusCodeLogLevelRules as IReadOnlyList ?? options.StatusCodeLogLevelRules.ToArray(); + _statusCodeLogLevelRules = options.StatusCodeLogLevelRules is { } rules + ? rules as IReadOnlyList ?? rules.ToArray() + : []; _exceptionLogLevel = options.ExceptionLogLevel; } From d59eb042ec53ce03b6e6e7956f0a37d3017f473c Mon Sep 17 00:00:00 2001 From: "Marcus Kanon (consultant)" Date: Fri, 17 Jul 2026 13:12:02 +0200 Subject: [PATCH 3/4] Remove unused using (S1128) --- .../Logging/HttpClientLoggerStatusCodeLogLevelTest.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Logging/HttpClientLoggerStatusCodeLogLevelTest.cs b/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Logging/HttpClientLoggerStatusCodeLogLevelTest.cs index bd6c916c0c0..b304dac6d3e 100644 --- a/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Logging/HttpClientLoggerStatusCodeLogLevelTest.cs +++ b/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Logging/HttpClientLoggerStatusCodeLogLevelTest.cs @@ -7,7 +7,6 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Compliance.Classification; -using Microsoft.Extensions.Compliance.Testing; using Microsoft.Extensions.Http.Diagnostics; using Microsoft.Extensions.Http.Diagnostics.Test.Logging.Internal; using Microsoft.Extensions.Http.Logging.Internal; From ffb56054db670e19913bbe39575fdae0d0167eef Mon Sep 17 00:00:00 2001 From: "Marcus Kanon (consultant)" Date: Fri, 17 Jul 2026 13:15:37 +0200 Subject: [PATCH 4/4] Remove accidental scratch file --- api-proposal-status-code-log-level.md | 206 -------------------------- 1 file changed, 206 deletions(-) delete mode 100644 api-proposal-status-code-log-level.md diff --git a/api-proposal-status-code-log-level.md b/api-proposal-status-code-log-level.md deleted file mode 100644 index d5b9041f617..00000000000 --- a/api-proposal-status-code-log-level.md +++ /dev/null @@ -1,206 +0,0 @@ -# [API Proposal]: Configurable log level for HTTP response status codes in `AddExtendedHttpClientLogging` - -## Background and motivation - -`HttpClientLogger` in `Microsoft.Extensions.Http.Diagnostics` hard-codes the log level based on HTTP status code: - -```csharp -private static LogLevel GetLogLevel(LogRecord logRecord) -{ - const int HttpErrorsRangeStart = 400; - const int HttpErrorsRangeEnd = 599; - int statusCode = logRecord.StatusCode!.Value; - - if (statusCode >= HttpErrorsRangeStart && statusCode <= HttpErrorsRangeEnd) - { - return LogLevel.Error; - } - - return LogLevel.Information; -} -``` - -Similarly, when an exception occurs (no response), it always logs at `LogLevel.Error` via `Log.OutgoingRequestError`. - -This is problematic for real-world applications where certain HTTP error status codes are **expected** as part of normal business logic: - -- **404 Not Found** is used for existence checks, cache lookups, or "get-or-create" patterns. The caller handles the 404 gracefully, yet the logger emits it at `Error`. -- **409 Conflict** is used for optimistic concurrency control. The application retries or merges, but the log says `Error`. -- **429 Too Many Requests** is handled by the resilience pipeline transparently, but every attempt is logged at `Error`. - -### The core problem - -We **still want to see these log entries**. They're useful for debugging and observability. But we need them at `Warning` or `Information`, not `Error`. - -The standard .NET log filtering system (`Logging:LogLevel:CategoryName`) only supports **minimum-level thresholds**. It can suppress logs below a level, but cannot *remap* a log from one level to another. So the only options today are: - -1. **Suppress all logs** from `HttpClientLogger` below `Critical`, which loses visibility entirely. -2. **Leave them at `Error`**, which triggers false alarms in monitoring (Elastic Watchers, PagerDuty, Azure Monitor alert rules, Slack channels) because the alerting systems cannot distinguish "expected 404 during a cache lookup" from "genuine 500 from a broken dependency." - -Neither is acceptable. We need the logs, but at the correct severity. - -### Real-world impact - -In our application, HTTP clients call multiple downstream services where 404/409 responses are part of normal flows. These responses are logged at `Error`, which: -- Spams our alerting channels with non-actionable noise -- Causes on-call engineers to develop alert fatigue -- Makes it harder to spot genuine errors in the noise -- Cannot be fixed by log filtering because we'd lose the diagnostic value of seeing the calls - -## API Proposal - -```csharp -namespace Microsoft.Extensions.Http.Logging; - -/// -/// Maps a status code or range of status codes to a specific log level. -/// -public class HttpStatusCodeLogLevelRule -{ - /// - /// Gets or sets the minimum status code this rule applies to (inclusive). - /// - [Range(100, 599)] - public int FromStatusCode { get; set; } - - /// - /// Gets or sets the maximum status code this rule applies to (inclusive). - /// When null, matches only (exact match). - /// - [Range(100, 599)] - public int? ToStatusCode { get; set; } - - /// - /// Gets or sets the log level to use for responses matching this rule. - /// - public LogLevel LogLevel { get; set; } = LogLevel.Information; -} - -// Additions to the existing LoggingOptions class: -namespace Microsoft.Extensions.Http.Logging; - -public class LoggingOptions -{ - // ... existing properties ... - - /// - /// Gets or sets rules that map HTTP response status codes to log levels. - /// Rules are evaluated in order; the first matching rule wins. - /// If no rule matches, the default behavior applies - /// (Information for 1xx-3xx, Error for 4xx-5xx). - /// - public IList StatusCodeLogLevelRules { get; set; } = []; - - /// - /// Gets or sets the log level used when the request fails with an exception - /// (no HTTP response available). - /// - /// - /// The default value is . - /// - public LogLevel ExceptionLogLevel { get; set; } = LogLevel.Error; -} -``` - -## API Usage - -### Code configuration - -```csharp -builder.Services.AddHttpClient() - .AddExtendedHttpClientLogging(options => - { - options.StatusCodeLogLevelRules = - [ - // Expected "not found" responses during cache lookups - new() { FromStatusCode = 404, LogLevel = LogLevel.Information }, - // Rate limiting is a warning, not an error - new() { FromStatusCode = 429, LogLevel = LogLevel.Warning }, - // All other 4xx are warnings - new() { FromStatusCode = 400, ToStatusCode = 499, LogLevel = LogLevel.Warning }, - // 5xx remain errors (explicit, same as default) - new() { FromStatusCode = 500, ToStatusCode = 599, LogLevel = LogLevel.Error }, - ]; - - // Exceptions (e.g. timeouts, DNS failures) log at Warning instead of Error - options.ExceptionLogLevel = LogLevel.Warning; - }); -``` - -### Configuration from appsettings.json - -```json -{ - "HttpClientLogging": { - "StatusCodeLogLevelRules": [ - { "FromStatusCode": 404, "LogLevel": "Information" }, - { "FromStatusCode": 429, "LogLevel": "Warning" }, - { "FromStatusCode": 400, "ToStatusCode": 499, "LogLevel": "Warning" }, - { "FromStatusCode": 500, "ToStatusCode": 599, "LogLevel": "Error" } - ], - "ExceptionLogLevel": "Warning" - } -} -``` - -### Per-client configuration (via keyed options) - -The existing keyed options mechanism means different clients can have different rules: - -```csharp -builder.Services.AddHttpClient() - .AddExtendedHttpClientLogging(options => - { - // Payment gateway: all errors are critical - options.StatusCodeLogLevelRules = []; // Use defaults (Error for 4xx/5xx) - }); - -builder.Services.AddHttpClient() - .AddExtendedHttpClientLogging(options => - { - // Cache: 404 is normal, 5xx is warning (cache is non-critical) - options.StatusCodeLogLevelRules = - [ - new() { FromStatusCode = 404, LogLevel = LogLevel.Information }, - new() { FromStatusCode = 500, ToStatusCode = 599, LogLevel = LogLevel.Warning }, - ]; - }); -``` - -## Alternative Designs - -### 1. Delegate-based approach - -```csharp -public Func? LogLevelSelector { get; set; } -``` - -Pros: Maximum flexibility. -Cons: Not JSON-serializable, not bindable from configuration, can't be validated, opaque to tooling. - -### 2. Fixed properties per status code class - -```csharp -public LogLevel ClientErrorLogLevel { get; set; } = LogLevel.Error; // 4xx -public LogLevel ServerErrorLogLevel { get; set; } = LogLevel.Error; // 5xx -public IDictionary StatusCodeOverrides { get; set; } // exact codes -``` - -Pros: Simpler for the common case. -Cons: Two different configuration shapes for the same concern. Cannot express arbitrary ranges (e.g. 500-502 = Warning, 503-599 = Error). - -### 3. Dictionary-only approach - -```csharp -public IDictionary StatusCodeLogLevels { get; set; } -``` - -Pros: Very simple. -Cons: Cannot express ranges without listing every code. A 404+429 override needs two entries but "all 4xx = Warning" requires 100 entries. - -## Risks - -- **Backward compatibility:** Fully backward-compatible. Default behavior is preserved when `StatusCodeLogLevelRules` is empty (which is the default). -- **Performance:** Rule evaluation is a linear scan over a small list (typically 2-5 rules). Given that this runs once per HTTP request at logging time, the overhead is negligible. -- **Validation:** `LoggingOptionsValidator` needs a new rule to enforce `FromStatusCode <= ToStatusCode` when both are set. Straightforward addition. -- **Source-generated logging:** `Log.OutgoingRequestError` currently hard-codes `LogLevel.Error` via `[LoggerMessage]`. The exception path would need to call the dynamic `OutgoingRequest(logger, level, ...)` method instead, which already exists for the non-exception path.