diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/HybridCacheOptions.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/HybridCacheOptions.cs
index d55ac1a4ea1..aac2d7c4bfc 100644
--- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/HybridCacheOptions.cs
+++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/HybridCacheOptions.cs
@@ -53,8 +53,37 @@ public class HybridCacheOptions
/// to use "tags" data as dimensions on metric reporting; otherwise, .
///
///
- /// If enabled, take care to ensure that tags don't contain data that
- /// should not be visible in metrics systems.
+ ///
+ /// When enabled, cache operations will emit System.Diagnostics.Metrics with tag values as dimensions,
+ /// providing richer telemetry for cache performance analysis by tag categories.
+ ///
+ ///
+ /// Important PII and Security Considerations:
+ ///
+ ///
+ /// -
+ /// Ensure that tag values do not contain personally identifiable information (PII),
+ /// sensitive data, or high-cardinality values that could overwhelm metrics systems.
+ ///
+ /// -
+ /// Tag values will be visible in metrics collection systems, dashboards, and telemetry pipelines.
+ /// Only use tags that are safe for observability purposes.
+ ///
+ /// -
+ /// Consider using categorical values like "region", "service", "environment" rather than
+ /// user-specific identifiers or sensitive business data.
+ ///
+ /// -
+ /// High-cardinality tags (e.g., user IDs, session IDs) can cause performance issues
+ /// in metrics systems and should be avoided.
+ ///
+ ///
+ ///
+ /// Example of appropriate tags: ["region:us-west", "service:api", "environment:prod"].
+ ///
+ ///
+ /// Example of inappropriate tags: ["user:john.doe@company.com", "session:abc123", "customer-data:sensitive"].
+ ///
///
public bool ReportTagMetrics { get; set; }
diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.L2.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.L2.cs
index 4293d54bc30..a5929deffc3 100644
--- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.L2.cs
+++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.L2.cs
@@ -211,9 +211,9 @@ internal void SetL1(string key, CacheItem value, HybridCacheEntryOptions?
// commit
cacheEntry.Dispose();
- if (HybridCacheEventSource.Log.IsEnabled())
+ if (HybridCacheEventSource.Log.IsEnabled() || _options.ReportTagMetrics)
{
- HybridCacheEventSource.Log.LocalCacheWrite();
+ HybridCacheEventSource.Log.LocalCacheWriteWithTags(value.Tags, _options.ReportTagMetrics);
}
}
}
diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.StampedeStateT.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.StampedeStateT.cs
index 34a68ef30aa..31e5fa83da6 100644
--- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.StampedeStateT.cs
+++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.StampedeStateT.cs
@@ -5,11 +5,9 @@
using System.Buffers;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
-using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
-using static Microsoft.Extensions.Caching.Hybrid.Internal.DefaultHybridCache;
namespace Microsoft.Extensions.Caching.Hybrid.Internal;
@@ -188,15 +186,15 @@ private async Task BackgroundFetchAsync()
}
result = await Cache.GetFromL2DirectAsync(Key.Key, SharedToken).ConfigureAwait(false);
- if (eventSourceEnabled)
+ if (eventSourceEnabled || Cache._options.ReportTagMetrics)
{
if (result.HasValue)
{
- HybridCacheEventSource.Log.DistributedCacheHit();
+ HybridCacheEventSource.Log.DistributedCacheHitWithTags(CacheItem.Tags, Cache._options.ReportTagMetrics);
}
else
{
- HybridCacheEventSource.Log.DistributedCacheMiss();
+ HybridCacheEventSource.Log.DistributedCacheMissWithTags(CacheItem.Tags, Cache._options.ReportTagMetrics);
}
}
}
@@ -367,9 +365,9 @@ private async Task BackgroundFetchAsync()
{
await Cache.SetL2Async(Key.Key, cacheItem, in buffer, _options, SharedToken).ConfigureAwait(false);
- if (eventSourceEnabled)
+ if (eventSourceEnabled || Cache._options.ReportTagMetrics)
{
- HybridCacheEventSource.Log.DistributedCacheWrite();
+ HybridCacheEventSource.Log.DistributedCacheWriteWithTags(CacheItem.Tags, Cache._options.ReportTagMetrics);
}
}
catch (Exception ex)
diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.TagInvalidation.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.TagInvalidation.cs
index ef5b7f1a01a..0ca54e2e26e 100644
--- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.TagInvalidation.cs
+++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.TagInvalidation.cs
@@ -254,9 +254,9 @@ private void InvalidateTagLocalCore(string tag, long timestamp, bool isNow)
{
_tagInvalidationTimes[tag] = timestampTask;
- if (HybridCacheEventSource.Log.IsEnabled())
+ if (HybridCacheEventSource.Log.IsEnabled() || _options.ReportTagMetrics)
{
- HybridCacheEventSource.Log.TagInvalidated();
+ HybridCacheEventSource.Log.TagInvalidatedWithTags(tag, _options.ReportTagMetrics);
}
}
}
diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.cs
index 93e1e5457cb..0ba31caa5a8 100644
--- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.cs
+++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.cs
@@ -152,6 +152,7 @@ public override ValueTask GetOrCreateAsync(string key, TState stat
}
bool eventSourceEnabled = HybridCacheEventSource.Log.IsEnabled();
+ TagSet tagSet = TagSet.Create(tags);
if ((flags & HybridCacheEntryFlags.DisableLocalCacheRead) == 0)
{
@@ -159,18 +160,18 @@ public override ValueTask GetOrCreateAsync(string key, TState stat
&& typed.TryGetValue(_logger, out T? value))
{
// short-circuit
- if (eventSourceEnabled)
+ if (eventSourceEnabled || _options.ReportTagMetrics)
{
- HybridCacheEventSource.Log.LocalCacheHit();
+ HybridCacheEventSource.Log.LocalCacheHitWithTags(tagSet, _options.ReportTagMetrics);
}
return new(value);
}
else
{
- if (eventSourceEnabled)
+ if (eventSourceEnabled || _options.ReportTagMetrics)
{
- HybridCacheEventSource.Log.LocalCacheMiss();
+ HybridCacheEventSource.Log.LocalCacheMissWithTags(tagSet, _options.ReportTagMetrics);
}
}
}
diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/HybridCacheEventSource.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/HybridCacheEventSource.cs
index 412f713034f..6ed9ecc5824 100644
--- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/HybridCacheEventSource.cs
+++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/HybridCacheEventSource.cs
@@ -1,7 +1,9 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
+using System.Collections.Generic;
using System.Diagnostics;
+using System.Diagnostics.Metrics;
using System.Diagnostics.Tracing;
using System.Runtime.CompilerServices;
using System.Threading;
@@ -13,6 +15,16 @@ internal sealed class HybridCacheEventSource : EventSource
{
public static readonly HybridCacheEventSource Log = new();
+ // System.Diagnostics.Metrics instruments for tag-aware metrics
+ private static readonly Meter _sMeter = new("Microsoft.Extensions.Caching.Hybrid");
+ private static readonly Counter _sLocalCacheHits = _sMeter.CreateCounter("hybrid_cache.local.hits", description: "Total number of local cache hits");
+ private static readonly Counter _sLocalCacheMisses = _sMeter.CreateCounter("hybrid_cache.local.misses", description: "Total number of local cache misses");
+ private static readonly Counter _sDistributedCacheHits = _sMeter.CreateCounter("hybrid_cache.distributed.hits", description: "Total number of distributed cache hits");
+ private static readonly Counter _sDistributedCacheMisses = _sMeter.CreateCounter("hybrid_cache.distributed.misses", description: "Total number of distributed cache misses");
+ private static readonly Counter _sLocalCacheWrites = _sMeter.CreateCounter("hybrid_cache.local.writes", description: "Total number of local cache writes");
+ private static readonly Counter _sDistributedCacheWrites = _sMeter.CreateCounter("hybrid_cache.distributed.writes", description: "Total number of distributed cache writes");
+ private static readonly Counter _sTagInvalidations = _sMeter.CreateCounter("hybrid_cache.tag.invalidations", description: "Total number of tag invalidations");
+
internal const int EventIdLocalCacheHit = 1;
internal const int EventIdLocalCacheMiss = 2;
internal const int EventIdDistributedCacheGet = 3;
@@ -196,6 +208,264 @@ internal void TagInvalidated()
WriteEvent(EventIdTagInvalidated);
}
+ ///
+ /// Reports a local cache hit with optional tag dimensions for System.Diagnostics.Metrics.
+ ///
+ /// The cache entry tags to include as metric dimensions.
+ /// Whether to emit tag dimensions in System.Diagnostics.Metrics.
+ [NonEvent]
+ public void LocalCacheHitWithTags(TagSet tags, bool reportTagMetrics)
+ {
+ if (IsEnabled())
+ {
+ LocalCacheHit();// Emit EventSource event
+ }
+
+ // Also emit metrics when requested
+ if (reportTagMetrics)
+ {
+ if (tags.Count > 0)
+ {
+ EmitLocalCacheHitMetric(tags);
+ }
+ else
+ {
+ _sLocalCacheHits.Add(1);
+ }
+ }
+ }
+
+ ///
+ /// Reports a local cache miss with optional tag dimensions for System.Diagnostics.Metrics.
+ ///
+ /// The cache entry tags to include as metric dimensions.
+ /// Whether to emit tag dimensions in System.Diagnostics.Metrics.
+ [NonEvent]
+ public void LocalCacheMissWithTags(TagSet tags, bool reportTagMetrics)
+ {
+ if (IsEnabled())
+ {
+ LocalCacheMiss();// Emit EventSource event
+ }
+
+ // Also emit metrics when requested
+ if (reportTagMetrics)
+ {
+ if (tags.Count > 0)
+ {
+ EmitLocalCacheMissMetric(tags);
+ }
+ else
+ {
+ _sLocalCacheMisses.Add(1);
+ }
+ }
+ }
+
+ ///
+ /// Reports a distributed cache hit with optional tag dimensions for System.Diagnostics.Metrics.
+ ///
+ /// The cache entry tags to include as metric dimensions.
+ /// Whether to emit tag dimensions in System.Diagnostics.Metrics.
+ [NonEvent]
+ public void DistributedCacheHitWithTags(TagSet tags, bool reportTagMetrics)
+ {
+ if (IsEnabled())
+ {
+ DistributedCacheHit();// Emit EventSource event
+ }
+
+ // Also emit metrics when requested
+ if (reportTagMetrics)
+ {
+ if (tags.Count > 0)
+ {
+ EmitDistributedCacheHitMetric(tags);
+ }
+ else
+ {
+ _sDistributedCacheHits.Add(1);
+ }
+ }
+ }
+
+ ///
+ /// Reports a distributed cache miss with optional tag dimensions for System.Diagnostics.Metrics.
+ ///
+ /// The cache entry tags to include as metric dimensions.
+ /// Whether to emit tag dimensions in System.Diagnostics.Metrics.
+ [NonEvent]
+ public void DistributedCacheMissWithTags(TagSet tags, bool reportTagMetrics)
+ {
+ if (IsEnabled())
+ {
+ DistributedCacheMiss();// Emit EventSource event
+ }
+
+ // Also emit metrics when requested
+ if (reportTagMetrics)
+ {
+ if (tags.Count > 0)
+ {
+ EmitDistributedCacheMissMetric(tags);
+ }
+ else
+ {
+ _sDistributedCacheMisses.Add(1);
+ }
+ }
+ }
+
+ ///
+ /// Reports a local cache write with optional tag dimensions for System.Diagnostics.Metrics.
+ ///
+ /// The cache entry tags to include as metric dimensions.
+ /// Whether to emit tag dimensions in System.Diagnostics.Metrics.
+ [NonEvent]
+ public void LocalCacheWriteWithTags(TagSet tags, bool reportTagMetrics)
+ {
+ if (IsEnabled())
+ {
+ LocalCacheWrite();// Emit EventSource event
+ }
+
+ // Also emit metrics when requested
+ if (reportTagMetrics)
+ {
+ if (tags.Count > 0)
+ {
+ EmitLocalCacheWriteMetric(tags);
+ }
+ else
+ {
+ _sLocalCacheWrites.Add(1);
+ }
+ }
+ }
+
+ ///
+ /// Reports a distributed cache write with optional tag dimensions for System.Diagnostics.Metrics.
+ ///
+ /// The cache entry tags to include as metric dimensions.
+ /// Whether to emit tag dimensions in System.Diagnostics.Metrics.
+ [NonEvent]
+ public void DistributedCacheWriteWithTags(TagSet tags, bool reportTagMetrics)
+ {
+ if (IsEnabled())
+ {
+ DistributedCacheWrite();// Emit EventSource event
+ }
+
+ // Also emit metrics when requested
+ if (reportTagMetrics)
+ {
+ if (tags.Count > 0)
+ {
+ EmitDistributedCacheWriteMetric(tags);
+ }
+ else
+ {
+ _sDistributedCacheWrites.Add(1);
+ }
+ }
+ }
+
+ ///
+ /// Reports a tag invalidation with optional tag dimensions for System.Diagnostics.Metrics.
+ ///
+ /// The specific tag that was invalidated.
+ /// Whether to emit tag dimensions in System.Diagnostics.Metrics.
+ [NonEvent]
+ public void TagInvalidatedWithTags(string tag, bool reportTagMetrics)
+ {
+ if (IsEnabled())
+ {
+ TagInvalidated();// Emit EventSource event
+ }
+
+ // Also emit metrics when requested
+ if (reportTagMetrics)
+ {
+ _sTagInvalidations.Add(1, new KeyValuePair("tag", tag));
+ }
+ }
+
+ ///
+ /// Emits a local cache hit metric with tag dimensions.
+ ///
+ /// The tags to include as metric dimensions.
+ [NonEvent]
+ private static void EmitLocalCacheHitMetric(TagSet tags)
+ {
+ var tagList = CreateTagList(tags);
+ _sLocalCacheHits.Add(1, tagList);
+ }
+
+ [NonEvent]
+ private static void EmitLocalCacheMissMetric(TagSet tags)
+ {
+ var tagList = CreateTagList(tags);
+ _sLocalCacheMisses.Add(1, tagList);
+ }
+
+ [NonEvent]
+ private static void EmitDistributedCacheHitMetric(TagSet tags)
+ {
+ var tagList = CreateTagList(tags);
+ _sDistributedCacheHits.Add(1, tagList);
+ }
+
+ [NonEvent]
+ private static void EmitDistributedCacheMissMetric(TagSet tags)
+ {
+ var tagList = CreateTagList(tags);
+ _sDistributedCacheMisses.Add(1, tagList);
+ }
+
+ [NonEvent]
+ private static void EmitLocalCacheWriteMetric(TagSet tags)
+ {
+ var tagList = CreateTagList(tags);
+ _sLocalCacheWrites.Add(1, tagList);
+ }
+
+ [NonEvent]
+ private static void EmitDistributedCacheWriteMetric(TagSet tags)
+ {
+ var tagList = CreateTagList(tags);
+ _sDistributedCacheWrites.Add(1, tagList);
+ }
+
+ ///
+ /// Converts a TagSet to a TagList for use with System.Diagnostics.Metrics instruments.
+ /// Tags are added with keys "tag_0", "tag_1", etc. to maintain order and avoid conflicts.
+ ///
+ /// The TagSet to convert.
+ /// A TagList containing the tag values as dimensions.
+ [NonEvent]
+ private static TagList CreateTagList(TagSet tags)
+ {
+ var tagList = default(TagList);
+ switch (tags.Count)
+ {
+ case 0:
+ break; // no tags to add
+ case 1:
+ tagList.Add("tag_0", tags.GetSinglePrechecked());
+ break;
+ default:
+ var span = tags.GetSpanPrechecked();
+ for (int i = 0; i < span.Length; i++)
+ {
+ tagList.Add($"tag_{i}", span[i]);
+ }
+
+ break;
+ }
+
+ return tagList;
+ }
+
#if !(NETSTANDARD2_0 || NET462)
[System.Diagnostics.CodeAnalysis.SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope", Justification = "Lifetime exceeds obvious scope; handed to event source")]
[NonEvent]
@@ -221,6 +491,118 @@ protected override void OnEventCommand(EventCommandEventArgs command)
base.OnEventCommand(command);
}
+
+ ///
+ /// Emits only System.Diagnostics.Metrics for local cache hit when ReportTagMetrics is enabled.
+ ///
+ /// The cache entry tags to include as metric dimensions.
+ [NonEvent]
+ public static void EmitLocalCacheHitMetrics(TagSet tags)
+ {
+ if (tags.Count > 0)
+ {
+ EmitLocalCacheHitMetric(tags);
+ }
+ else
+ {
+ _sLocalCacheHits.Add(1);
+ }
+ }
+
+ ///
+ /// Emits only System.Diagnostics.Metrics for local cache miss when ReportTagMetrics is enabled.
+ ///
+ /// The cache entry tags to include as metric dimensions.
+ [NonEvent]
+ public static void EmitLocalCacheMissMetrics(TagSet tags)
+ {
+ if (tags.Count > 0)
+ {
+ EmitLocalCacheMissMetric(tags);
+ }
+ else
+ {
+ _sLocalCacheMisses.Add(1);
+ }
+ }
+
+ ///
+ /// Emits only System.Diagnostics.Metrics for distributed cache hit when ReportTagMetrics is enabled.
+ ///
+ /// The cache entry tags to include as metric dimensions.
+ [NonEvent]
+ public static void EmitDistributedCacheHitMetrics(TagSet tags)
+ {
+ if (tags.Count > 0)
+ {
+ EmitDistributedCacheHitMetric(tags);
+ }
+ else
+ {
+ _sDistributedCacheHits.Add(1);
+ }
+ }
+
+ ///
+ /// Emits only System.Diagnostics.Metrics for distributed cache miss when ReportTagMetrics is enabled.
+ ///
+ /// The cache entry tags to include as metric dimensions.
+ [NonEvent]
+ public static void EmitDistributedCacheMissMetrics(TagSet tags)
+ {
+ if (tags.Count > 0)
+ {
+ EmitDistributedCacheMissMetric(tags);
+ }
+ else
+ {
+ _sDistributedCacheMisses.Add(1);
+ }
+ }
+
+ ///
+ /// Emits only System.Diagnostics.Metrics for local cache write when ReportTagMetrics is enabled.
+ ///
+ /// The cache entry tags to include as metric dimensions.
+ [NonEvent]
+ public static void EmitLocalCacheWriteMetrics(TagSet tags)
+ {
+ if (tags.Count > 0)
+ {
+ EmitLocalCacheWriteMetric(tags);
+ }
+ else
+ {
+ _sLocalCacheWrites.Add(1);
+ }
+ }
+
+ ///
+ /// Emits only System.Diagnostics.Metrics for distributed cache write when ReportTagMetrics is enabled.
+ ///
+ /// The cache entry tags to include as metric dimensions.
+ [NonEvent]
+ public static void EmitDistributedCacheWriteMetrics(TagSet tags)
+ {
+ if (tags.Count > 0)
+ {
+ EmitDistributedCacheWriteMetric(tags);
+ }
+ else
+ {
+ _sDistributedCacheWrites.Add(1);
+ }
+ }
+
+ ///
+ /// Emits only System.Diagnostics.Metrics for tag invalidation when ReportTagMetrics is enabled.
+ ///
+ /// The specific tag that was invalidated.
+ [NonEvent]
+ public static void EmitTagInvalidationMetrics(string tag)
+ {
+ _sTagInvalidations.Add(1, new KeyValuePair("tag", tag));
+ }
#endif
[NonEvent]
diff --git a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/HybridCacheEventSourceTests.cs b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/HybridCacheEventSourceTests.cs
index 8e23143475f..d35c73576cf 100644
--- a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/HybridCacheEventSourceTests.cs
+++ b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/HybridCacheEventSourceTests.cs
@@ -242,4 +242,79 @@ private async Task AssertCountersAsync()
Skip.If(count == 0, "No counters received");
}
+
+ [SkippableFact]
+ public void LocalCacheHitWithTags()
+ {
+ AssertEnabled();
+
+ var tags = TagSet.Create(["region", "product"]);
+
+ listener.Reset().Source.LocalCacheHitWithTags(tags, reportTagMetrics: false);
+ listener.AssertSingleEvent(HybridCacheEventSource.EventIdLocalCacheHit, "LocalCacheHit", EventLevel.Verbose);
+ }
+
+ [SkippableFact]
+ public void LocalCacheMissWithTags()
+ {
+ AssertEnabled();
+
+ var tags = TagSet.Create(["region", "product"]);
+
+ listener.Reset().Source.LocalCacheMissWithTags(tags, reportTagMetrics: false);
+ listener.AssertSingleEvent(HybridCacheEventSource.EventIdLocalCacheMiss, "LocalCacheMiss", EventLevel.Verbose);
+ }
+
+ [SkippableFact]
+ public void DistributedCacheHitWithTags()
+ {
+ AssertEnabled();
+
+ var tags = TagSet.Create(["region", "product"]);
+
+ listener.Reset().Source.DistributedCacheHitWithTags(tags, reportTagMetrics: false);
+ listener.AssertSingleEvent(HybridCacheEventSource.EventIdDistributedCacheHit, "DistributedCacheHit", EventLevel.Verbose);
+ }
+
+ [SkippableFact]
+ public void DistributedCacheMissWithTags()
+ {
+ AssertEnabled();
+
+ var tags = TagSet.Create(["region", "product"]);
+
+ listener.Reset().Source.DistributedCacheMissWithTags(tags, reportTagMetrics: false);
+ listener.AssertSingleEvent(HybridCacheEventSource.EventIdDistributedCacheMiss, "DistributedCacheMiss", EventLevel.Verbose);
+ }
+
+ [SkippableFact]
+ public void LocalCacheWriteWithTags()
+ {
+ AssertEnabled();
+
+ var tags = TagSet.Create(["region", "product"]);
+
+ listener.Reset().Source.LocalCacheWriteWithTags(tags, reportTagMetrics: false);
+ listener.AssertSingleEvent(HybridCacheEventSource.EventIdLocalCacheWrite, "LocalCacheWrite", EventLevel.Verbose);
+ }
+
+ [SkippableFact]
+ public void DistributedCacheWriteWithTags()
+ {
+ AssertEnabled();
+
+ var tags = TagSet.Create(["region", "product"]);
+
+ listener.Reset().Source.DistributedCacheWriteWithTags(tags, reportTagMetrics: false);
+ listener.AssertSingleEvent(HybridCacheEventSource.EventIdDistributedCacheWrite, "DistributedCacheWrite", EventLevel.Verbose);
+ }
+
+ [SkippableFact]
+ public void TagInvalidatedWithTags()
+ {
+ AssertEnabled();
+
+ listener.Reset().Source.TagInvalidatedWithTags("test-tag", reportTagMetrics: false);
+ listener.AssertSingleEvent(HybridCacheEventSource.EventIdTagInvalidated, "TagInvalidated", EventLevel.Verbose);
+ }
}
diff --git a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/Microsoft.Extensions.Caching.Hybrid.Tests.csproj b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/Microsoft.Extensions.Caching.Hybrid.Tests.csproj
index 3cd6a56dca5..7dfe3ba3bf5 100644
--- a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/Microsoft.Extensions.Caching.Hybrid.Tests.csproj
+++ b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/Microsoft.Extensions.Caching.Hybrid.Tests.csproj
@@ -9,6 +9,7 @@
+
diff --git a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/ReportTagMetricsIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/ReportTagMetricsIntegrationTests.cs
new file mode 100644
index 00000000000..ab9a47419df
--- /dev/null
+++ b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/ReportTagMetricsIntegrationTests.cs
@@ -0,0 +1,151 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Diagnostics.Metrics.Testing;
+
+namespace Microsoft.Extensions.Caching.Hybrid.Tests;
+
+public sealed class ReportTagMetricsIntegrationTests : IDisposable
+{
+ private readonly ServiceProvider _serviceProvider;
+ private readonly HybridCache _cache;
+ private bool _disposed;
+
+ public ReportTagMetricsIntegrationTests()
+ {
+ var services = new ServiceCollection();
+ services.AddHybridCache(options =>
+ {
+ options.ReportTagMetrics = true;
+ });
+
+ _serviceProvider = services.BuildServiceProvider();
+ _cache = _serviceProvider.GetRequiredService();
+ }
+
+ [Fact]
+ public async Task GetOrCreateAsync_WithTags_EmitsTaggedMetrics()
+ {
+ // Arrange
+ using var collector = new MetricCollector(null, "Microsoft.Extensions.Caching.Hybrid", "hybrid_cache.local.misses");
+
+ // Act - first call should miss
+ var result1 = await _cache.GetOrCreateAsync("test-key", "initial-state",
+ (state, token) => new ValueTask($"value-for-{state}"),
+ tags: ["region:us-west", "service:test"]);
+
+ // Act - second call should hit
+ var result2 = await _cache.GetOrCreateAsync("test-key", "second-state",
+ (state, token) => new ValueTask($"value-for-{state}"),
+ tags: ["region:us-west", "service:test"]);
+
+ // Assert
+ Assert.Equal("value-for-initial-state", result1);
+ Assert.Equal("value-for-initial-state", result2); // Should get cached value
+
+ // Verify metrics were emitted
+ var measurements = collector.GetMeasurementSnapshot();
+ Assert.True(measurements.Count > 0, "Expected cache miss metrics to be emitted");
+
+ var latestMeasurement = measurements.Last();
+ Assert.Equal(1, latestMeasurement.Value);
+ Assert.True(latestMeasurement.Tags.Count >= 2, "Expected tag dimensions in metrics");
+ }
+
+ [Fact]
+ public async Task SetAsync_WithTags_EmitsTaggedWriteMetrics()
+ {
+ // Arrange
+ using var writeCollector = new MetricCollector(null, "Microsoft.Extensions.Caching.Hybrid", "hybrid_cache.local.writes");
+
+ // Act
+ await _cache.SetAsync("set-key", "set-value", tags: ["operation:set", "category:test"]);
+
+ // Assert
+ var measurements = writeCollector.GetMeasurementSnapshot();
+ Assert.True(measurements.Count > 0, "Expected cache write metrics to be emitted");
+
+ var latestMeasurement = measurements.Last();
+ Assert.Equal(1, latestMeasurement.Value);
+ Assert.True(latestMeasurement.Tags.Count >= 2, "Expected tag dimensions in write metrics");
+ }
+
+ [Fact]
+ public async Task RemoveByTagAsync_EmitsTagInvalidationMetrics()
+ {
+ // Arrange
+ using var invalidationCollector = new MetricCollector(null, "Microsoft.Extensions.Caching.Hybrid", "hybrid_cache.tag.invalidations");
+
+ // Setup - add some data first
+ await _cache.SetAsync("tagged-key", "tagged-value", tags: ["invalidation-test"]);
+
+ // Act
+ await _cache.RemoveByTagAsync("invalidation-test");
+
+ // Assert
+ var measurements = invalidationCollector.GetMeasurementSnapshot();
+ Assert.True(measurements.Count > 0, "Expected tag invalidation metrics to be emitted");
+
+ var latestMeasurement = measurements.Last();
+ Assert.Equal(1, latestMeasurement.Value);
+ Assert.Contains(latestMeasurement.Tags, kvp => kvp.Key == "tag" && kvp.Value?.ToString() == "invalidation-test");
+ }
+
+ [Fact]
+ public async Task CacheOperations_WithoutTags_EmitsMetricsWithoutDimensions()
+ {
+ // Arrange
+ using var collector = new MetricCollector(null, "Microsoft.Extensions.Caching.Hybrid", "hybrid_cache.local.misses");
+
+ // Act - cache operation without tags
+ var result = await _cache.GetOrCreateAsync("no-tags-key", "state",
+ (state, token) => new ValueTask($"value-{state}"));
+
+ // Assert
+ Assert.Equal("value-state", result);
+
+ var measurements = collector.GetMeasurementSnapshot();
+ Assert.True(measurements.Count > 0, "Expected metrics to be emitted even without tags");
+
+ var latestMeasurement = measurements.Last();
+ Assert.Equal(1, latestMeasurement.Value);
+ Assert.Empty(latestMeasurement.Tags);// No dimensions when no tags
+ }
+
+ [Theory]
+ [InlineData("single-tag")]
+ [InlineData("tag1", "tag2")]
+ [InlineData("tag1", "tag2", "tag3", "tag4", "tag5")]
+ public async Task CacheOperations_WithVariousTagCounts_EmitsCorrectDimensions(params string[] tags)
+ {
+ // Arrange
+ using var collector = new MetricCollector(null, "Microsoft.Extensions.Caching.Hybrid", "hybrid_cache.local.misses");
+
+ // Act
+ var result = await _cache.GetOrCreateAsync($"key-{string.Join("-", tags)}", "state",
+ (state, token) => new ValueTask($"value-{state}"),
+ tags: tags);
+
+ // Assert
+ Assert.Equal("value-state", result);
+
+ var measurements = collector.GetMeasurementSnapshot();
+ Assert.True(measurements.Count > 0, "Expected metrics to be emitted");
+
+ var latestMeasurement = measurements.Last();
+ Assert.Equal(1, latestMeasurement.Value);
+ Assert.Equal(tags.Length, latestMeasurement.Tags.Count); // Should have one dimension per tag
+ }
+
+ public void Dispose()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _serviceProvider.Dispose();
+ _disposed = true;
+ }
+}
diff --git a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/ReportTagMetricsTests.cs b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/ReportTagMetricsTests.cs
new file mode 100644
index 00000000000..f6044074aea
--- /dev/null
+++ b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/ReportTagMetricsTests.cs
@@ -0,0 +1,184 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Diagnostics.Metrics;
+using Microsoft.Extensions.Caching.Hybrid.Internal;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Diagnostics.Metrics.Testing;
+
+namespace Microsoft.Extensions.Caching.Hybrid.Tests;
+
+public class ReportTagMetricsTests
+{
+ [Fact]
+ public void HybridCacheMetricsInstrumentsAreCreated()
+ {
+ // Verify that the System.Diagnostics.Metrics instruments are properly created
+ using var meterListener = new MeterListener();
+ var meterNames = new List();
+
+ meterListener.InstrumentPublished = (instrument, listener) =>
+ {
+ if (instrument.Meter.Name == "Microsoft.Extensions.Caching.Hybrid")
+ {
+ meterNames.Add(instrument.Name);
+ listener.EnableMeasurementEvents(instrument);
+ }
+ };
+
+ meterListener.Start();
+
+ // Creating HybridCacheEventSource should initialize the metrics
+ using var eventSource = new HybridCacheEventSource();
+
+ // Verify expected metric names are registered
+ Assert.Contains("hybrid_cache.local.hits", meterNames);
+ Assert.Contains("hybrid_cache.local.misses", meterNames);
+ Assert.Contains("hybrid_cache.distributed.hits", meterNames);
+ Assert.Contains("hybrid_cache.distributed.misses", meterNames);
+ Assert.Contains("hybrid_cache.local.writes", meterNames);
+ Assert.Contains("hybrid_cache.distributed.writes", meterNames);
+ Assert.Contains("hybrid_cache.tag.invalidations", meterNames);
+ }
+
+ [Fact]
+ public async Task ReportTagMetrics_Enabled_EmitsTagDimensions()
+ {
+ var services = new ServiceCollection();
+ services.AddHybridCache(options =>
+ {
+ options.ReportTagMetrics = true;
+ });
+
+ await using var provider = services.BuildServiceProvider();
+ var cache = provider.GetRequiredService();
+
+ using var collector = new MetricCollector(null, "Microsoft.Extensions.Caching.Hybrid", "hybrid_cache.local.hits");
+
+ // Perform cache operation with tags
+ await cache.GetOrCreateAsync("test-key", "test-state",
+ (state, token) => new ValueTask("test-value"),
+ tags: ["region:us-west", "service:api"]);
+
+ // Get the value again to trigger a cache hit
+ await cache.GetOrCreateAsync("test-key", "test-state",
+ (state, token) => new ValueTask("test-value"),
+ tags: ["region:us-west", "service:api"]);
+
+ // Check that metrics with tag dimensions were emitted
+ var measurements = collector.GetMeasurementSnapshot();
+ if (measurements.Count > 0)
+ {
+ var measurement = measurements.Last();
+ Assert.True(measurement.Tags.Count > 0, "Expected tag dimensions to be present when ReportTagMetrics is enabled");
+ }
+ }
+
+ [Fact]
+ public async Task ReportTagMetrics_Disabled_NoTagDimensions()
+ {
+ var services = new ServiceCollection();
+ services.AddHybridCache(options =>
+ {
+ options.ReportTagMetrics = false; // Explicitly disabled
+ });
+
+ await using var provider = services.BuildServiceProvider();
+ var cache = provider.GetRequiredService();
+
+ using var collector = new MetricCollector(null, "Microsoft.Extensions.Caching.Hybrid", "hybrid_cache.local.hits");
+
+ // Perform cache operation with tags
+ await cache.GetOrCreateAsync("test-key", "test-state",
+ (state, token) => new ValueTask("test-value"),
+ tags: ["region:us-west", "service:api"]);
+
+ // Get the value again to trigger a cache hit
+ await cache.GetOrCreateAsync("test-key", "test-state",
+ (state, token) => new ValueTask("test-value"),
+ tags: ["region:us-west", "service:api"]);
+
+ // No metric measurements should be emitted when ReportTagMetrics is disabled
+ var measurements = collector.GetMeasurementSnapshot();
+
+ // We expect no measurements or measurements without tag dimensions when ReportTagMetrics is disabled
+ Assert.True(measurements.Count == 0 || measurements.All(m => m.Tags.Count == 0),
+ "Expected no tag dimensions when ReportTagMetrics is disabled");
+ }
+
+ [Fact]
+ public void EventSource_LocalCacheHitWithTags_ReportTagMetrics_True()
+ {
+ using var collector = new MetricCollector(null, "Microsoft.Extensions.Caching.Hybrid", "hybrid_cache.local.hits");
+
+ var tags = TagSet.Create(["region", "product"]);
+ var eventSource = HybridCacheEventSource.Log;
+
+ eventSource.LocalCacheHitWithTags(tags, reportTagMetrics: true);
+
+ var measurements = collector.GetMeasurementSnapshot();
+ Assert.True(measurements.Count > 0, "Expected metrics to be emitted when reportTagMetrics is true");
+
+ var measurement = measurements.Last();
+ Assert.Equal(1, measurement.Value);
+ Assert.True(measurement.Tags.Count >= 2, "Expected tag dimensions to be present");
+ }
+
+ [Fact]
+ public void EventSource_LocalCacheHitWithTags_ReportTagMetrics_False()
+ {
+ using var collector = new MetricCollector(null, "Microsoft.Extensions.Caching.Hybrid", "hybrid_cache.local.hits");
+
+ var tags = TagSet.Create(["region", "product"]);
+ var eventSource = HybridCacheEventSource.Log;
+
+ eventSource.LocalCacheHitWithTags(tags, reportTagMetrics: false);
+
+ // When reportTagMetrics is false, no System.Diagnostics.Metrics should be emitted
+ var measurements = collector.GetMeasurementSnapshot();
+ Assert.True(measurements.Count == 0, "Expected no metrics to be emitted when reportTagMetrics is false");
+ }
+
+ [Fact]
+ public void EventSource_TagInvalidatedWithTags_ReportTagMetrics_True()
+ {
+ using var collector = new MetricCollector(null, "Microsoft.Extensions.Caching.Hybrid", "hybrid_cache.tag.invalidations");
+
+ var eventSource = HybridCacheEventSource.Log;
+
+ eventSource.TagInvalidatedWithTags("test-tag", reportTagMetrics: true);
+
+ var measurements = collector.GetMeasurementSnapshot();
+ Assert.True(measurements.Count > 0, "Expected metrics to be emitted when reportTagMetrics is true");
+
+ var measurement = measurements.Last();
+ Assert.Equal(1, measurement.Value);
+ Assert.Contains(measurement.Tags, kvp => kvp.Key == "tag" && kvp.Value?.ToString() == "test-tag");
+ }
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public void EventSource_EmptyTags_ReportTagMetrics(bool reportTagMetrics)
+ {
+ using var collector = new MetricCollector(null, "Microsoft.Extensions.Caching.Hybrid", "hybrid_cache.local.hits");
+
+ var emptyTags = TagSet.Empty;
+ var eventSource = HybridCacheEventSource.Log;
+
+ eventSource.LocalCacheHitWithTags(emptyTags, reportTagMetrics);
+
+ var measurements = collector.GetMeasurementSnapshot();
+ if (reportTagMetrics)
+ {
+ Assert.True(measurements.Count > 0, "Expected metrics to be emitted when reportTagMetrics is true, even with empty tags");
+ var measurement = measurements.Last();
+ Assert.Equal(1, measurement.Value);
+ Assert.Empty(measurement.Tags); // No tag dimensions for empty tags
+ }
+ else
+ {
+ Assert.True(measurements.Count == 0, "Expected no metrics when reportTagMetrics is false");
+ }
+ }
+}