
.NET 11 adds a small but useful improvement to the built-in observability story: MemoryCache can now publish OpenTelemetry-compatible metrics directly through System.Diagnostics.Metrics. That makes cache behavior visible without adding a custom wrapper around every Get, Set and Remove call.
The interesting part is not that MeterListener replaces OpenTelemetry. It does not. MeterListener is the low-level listener API for Meter instruments, useful for diagnostics tools, adapters, focused tests and local experiments. The production telemetry path usually remains the OpenTelemetry SDK, an exporter and a backend such as Prometheus, Azure Monitor, Grafana, or another observability platform.
Still, MeterListener is worth understanding because it shows exactly what a .NET application emits before any exporter, aggregation configuration, or backend-specific interpretation is added. With .NET 11 currently in preview, it is also a convenient way to inspect the new cache metrics while keeping the experiment local and dependency-light.
What changed in .NET 11
Microsoft.Extensions.Caching.Memory.MemoryCache can now emit built-in metrics from the meter named Microsoft.Extensions.Caching.Memory.MemoryCache. The feature is opt-in through MemoryCacheOptions.TrackStatistics.
When statistics tracking is enabled, the meter publishes four instruments:
dotnet.cache.requestsdotnet.cache.evictionsdotnet.cache.entriesdotnet.cache.estimated_size
The dotnet.cache.requests instrument includes the dotnet.cache.request.type tag, which distinguishes cache hits from cache misses. That tag is the most important one for day-to-day cache behavior because it turns a single request counter into a hit-rate signal.
The other instruments describe cache churn and current state. Evictions show items removed by cache policy. Entries describe how many items are currently present. Estimated size reflects the cache size accounting model, which is only meaningful when entries consistently use size metadata.
This is a useful change because cache metrics often arrive too late in application design. Teams commonly notice cache problems indirectly through slower endpoints, higher database load, or memory pressure. A first-party meter makes cache behavior observable at the same level as other .NET runtime and application metrics.
MeterListener is the raw listener, not the metrics backend
MeterListener listens to instruments created through System.Diagnostics.Metrics. It can observe measurements from counters, histograms, gauges and observable instruments after those instruments have been enabled for the listener.
The API has a deliberately low-level shape:
InstrumentPublishedis called when an instrument becomes visible.EnableMeasurementEventsenables measurement callbacks for a selected instrument.SetMeasurementEventCallback<T>registers callbacks for specific numeric types.RecordObservableInstrumentsasks observable instruments to produce their current measurements.MeasurementsCompletedreports that measurement events have stopped for an instrument.
That design is close to the runtime surface. It does not choose aggregations, export intervals, histogram buckets, temporality, resource attributes, or backend naming rules. Those responsibilities belong to higher-level collection libraries such as the OpenTelemetry SDK.
This boundary matters. MeterListener is excellent for answering questions such as: “Is this instrument being published?”, “Which tags are attached?”, “Which numeric type is used?” and “Does a cache operation emit a measurement at all?” It is not a complete monitoring architecture.
Enabling the new cache metrics
The feature starts with statistics tracking. Without it, MemoryCache avoids the extra accounting and the new metrics have nothing useful to publish.
For ASP.NET Core applications using dependency injection, the configuration is straightforward:
1using Microsoft.AspNetCore.Builder;
2using Microsoft.Extensions.DependencyInjection;
3
4WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
5
6builder.Services.AddMemoryCache(options =>
7{
8 options.TrackStatistics = true;
9});
10
11WebApplication app = builder.Build();
12
13app.MapGet("/", () => "Cache metrics are enabled.");
14
15app.Run();
For a standalone cache instance, the same option is set directly:
1using Microsoft.Extensions.Caching.Memory;
2
3using MemoryCache cache = new MemoryCache(new MemoryCacheOptions
4{
5 TrackStatistics = true,
6 SizeLimit = 100
7});
The SizeLimit is not required for request or eviction metrics. It only becomes relevant when the application wants size-related values to have a clear meaning. MemoryCache does not infer object sizes automatically. Size is an application-defined unit.
Listening to the MemoryCache meter
A minimal listener starts by filtering instruments by meter name. Filtering is important because a modern .NET process can publish many meters, including runtime, hosting, HTTP and custom application instruments.
The following sample listens only to the MemoryCache meter and prints measurements with their tags:
1using System.Diagnostics.Metrics;
2using Microsoft.Extensions.Caching.Memory;
3
4using MeterListener listener = new MeterListener();
5
6listener.InstrumentPublished = (Instrument instrument, MeterListener meterListener) =>
7{
8 if (instrument.Meter.Name == "Microsoft.Extensions.Caching.Memory.MemoryCache")
9 {
10 meterListener.EnableMeasurementEvents(instrument);
11 }
12};
13
14listener.SetMeasurementEventCallback<int>(WriteMeasurement);
15listener.SetMeasurementEventCallback<long>(WriteMeasurement);
16listener.SetMeasurementEventCallback<double>(WriteMeasurement);
17listener.Start();
18
19using MemoryCache cache = new MemoryCache(new MemoryCacheOptions
20{
21 TrackStatistics = true,
22 SizeLimit = 100
23});
24
25cache.Set(
26 "product:42",
27 "cached payload",
28 new MemoryCacheEntryOptions
29 {
30 Size = 1,
31 SlidingExpiration = TimeSpan.FromMinutes(5)
32 });
33
34bool hit = cache.TryGetValue("product:42", out string? cachedPayload);
35bool miss = cache.TryGetValue("product:404", out string? missingPayload);
36
37cache.Remove("product:42");
38
39listener.RecordObservableInstruments();
40
41static void WriteMeasurement<T>(
42 Instrument instrument,
43 T measurement,
44 ReadOnlySpan<KeyValuePair<string, object?>> tags,
45 object? state)
46 where T : struct
47{
48 Console.WriteLine($"{instrument.Meter.Name}/{instrument.Name}: {measurement}");
49
50 foreach (KeyValuePair<string, object?> tag in tags)
51 {
52 Console.WriteLine($" {tag.Key}={tag.Value}");
53 }
54}
The listener starts before the cache instance is created, but MeterListener.Start() also reports instruments that were already published. Starting early simply keeps the sample easy to reason about.
The three measurement callbacks are intentional. Instruments decide their numeric type. Registering only double, for example, would miss a long counter. For diagnostic listeners and small tooling, registering the expected numeric types explicitly keeps that behavior visible.
Observable instruments need special attention. Counters report measurements when application code records them. Observable instruments are pulled by the collection side. That is why the sample calls RecordObservableInstruments() after cache activity. In a real collector, this would usually happen on an interval.
Understanding the emitted cache signals
The four instruments represent different questions.
dotnet.cache.requests answers whether the cache is being used successfully. The dotnet.cache.request.type tag separates hit and miss, so a collector can calculate hit rate over time. A rising miss rate may indicate poor key design, overly short expirations, cache fragmentation, or a workload that is not cache-friendly.
dotnet.cache.evictions answers how often items leave the cache because of cache policy. Evictions are not automatically bad. They are expected when size limits, memory pressure, absolute expirations, sliding expirations, or priority settings are part of the design. The signal becomes interesting when eviction volume increases together with miss rate or backend load.
dotnet.cache.entries answers how many entries are currently stored. It is a state signal, not a throughput signal. It helps distinguish “the cache is empty because nothing is being cached” from “the cache is full but ineffective”.
dotnet.cache.estimated_size answers how much application-defined size is currently accounted for. The word “estimated” is important. MemoryCache does not inspect object graphs and calculate memory usage. It sums the sizes supplied by cache entries. If one part of the application uses bytes, another uses item counts and a third does not set size at all, the metric loses operational meaning.
Hit rate is useful, but not sufficient
Cache hit rate is the obvious headline metric, but it is not the whole story. A cache can have a high hit rate and still be harmful if the cached values are too large, too stale, too expensive to serialize, or too broad for the access pattern.
A useful cache review usually combines several signals:
- hit and miss rate
- eviction rate
- current entry count
- estimated size
- backend dependency latency
- backend dependency request rate
- endpoint latency
The cache metrics explain cache behavior. They do not prove that the cache improves the system. The broader performance story still needs request metrics, dependency metrics, traces and sometimes profiling.
This is also where MeterListener reaches its natural limit. It can show raw measurements. It does not provide long-term storage, alerting, correlation, dashboards, cardinality controls, or production-grade aggregation. For that, the same meter should normally be consumed through OpenTelemetry.
Using the same meter through OpenTelemetry
The MemoryCache meter can be added to a regular OpenTelemetry metrics pipeline. That is the more realistic setup for production systems.
1using Microsoft.AspNetCore.Builder;
2using Microsoft.Extensions.DependencyInjection;
3using OpenTelemetry.Metrics;
4
5WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
6
7builder.Services.AddMemoryCache(options =>
8{
9 options.TrackStatistics = true;
10});
11
12builder.Services.AddOpenTelemetry()
13 .WithMetrics(metrics =>
14 {
15 metrics.AddMeter("Microsoft.Extensions.Caching.Memory.MemoryCache");
16 metrics.AddOtlpExporter();
17 });
18
19WebApplication app = builder.Build();
20
21app.MapGet("/", () => "MemoryCache metrics are exported through OpenTelemetry.");
22
23app.Run();
This version delegates aggregation and export behavior to the OpenTelemetry SDK. MeterListener remains useful beside it when a small local probe or a test needs to verify what the meter emits before the exporter touches it.
Per-instance metrics and meter factories
.NET 11 also matters for applications that create multiple cache instances. MemoryCache has a constructor overload that accepts an IMeterFactory together with options and logging. Passing a meter factory allows metrics to be scoped per instance instead of falling back to process-wide aggregation on a shared meter.
That distinction is important for applications with separate caches for unrelated workloads. A product-catalog cache and a short-lived authorization cache can have very different hit-rate and eviction profiles. Aggregating them into one undifferentiated process-level metric may hide the real problem.
Per-instance visibility should still be used carefully. Every extra dimension in metrics has storage and cardinality costs. A small number of named cache instances can be valuable. Dynamically creating one cache meter per tenant, customer, request, or user would be a serious observability problem.
Performance and failure considerations
Metrics code is part of the application hot path. The underlying System.Diagnostics.Metrics APIs are designed to be cheap when no listener is active, but measurement cost is not zero when collection is enabled.
For MemoryCache, TrackStatistics also has an accounting cost. That cost is usually reasonable for applications that need cache observability, but the option should still be treated as an explicit design decision. Very high-throughput cache paths should be benchmarked with metrics collection enabled, not only in the no-listener case.
Listener callbacks should stay short. A callback that logs synchronously, blocks on I/O, allocates heavily, or takes locks can distort the workload being measured. For production collection, the OpenTelemetry SDK is a better place to handle buffering, batching, export retries and back pressure.
Observable instruments have a different failure shape. A slow observable callback can delay collection of other observable instruments. Calling RecordObservableInstruments() too frequently can create avoidable overhead, while calling it too rarely can make state metrics look stale. The right interval depends on the operational question, but cache state usually does not need sub-second collection.
Testing cache instrumentation
MeterListener is useful in tests when the test needs to verify that instrumentation emits expected measurements. For application metrics, the Microsoft.Extensions.Diagnostics.Metrics.Testing package and MetricCollector<T> are often more convenient. For platform metrics or raw listener behavior, MeterListener remains the lowest-level option.
A test should avoid asserting every incidental detail. Stable assertions focus on the meter name, instrument name, critical tags and basic measurement direction. For example, a cache hit should produce a request measurement tagged as hit and a cache miss should produce one tagged as miss. Exact timing and export-specific aggregation output belong in integration tests around the collector pipeline, not unit tests around cache behavior.
Global meters also require test isolation. A listener attached to a process-wide meter can observe measurements from other tests running in parallel. Tests that listen to global meters should either use isolated processes, isolated service providers with meter factories where possible, or non-parallel test collections.
Migration guidance for existing applications
Existing applications that already use OpenTelemetry do not need a new metrics architecture. The practical migration is smaller:
- enable
TrackStatisticsfor the relevantMemoryCacheinstances - add the
Microsoft.Extensions.Caching.Memory.MemoryCachemeter to the metrics pipeline - decide whether cache instance boundaries should be visible
- review dashboard cardinality before adding custom cache tags or instance labels
- validate hit, miss, eviction, entry and size signals under realistic load
Applications with hand-written cache counters can usually simplify over time. The built-in meter covers the generic cache behavior, while custom metrics can focus on domain-specific questions: cache warm-up duration, refresh failures, stale value usage, or fallback paths.
The best outcome is not more metrics. It is fewer duplicated metrics with clearer ownership.
Conclusion
.NET 11 makes MemoryCache easier to observe by publishing first-party, OpenTelemetry-compatible metrics through System.Diagnostics.Metrics. MeterListener provides a direct view into that stream and is especially useful for local inspection, adapters and focused tests.
For production monitoring, the same meter should usually flow through OpenTelemetry. That keeps aggregation, export, resource metadata and backend integration in the right layer. The low-level listener remains valuable because it makes the underlying contract visible: instruments, measurement values and tags.
The result is a cleaner cache observability model. Cache behavior no longer has to be inferred only from application latency or custom wrapper code. With TrackStatistics enabled and the new meter collected, hits, misses, evictions, entries and estimated size become part of the normal .NET telemetry surface.
Related articles

Jul 10, 2026 - 11 min read
Top 10 recommendations for .NET developers working with Azure Cosmos DB
Azure Cosmos DB has a habit of exposing architectural weaknesses very early. Classic data-access patterns that work perfectly fine with …

Jul 05, 2026 - 7 min read
How to migrate .NET versioning from x.x.x.x to SemVer
Many long-lived .NET repositories did not start with semantic versioning. They grew up with major.minor.build.revision, often set in …

Jul 03, 2026 - 7 min read
Modern versioning for ASP.NET Core and web apps and how to produce idempotent artifacts
ASP.NET Core applications are usually released often enough that versioning stops being a documentation concern and turns into delivery …
Let's Work Together
Looking for an experienced Platform Architect or Engineer for your next project? Whether it's cloud migration, platform modernization or building new solutions from scratch - I'm here to help you succeed.
