Mert Özen Further With Every Line
DevOps Observability — #12

What's New in Observability from .NET 8 to .NET 11: IMeterFactory, Gauge, New Built-in Metrics and Built-in Tracing in ASP.NET Core

Mert Özen Nov 15, 2026 9 min 14 views
What's New in Observability from .NET 8 to .NET 11: IMeterFactory, Gauge, New Built-in Metrics and Built-in Tracing in ASP.NET Core

In .NET 11 ASP.NET Core writes the OpenTelemetry attributes of the HTTP server span itself and MemoryCache publishes dotnet.cache.* metrics, with no instrumentation package. We map the releases from .NET 8's IMeterFactory and .NET 9's Gauge to .NET 10's Identity metrics and schema URLs, run the .NET 11 items on the GA SDK and give tips for reading older code.

Version note: The examples in this post were run with .NET 11 (SDK 11.0.100) and OpenTelemetry .NET 1.21. The sample project is in the repo under samples/Net11Observability and asks for the .NET 11 SDK through its own global.json; Bookstore itself stays on .NET 10 (LTS).

Guess First

Since the start of the series, in Bookstore, the online bookstore used as the shared example across this site's series, the HTTP spans have come from the OpenTelemetry.Instrumentation.AspNetCore package: AddAspNetCoreInstrumentation(). On .NET 11 we wrote a small application and never added that package; for tracing we only wrote AddSource("Microsoft.AspNetCore"). Is there a GET /api/books/{isbn} span in the console exporter's output? If there is, does it carry attributes such as http.route and http.response.status_code? And if you don't remove the package, does the span double?

Why Look Release by Release?

.NET's observability APIs changed a lot over the last four releases, and production code is never all on the same version: one service may be on .NET 8, another on .NET 10. To understand why a dashboard shows a metric for one service and not for another, or why an instrumentation package is no longer needed, you need to know which feature came in which release. This post is a release map: .NET 8, 9 and 10 briefly, and .NET 11 by running it. The .NET 11 items come from the official "What's new" pages of the general availability (GA) release on 2026-11-10; we only took what is written there, and every output we show was run with the .NET 11 SDK.

.NET 8 and .NET 9: Metrics Move into the Platform

.NET 8 laid the foundation on the metrics side. With IMeterFactory, meters could be created through DI, so they became testable and separate per host (Bookstore's OrderingMetrics uses it). ASP.NET Core, Kestrel and HttpClient started writing their own metrics: http.server.request.duration, kestrel.active_connections, http.client.request.duration and others. They came with names and units that follow the OpenTelemetry semantic conventions; most of the histograms we used in the series' metrics post come from here. On the testing side, MetricCollector<T> arrived in the Microsoft.Extensions.Diagnostics.Testing package; it's the way to unit test a metric.

.NET 9 filled three gaps. Gauge<T> made it possible to record a current value set from outside synchronously; before that there was only the callback-based ObservableGauge. Bookstore's count of books running low is written with this instrument. With Activity.AddLink a span can get a link to another trace context after it was created; before, links could only be given when the span was created. That helps when it becomes clear only later which operation a message is processed in. The third is listening from outside the process: tools such as dotnet-counters could listen to all meters with * or a prefix such as Bookstore*, without the full meter name. The runtime's own metrics also moved to the System.Runtime meter in this release; in the post on producing metrics we saw that on .NET 9 and later the runtime instrumentation package only turns this meter on.

.NET 10: Security Metrics and Schema URLs

ASP.NET Core 10 added metrics on the security side. The Microsoft.AspNetCore.Identity meter writes metrics such as user create and update durations, password check attempts, generated and verified tokens, and sign-in and sign-out counts (aspnetcore.identity.sign_in.sign_ins, aspnetcore.identity.user.check_password_attempts and others). The authentication and authorization middleware also count challenges, forbids and requests that require authorization, and metrics arrived for the memory pool used by Kestrel, IIS and HTTP.sys. How to read these from a security point of view is the topic of the ASP.NET Core Security series.

On the library side, ActivitySource and Meter can say which semantic conventions version their telemetry follows:

using System.Diagnostics;

// .NET 10: an ActivitySource (and a Meter) can say which semantic conventions version its telemetry follows.
public static class BookstoreTelemetry
{
    public static readonly ActivitySource Catalog = new(new ActivitySourceOptions("Bookstore.Catalog")
    {
        Version = "1.0.0",
        TelemetrySchemaUrl = "https://opentelemetry.io/schemas/1.37.0"
    });
}

The OpenTelemetry SDK puts this URL into the instrumentation scope; a backend can use it to translate between two versions when attribute names change. Trace data exported out of process also started carrying Activity events and links in .NET 10, and gained a sampling option that limits root activities per second. A note: recent releases of OpenTelemetry .NET dropped net8.0 and net9.0 from their target framework list (they keep working through netstandard2.0), so think about the SDK version together with the application's .NET version.

.NET 11: ASP.NET Core's Own Span

.NET 11's most visible observability change is in ASP.NET Core: the server activity now writes the attributes that OpenTelemetry defines for an HTTP server span by itself, and no separate instrumentation package is needed for that. This is the whole sample project:

using System.Diagnostics;
using Microsoft.Extensions.Caching.Memory;
using OpenTelemetry.Metrics;
using OpenTelemetry.Trace;

// .NET 11: what ASP.NET Core and MemoryCache now emit by themselves, without instrumentation packages.
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);

// MemoryCache publishes dotnet.cache.* metrics once statistics are on.
builder.Services.AddMemoryCache(options => options.TrackStatistics = true);

builder.Services.AddOpenTelemetry()
    .WithTracing(tracing => tracing
        // No OpenTelemetry.Instrumentation.AspNetCore: the server activity already carries the HTTP attributes.
        .AddSource("Microsoft.AspNetCore", "Bookstore.*")
        .AddConsoleExporter())
    .WithMetrics(metrics => metrics
        .AddMeter("Microsoft.Extensions.Caching.Memory.MemoryCache")
        .AddConsoleExporter());

WebApplication app = builder.Build();

app.MapGet("/api/books/{isbn}", (string isbn, IMemoryCache cache) =>
    cache.GetOrCreate(isbn, _ =>
    {
        // Only a cache miss loads the book (and gets a span).
        using Activity? activity = BookstoreTelemetry.Catalog.StartActivity("load book");
        activity?.SetTag("bookstore.book.isbn", isbn);
        return new BookView(isbn, "Dune");
    }));

app.Run();

internal sealed record BookView(string Isbn, string Title);

We ran the application and requested the same book three times and a non-existent address once (launchSettings.json sets the address to 127.0.0.1:5096 and the metric export interval to 5 seconds):

cd samples/Net11Observability
dotnet run
curl http://127.0.0.1:5096/api/books/9780441172719
curl http://127.0.0.1:5096/api/books/9780441172719
curl http://127.0.0.1:5096/api/books/9780441172719
curl http://127.0.0.1:5096/api/nothing

The span of one of the book requests:

Activity.DisplayName:        GET /api/books/{isbn}
Activity.Kind:               Server
Activity.Tags:
    client.address: 127.0.0.1
    network.peer.address: 127.0.0.1
    network.peer.port: 54457
    server.address: 127.0.0.1
    server.port: 5096
    http.request.method: GET
    user_agent.original: curl/8.7.1
    url.scheme: http
    url.path: /api/books/9780441172719
    http.response.status_code: 200
    network.protocol.version: 1.1
    http.route: /api/books/{isbn}
Instrumentation scope (ActivitySource):
    Name: Microsoft.AspNetCore

The answer to the first two questions of Guess First is yes: the span is there, its name comes from the route template, and all the attributes the semantic conventions ask for are present. For the request that returned 404 the span name is just GET, because no route matched, and http.route is missing. That's the right behavior to avoid high cardinality: if every unknown URL became its own span name, the backend would get an unbounded number of operations. For applications that don't want this behavior there's an AppContext switch: Microsoft.AspNetCore.Hosting.SuppressActivityOpenTelemetryData.

For the third question we added OpenTelemetry.Instrumentation.AspNetCore 1.21.0 to the same sample and called AddAspNetCoreInstrumentation() as well. A request still produced a single server span: the instrumentation package doesn't open its own span, it listens to ASP.NET Core's activity and enriches it. So when you move to .NET 11 you don't have to remove the package right away; but if you don't use its extras, such as recording exceptions, filtering and enrich callbacks, it's one dependency fewer.

Our own span, opened on a cache miss, carries .NET 10's schema URL:

Activity.DisplayName:        load book
Activity.Kind:               Internal
Activity.Tags:
    bookstore.book.isbn: 9780441172719
Instrumentation scope (ActivitySource):
    Name: Bookstore.Catalog
    Version: 1.0.0
    Schema URL: https://opentelemetry.io/schemas/1.37.0

.NET 11: MemoryCache Metrics

The second new feature is in MemoryCache: with TrackStatistics on, the Microsoft.Extensions.Caching.Memory.MemoryCache meter publishes four observable instruments: dotnet.cache.requests, dotnet.cache.evictions, dotnet.cache.entries and dotnet.cache.estimated_size. The first export after three requests:

Metric Name: dotnet.cache.requests, Description: Total cache requests., Unit: {request}, Metric Type: LongSum
Instrumentation scope (Meter):
	Name: Microsoft.Extensions.Caching.Memory.MemoryCache
	Tags:
		dotnet.cache.name: Default
(2026-11-14T20:29:40.1109490Z, 2026-11-14T20:29:42.9744830Z] dotnet.cache.name: Default dotnet.cache.request.result: hit
Value: 2
(2026-11-14T20:29:40.1109490Z, 2026-11-14T20:29:42.9744830Z] dotnet.cache.name: Default dotnet.cache.request.result: miss
Value: 1

Metric Name: dotnet.cache.entries, Description: Current number of cache entries., Unit: {entry}, Metric Type: LongSumNonMonotonic
(2026-11-14T20:29:40.1119160Z, 2026-11-14T20:29:42.9745160Z] dotnet.cache.name: Default
Value: 1

One miss, two hits; the hit ratio can now be computed with PromQL without an extra adapter or counters of our own. dotnet.cache.estimated_size isn't in the output because we gave the cache no size limit; the value is computed from size units the application defines. Thanks to the meter's dotnet.cache.name tag and the IMeterFactory that comes through DI, every cache instance is measured separately. What the cache holds, for how long, and how to read these metrics is the Caching series' topic.

.NET 11: Rule-Based Tracing and the Rest

Microsoft.Extensions.Diagnostics got a rule-based tracing configuration API: with AddTracing, which ActivitySources and operations are listened to can be turned on and off by rules and from configuration. Next to it came ActivitySourceFactory, and ActivitySource is no longer sealed. We tried how this works together with OpenTelemetry:

using Microsoft.Extensions.Diagnostics.Tracing;

builder.Services.AddTracing(tracing => tracing.DisableTracing(sourceName: "Microsoft.AspNetCore"));

We ran the sample again with this line and sent a request; the console exporter still wrote the span. OpenTelemetry .NET 1.21's TracerProvider uses its own ActivityListener, and these rules didn't change what it listens to. The rules make sense for listeners registered through the new infrastructure; when and how the OpenTelemetry SDK will support them is something to follow in its own release notes. In OpenTelemetry, what gets collected is still decided by AddSource and the sampler today.

There are two smaller items. HttpClient's high-cardinality open_connections and active_requests metrics became observable instruments; code that uses MeterListener directly no longer sees these values unless it calls RecordObservableInstruments(). The OpenTelemetry SDK and dotnet-counters already do that. On the logging side the [LoggerMessage] source generator supports generic methods, which is handy for avoiding boxing.

Reading Old Code

Production codebases also contain observability code written before these features. Recognizing three leftovers helps. The first is Application Insights' classic SDK: TelemetryClient.TrackEvent, TrackDependency, TrackMetric calls and ITelemetryInitializers. The 3.x releases of the Application Insights .NET SDK were rewritten on top of OpenTelemetry; Microsoft's recommendation is that those on 2.x move to 3.x to carry existing code over with the fewest changes, while new applications start directly with the OpenTelemetry-based distro (in the series' production post). The second is EventCounters: counters written with EventCounter and PollingCounter. dotnet-counters reads both, but the API that goes to OpenTelemetry, supports tags and can write histograms is System.Diagnostics.Metrics; don't use EventCounters in new code. The third is OpenTracing and OpenCensus: OpenTelemetry was born from merging these two projects, and both were archived. If you see an OpenTracing.ITracer or an OpenCensus exporter in code, that code depends on a library that no longer gets maintenance. Backends are dropping support too: Tempo 3.0, which we saw in the trace backend post, removed its OpenCensus receiver.

In Production

When you upgrade a service's .NET version, compare its telemetry too. A service that moves to .NET 11 produces spans whose attributes are very close to what the old instrumentation package produced, but don't assume they're identical: check in a staging environment that the attributes your dashboards and alerts use still arrive after the upgrade.

Don't collect all built-in metrics. Every new release brings new meters (Identity, the memory pool, the cache), and a broad selection such as AddMeter("*") grows the cardinality budget without anyone noticing. Add the meters you need by name.

Our sample project stays separate from Bookstore, because Bookstore is on .NET 10 (LTS), and moving from an LTS release to an STS release is a product decision. .NET 11's observability features aren't a reason to upgrade on their own; but when moving to the .NET 12 LTS, the items in this post will be part of the to-do list.

Common Pitfalls

Removing the instrumentation package after moving to .NET 11 and forgetting to add AddSource("Microsoft.AspNetCore"). The package registered its own listener; once it's gone, nobody listens to ASP.NET Core's activity and HTTP spans silently disappear.

Using new APIs without checking the version. Gauge<T> comes with .NET 9, ActivitySourceOptions.TelemetrySchemaUrl with .NET 10, the MemoryCache metrics with .NET 11. If you write a shared library, you need #if or separate packages depending on its target frameworks.

Writing your own metrics collector with MeterListener. Changes such as the move to observable instruments silently break that kind of code. MetricCollector<T> is enough for tests, the OpenTelemetry SDK for production.

Starting both Azure distros in the same application. Microsoft's docs say plainly that the new Microsoft OpenTelemetry Distro and the old Azure Monitor OpenTelemetry Distro must not be initialized together; during a migration, don't add one without removing the other.

A Small Experiment

★☆☆ Add OpenTelemetry.Instrumentation.AspNetCore to the sample project, call AddAspNetCoreInstrumentation() and compare the span of the request that returned 404 in both cases. Is there any difference in the attribute list?

★★☆ Give AddMemoryCache a SizeLimit, set Size = 1 on every entry and request 20 different ISBNs. Watch how dotnet.cache.estimated_size and dotnet.cache.evictions change; then write a plan for applying this to Bookstore's catalog queries.

★★★ Write an EnableTracing("Bookstore.Catalog") rule with AddTracing and try to register your own ActivityListener through this infrastructure. Read the rules from appsettings.json, change them while the application runs and watch the "load book" spans turn on and off.

The series' last post shows, hands-on, how to query telemetry in Application Insights and Log Analytics with KQL. To sum up the road so far: in the first post we started with Gül's "sometimes 8 seconds" complaint; we read logs, metrics and traces separately and linked together, managed telemetry in one place with the Collector, followed the problem from a dashboard to a trace and from a trace to logs with Prometheus, Grafana, Tempo and Loki, fixed the retry storm and the N+1, and talked about alerts and production cost. From now on the question for every new feature is the same: when this breaks, who will see it first, and through which signal?