Skip to content

Commit 5996804

Browse files
Remove HttpMetricsEnrichmentContext caching (#110628)
Co-authored-by: Miha Zupan <[email protected]>
1 parent 0ded51b commit 5996804

File tree

3 files changed

+169
-73
lines changed

3 files changed

+169
-73
lines changed
Lines changed: 33 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,9 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4-
using System.Collections.Concurrent;
54
using System.Collections.Generic;
65
using System.Diagnostics;
76
using System.Diagnostics.Metrics;
8-
using System.Runtime.InteropServices;
9-
using System.Threading;
107

118
namespace System.Net.Http.Metrics
129
{
@@ -19,22 +16,18 @@ namespace System.Net.Http.Metrics
1916
/// information exposed on the <see cref="HttpMetricsEnrichmentContext"/> instance.
2017
///
2118
/// > [!IMPORTANT]
22-
/// > The <see cref="HttpMetricsEnrichmentContext"/> intance must not be used from outside of the enrichment callbacks.
19+
/// > The <see cref="HttpMetricsEnrichmentContext"/> instance must not be used from outside of the enrichment callbacks.
2320
/// </remarks>
2421
public sealed class HttpMetricsEnrichmentContext
2522
{
26-
private static readonly HttpRequestOptionsKey<HttpMetricsEnrichmentContext> s_optionsKeyForContext = new(nameof(HttpMetricsEnrichmentContext));
27-
private static readonly ConcurrentQueue<HttpMetricsEnrichmentContext> s_pool = new();
28-
private static int s_poolItemCount;
29-
private const int PoolCapacity = 1024;
23+
private static readonly HttpRequestOptionsKey<List<Action<HttpMetricsEnrichmentContext>>> s_optionsKeyForCallbacks = new(nameof(HttpMetricsEnrichmentContext));
3024

31-
private readonly List<Action<HttpMetricsEnrichmentContext>> _callbacks = new();
3225
private HttpRequestMessage? _request;
3326
private HttpResponseMessage? _response;
3427
private Exception? _exception;
35-
private List<KeyValuePair<string, object?>> _tags = new(capacity: 16);
28+
private TagList _tags;
3629

37-
internal HttpMetricsEnrichmentContext() { } // Hide the default parameterless constructor.
30+
private HttpMetricsEnrichmentContext() { } // Hide the default parameterless constructor.
3831

3932
/// <summary>
4033
/// Gets the <see cref="HttpRequestMessage"/> that has been sent.
@@ -68,7 +61,7 @@ public sealed class HttpMetricsEnrichmentContext
6861
/// <remarks>
6962
/// This method must not be used from outside of the enrichment callbacks.
7063
/// </remarks>
71-
public void AddCustomTag(string name, object? value) => _tags.Add(new KeyValuePair<string, object?>(name, value));
64+
public void AddCustomTag(string name, object? value) => _tags.Add(name, value);
7265

7366
/// <summary>
7467
/// Adds a callback to register custom tags for request metrics `http-client-request-duration` and `http-client-failed-requests`.
@@ -77,85 +70,55 @@ public sealed class HttpMetricsEnrichmentContext
7770
/// <param name="callback">The callback responsible to add custom tags via <see cref="AddCustomTag(string, object?)"/>.</param>
7871
public static void AddCallback(HttpRequestMessage request, Action<HttpMetricsEnrichmentContext> callback)
7972
{
73+
ArgumentNullException.ThrowIfNull(request);
74+
ArgumentNullException.ThrowIfNull(callback);
75+
8076
HttpRequestOptions options = request.Options;
8177

82-
// We associate an HttpMetricsEnrichmentContext with the request on the first call to AddCallback(),
83-
// and store the callbacks in the context. This allows us to cache all the enrichment objects together.
84-
if (!options.TryGetValue(s_optionsKeyForContext, out HttpMetricsEnrichmentContext? context))
78+
if (options.TryGetValue(s_optionsKeyForCallbacks, out List<Action<HttpMetricsEnrichmentContext>>? callbacks))
79+
{
80+
callbacks.Add(callback);
81+
}
82+
else
8583
{
86-
if (s_pool.TryDequeue(out context))
87-
{
88-
Debug.Assert(context._callbacks.Count == 0);
89-
Interlocked.Decrement(ref s_poolItemCount);
90-
}
91-
else
92-
{
93-
context = new HttpMetricsEnrichmentContext();
94-
}
95-
96-
options.Set(s_optionsKeyForContext, context);
84+
options.Set(s_optionsKeyForCallbacks, [callback]);
9785
}
98-
context._callbacks.Add(callback);
9986
}
10087

101-
internal static HttpMetricsEnrichmentContext? GetEnrichmentContextForRequest(HttpRequestMessage request)
88+
internal static List<Action<HttpMetricsEnrichmentContext>>? GetEnrichmentCallbacksForRequest(HttpRequestMessage request)
10289
{
103-
if (request._options is null)
90+
if (request._options is HttpRequestOptions options &&
91+
options.Remove(s_optionsKeyForCallbacks.Key, out object? callbacks))
10492
{
105-
return null;
93+
return (List<Action<HttpMetricsEnrichmentContext>>)callbacks!;
10694
}
107-
request._options.TryGetValue(s_optionsKeyForContext, out HttpMetricsEnrichmentContext? context);
108-
return context;
95+
96+
return null;
10997
}
11098

111-
internal void RecordDurationWithEnrichment(HttpRequestMessage request,
99+
internal static void RecordDurationWithEnrichment(
100+
List<Action<HttpMetricsEnrichmentContext>> callbacks,
101+
HttpRequestMessage request,
112102
HttpResponseMessage? response,
113103
Exception? exception,
114104
TimeSpan durationTime,
115105
in TagList commonTags,
116106
Histogram<double> requestDuration)
117107
{
118-
_request = request;
119-
_response = response;
120-
_exception = exception;
121-
122-
Debug.Assert(_tags.Count == 0);
123-
124-
// Adding the enrichment tags to the TagList would likely exceed its' on-stack capacity, leading to an allocation.
125-
// To avoid this, we add all the tags to a List<T> which is cached together with HttpMetricsEnrichmentContext.
126-
// Use a for loop to iterate over the TagList, since TagList.GetEnumerator() allocates, see
127-
// https://github.com/dotnet/runtime/issues/87022.
128-
for (int i = 0; i < commonTags.Count; i++)
108+
var context = new HttpMetricsEnrichmentContext
129109
{
130-
_tags.Add(commonTags[i]);
131-
}
110+
_request = request,
111+
_response = response,
112+
_exception = exception,
113+
_tags = commonTags,
114+
};
132115

133-
try
116+
foreach (Action<HttpMetricsEnrichmentContext> callback in callbacks)
134117
{
135-
foreach (Action<HttpMetricsEnrichmentContext> callback in _callbacks)
136-
{
137-
callback(this);
138-
}
139-
140-
requestDuration.Record(durationTime.TotalSeconds, CollectionsMarshal.AsSpan(_tags));
141-
}
142-
finally
143-
{
144-
_request = null;
145-
_response = null;
146-
_exception = null;
147-
_callbacks.Clear();
148-
_tags.Clear();
149-
150-
if (Interlocked.Increment(ref s_poolItemCount) <= PoolCapacity)
151-
{
152-
s_pool.Enqueue(this);
153-
}
154-
else
155-
{
156-
Interlocked.Decrement(ref s_poolItemCount);
157-
}
118+
callback(context);
158119
}
120+
121+
requestDuration.Record(durationTime.TotalSeconds, context._tags);
159122
}
160123
}
161124
}

src/libraries/System.Net.Http/src/System/Net/Http/Metrics/MetricsHandler.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -120,14 +120,14 @@ private void RequestStop(HttpRequestMessage request, HttpResponseMessage? respon
120120

121121
TimeSpan durationTime = Stopwatch.GetElapsedTime(startTimestamp, Stopwatch.GetTimestamp());
122122

123-
HttpMetricsEnrichmentContext? enrichmentContext = HttpMetricsEnrichmentContext.GetEnrichmentContextForRequest(request);
124-
if (enrichmentContext is null)
123+
List<Action<HttpMetricsEnrichmentContext>>? callbacks = HttpMetricsEnrichmentContext.GetEnrichmentCallbacksForRequest(request);
124+
if (callbacks is null)
125125
{
126126
_requestsDuration.Record(durationTime.TotalSeconds, tags);
127127
}
128128
else
129129
{
130-
enrichmentContext.RecordDurationWithEnrichment(request, response, exception, durationTime, tags, _requestsDuration);
130+
HttpMetricsEnrichmentContext.RecordDurationWithEnrichment(callbacks, request, response, exception, durationTime, tags, _requestsDuration);
131131
}
132132
}
133133

src/libraries/System.Net.Http/tests/FunctionalTests/MetricsTest.cs

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,49 @@ public Task RequestDuration_CustomTags_Recorded()
354354
VerifyRequestDuration(m, uri, UseVersion, 200);
355355
Assert.Equal("/test", m.Tags.ToArray().Single(t => t.Key == "route").Value);
356356

357+
}, async server =>
358+
{
359+
await server.HandleRequestAsync();
360+
});
361+
}
362+
363+
[Fact]
364+
public Task RequestDuration_MultipleCallbacksPerRequest_AllCalledInOrder()
365+
{
366+
return LoopbackServerFactory.CreateClientAndServerAsync(async uri =>
367+
{
368+
using HttpMessageInvoker client = CreateHttpMessageInvoker();
369+
using InstrumentRecorder<double> recorder = SetupInstrumentRecorder<double>(InstrumentNames.RequestDuration);
370+
using HttpRequestMessage request = new(HttpMethod.Get, uri) { Version = UseVersion };
371+
372+
int lastCallback = -1;
373+
374+
HttpMetricsEnrichmentContext.AddCallback(request, ctx =>
375+
{
376+
Assert.Equal(-1, lastCallback);
377+
lastCallback = 1;
378+
ctx.AddCustomTag("custom1", "foo");
379+
});
380+
HttpMetricsEnrichmentContext.AddCallback(request, ctx =>
381+
{
382+
Assert.Equal(1, lastCallback);
383+
lastCallback = 2;
384+
ctx.AddCustomTag("custom2", "bar");
385+
});
386+
HttpMetricsEnrichmentContext.AddCallback(request, ctx =>
387+
{
388+
Assert.Equal(2, lastCallback);
389+
ctx.AddCustomTag("custom3", "baz");
390+
});
391+
392+
using HttpResponseMessage response = await SendAsync(client, request);
393+
394+
Measurement<double> m = Assert.Single(recorder.GetMeasurements());
395+
VerifyRequestDuration(m, uri, UseVersion, 200);
396+
Assert.Equal("foo", Assert.Single(m.Tags.ToArray(), t => t.Key == "custom1").Value);
397+
Assert.Equal("bar", Assert.Single(m.Tags.ToArray(), t => t.Key == "custom2").Value);
398+
Assert.Equal("baz", Assert.Single(m.Tags.ToArray(), t => t.Key == "custom3").Value);
399+
357400
}, async server =>
358401
{
359402
await server.AcceptConnectionSendResponseAndCloseAsync();
@@ -928,6 +971,96 @@ public class HttpMetricsTest_Http11_Async_HttpMessageInvoker : HttpMetricsTest_H
928971
public HttpMetricsTest_Http11_Async_HttpMessageInvoker(ITestOutputHelper output) : base(output)
929972
{
930973
}
974+
975+
[Fact]
976+
public async Task RequestDuration_RequestReused_EnrichmentCallbacksAreCleared()
977+
{
978+
await LoopbackServerFactory.CreateClientAndServerAsync(async uri =>
979+
{
980+
using HttpMessageInvoker client = CreateHttpMessageInvoker();
981+
using InstrumentRecorder<double> recorder = SetupInstrumentRecorder<double>(InstrumentNames.RequestDuration);
982+
983+
using HttpRequestMessage request = new(HttpMethod.Get, uri);
984+
985+
int firstCallbackCalls = 0;
986+
987+
HttpMetricsEnrichmentContext.AddCallback(request, ctx =>
988+
{
989+
firstCallbackCalls++;
990+
ctx.AddCustomTag("key1", "foo");
991+
});
992+
993+
(await SendAsync(client, request)).Dispose();
994+
Assert.Equal(1, firstCallbackCalls);
995+
996+
Measurement<double> m = Assert.Single(recorder.GetMeasurements());
997+
Assert.Equal("key1", Assert.Single(m.Tags.ToArray(), t => t.Value as string == "foo").Key);
998+
999+
HttpMetricsEnrichmentContext.AddCallback(request, static ctx =>
1000+
{
1001+
ctx.AddCustomTag("key2", "foo");
1002+
});
1003+
1004+
(await SendAsync(client, request)).Dispose();
1005+
Assert.Equal(1, firstCallbackCalls);
1006+
1007+
Assert.Equal(2, recorder.GetMeasurements().Count);
1008+
m = recorder.GetMeasurements()[1];
1009+
Assert.Equal("key2", Assert.Single(m.Tags.ToArray(), t => t.Value as string == "foo").Key);
1010+
}, async server =>
1011+
{
1012+
await server.HandleRequestAsync();
1013+
await server.HandleRequestAsync();
1014+
});
1015+
}
1016+
1017+
[ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsThreadingSupported))]
1018+
public async Task RequestDuration_ConcurrentRequestsSeeDifferentContexts()
1019+
{
1020+
await LoopbackServerFactory.CreateClientAndServerAsync(async uri =>
1021+
{
1022+
using HttpMessageInvoker client = CreateHttpMessageInvoker();
1023+
using var _ = SetupInstrumentRecorder<double>(InstrumentNames.RequestDuration);
1024+
1025+
using HttpRequestMessage request1 = new(HttpMethod.Get, uri);
1026+
using HttpRequestMessage request2 = new(HttpMethod.Get, uri);
1027+
1028+
HttpMetricsEnrichmentContext.AddCallback(request1, _ => { });
1029+
(await client.SendAsync(request1, CancellationToken.None)).Dispose();
1030+
1031+
HttpMetricsEnrichmentContext context1 = null;
1032+
HttpMetricsEnrichmentContext context2 = null;
1033+
CountdownEvent countdownEvent = new(2);
1034+
1035+
HttpMetricsEnrichmentContext.AddCallback(request1, ctx =>
1036+
{
1037+
context1 = ctx;
1038+
countdownEvent.Signal();
1039+
Assert.True(countdownEvent.Wait(TestHelper.PassingTestTimeout));
1040+
});
1041+
HttpMetricsEnrichmentContext.AddCallback(request2, ctx =>
1042+
{
1043+
context2 = ctx;
1044+
countdownEvent.Signal();
1045+
Assert.True(countdownEvent.Wait(TestHelper.PassingTestTimeout));
1046+
});
1047+
1048+
Task<HttpResponseMessage> task1 = Task.Run(() => client.SendAsync(request1, CancellationToken.None));
1049+
Task<HttpResponseMessage> task2 = Task.Run(() => client.SendAsync(request2, CancellationToken.None));
1050+
1051+
(await task1).Dispose();
1052+
(await task2).Dispose();
1053+
1054+
Assert.NotSame(context1, context2);
1055+
}, async server =>
1056+
{
1057+
await server.HandleRequestAsync();
1058+
1059+
await Task.WhenAll(
1060+
server.HandleRequestAsync(),
1061+
server.HandleRequestAsync());
1062+
}, options: new GenericLoopbackOptions { ListenBacklog = 2 });
1063+
}
9311064
}
9321065

9331066
[ConditionalClass(typeof(PlatformDetection), nameof(PlatformDetection.IsNotMobile))]

0 commit comments

Comments
 (0)