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"); + } + } +}