A direct MeterListener can appear healthy after a .NET 11 upgrade while silently missing two important HTTP gauges. The listener still discovers instruments and still receives other measurements, but http.client.open_connections and http.server.active_requests no longer push every change to the listener.
.NET 11 changes those high-cardinality instruments to observable instruments. OpenTelemetry-style metric pipelines collect them on their normal collection cycle. Code that uses MeterListener directly must explicitly call RecordObservableInstruments(). This guide adds that boundary, preserves tags, and builds a verification harness that fails when either metric disappears.
Table of Contents
Understand the .NET 11 HTTP metrics change
The affected instruments are http.client.open_connections and http.server.active_requests. They include peer or request attributes that can accumulate at the monitoring side. The runtime change reports current values when a collector asks for them instead of emitting an update on every increment and decrement.
That implementation detail becomes a compatibility break for a direct listener. A normal counter calls its callback when application code records a measurement. An observable instrument runs its observation callback during collection. If no collection is requested, the listener may discover and enable the instrument but never receive the current value.
This does not mean the metric names or semantic meanings changed. The client metric still represents outbound connections that are active or idle. The server metric still represents requests currently being processed. The migration is about collection mechanics and tests, not renaming dashboards.
Find direct MeterListener consumers
Search for code that constructs MeterListener, assigns InstrumentPublished, or registers measurement callbacks. Also inspect test utilities and health probes. A production exporter may be correct while a home-grown assertion helper remains broken.
rg -n 'new MeterListener|InstrumentPublished|SetMeasurementEventCallback|RecordObservableInstruments' + src tests tools
Classify each result. If the code uses an OpenTelemetry MeterProvider and exporter, its reader already has a collection cycle; confirm the library version and integration test rather than adding a second loop. If your code owns a direct MeterListener, it also owns the time and lifetime of observable collection.
A listener that consumes only event-like counters may not need any change. The risk is a mixed listener: most measurements still arrive, so a broad “received metrics” test passes even when these two instruments vanish. Assert their exact names and at least one relevant tag set.
Collect observable HTTP metrics explicitly
Create the listener once, enable only the instruments you own, and keep callback work small. The following collector stores the latest measurement for each metric and tag combination. It exposes collection as an explicit method so tests and scheduled scrapes can decide when a snapshot is required.
using System.Collections.Concurrent;
using System.Diagnostics.Metrics;
public sealed class HttpMetricSnapshot : IDisposable
{
private static readonly HashSet<string> Expected =
[
"http.client.open_connections",
"http.server.active_requests"
];
private readonly MeterListener _listener = new();
private readonly ConcurrentDictionary<string, long> _latest = new();
public HttpMetricSnapshot()
{
_listener.InstrumentPublished = (instrument, listener) =>
{
if (Expected.Contains(instrument.Name))
{
listener.EnableMeasurementEvents(instrument);
}
};
_listener.SetMeasurementEventCallback<long>(
(instrument, measurement, tags, _) =>
{
var key = BuildKey(instrument.Name, tags);
_latest[key] = measurement;
});
_listener.Start();
}
public IReadOnlyDictionary<string, long> Collect()
{
_listener.RecordObservableInstruments();
return new Dictionary<string, long>(_latest);
}
public void Dispose() => _listener.Dispose();
private static string BuildKey(
string name,
ReadOnlySpan<KeyValuePair<string, object?>> tags)
{
var ordered = tags.ToArray()
.OrderBy(pair => pair.Key, StringComparer.Ordinal);
return string.Join(
"|",
new[] { name }.Concat(
ordered.Select(pair => $"{pair.Key}={pair.Value}")));
}
}
Use the measurement type exposed by the instrument. The .NET 11 runtime implementation for these current-value instruments reports integral measurements, so the callback above registers long. If your listener is generic infrastructure, log the discovered instrument type during validation and register the matching callback deliberately.
Keep collection bounded and thread safe
RecordObservableInstruments() invokes observation callbacks synchronously for enabled observable instruments. Do not call it from a measurement callback, and do not perform network I/O, blocking locks, or expensive formatting inside the callback. Copy the values, then export or evaluate them outside the collection path.
using var snapshot = new HttpMetricSnapshot();
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(30));
while (await timer.WaitForNextTickAsync(stoppingToken))
{
var values = snapshot.Collect();
await WriteSnapshotAsync(values, stoppingToken);
}
Choose one collection owner. Two timers calling the same listener create confusing scrape timing and may duplicate downstream work. Serialize calls if multiple control paths can request a snapshot, and cancel the outer loop during shutdown before disposing the listener.
Cardinality still matters. The runtime change mitigates accumulation by reporting current series when observed, but your storage can recreate the problem if it retains every peer or route key forever. Replace the in-memory snapshot on each completed collection, bound any history, and apply the same attribute policy used by the production exporter.
Verify client and server measurements
A useful test proves both the failure mode and the fix. Start an in-process HTTP server whose handler waits on a gate, issue one request through a named HttpClient, collect while the request is active, release the gate, and collect again. The test should assert that both exact instrument names were observed; avoid depending on a specific connection count after the request completes.
[Fact]
public async Task Collect_observes_client_and_server_current_values()
{
using var snapshot = new HttpMetricSnapshot();
await using var app = await TestHttpApp.StartWithBlockedHandlerAsync();
using var client = new HttpClient();
var request = client.GetAsync(app.Url);
await app.RequestEntered;
var active = snapshot.Collect();
Assert.Contains(
active.Keys,
key => key.StartsWith(
"http.client.open_connections|",
StringComparison.Ordinal));
Assert.Contains(
active.Keys,
key => key.StartsWith(
"http.server.active_requests|",
StringComparison.Ordinal));
app.ReleaseRequest();
await request;
var completed = snapshot.Collect();
Assert.Contains(
completed,
pair => pair.Key.StartsWith(
"http.server.active_requests|",
StringComparison.Ordinal) &&
pair.Value == 0);
}
The helper names are placeholders for your existing test server. Keep the assertions at the semantic boundary: discovery, explicit collection, metric identity, and the active-to-completed transition. Do not copy an expected tag list from a different runtime version without checking the official semantic conventions.
Run the same test against your .NET 10 baseline and .NET 11 target when the project multi-targets. The legacy runtime may deliver changes without an explicit collection, while the new target requires it. The portable expectation is that a collection request produces the current values.
Roll out without telemetry blind spots
- Inventory direct listeners separately from OpenTelemetry exporters and diagnostic event consumers.
- Add exact-name tests for both affected HTTP metrics before changing the production runtime.
- Expose collection-cycle failures and the age of the last successful snapshot.
- Compare direct-listener output with the normal exporter during a canary deployment.
- Bound retained tag combinations and remove series that disappear from the next snapshot.
Rollback can return the service to the previous runtime, but keep the explicit collection call unless your compatibility matrix proves it causes a problem. It documents ownership and works with the observable model. If a canary shows missing metrics, stop rollout before changing dashboards or alert thresholds; the first question is whether collection ran.
The .NET 11 HTTP metrics change is small in code and large in operational effect. Direct MeterListener consumers must request observable measurements, verify the two exact instruments, and keep collection bounded. Once that boundary is explicit, dashboards can survive the runtime upgrade without a quiet telemetry gap.
References
- .NET 11 RC1 libraries release notes
- dotnet/runtime PR 131275: High-cardinality HTTP metrics to observable
- OpenTelemetry semantic conventions for .NET HTTP metrics
- Microsoft Learn: MeterListener.RecordObservableInstruments
Found this useful? Support more practical developer content.