Skip to content

Commit 2d7d40d

Browse files
rjmurilloCopilot
andcommitted
Add built-in OpenTelemetry metrics to MemoryCache
Implements #124140: - Add MemoryCacheStatistics.TotalEvictions property - Add MemoryCacheOptions.Name property (default: "Default") - Add 3-param ctor: MemoryCache(IOptions, ILoggerFactory?, IMeterFactory?) - Make 2-param ctor accept nullable ILoggerFactory - Add Observable instruments: cache.requests, cache.evictions, cache.entries, cache.estimated_size - Track evictions at 4 sites: PostProcessTryGetValue, EntryExpired, ScanForExpiredItems, Compact - Add SharedMeter singleton for non-DI scenarios - Add DiagnosticSource project reference (conditional for non-netcoreapp) - Update ref assemblies for Abstractions and Memory - Add eviction stat tests and comprehensive metrics tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 91557f5 commit 2d7d40d

9 files changed

Lines changed: 329 additions & 5 deletions

src/libraries/Microsoft.Extensions.Caching.Abstractions/ref/Microsoft.Extensions.Caching.Abstractions.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ public MemoryCacheStatistics() { }
142142
public long? CurrentEstimatedSize { get { throw null; } init { } }
143143
public long TotalHits { get { throw null; } init { } }
144144
public long TotalMisses { get { throw null; } init { } }
145+
public long TotalEvictions { get { throw null; } init { } }
145146
}
146147
public partial class PostEvictionCallbackRegistration
147148
{

src/libraries/Microsoft.Extensions.Caching.Abstractions/src/MemoryCacheStatistics.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,5 +35,10 @@ public MemoryCacheStatistics() { }
3535
/// Gets the total number of cache hits.
3636
/// </summary>
3737
public long TotalHits { get; init; }
38+
39+
/// <summary>
40+
/// Gets the total number of cache evictions.
41+
/// </summary>
42+
public long TotalEvictions { get; init; }
3843
}
3944
}

src/libraries/Microsoft.Extensions.Caching.Memory/ref/Microsoft.Extensions.Caching.Memory.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ namespace Microsoft.Extensions.Caching.Memory
2525
public partial class MemoryCache : Microsoft.Extensions.Caching.Memory.IMemoryCache, System.IDisposable
2626
{
2727
public MemoryCache(Microsoft.Extensions.Options.IOptions<Microsoft.Extensions.Caching.Memory.MemoryCacheOptions> optionsAccessor) { }
28-
public MemoryCache(Microsoft.Extensions.Options.IOptions<Microsoft.Extensions.Caching.Memory.MemoryCacheOptions> optionsAccessor, Microsoft.Extensions.Logging.ILoggerFactory loggerFactory) { }
28+
public MemoryCache(Microsoft.Extensions.Options.IOptions<Microsoft.Extensions.Caching.Memory.MemoryCacheOptions> optionsAccessor, Microsoft.Extensions.Logging.ILoggerFactory? loggerFactory) { }
29+
public MemoryCache(Microsoft.Extensions.Options.IOptions<Microsoft.Extensions.Caching.Memory.MemoryCacheOptions> optionsAccessor, Microsoft.Extensions.Logging.ILoggerFactory? loggerFactory, System.Diagnostics.Metrics.IMeterFactory? meterFactory) { }
2930
public int Count { get { throw null; } }
3031
public System.Collections.Generic.IEnumerable<object> Keys { get { throw null; } }
3132
public void Clear() { }
@@ -51,6 +52,7 @@ public MemoryCacheOptions() { }
5152
public long? SizeLimit { get { throw null; } set { } }
5253
public bool TrackLinkedCacheEntries { get { throw null; } set { } }
5354
public bool TrackStatistics { get { throw null; } set { } }
55+
public string Name { get { throw null; } set { } }
5456
}
5557
public partial class MemoryDistributedCacheOptions : Microsoft.Extensions.Caching.Memory.MemoryCacheOptions
5658
{

src/libraries/Microsoft.Extensions.Caching.Memory/src/MemoryCache.cs

Lines changed: 108 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
using System.Collections.Generic;
88
using System.Diagnostics;
99
using System.Diagnostics.CodeAnalysis;
10+
using System.Diagnostics.Metrics;
1011
using System.Runtime.CompilerServices;
1112
using System.Runtime.InteropServices;
1213
using System.Threading;
@@ -30,7 +31,9 @@ public class MemoryCache : IMemoryCache
3031
private readonly List<Stats>? _allStats;
3132
private long _accumulatedHits;
3233
private long _accumulatedMisses;
34+
private long _accumulatedEvictions;
3335
private readonly ThreadLocal<StatsHandler>? _stats;
36+
private readonly Meter? _meter;
3437
private CoherentState _coherentState;
3538
private bool _disposed;
3639
private DateTime _lastExpirationScan;
@@ -47,13 +50,21 @@ public MemoryCache(IOptions<MemoryCacheOptions> optionsAccessor)
4750
/// </summary>
4851
/// <param name="optionsAccessor">The options of the cache.</param>
4952
/// <param name="loggerFactory">The factory used to create loggers.</param>
50-
public MemoryCache(IOptions<MemoryCacheOptions> optionsAccessor, ILoggerFactory loggerFactory)
53+
public MemoryCache(IOptions<MemoryCacheOptions> optionsAccessor, ILoggerFactory? loggerFactory)
54+
: this(optionsAccessor, loggerFactory, meterFactory: null) { }
55+
56+
/// <summary>
57+
/// Creates a new <see cref="MemoryCache"/> instance.
58+
/// </summary>
59+
/// <param name="optionsAccessor">The options of the cache.</param>
60+
/// <param name="loggerFactory">The factory used to create loggers.</param>
61+
/// <param name="meterFactory">The factory used to create meters for metrics collection.</param>
62+
public MemoryCache(IOptions<MemoryCacheOptions> optionsAccessor, ILoggerFactory? loggerFactory, IMeterFactory? meterFactory)
5163
{
5264
ArgumentNullException.ThrowIfNull(optionsAccessor);
53-
ArgumentNullException.ThrowIfNull(loggerFactory);
5465

5566
_options = optionsAccessor.Value;
56-
_logger = loggerFactory.CreateLogger<MemoryCache>();
67+
_logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger<MemoryCache>();
5768

5869
_coherentState = new CoherentState();
5970

@@ -65,6 +76,12 @@ public MemoryCache(IOptions<MemoryCacheOptions> optionsAccessor, ILoggerFactory
6576

6677
_lastExpirationScan = UtcNow;
6778
TrackLinkedCacheEntries = _options.TrackLinkedCacheEntries; // we store the setting now so it's consistent for entire MemoryCache lifetime
79+
80+
if (_options.TrackStatistics)
81+
{
82+
_meter = meterFactory?.Create("Microsoft.Extensions.Caching.Memory.MemoryCache") ?? SharedMeter.Instance;
83+
InitializeMetrics();
84+
}
6885
}
6986

7087
private DateTime UtcNow => _options.Clock?.UtcNow.UtcDateTime ?? DateTime.UtcNow;
@@ -297,6 +314,11 @@ private bool PostProcessTryGetValue(CoherentState coherentState, CacheEntry? ent
297314
{
298315
// TODO: For efficiency queue this up for batch removal
299316
coherentState.RemoveEntry(entry, _options);
317+
318+
if (_allStats is not null)
319+
{
320+
Interlocked.Increment(ref _accumulatedEvictions);
321+
}
300322
}
301323
}
302324

@@ -367,7 +389,8 @@ public void Clear()
367389
TotalMisses = sumTotal.miss,
368390
TotalHits = sumTotal.hit,
369391
CurrentEntryCount = Count,
370-
CurrentEstimatedSize = _options.HasSizeLimit ? Size : null
392+
CurrentEstimatedSize = _options.HasSizeLimit ? Size : null,
393+
TotalEvictions = Interlocked.Read(ref _accumulatedEvictions)
371394
};
372395
}
373396

@@ -378,6 +401,12 @@ internal void EntryExpired(CacheEntry entry)
378401
{
379402
// TODO: For efficiency consider processing these expirations in batches.
380403
_coherentState.RemoveEntry(entry, _options);
404+
405+
if (_allStats is not null)
406+
{
407+
Interlocked.Increment(ref _accumulatedEvictions);
408+
}
409+
381410
StartScanForExpiredItemsIfNeeded(UtcNow);
382411
}
383412

@@ -487,6 +516,11 @@ private void ScanForExpiredItems()
487516
if (entry.CheckExpired(utcNow))
488517
{
489518
coherentState.RemoveEntry(entry, _options);
519+
520+
if (_allStats is not null)
521+
{
522+
Interlocked.Increment(ref _accumulatedEvictions);
523+
}
490524
}
491525
}
492526
}
@@ -642,6 +676,11 @@ private void Compact(long removalSizeTarget, Func<CacheEntry, long> computeEntry
642676
coherentState.RemoveEntry(entry, _options);
643677
}
644678

679+
if (_allStats is not null && entriesToRemove.Count > 0)
680+
{
681+
Interlocked.Add(ref _accumulatedEvictions, entriesToRemove.Count);
682+
}
683+
645684
// Policy:
646685
// 1. Least recently used objects.
647686
// ?. Items with the soonest absolute expiration.
@@ -838,5 +877,70 @@ int IEqualityComparer.GetHashCode(object obj)
838877
}
839878
#endif
840879
}
880+
881+
private void InitializeMetrics()
882+
{
883+
Debug.Assert(_meter is not null);
884+
885+
KeyValuePair<string, object?> cacheNameTag = new("cache.name", _options.Name);
886+
887+
_meter.CreateObservableCounter("cache.requests",
888+
() =>
889+
{
890+
MemoryCacheStatistics? stats = GetCurrentStatistics();
891+
return stats is null
892+
? []
893+
: new Measurement<long>[]
894+
{
895+
new(stats.TotalHits, cacheNameTag, new("cache.request.type", "hit")),
896+
new(stats.TotalMisses, cacheNameTag, new("cache.request.type", "miss")),
897+
};
898+
},
899+
unit: "{requests}",
900+
description: "Total cache requests.");
901+
902+
_meter.CreateObservableCounter("cache.evictions",
903+
() =>
904+
{
905+
MemoryCacheStatistics? stats = GetCurrentStatistics();
906+
return stats is null ? default : new Measurement<long>(stats.TotalEvictions, cacheNameTag);
907+
},
908+
unit: "{evictions}",
909+
description: "Total cache evictions.");
910+
911+
_meter.CreateObservableUpDownCounter("cache.entries",
912+
() =>
913+
{
914+
MemoryCacheStatistics? stats = GetCurrentStatistics();
915+
return stats is null ? default : new Measurement<long>(stats.CurrentEntryCount, cacheNameTag);
916+
},
917+
unit: "{entries}",
918+
description: "Current number of cache entries.");
919+
920+
_meter.CreateObservableGauge("cache.estimated_size",
921+
() =>
922+
{
923+
MemoryCacheStatistics? stats = GetCurrentStatistics();
924+
return stats?.CurrentEstimatedSize is long size
925+
? new Measurement<long>(size, cacheNameTag)
926+
: default;
927+
},
928+
unit: "By",
929+
description: "Estimated size of the cache.");
930+
}
931+
932+
private sealed class SharedMeter : Meter
933+
{
934+
public static Meter Instance { get; } = new SharedMeter();
935+
private SharedMeter()
936+
: base("Microsoft.Extensions.Caching.Memory.MemoryCache")
937+
{
938+
}
939+
940+
protected override void Dispose(bool disposing)
941+
{
942+
// NOP to prevent disposing the global instance from MeterListener callbacks.
943+
}
944+
}
841945
}
842946
}

src/libraries/Microsoft.Extensions.Caching.Memory/src/MemoryCacheOptions.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,11 @@ public double CompactionPercentage
9898
/// </value>
9999
public bool TrackStatistics { get; set; }
100100

101+
/// <summary>
102+
/// Gets or sets the name of this cache instance.
103+
/// </summary>
104+
public string Name { get; set; } = "Default";
105+
101106
MemoryCacheOptions IOptions<MemoryCacheOptions>.Value
102107
{
103108
get { return this; }

src/libraries/Microsoft.Extensions.Caching.Memory/src/Microsoft.Extensions.Caching.Memory.csproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@
1616
<ProjectReference Include="$(LibrariesProjectRoot)Microsoft.Extensions.Primitives\src\Microsoft.Extensions.Primitives.csproj" />
1717
</ItemGroup>
1818

19+
<ItemGroup Condition="'$(TargetFramework)' != '$(NetCoreAppCurrent)'">
20+
<ProjectReference Include="$(LibrariesProjectRoot)System.Diagnostics.DiagnosticSource\src\System.Diagnostics.DiagnosticSource.csproj" />
21+
</ItemGroup>
22+
1923
<ItemGroup Condition="'$(TargetFrameworkIdentifier)' == '.NETFramework'">
2024
<PackageReference Include="System.ValueTuple" Version="$(SystemValueTupleVersion)" />
2125
</ItemGroup>

src/libraries/Microsoft.Extensions.Caching.Memory/tests/MemoryCacheGetCurrentStatisticsTests.cs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,36 @@ public void GetCurrentStatistics_DIMReturnsNull()
114114
}
115115
#endif
116116

117+
[Fact]
118+
public void GetCurrentStatistics_ExplicitRemove_DoesNotTrackEviction()
119+
{
120+
var cache = new MemoryCache(new MemoryCacheOptions { TrackStatistics = true });
121+
122+
cache.Set("key", "value");
123+
cache.Remove("key");
124+
125+
MemoryCacheStatistics? stats = cache.GetCurrentStatistics();
126+
Assert.NotNull(stats);
127+
Assert.Equal(0, stats.TotalEvictions);
128+
}
129+
130+
[Fact]
131+
public void GetCurrentStatistics_Compact_TracksTotalEvictions()
132+
{
133+
var cache = new MemoryCache(new MemoryCacheOptions { TrackStatistics = true });
134+
135+
for (int i = 0; i < 10; i++)
136+
{
137+
cache.Set($"key{i}", $"value{i}");
138+
}
139+
140+
cache.Compact(1.0);
141+
142+
MemoryCacheStatistics? stats = cache.GetCurrentStatistics();
143+
Assert.NotNull(stats);
144+
Assert.Equal(10, stats.TotalEvictions);
145+
}
146+
117147
private class FakeMemoryCache : IMemoryCache
118148
{
119149
public ICacheEntry CreateEntry(object key) => throw new NotImplementedException();

0 commit comments

Comments
 (0)