Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Maps a status code or range of status codes to a specific log level.
/// </summary>
[Experimental(diagnosticId: DiagnosticIds.Experiments.Telemetry, UrlFormat = DiagnosticIds.UrlFormat)]
public class HttpStatusCodeLogLevelRule : IValidatableObject
{
/// <summary>
/// Gets or sets the minimum status code this rule applies to (inclusive).
/// </summary>
[Range(100, 599)]
public int FromStatusCode { get; set; }

/// <summary>
/// Gets or sets the maximum status code this rule applies to (inclusive).
/// When <see langword="null"/>, matches only <see cref="FromStatusCode"/> (exact match).
/// </summary>
[Range(100, 599)]
public int? ToStatusCode { get; set; }

/// <summary>
/// Gets or sets the log level to use for responses matching this rule.
/// </summary>
public LogLevel LogLevel { get; set; } = LogLevel.Information;

/// <inheritdoc/>
public IEnumerable<ValidationResult> 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)]);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ internal sealed class HttpClientLogger : IHttpClientAsyncLogger
private readonly bool _logResponseHeaders;
private readonly bool _logRequestHeaders;
private readonly bool _pathParametersRedactionSkipped;
private readonly IReadOnlyList<HttpStatusCodeLogLevelRule> _statusCodeLogLevelRules;
private readonly LogLevel _exceptionLogLevel;
private ILogger<HttpClientLogger> _logger;
private IHttpRequestReader _httpRequestReader;
private IHttpClientLogEnricher[] _enrichers;
Expand Down Expand Up @@ -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<HttpStatusCodeLogLevelRule> ?? rules.ToArray()
: [];
_exceptionLogLevel = options.ExceptionLogLevel;
}

public async ValueTask<object?> LogRequestStartAsync(HttpRequestMessage request, CancellationToken cancellationToken = default)
Expand Down Expand Up @@ -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)
{
Expand Down Expand Up @@ -188,7 +206,7 @@ private async ValueTask LogResponseAsync(
}
else
{
Log.OutgoingRequestError(_logger, logRecord, exception);
Log.OutgoingRequestError(_logger, _exceptionLogLevel, logRecord, exception);
}
}
catch (Exception ex)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -163,4 +165,27 @@ public class LoggingOptions
/// </remarks>
[Experimental(diagnosticId: DiagnosticIds.Experiments.Telemetry, UrlFormat = DiagnosticIds.UrlFormat)]
public bool LogContentHeaders { get; set; }

/// <summary>
/// Gets or sets a list of rules that map HTTP status codes or ranges to specific log levels.
/// </summary>
/// <value>
/// The default value is an empty list. When empty, the built-in behavior applies: 400-599 logs at <see cref="Microsoft.Extensions.Logging.LogLevel.Error"/>,
/// all other status codes log at <see cref="Microsoft.Extensions.Logging.LogLevel.Information"/>.
/// </value>
/// <remarks>
/// Rules are evaluated in order; the first matching rule wins. If no rule matches, the built-in default applies.
/// </remarks>
[Experimental(diagnosticId: DiagnosticIds.Experiments.Telemetry, UrlFormat = DiagnosticIds.UrlFormat)]
[ValidateEnumeratedItems]
public IList<HttpStatusCodeLogLevelRule> StatusCodeLogLevelRules { get; set; } = [];

/// <summary>
/// Gets or sets the log level used when an HTTP request throws an exception.
/// </summary>
/// <value>
/// The default value is <see cref="Microsoft.Extensions.Logging.LogLevel.Error"/>.
/// </value>
[Experimental(diagnosticId: DiagnosticIds.Experiments.Telemetry, UrlFormat = DiagnosticIds.UrlFormat)]
public LogLevel ExceptionLogLevel { get; set; } = LogLevel.Error;
}
Original file line number Diff line number Diff line change
@@ -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<HttpClientLogger>();
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<HttpClientLogger>();
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<HttpClientLogger>();
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<HttpClientLogger>();
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<HttpClientLogger>();

using var handler = new TestLoggingHandler(
new HttpClientLogger(
fakeLogger,
Mock.Of<IHttpRequestReader>(),
[],
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<HttpRequestException>(() => 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<HttpClientLogger>();

using var handler = new TestLoggingHandler(
new HttpClientLogger(
fakeLogger,
Mock.Of<IHttpRequestReader>(),
[],
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<HttpRequestException>(() => 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<HttpClientLogger>();
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<HttpClientLogger> fakeLogger,
LoggingOptions options,
HttpResponseMessage response)
{
var mockHeadersRedactor = new Mock<IHttpHeadersRedactor>();
mockHeadersRedactor
.Setup(r => r.Redact(It.IsAny<IEnumerable<string>>(), It.IsAny<DataClassification>()))
.Returns("Redacted");

var headersReader = new HttpHeadersReader(options.ToOptionsMonitor(), mockHeadersRedactor.Object);

return new TestLoggingHandler(
new HttpClientLogger(
fakeLogger,
new HttpRequestReader(
options,
Mock.Of<IHttpRouteFormatter>(),
Mock.Of<IHttpRouteParser>(),
headersReader,
Mock.Of<IOutgoingRequestContext>()),
[],
options),
new TestingHandlerStub((_, _) => Task.FromResult(response)));
}
}
Loading
Loading