Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

HttpClient logging based on new .NET 8 runtime APIs #4224

Merged
merged 39 commits into from
Aug 21, 2023
Merged
Show file tree
Hide file tree
Changes from 33 commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
4614a5a
fixes + tests
xakep139 Jul 18, 2023
8e30724
try to use new logging APIs
xakep139 Jul 19, 2023
8dc3a32
minor fix
xakep139 Jul 19, 2023
abf5d88
update dependencies
xakep139 Jul 20, 2023
8a5ae6a
Merge branch 'main' into xakep139/outgoing-http-logging
xakep139 Jul 24, 2023
46b3c52
implement async logging part
xakep139 Jul 24, 2023
e1eb602
simplify extensions
xakep139 Jul 27, 2023
e9fee43
fixes + further dev
xakep139 Jul 27, 2023
2026789
further dev
xakep139 Jul 27, 2023
57be31d
fixes + update tests
xakep139 Jul 28, 2023
bff6a42
Merge branch 'main' into xakep139/outgoing-http-logging
xakep139 Jul 31, 2023
7ae1508
CR
xakep139 Jul 31, 2023
82a3f0c
Update src/LegacySupport/TaskWaitAsync/TaskExtensions.cs
xakep139 Jul 31, 2023
f223f8b
fix a warning
xakep139 Jul 31, 2023
7f17330
CR
xakep139 Aug 1, 2023
b9e6e7d
Merge branch 'main' into xakep139/outgoing-http-logging
xakep139 Aug 2, 2023
30bc9e8
fixes after merge
xakep139 Aug 2, 2023
087a327
Merge branch 'main' into xakep139/outgoing-http-logging
xakep139 Aug 3, 2023
cc6086c
fix an edge case + test
xakep139 Aug 3, 2023
08d093d
fix more bugs + tests
xakep139 Aug 3, 2023
6fa3547
minor fixes
xakep139 Aug 3, 2023
cec1ae8
Merge branch 'main' into xakep139/outgoing-http-logging
xakep139 Aug 4, 2023
a2a15ed
fixes after merge with main
xakep139 Aug 4, 2023
a7c3428
fix a bug in auto activation
xakep139 Aug 4, 2023
4f2bfbf
Update AutoActivationExtensions.cs
xakep139 Aug 4, 2023
f9039f5
Merge branch 'main' into xakep139/outgoing-http-logging
xakep139 Aug 8, 2023
ab8c33b
CR
xakep139 Aug 8, 2023
ee19b43
Leverage KeyedServices feature in HttpClient logging (#4253)
xakep139 Aug 14, 2023
c0f4a9c
merge with main
xakep139 Aug 14, 2023
687509c
fixes after merge
xakep139 Aug 14, 2023
cf5daef
Merge branch 'main' into xakep139/outgoing-http-logging
xakep139 Aug 14, 2023
af7dbf1
resolve a TODO item;
xakep139 Aug 14, 2023
5f69923
fix tests;
xakep139 Aug 14, 2023
ac0d2eb
Merge branch 'main' into xakep139/outgoing-http-logging
xakep139 Aug 15, 2023
67605df
Merge branch 'main' into xakep139/outgoing-http-logging
xakep139 Aug 16, 2023
c05d8e1
increase code coverage;
xakep139 Aug 16, 2023
9b1ea6e
Merge branch 'main' into xakep139/outgoing-http-logging
xakep139 Aug 17, 2023
045a6ca
Merge branch 'main' into xakep139/outgoing-http-logging
xakep139 Aug 21, 2023
7203050
resolve the last TODO item
xakep139 Aug 21, 2023
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
@@ -1,6 +1,7 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using System.Net.Http;
using Microsoft.Extensions.Telemetry.Enrichment;

Expand All @@ -11,8 +12,7 @@ internal sealed class BenchEnricher : IHttpClientLogEnricher
private const string Key = "Performance in R9";
private const string Value = "is paramount.";

public void Enrich(IEnrichmentTagCollector collector, HttpRequestMessage? request = null,
HttpResponseMessage? response = null)
public void Enrich(IEnrichmentTagCollector collector, HttpRequestMessage request, HttpResponseMessage? response = null, Exception? exception = null)
{
if (request is not null)
{
Expand Down
2 changes: 1 addition & 1 deletion eng/common/tools.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,7 @@ function InstallDotNet([string] $dotnetRoot,
if ($runtime -eq "aspnetcore") { $runtimePath = $runtimePath + "\Microsoft.AspNetCore.App" }
if ($runtime -eq "windowsdesktop") { $runtimePath = $runtimePath + "\Microsoft.WindowsDesktop.App" }
$runtimePath = $runtimePath + "\" + $version

$dotnetVersionLabel = "runtime toolset '$runtime/$architecture v$version'"

if (Test-Path $runtimePath) {
Expand Down
2 changes: 1 addition & 1 deletion src/LegacySupport/TaskWaitAsync/TaskExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ private static async Task<TResult> WaitTaskAsync<TResult>(Task<TResult> task, Ca
{
var tcs = new TaskCompletionSource<TResult>(TaskCreationOptions.RunContinuationsAsynchronously);
using (cancellationToken.Register(
static o => ((TaskCompletionSource<TResult>)o!).SetCanceled(), tcs, false))
static state => ((TaskCompletionSource<TResult>)state!).SetCanceled(), tcs, false))
{
var t = await Task.WhenAny(task, tcs.Task).ConfigureAwait(false);
#pragma warning disable VSTHRD103 // Call async methods when in an async method
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
Expand All @@ -14,7 +15,7 @@
namespace Microsoft.Extensions.Http.Telemetry.Latency.Internal;

/// <summary>
/// The enrciher appends checkpoints for the outgoing http request.
/// The enricher appends checkpoints for the outgoing http request.
/// It also logs the server name from the response header to correlate logs between client and server.
/// </summary>
internal sealed class HttpClientLatencyLogEnricher : IHttpClientLogEnricher
Expand All @@ -30,7 +31,7 @@ public HttpClientLatencyLogEnricher(HttpClientLatencyContext latencyContext, ILa
_enricherInvoked = tokenIssuer.GetCheckpointToken(HttpCheckpoints.EnricherInvoked);
xakep139 marked this conversation as resolved.
Show resolved Hide resolved
}

public void Enrich(IEnrichmentTagCollector collector, HttpRequestMessage? request = null, HttpResponseMessage? response = null)
public void Enrich(IEnrichmentTagCollector collector, HttpRequestMessage request, HttpResponseMessage? response = null, Exception? exception = null)
{
if (response != null)
{
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
namespace Microsoft.Extensions.Http.Telemetry.Logging;

/// <summary>
/// Constants used for HTTP client logging dimensions.
/// Constants used for HTTP client logging tags.
/// </summary>
public static class HttpClientLoggingTagNames
{
Expand Down Expand Up @@ -57,10 +57,10 @@ public static class HttpClientLoggingTagNames
public const string StatusCode = "httpStatusCode";

/// <summary>
/// Gets a list of all dimension names.
/// Gets a list of all tag names.
/// </summary>
/// <returns>A read-only <see cref="IReadOnlyList{String}"/> of all dimension names.</returns>
public static IReadOnlyList<string> DimensionNames { get; } =
/// <returns>A read-only <see cref="IReadOnlyList{String}"/> of all tag names.</returns>
public static IReadOnlyList<string> TagNames { get; } =
Array.AsReadOnly(new[]
{
Duration,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using System.Net.Http;
using Microsoft.Extensions.Telemetry.Enrichment;

Expand All @@ -17,5 +18,8 @@
/// <param name="collector">Tag collector to add tags to.</param>
/// <param name="request"><see cref="HttpRequestMessage"/> object associated with the outgoing HTTP request.</param>
/// <param name="response"><see cref="HttpResponseMessage"/> object associated with the outgoing HTTP request.</param>
void Enrich(IEnrichmentTagCollector collector, HttpRequestMessage? request = null, HttpResponseMessage? response = null);
xakep139 marked this conversation as resolved.
Show resolved Hide resolved
/// <param name="exception">An optional <see cref="Exception"/> that was thrown within the outgoing HTTP request processing.</param>
// TODO: adding a new parameter to the interface method is a breaking change. Pay additional attention to this during review.

Check failure on line 22 in src/Libraries/Microsoft.Extensions.Http.Telemetry/Logging/IHttpClientLogEnricher.cs

View check run for this annotation

Azure Pipelines / extensions-ci (Correctness WarningsCheck)

src/Libraries/Microsoft.Extensions.Http.Telemetry/Logging/IHttpClientLogEnricher.cs#L22

src/Libraries/Microsoft.Extensions.Http.Telemetry/Logging/IHttpClientLogEnricher.cs(22,8): error S1135: (NETCORE_ENGINEERING_TELEMETRY=Build) Complete the task associated to this 'TODO' comment. (https://rules.sonarsource.com/csharp/RSPEC-1135)

Check failure on line 22 in src/Libraries/Microsoft.Extensions.Http.Telemetry/Logging/IHttpClientLogEnricher.cs

View check run for this annotation

Azure Pipelines / extensions-ci (Correctness WarningsCheck)

src/Libraries/Microsoft.Extensions.Http.Telemetry/Logging/IHttpClientLogEnricher.cs#L22

src/Libraries/Microsoft.Extensions.Http.Telemetry/Logging/IHttpClientLogEnricher.cs(22,8): error S1135: (NETCORE_ENGINEERING_TELEMETRY=Build) Complete the task associated to this 'TODO' comment. (https://rules.sonarsource.com/csharp/RSPEC-1135)

Check failure on line 22 in src/Libraries/Microsoft.Extensions.Http.Telemetry/Logging/IHttpClientLogEnricher.cs

View check run for this annotation

Azure Pipelines / extensions-ci (Correctness WarningsCheck)

src/Libraries/Microsoft.Extensions.Http.Telemetry/Logging/IHttpClientLogEnricher.cs#L22

src/Libraries/Microsoft.Extensions.Http.Telemetry/Logging/IHttpClientLogEnricher.cs(22,8): error S1135: (NETCORE_ENGINEERING_TELEMETRY=Build) Complete the task associated to this 'TODO' comment. (https://rules.sonarsource.com/csharp/RSPEC-1135)

Check failure on line 22 in src/Libraries/Microsoft.Extensions.Http.Telemetry/Logging/IHttpClientLogEnricher.cs

View check run for this annotation

Azure Pipelines / extensions-ci

src/Libraries/Microsoft.Extensions.Http.Telemetry/Logging/IHttpClientLogEnricher.cs#L22

src/Libraries/Microsoft.Extensions.Http.Telemetry/Logging/IHttpClientLogEnricher.cs(22,8): error S1135: (NETCORE_ENGINEERING_TELEMETRY=Build) Complete the task associated to this 'TODO' comment. (https://rules.sonarsource.com/csharp/RSPEC-1135)

Check failure on line 22 in src/Libraries/Microsoft.Extensions.Http.Telemetry/Logging/IHttpClientLogEnricher.cs

View check run for this annotation

Azure Pipelines / extensions-ci

src/Libraries/Microsoft.Extensions.Http.Telemetry/Logging/IHttpClientLogEnricher.cs#L22

src/Libraries/Microsoft.Extensions.Http.Telemetry/Logging/IHttpClientLogEnricher.cs(22,8): error S1135: (NETCORE_ENGINEERING_TELEMETRY=Build) Complete the task associated to this 'TODO' comment. (https://rules.sonarsource.com/csharp/RSPEC-1135)

Check failure on line 22 in src/Libraries/Microsoft.Extensions.Http.Telemetry/Logging/IHttpClientLogEnricher.cs

View check run for this annotation

Azure Pipelines / extensions-ci

src/Libraries/Microsoft.Extensions.Http.Telemetry/Logging/IHttpClientLogEnricher.cs#L22

src/Libraries/Microsoft.Extensions.Http.Telemetry/Logging/IHttpClientLogEnricher.cs(22,8): error S1135: (NETCORE_ENGINEERING_TELEMETRY=Build) Complete the task associated to this 'TODO' comment. (https://rules.sonarsource.com/csharp/RSPEC-1135)
// Alternatively, we can decide to add another overload instead - where exception in not nullable.
void Enrich(IEnrichmentTagCollector collector, HttpRequestMessage request, HttpResponseMessage? response = null, Exception? exception = null);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http.Logging;
using Microsoft.Extensions.Http.Telemetry.Logging.Internal;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.ObjectPool;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Telemetry.Logging;
using Microsoft.Shared.Pools;

namespace Microsoft.Extensions.Http.Telemetry.Logging;

internal sealed class HttpClientLogger : IHttpClientAsyncLogger
{
private const string SyncLoggingExceptionMessage = "Synchronous logging is not supported";

private readonly ObjectPool<List<KeyValuePair<string, string>>> _headersPool =
PoolFactory.CreateListPool<KeyValuePair<string, string>>();

private readonly ObjectPool<LogRecord> _logRecordPool =
PoolFactory.CreateResettingPool<LogRecord>();

private readonly bool _logRequestStart;
private readonly bool _logResponseHeaders;
private readonly bool _logRequestHeaders;
private readonly bool _pathParametersRedactionSkipped;
private ILogger<HttpClientLogger> _logger;
private IHttpRequestReader _httpRequestReader;
private IHttpClientLogEnricher[] _enrichers;

public HttpClientLogger(
IServiceProvider serviceProvider,
ILogger<HttpClientLogger> logger,
IEnumerable<IHttpClientLogEnricher> enrichers,
IOptionsMonitor<LoggingOptions> optionsMonitor,
[ServiceKey] string? serviceKey = null)
: this(
logger,
serviceProvider.GetRequiredOrKeyedRequiredService<IHttpRequestReader>(serviceKey),
enrichers,
optionsMonitor.GetKeyedOrCurrent(serviceKey))
{
}

internal HttpClientLogger(
ILogger<HttpClientLogger> logger,
IHttpRequestReader httpRequestReader,
IEnumerable<IHttpClientLogEnricher> enrichers,
LoggingOptions options)
{
_logger = logger;
_httpRequestReader = httpRequestReader;
_enrichers = enrichers.Where(static x => x is not null).ToArray();
_logRequestStart = options.LogRequestStart;
_logResponseHeaders = options.ResponseHeadersDataClasses.Count > 0;
_logRequestHeaders = options.RequestHeadersDataClasses.Count > 0;
_pathParametersRedactionSkipped = options.RequestPathParameterRedactionMode == HttpRouteParameterRedactionMode.None;
}

[SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "The logger shouldn't throw")]
public async ValueTask<object?> LogRequestStartAsync(HttpRequestMessage request, CancellationToken cancellationToken = default)
{
var logRecord = _logRecordPool.Get();

List<KeyValuePair<string, string>>? requestHeadersBuffer = null;
if (_logRequestHeaders)
{
requestHeadersBuffer = _headersPool.Get();
}

try
{
await _httpRequestReader.ReadRequestAsync(logRecord, request, requestHeadersBuffer, cancellationToken).ConfigureAwait(false);

if (_logRequestStart)
{
Log.OutgoingRequest(_logger, LogLevel.Information, logRecord);
}

return logRecord;
}
catch (Exception ex)
{
// If redaction is skipped, we can log unredacted request path; otherwise use "logRecord.Path" (even though it might not be set):
var pathToLog = _pathParametersRedactionSkipped
? request.RequestUri?.AbsolutePath
: logRecord.Path;

Log.RequestReadError(_logger, ex, request.Method, request.RequestUri?.Host, pathToLog);

// Return back pooled objects (since the logRecord wasn't fully prepared):
_logRecordPool.Return(logRecord);

if (requestHeadersBuffer is not null)
{
_headersPool.Return(requestHeadersBuffer);
}

// Recommendation is to swallow the exception (logger shouldn't throw), so we don't re-throw here:
return null;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As @CarnaViire pointed, a logger shouldn't re-throw (but it will change our current behavior)

}
}

public async ValueTask LogRequestStopAsync(
object? context,
HttpRequestMessage request,
HttpResponseMessage response,
TimeSpan elapsed,
CancellationToken cancellationToken = default)
=> await LogResponseAsync(context, request, response, null, elapsed, cancellationToken).ConfigureAwait(false);

public async ValueTask LogRequestFailedAsync(
object? context,
HttpRequestMessage request,
HttpResponseMessage? response,
Exception exception,
TimeSpan elapsed,
CancellationToken cancellationToken = default)
=> await LogResponseAsync(context, request, response, exception, elapsed, cancellationToken).ConfigureAwait(false);

public object? LogRequestStart(HttpRequestMessage request)
=> throw new NotSupportedException(SyncLoggingExceptionMessage);

public void LogRequestStop(object? context, HttpRequestMessage request, HttpResponseMessage response, TimeSpan elapsed)
=> throw new NotSupportedException(SyncLoggingExceptionMessage);

public void LogRequestFailed(object? context, HttpRequestMessage request, HttpResponseMessage? response, Exception exception, TimeSpan elapsed)
=> throw new NotSupportedException(SyncLoggingExceptionMessage);

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;
}

[SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "The logger shouldn't throw")]
private async ValueTask LogResponseAsync(
object? context,
HttpRequestMessage request,
HttpResponseMessage? response,
Exception? exception,
TimeSpan elapsed,
CancellationToken cancellationToken)
{
if (context is not LogRecord logRecord)
{
var requestState = response is null
? "failed"
: "completed";

Log.LoggerContextMissing(_logger, exception, requestState, request.Method, request.RequestUri?.Host);
return;
}

LoggerMessageState? loggerMessageState = null;
List<KeyValuePair<string, string>>? responseHeadersBuffer = null;
try
{
if (response is not null)
{
if (_logResponseHeaders)
{
responseHeadersBuffer = _headersPool.Get();
}

await _httpRequestReader.ReadResponseAsync(logRecord, response, responseHeadersBuffer, cancellationToken).ConfigureAwait(false);
}

loggerMessageState = LoggerMessageHelper.ThreadLocalState;
FillLogRecord(logRecord, loggerMessageState, in elapsed, request, response, exception);

if (exception is null)
{
Log.OutgoingRequest(_logger, GetLogLevel(logRecord), logRecord);
}
else
{
Log.OutgoingRequestError(_logger, logRecord, exception);
}
}
catch (Exception ex)
{
// Logger shouldn't throw, so we just log the exception and don't re-throw it:
Log.ResponseReadError(_logger, ex, request.Method, logRecord.Host, logRecord.Path);
}
finally
{
var requestHeadersBuffer = logRecord.RequestHeaders; // Store the value first, and then return logRecord to the pool.
_logRecordPool.Return(logRecord);
loggerMessageState?.Clear();

if (responseHeadersBuffer is not null)
{
_headersPool.Return(responseHeadersBuffer);
}

if (requestHeadersBuffer is not null)
{
_headersPool.Return(requestHeadersBuffer);
}
}
}

[SuppressMessage("Design", "CA1031:Do not catch general exception types",
Justification = "We intentionally catch all exception types to make Telemetry code resilient to failures.")]
private void FillLogRecord(
LogRecord logRecord, LoggerMessageState loggerMessageState, in TimeSpan elapsed,
HttpRequestMessage request, HttpResponseMessage? response, Exception? exception)
{
foreach (var enricher in _enrichers)
{
try
{
enricher.Enrich(loggerMessageState, request, response, exception);
}
catch (Exception e)
{
Log.EnrichmentError(_logger, e, enricher.GetType().FullName, request.Method, logRecord.Host, logRecord.Path);
}
}

logRecord.EnrichmentTags = loggerMessageState;
logRecord.Duration = (long)elapsed.TotalMilliseconds;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@
using System.Net.Http;
using System.Net.Http.Headers;
using Microsoft.Extensions.Compliance.Classification;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Telemetry.Internal;
using Microsoft.Shared.Diagnostics;

namespace Microsoft.Extensions.Http.Telemetry.Logging.Internal;

Expand All @@ -19,14 +19,14 @@ internal sealed class HttpHeadersReader : IHttpHeadersReader
private readonly FrozenDictionary<string, DataClassification> _responseHeaders;
private readonly IHttpHeadersRedactor _redactor;

public HttpHeadersReader(IOptions<LoggingOptions> options, IHttpHeadersRedactor redactor)
public HttpHeadersReader(IOptionsMonitor<LoggingOptions> optionsMonitor, IHttpHeadersRedactor redactor, [ServiceKey] string? serviceKey = null)
{
_ = Throw.IfMemberNull(options, options.Value);
var options = optionsMonitor.GetKeyedOrCurrent(serviceKey);

_redactor = redactor;

_requestHeaders = options.Value.RequestHeadersDataClasses.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
_responseHeaders = options.Value.ResponseHeadersDataClasses.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
_requestHeaders = options.RequestHeadersDataClasses.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
_responseHeaders = options.ResponseHeadersDataClasses.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
}

public void ReadRequestHeaders(HttpRequestMessage request, List<KeyValuePair<string, string>>? destination)
Expand Down
Loading