Skip to content

[HTTP] High cardinality metrics to observable - #131275

Open
ManickaP wants to merge 10 commits into
dotnet:mainfrom
ManickaP:http-metrics-observable
Open

[HTTP] High cardinality metrics to observable#131275
ManickaP wants to merge 10 commits into
dotnet:mainfrom
ManickaP:http-metrics-observable

Conversation

@ManickaP

@ManickaP ManickaP commented Jul 23, 2026

Copy link
Copy Markdown
Member

The open_connections and active_requests up down counters both use peer address in their tags and suffer from accumulation of data at the monitoring side. This PR changes them to their observable counterpart that counts the values and reports them only when asked.

This does not solve the underlying issue, this just somewhat mitigates the biggest pain points for customers. Eventually, this change should be reverted when the issue is solved on OpenTelemtry side, see open-telemetry/semantic-conventions#3279.

Unintentianal side effect is that if someone is directly using MeterListener this change will be breaking for them. They also need to start using RecordObservableInstruments to get the values now, see changes in our metrics tests.

Fixes #122752

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 4 pipeline(s).
12 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @karelz, @dotnet/ncl
See info in area-owners.md if you want to be subscribed.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR updates System.Net.Http client metrics to mitigate high-cardinality tag fallout by switching http.client.open_connections and http.client.active_requests from UpDownCounter<long> (event-based adds) to ObservableUpDownCounter<long> (snapshot reporting via callbacks).

Changes:

  • Introduces per-tag-combination trackers (ConcurrentDictionary<..., long>) to compute current counts and feed observable instruments.
  • Updates MetricsHandler and SocketsHttpHandler connection metrics to increment/decrement the trackers instead of calling Add(...).
  • Adjusts System.Net.Http metrics functional tests to explicitly call MeterListener.RecordObservableInstruments() and to validate snapshot-style observations.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.

File Description
src/libraries/System.Net.Http/tests/FunctionalTests/MetricsTest.cs Updates tests to collect observable instrument values and adapts expectations to snapshot semantics.
src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Metrics/SocketsHttpHandlerMetrics.cs Converts open_connections to an observable counter and adds a tracker + tag key infrastructure.
src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Metrics/ConnectionMetrics.cs Routes connection open/close/idle transitions through the new open-connections tracker.
src/libraries/System.Net.Http/src/System/Net/Http/Metrics/MetricsHandler.cs Converts active_requests to an observable counter backed by an active-requests tracker keyed by request tags.

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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (4)

src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Metrics/SocketsHttpHandlerMetrics.cs:8

  • using System.Threading; appears unused in this file and can trigger analyzer warnings. Remove it to keep the using list minimal.
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.Metrics;

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

  • Assigned requestData is never used; this creates an unnecessary local (and can produce warnings). Just await the read.
                        var requestData = await connection.ReadRequestDataAsync();

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

  • Assigned requestData is never used; this creates an unnecessary local (and can produce warnings). Just await the read.
                        var requestData = await connection.ReadRequestDataAsync();

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

  • Assigned requestData is never used; this creates an unnecessary local (and can produce warnings). Just await the read.
                        var requestData = await connection.ReadRequestDataAsync();

Comment thread src/libraries/System.Net.Http/tests/FunctionalTests/MetricsTest.cs Outdated
Copilot AI review requested due to automatic review settings July 24, 2026 07:35
@ManickaP
ManickaP force-pushed the http-metrics-observable branch from 02d0a60 to 877451e Compare July 24, 2026 07:37

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (4)

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

  • Unused local requestData will trigger CS0219 (assigned but never used) under TreatWarningsAsErrors, potentially breaking the test build. Just await the read without assigning it.
                await server.AcceptConnectionAsync(async connection =>
                {
                    await connection.ReadRequestDataAsync();
                    serverTcs.SetResult();
                    await clientTcs.Task.WaitAsync(TestHelper.PassingTestTimeout);

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

  • Unused local requestData will trigger CS0219 (assigned but never used) under TreatWarningsAsErrors, potentially breaking the test build. Just await the read without assigning it.
                    var serverTask = originalServer.AcceptConnectionAsync(async connection =>
                    {
                        var requestData = await connection.ReadRequestDataAsync();
                        originalServerTcs.SetResult();
                        await clientTcs1.Task.WaitAsync(TestHelper.PassingTestTimeout);

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

  • Unused local requestData will trigger CS0219 (assigned but never used) under TreatWarningsAsErrors, potentially breaking the test build. Just await the read without assigning it.
                    serverTask = redirectServer.AcceptConnectionAsync(async connection =>
                    {
                        var requestData = await connection.ReadRequestDataAsync();
                        redirectServerTcs.SetResult();
                        await clientTcs2.Task.WaitAsync(TestHelper.PassingTestTimeout);

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

  • Unused local requestData will trigger CS0219 (assigned but never used) under TreatWarningsAsErrors, potentially breaking the test build. Just await the read without assigning it.
                    await server.AcceptConnectionAsync(async connection =>
                    {
                        var requestData = await connection.ReadRequestDataAsync();
                        serverTcs.SetResult();
                        await clientTcs.Task.WaitAsync(TestHelper.PassingTestTimeout);

Copilot AI review requested due to automatic review settings July 24, 2026 07:41
@@ -36,7 +36,7 @@ private TagList GetTags()
tags.Add("network.protocol.version", _protocolVersionTag);
tags.Add("url.scheme", _schemeTag);
tags.Add("server.address", _hostTag);
tags.Add("server.port", _portTag);
tags.Add("server.port", DiagnosticsHelper.GetBoxedInt32(_portTag));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

is this called often? If so, than reusing a boxed int might save some allocations. but feel free to ignore this.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

It is reusing the boxed int.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (1)

src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Metrics/ConnectionMetrics.cs:82

  • IdleStateChanged updates _currentlyIdle before updating the tracker. If ConnectionClosed runs concurrently and reads the new _currentlyIdle value before the tracker Increment for that new-state key, its Decrement can be dropped (missing key), leaving the new-state count stuck at 1. Locking and updating _currentlyIdle after tracker updates avoids this race.
            if (_openConnectionsEnabled && _currentlyIdle != idle)
            {
                _currentlyIdle = idle;
                _metrics.OpenConnectionsTracker.Decrement(CreateTagKey(idle: !idle));
                _metrics.OpenConnectionsTracker.Increment(CreateTagKey(idle: idle));

@MihaZupan MihaZupan left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

A bit scary we're doing bookkeeping like this where any subtle issue (#131275 (comment)) can become a memory leak, but it's also apparently no worse than what was already happening before, just in a different component.

Did you check what this looks like in the consuming tools (e.g. do all graphs end up going to 0 when we stop reporting them)?

Comment thread src/libraries/System.Net.Http/src/System/Net/Http/Metrics/MetricsHandler.cs Outdated
Comment thread src/libraries/System.Net.Http/src/System/Net/Http/Metrics/MetricsHandler.cs Outdated
@ManickaP

Copy link
Copy Markdown
Member Author

Did you check what this looks like in the consuming tools (e.g. do all graphs end up going to 0 when we stop reporting them)?

They do, but that was the case before as well. The problem was that the exporter would remember even the 0s with all their tags for eternity.

I'm right now testing this with Aspire Dashboard, and I get a suspicion this won't be enough. The UI is still showing long time dead active_request tags 😢
image

I'll investigate more with console exporter to see where the problem is. In the end, we might decide not to make this change...

Copilot AI review requested due to automatic review settings July 24, 2026 11:46

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 5 comments.

Comments suppressed due to low confidence (6)

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

  • requestData is assigned but never used. With TreatWarningsAsErrors=true repo-wide, this can fail the build (CS0219). If the intent is just to ensure the request has been received, the assignment can be dropped.
                        var requestData = await connection.ReadRequestDataAsync();

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

  • requestData is assigned but never used. With TreatWarningsAsErrors=true repo-wide, this can fail the build (CS0219). If the intent is just to ensure the request has been received, the assignment can be dropped.
                        var requestData = await connection.ReadRequestDataAsync();

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

  • requestData is assigned but never used. With TreatWarningsAsErrors=true repo-wide, this can fail the build (CS0219). If the intent is just to ensure the request has been received, the assignment can be dropped.
                        var requestData = await connection.ReadRequestDataAsync();

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

  • These new TaskCompletionSource instances are used for cross-task coordination between client/server code paths. Consider using TaskCreationOptions.RunContinuationsAsynchronously to avoid running continuations inline on the completing thread, which can cause hard-to-debug hangs/flakiness in tests.
            var originalServerTcs = new TaskCompletionSource();
            var redirectServerTcs = new TaskCompletionSource();
            var clientTcs1 = new TaskCompletionSource();
            var clientTcs2 = new TaskCompletionSource();

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

  • These new TaskCompletionSource instances are used for cross-task coordination between client/server code paths. Consider using TaskCreationOptions.RunContinuationsAsynchronously to avoid running continuations inline on the completing thread, which can cause hard-to-debug hangs/flakiness in tests.
                var serverTcs = new TaskCompletionSource();
                var clientTcs = new TaskCompletionSource();

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

  • After switching ActiveRequests to an observable instrument, this test currently records while the request is in-flight but does not explicitly verify that the active-requests count returns back to zero when the request completes. A simple way to cover the decrement path is to call RecordObservableInstruments() again after await requestTask and assert that no additional measurements were produced (i.e., the tracker entry was removed).
                var requestTask = SendAsync(client, request);
                await serverTcs.Task.WaitAsync(TestHelper.PassingTestTimeout);
                recorder.RecordObservableInstruments();
                clientTcs.SetResult();
                var response = await requestTask;
                response.Dispose(); // Make sure disposal doesn't interfere with recording by enforcing early disposal.

                Assert.Collection(recorder.GetMeasurements(),
                    m => VerifyActiveRequests(m, 1, uri));
            }, async server =>

Comment thread src/libraries/System.Net.Http/tests/FunctionalTests/MetricsTest.cs Outdated
Comment thread src/libraries/System.Net.Http/tests/FunctionalTests/MetricsTest.cs
Comment thread src/libraries/System.Net.Http/tests/FunctionalTests/MetricsTest.cs
Copilot AI review requested due to automatic review settings July 24, 2026 12:03

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (3)

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

  • Unused local requestData adds noise; the awaited call is only needed for its side-effect. Drop the assignment.
                        var requestData = await connection.ReadRequestDataAsync();

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

  • Unused local requestData adds noise; the awaited call is only needed for its side-effect. Drop the assignment.
                        var requestData = await connection.ReadRequestDataAsync();

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

  • Unused local requestData adds noise; the awaited call is only needed for its side-effect. Drop the assignment.
                        var requestData = await connection.ReadRequestDataAsync();

@ManickaP

Copy link
Copy Markdown
Member Author

So the console exporter looks good, after the requests are handled and connections scavenged, only remaining stuff are Histograms:

Resource associated with Metrics:
        service.name: OTLP-Example
        telemetry.sdk.name: opentelemetry
        telemetry.sdk.language: dotnet
        telemetry.sdk.version: 1.17.0

Metric Name: http.client.request.duration, Description: Duration of HTTP client requests., Unit: s, Metric Type: Histogram
Instrumentation scope (Meter):
        Name: System.Net.Http
(2026-07-24T12:04:25.5427752Z, 2026-07-24T12:06:57.6153043Z] http.request.method: GET http.response.status_code: 200 network.protocol.version: 1.1 server.address: localhost server.port: 5086 url.scheme: http 
Value: Sum: 75.1558477 Count: 15 Min: 5.0003587 Max: 5.1272355 
(-Infinity,0.005]:0
(0.005,0.01]:0
(0.01,0.025]:0
(0.025,0.05]:0
(0.05,0.075]:0
(0.075,0.1]:0
(0.1,0.25]:0
(0.25,0.5]:0
(0.5,0.75]:0
(0.75,1]:0
(1,2.5]:0
(2.5,5]:0
(5,7.5]:15
(7.5,10]:0
(10,+Infinity]:0


Metric Name: http.client.connection.duration, Description: The duration of successfully established outbound HTTP connections., Unit: s, Metric Type: Histogram
Instrumentation scope (Meter):
        Name: System.Net.Http
(2026-07-24T12:04:25.5435029Z, 2026-07-24T12:06:57.6153076Z] network.peer.address: ::1 network.protocol.version: 1.1 server.address: localhost server.port: 5086 url.scheme: http 
Value: Sum: 965.0390000000001 Count: 14 Min: 65.264 Max: 79.958 
(-Infinity,0.01]:0
(0.01,0.02]:0
(0.02,0.05]:0
(0.05,0.1]:0
(0.1,0.2]:0
(0.2,0.5]:0
(0.5,1]:0
(1,2]:0
(2,5]:0
(5,10]:0
(10,30]:0
(30,60]:0
(60,120]:14
(120,300]:0
(300,+Infinity]:0


Metric Name: http.client.request.time_in_queue, Description: The amount of time requests spent on a queue waiting for an available connection., Unit: s, Metric Type: Histogram
Instrumentation scope (Meter):
        Name: System.Net.Http
(2026-07-24T12:04:25.5438096Z, 2026-07-24T12:06:57.6153098Z] http.request.method: GET network.protocol.version: 1.1 server.address: localhost server.port: 5086 url.scheme: http 
Value: Sum: 0.03775610000000001 Count: 14 Min: 0.0005906 Max: 0.0237272 
(-Infinity,0.005]:13
(0.005,0.01]:0
(0.01,0.025]:1
(0.025,0.05]:0
(0.05,0.075]:0
(0.075,0.1]:0
(0.1,0.25]:0
(0.25,0.5]:0
(0.5,0.75]:0
(0.75,1]:0
(1,2.5]:0
(2.5,5]:0
(5,7.5]:0
(7.5,10]:0
(10,+Infinity]:0

So there's likely another layer of the issue on the dashboard side (i.e. in Aspire).

Honestly, I'm torn whether we should merge this or not. I'd appreciate any opinions on it.

Copilot AI review requested due to automatic review settings July 27, 2026 09:49

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (2)

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

  • This local is assigned but never used. In test projects this commonly becomes a warnings-as-errors build break. Just await the call without storing the result.
                        var requestData = await connection.ReadRequestDataAsync();

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

  • This local is assigned but never used. In test projects this commonly becomes a warnings-as-errors build break. Just await the call without storing the result.
                        var requestData = await connection.ReadRequestDataAsync();

Comment thread src/libraries/System.Net.Http/tests/FunctionalTests/MetricsTest.cs Outdated
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings July 28, 2026 08:06
@ManickaP

Copy link
Copy Markdown
Member Author

/azp list

@azure-pipelines

Copy link
Copy Markdown
CI/CD Pipelines for this repository:

@ManickaP

Copy link
Copy Markdown
Member Author

/azp run runtime-libraries-coreclr outerloop

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 1 pipeline(s).

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (4)

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

  • The test only observes http.client.active_requests while the request is in-flight. If the new tracker-based implementation fails to decrement/remove the entry at request completion, this test would still pass. Add a second RecordObservableInstruments() after the response is disposed and keep asserting a single measurement, so the test fails if the count is stuck > 0.
                var response = await requestTask;
                response.Dispose(); // Make sure disposal doesn't interfere with recording by enforcing early disposal.

                Assert.Collection(recorder.GetMeasurements(),
                    m => VerifyActiveRequests(m, 1, uri));

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

  • Similar to the non-redirect test: after the redirect flow completes, add a final RecordObservableInstruments() and keep asserting only the two in-flight observations. This makes the test fail if the active-requests tracker doesn’t get decremented/removed at the end of either span.
                    await TestHelper.WhenAllCompletedOrAnyFailed(clientTask, serverTask);
                    await clientTask;

                    Assert.Collection(recorder.GetMeasurements(),
                        m => VerifyActiveRequests(m, 1, originalUri),

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

  • Now that active_requests and open_connections are observable instruments, tests that rely on InstrumentRecorder<T> must call RecordObservableInstruments() while the request/connection is active; otherwise GetMeasurements() can be empty and assertions become vacuously true. In this file, several tests still set up recorders for these instruments but never call RecordObservableInstruments() (e.g., method-tag normalization and server.address/Host-header verification tests), which means they no longer validate those metrics.
            public void RecordObservableInstruments() => _meterListener.RecordObservableInstruments();
            public IReadOnlyList<Measurement<T>> GetMeasurements() => _values.ToArray();
            public void Dispose() => _meterListener.Dispose();

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

  • ExternalServer_DurationMetrics_Recorded records http.client.open_connections while the HttpResponseMessage is still in scope (not yet disposed). Depending on when the connection is returned to the pool, the observable may report the connection as active rather than idle, making the later assertion for the idle state flaky/incorrect. Dispose the response before calling RecordObservableInstruments() so the observation happens after the connection has transitioned to idle.
                using HttpRequestMessage request = new(HttpMethod.Get, uri) { Version = UseVersion };
                using HttpResponseMessage response = await SendAsync(client, request);
                await response.Content.LoadIntoBufferAsync();
                openConnectionsRecorder.RecordObservableInstruments();
                await WaitForEnvironmentTicksToAdvance();

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Many dimensions with http.client.open_connections

4 participants