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..a3a247d8186 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,10 @@ internal HttpClientLogger( _logResponseHeaders = options.ResponseHeadersDataClasses.Count > 0; _logRequestHeaders = options.RequestHeadersDataClasses.Count > 0; _pathParametersRedactionSkipped = options.RequestPathParameterRedactionMode == HttpRouteParameterRedactionMode.None; + _statusCodeLogLevelRules = options.StatusCodeLogLevelRules is { } rules + ? rules as IReadOnlyList ?? rules.ToArray() + : []; + _exceptionLogLevel = options.ExceptionLogLevel; } public async ValueTask LogRequestStartAsync(HttpRequestMessage request, CancellationToken cancellationToken = default) @@ -133,11 +139,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 +206,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..b304dac6d3e --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Logging/HttpClientLoggerStatusCodeLogLevelTest.cs @@ -0,0 +1,228 @@ +// 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.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); + } +}