I have found an answer that is acceptable for me, and may be useful for others.
I've replaced the code in my question with this:
protected async Task<HttpResponseMessage> SendAsyncInternal(object logContext, HttpRequestMessage request, HttpCompletionOption completionOption, CancellationToken cancellationToken)
{
LoggingForHttpHandler.AssociateInvokerWithRequest(request ?? throw new ArgumentNullException(nameof(request)),
async (req, res, ex, ct) => await LogDelegateInvoker(logContext, req, res, ex, ct));
return await HttpClient.SendAsync(request, completionOption, cancellationToken);
}
Then in that same class, I have and additional private child class and some static handling:
...
private static HttpClient HttpClient => _httpClient ?? (_httpClient = new HttpClient(new LoggingForHttpHandler(new HttpClientHandler())));
private static HttpClient _httpClient;
private class LoggingForHttpHandler : DelegatingHandler
{
public LoggingForHttpHandler(HttpMessageHandler innerHandler) : base(innerHandler)
{
}
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
HttpResponseMessage response = null;
Func<HttpRequestMessage, HttpResponseMessage, Exception, CancellationToken, Task> logDelegateInvoker = null;
try
{
if (request.Headers.TryGetValues(SpecialHeaderName, out IEnumerable<string> specialHeaderValues))
{
request.Headers.Remove(SpecialHeaderName);
if (ulong.TryParse(specialHeaderValues.FirstOrDefault(), out ulong logDelegateInvokerKey))
{
if (!LogDelegateInvokers.TryRemove(logDelegateInvokerKey, out logDelegateInvoker))
{
logDelegateInvoker = null;
}
}
}
response = await base.SendAsync(request, cancellationToken);
}
catch(Exception ex)
{
if (logDelegateInvoker != null)
{
await logDelegateInvoker(request, response, ex, cancellationToken);
}
throw;
}
if (logDelegateInvoker != null)
{
await logDelegateInvoker(request, response, null, cancellationToken);
}
return response;
}
public static void AssociateInvokerWithRequest (HttpRequestMessage request, Func<HttpRequestMessage, HttpResponseMessage, Exception, CancellationToken, Task> logDelegateInvoker)
{
if (logDelegateInvoker != null && request != null)
{
ulong logDelegateInvokerKey = (ulong)(Interlocked.Increment(ref _incrementer) - long.MinValue);
if (LogDelegateInvokers.TryAdd(logDelegateInvokerKey, logDelegateInvoker))
{
request.Headers.Add(SpecialHeaderName, logDelegateInvokerKey.ToString());
}
}
}
private const string SpecialHeaderName = "__LogDelegateIndex";
private static long _incrementer = long.MinValue;
private static readonly ConcurrentDictionary<ulong, Func<HttpRequestMessage, HttpResponseMessage, Exception, CancellationToken, Task>> LogDelegateInvokers =
new ConcurrentDictionary<ulong, Func<HttpRequestMessage, HttpResponseMessage, Exception, CancellationToken, Task>>();
}
...
The key point here is to create the client with a custom delegating handler and to set it up so that delegating handler will actually invoke the delegate for the logging before the request is disposed. What is done to support this is to have a static threadsafe dictionary of invocations indexed by a 64 bit key, which is rendered as a string as "special" header on the request before it is submitted, and then stripped from the header before it is sent. Note that header access on requests of type HttpRequestMessage
is synchronous.
I acknowledge that this doesn't have perfect code smell, but it is efficient and fast.
I would still be thrilled to get a better solution!