From 6307180b99637c72adbd6c8702064ca99b2e1b71 Mon Sep 17 00:00:00 2001 From: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> Date: Fri, 4 Jul 2025 23:56:00 +0100 Subject: [PATCH 1/7] Add initial API for adding Hook custom dimensions / tags Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> --- src/OpenFeature/Hooks/MetricsHook.cs | 11 +++- src/OpenFeature/Hooks/MetricsHookOptions.cs | 58 +++++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 src/OpenFeature/Hooks/MetricsHookOptions.cs diff --git a/src/OpenFeature/Hooks/MetricsHook.cs b/src/OpenFeature/Hooks/MetricsHook.cs index 2f2314f0d..4d1d7a48d 100644 --- a/src/OpenFeature/Hooks/MetricsHook.cs +++ b/src/OpenFeature/Hooks/MetricsHook.cs @@ -24,15 +24,19 @@ public class MetricsHook : Hook private readonly Counter _evaluationSuccessCounter; private readonly Counter _evaluationErrorCounter; + private readonly MetricsHookOptions _options; + /// /// Initializes a new instance of the class. /// - public MetricsHook() + /// Optional configuration for the metrics hook. + public MetricsHook(MetricsHookOptions? options = null) { this._evaluationActiveUpDownCounter = Meter.CreateUpDownCounter(MetricsConstants.ActiveCountName, description: MetricsConstants.ActiveDescription); this._evaluationRequestCounter = Meter.CreateCounter(MetricsConstants.RequestsTotalName, "{request}", MetricsConstants.RequestsDescription); this._evaluationSuccessCounter = Meter.CreateCounter(MetricsConstants.SuccessTotalName, "{impression}", MetricsConstants.SuccessDescription); this._evaluationErrorCounter = Meter.CreateCounter(MetricsConstants.ErrorTotalName, description: MetricsConstants.ErrorDescription); + this._options = options ?? MetricsHookOptions.Default; } /// @@ -61,6 +65,11 @@ public override ValueTask AfterAsync(HookContext context, FlagEvaluationDe { TelemetryConstants.Reason, details.Reason ?? Reason.Unknown.ToString() } }; + foreach (var metadata in this._options.CustomDimensions) + { + tagList.Add(metadata.Key, metadata.Value); + } + this._evaluationSuccessCounter.Add(1, tagList); return base.AfterAsync(context, details, hints, cancellationToken); diff --git a/src/OpenFeature/Hooks/MetricsHookOptions.cs b/src/OpenFeature/Hooks/MetricsHookOptions.cs new file mode 100644 index 000000000..412b2f4d5 --- /dev/null +++ b/src/OpenFeature/Hooks/MetricsHookOptions.cs @@ -0,0 +1,58 @@ +namespace OpenFeature.Hooks; + +/// +/// Configuration options for the . +/// +public sealed class MetricsHookOptions +{ + /// + /// The default options for the . + /// + public static MetricsHookOptions Default { get; } = new MetricsHookOptions(); + + /// + /// Custom dimensions or tags to be associated with Meters in . + /// + public IReadOnlyCollection> CustomDimensions { get; } + + /// + /// Initializes a new instance of the class. + /// + /// Optional custom dimensions to tag Counter increments with. + public MetricsHookOptions(IReadOnlyCollection>? customDimensions = null) + { + this.CustomDimensions = customDimensions ?? []; + } + + /// + /// Creates a new builder for . + /// + public static MetricsHookOptionsBuilder CreateBuilder() => new MetricsHookOptionsBuilder(); + + /// + /// A builder for constructing instances. + /// + public sealed class MetricsHookOptionsBuilder + { + private readonly List> _customDimensions = new List>(); + + /// + /// Adds a custom dimension. + /// + /// The key for the custom dimension. + /// The value for the custom dimension. + public MetricsHookOptionsBuilder WithCustomDimension(string key, object? value) + { + this._customDimensions.Add(new KeyValuePair(key, value)); + return this; + } + + /// + /// Builds the instance. + /// + public MetricsHookOptions Build() + { + return new MetricsHookOptions(this._customDimensions.AsReadOnly()); + } + } +} From 74b0ea289044e8c2626fae6b311c33553fffb7c1 Mon Sep 17 00:00:00 2001 From: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> Date: Sat, 5 Jul 2025 10:40:33 +0100 Subject: [PATCH 2/7] Refactor to add callback to retrieve Flag Metadata keys Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> --- README.md | 18 ++++++++++ samples/AspNetCore/Program.cs | 7 +++- src/OpenFeature/Hooks/MetricsHook.cs | 13 +++++-- src/OpenFeature/Hooks/MetricsHookOptions.cs | 39 +++++++++++++++++++-- 4 files changed, 72 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 66a3d620c..cd18a6cfd 100644 --- a/README.md +++ b/README.md @@ -663,6 +663,24 @@ namespace OpenFeatureTestApp After running this example, you should be able to see some metrics being generated into the console. +You can specify custom dimensions on the `feature_flag.evaluation_success_total` metric by providing `MetricsHookOptions` when adding the hook: + +```csharp +var options = MetricsHookOptions.CreateBuilder() + .AddCustomDimension("custom_dimension_key", "custom_dimension_value") + .Build(); + +OpenFeature.Api.Instance.AddHooks(new MetricsHook(options)); +``` + +You can also write your own extraction logic against the Flag metadata by providing a callback to `WithFlagEvaluationMetadata`. + +```csharp +var options = MetricsHookOptions.CreateBuilder() + .WithFlagEvaluationMetadata("boolean", s => s.GetBool("boolean")) + .Build(); +``` + ## ⭐️ Support the project diff --git a/samples/AspNetCore/Program.cs b/samples/AspNetCore/Program.cs index 5f4f01461..0770be024 100644 --- a/samples/AspNetCore/Program.cs +++ b/samples/AspNetCore/Program.cs @@ -26,9 +26,14 @@ builder.Services.AddOpenFeature(featureBuilder => { + var metricsHookOptions = MetricsHookOptions.CreateBuilder() + .WithCustomDimension("region", "euw") + .WithFlagEvaluationMetadata("boolean", s => s.GetBool("boolean")) + .Build(); + featureBuilder.AddHostedFeatureLifecycle() .AddHook(sp => new LoggingHook(sp.GetRequiredService>())) - .AddHook() + .AddHook(_ => new MetricsHook(metricsHookOptions)) .AddHook() .AddInMemoryProvider("InMemory", _ => new Dictionary() { diff --git a/src/OpenFeature/Hooks/MetricsHook.cs b/src/OpenFeature/Hooks/MetricsHook.cs index 4d1d7a48d..aa24e853e 100644 --- a/src/OpenFeature/Hooks/MetricsHook.cs +++ b/src/OpenFeature/Hooks/MetricsHook.cs @@ -65,9 +65,18 @@ public override ValueTask AfterAsync(HookContext context, FlagEvaluationDe { TelemetryConstants.Reason, details.Reason ?? Reason.Unknown.ToString() } }; - foreach (var metadata in this._options.CustomDimensions) + foreach (var customDimension in this._options.CustomDimensions) { - tagList.Add(metadata.Key, metadata.Value); + tagList.Add(customDimension.Key, customDimension.Value); + } + + var metadata = details.FlagMetadata ?? new ImmutableMetadata(); + foreach (var item in this._options.FlagMetadataExpressions) + { + var flagMetadataCallback = item.Value; + var value = flagMetadataCallback(metadata); + + tagList.Add(item.Key, value); } this._evaluationSuccessCounter.Add(1, tagList); diff --git a/src/OpenFeature/Hooks/MetricsHookOptions.cs b/src/OpenFeature/Hooks/MetricsHookOptions.cs index 412b2f4d5..6b1040c4f 100644 --- a/src/OpenFeature/Hooks/MetricsHookOptions.cs +++ b/src/OpenFeature/Hooks/MetricsHookOptions.cs @@ -1,3 +1,6 @@ +using System.Linq.Expressions; +using OpenFeature.Model; + namespace OpenFeature.Hooks; /// @@ -15,13 +18,28 @@ public sealed class MetricsHookOptions /// public IReadOnlyCollection> CustomDimensions { get; } + /// + /// + /// + internal IReadOnlyCollection>> FlagMetadataExpressions { get; } + + /// + /// Initializes a new instance of the class with default values. + /// + private MetricsHookOptions() : this(null, null) + { + } + /// /// Initializes a new instance of the class. /// /// Optional custom dimensions to tag Counter increments with. - public MetricsHookOptions(IReadOnlyCollection>? customDimensions = null) + /// + internal MetricsHookOptions(IReadOnlyCollection>? customDimensions = null, + IReadOnlyCollection>>? flagMetadataSelectors = null) { this.CustomDimensions = customDimensions ?? []; + this.FlagMetadataExpressions = flagMetadataSelectors ?? []; } /// @@ -35,6 +53,7 @@ public MetricsHookOptions(IReadOnlyCollection>? cu public sealed class MetricsHookOptionsBuilder { private readonly List> _customDimensions = new List>(); + private readonly List>> _flagMetadataExpressions = new List>>(); /// /// Adds a custom dimension. @@ -47,12 +66,28 @@ public MetricsHookOptionsBuilder WithCustomDimension(string key, object? value) return this; } + /// + /// Provide a callback to evaluate flag metadata for a specific flag key. + /// + /// The key for the custom dimension. + /// The callback to retrieve the value to tag successful flag evaluations. + /// + public MetricsHookOptionsBuilder WithFlagEvaluationMetadata(string key, Expression> expression) + { + var flagMetadataCallback = expression.Compile(); + var kvp = new KeyValuePair>(key, flagMetadataCallback); + + this._flagMetadataExpressions.Add(kvp); + + return this; + } + /// /// Builds the instance. /// public MetricsHookOptions Build() { - return new MetricsHookOptions(this._customDimensions.AsReadOnly()); + return new MetricsHookOptions(this._customDimensions.AsReadOnly(), this._flagMetadataExpressions.AsReadOnly()); } } } From 6aa2a8cc5be61b4950b6542ea82943a67b346de7 Mon Sep 17 00:00:00 2001 From: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> Date: Sat, 5 Jul 2025 10:44:30 +0100 Subject: [PATCH 3/7] Fix README with usage of WithCustomDimension Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index cd18a6cfd..7125c73cc 100644 --- a/README.md +++ b/README.md @@ -667,7 +667,7 @@ You can specify custom dimensions on the `feature_flag.evaluation_success_total` ```csharp var options = MetricsHookOptions.CreateBuilder() - .AddCustomDimension("custom_dimension_key", "custom_dimension_value") + .WithCustomDimension("custom_dimension_key", "custom_dimension_value") .Build(); OpenFeature.Api.Instance.AddHooks(new MetricsHook(options)); From 65bb7049eac2d7595c0d8fd497038b97b31981c6 Mon Sep 17 00:00:00 2001 From: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> Date: Sat, 5 Jul 2025 10:48:09 +0100 Subject: [PATCH 4/7] Align AspNetCore sample with README example Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> --- samples/AspNetCore/Program.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/samples/AspNetCore/Program.cs b/samples/AspNetCore/Program.cs index 0770be024..e09213076 100644 --- a/samples/AspNetCore/Program.cs +++ b/samples/AspNetCore/Program.cs @@ -27,7 +27,7 @@ builder.Services.AddOpenFeature(featureBuilder => { var metricsHookOptions = MetricsHookOptions.CreateBuilder() - .WithCustomDimension("region", "euw") + .WithCustomDimension("custom_dimension_key", "custom_dimension_value") .WithFlagEvaluationMetadata("boolean", s => s.GetBool("boolean")) .Build(); From e269b6939cb46da3383e77224a8784e59ef6a83b Mon Sep 17 00:00:00 2001 From: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> Date: Sat, 5 Jul 2025 11:10:58 +0100 Subject: [PATCH 5/7] Add unit tests to cover new MetricsHookOptions Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> --- src/OpenFeature/Hooks/MetricsHook.cs | 4 +- src/OpenFeature/Hooks/MetricsHookOptions.cs | 10 +-- .../Hooks/MetricsHookOptionsTests.cs | 84 +++++++++++++++++++ .../Hooks/MetricsHookTests.cs | 70 ++++++++++++++++ 4 files changed, 160 insertions(+), 8 deletions(-) create mode 100644 test/OpenFeature.Tests/Hooks/MetricsHookOptionsTests.cs diff --git a/src/OpenFeature/Hooks/MetricsHook.cs b/src/OpenFeature/Hooks/MetricsHook.cs index aa24e853e..87ef41150 100644 --- a/src/OpenFeature/Hooks/MetricsHook.cs +++ b/src/OpenFeature/Hooks/MetricsHook.cs @@ -21,7 +21,7 @@ public class MetricsHook : Hook private readonly UpDownCounter _evaluationActiveUpDownCounter; private readonly Counter _evaluationRequestCounter; - private readonly Counter _evaluationSuccessCounter; + internal readonly Counter _evaluationSuccessCounter; private readonly Counter _evaluationErrorCounter; private readonly MetricsHookOptions _options; @@ -71,7 +71,7 @@ public override ValueTask AfterAsync(HookContext context, FlagEvaluationDe } var metadata = details.FlagMetadata ?? new ImmutableMetadata(); - foreach (var item in this._options.FlagMetadataExpressions) + foreach (var item in this._options.FlagMetadataCallbacks) { var flagMetadataCallback = item.Value; var value = flagMetadataCallback(metadata); diff --git a/src/OpenFeature/Hooks/MetricsHookOptions.cs b/src/OpenFeature/Hooks/MetricsHookOptions.cs index 6b1040c4f..553431496 100644 --- a/src/OpenFeature/Hooks/MetricsHookOptions.cs +++ b/src/OpenFeature/Hooks/MetricsHookOptions.cs @@ -1,4 +1,3 @@ -using System.Linq.Expressions; using OpenFeature.Model; namespace OpenFeature.Hooks; @@ -21,7 +20,7 @@ public sealed class MetricsHookOptions /// /// /// - internal IReadOnlyCollection>> FlagMetadataExpressions { get; } + internal IReadOnlyCollection>> FlagMetadataCallbacks { get; } /// /// Initializes a new instance of the class with default values. @@ -39,7 +38,7 @@ internal MetricsHookOptions(IReadOnlyCollection>? IReadOnlyCollection>>? flagMetadataSelectors = null) { this.CustomDimensions = customDimensions ?? []; - this.FlagMetadataExpressions = flagMetadataSelectors ?? []; + this.FlagMetadataCallbacks = flagMetadataSelectors ?? []; } /// @@ -70,11 +69,10 @@ public MetricsHookOptionsBuilder WithCustomDimension(string key, object? value) /// Provide a callback to evaluate flag metadata for a specific flag key. /// /// The key for the custom dimension. - /// The callback to retrieve the value to tag successful flag evaluations. + /// The callback to retrieve the value to tag successful flag evaluations. /// - public MetricsHookOptionsBuilder WithFlagEvaluationMetadata(string key, Expression> expression) + public MetricsHookOptionsBuilder WithFlagEvaluationMetadata(string key, Func flagMetadataCallback) { - var flagMetadataCallback = expression.Compile(); var kvp = new KeyValuePair>(key, flagMetadataCallback); this._flagMetadataExpressions.Add(kvp); diff --git a/test/OpenFeature.Tests/Hooks/MetricsHookOptionsTests.cs b/test/OpenFeature.Tests/Hooks/MetricsHookOptionsTests.cs new file mode 100644 index 000000000..89f0f56d7 --- /dev/null +++ b/test/OpenFeature.Tests/Hooks/MetricsHookOptionsTests.cs @@ -0,0 +1,84 @@ +using OpenFeature.Hooks; +using OpenFeature.Model; + +namespace OpenFeature.Tests.Hooks; + +public class MetricsHookOptionsTests +{ + [Fact] + public void Default_Options_Should_Be_Initialized_Correctly() + { + // Arrange & Act + var options = MetricsHookOptions.Default; + + // Assert + Assert.NotNull(options); + Assert.Empty(options.CustomDimensions); + Assert.Empty(options.FlagMetadataCallbacks); + } + + [Fact] + public void CreateBuilder_Should_Return_New_Builder_Instance() + { + // Arrange & Act + var builder = MetricsHookOptions.CreateBuilder(); + + // Assert + Assert.NotNull(builder); + Assert.IsType(builder); + } + + [Fact] + public void Build_Should_Return_Options() + { + // Arrange + var builder = MetricsHookOptions.CreateBuilder(); + + // Act + var options = builder.Build(); + + // Assert + Assert.NotNull(options); + Assert.IsType(options); + } + + [Theory] + [InlineData("custom_dimension_value")] + [InlineData(1.0)] + [InlineData(2025)] + [InlineData(null)] + [InlineData(true)] + public void Builder_Should_Allow_Adding_Custom_Dimensions(object? value) + { + // Arrange + var builder = MetricsHookOptions.CreateBuilder(); + var key = "custom_dimension_key"; + + // Act + builder.WithCustomDimension(key, value); + var options = builder.Build(); + + // Assert + Assert.Single(options.CustomDimensions); + Assert.Equal(key, options.CustomDimensions.First().Key); + Assert.Equal(value, options.CustomDimensions.First().Value); + } + + [Fact] + public void Builder_Should_Allow_Adding_Flag_Metadata_Expressions() + { + // Arrange + var builder = MetricsHookOptions.CreateBuilder(); + var key = "flag_metadata_key"; + static object? expression(ImmutableMetadata m) => m.GetString("flag_metadata_key"); + + // Act + builder.WithFlagEvaluationMetadata(key, expression); + var options = builder.Build(); + + // Assert + Assert.Single(options.FlagMetadataCallbacks); + Assert.Equal(key, options.FlagMetadataCallbacks.First().Key); + Assert.Equal(expression, options.FlagMetadataCallbacks.First().Value); + } +} diff --git a/test/OpenFeature.Tests/Hooks/MetricsHookTests.cs b/test/OpenFeature.Tests/Hooks/MetricsHookTests.cs index 54f6e19cc..0e82b47e1 100644 --- a/test/OpenFeature.Tests/Hooks/MetricsHookTests.cs +++ b/test/OpenFeature.Tests/Hooks/MetricsHookTests.cs @@ -1,3 +1,4 @@ +using Microsoft.Extensions.Diagnostics.Metrics.Testing; using OpenFeature.Hooks; using OpenFeature.Model; using OpenTelemetry; @@ -59,6 +60,75 @@ await metricsHook.AfterAsync(ctx, Assert.True(noOtherMetric); } + [Fact] + public async Task With_CustomDimensions_After_Test() + { + // Arrange + var metricHookOptions = MetricsHookOptions.CreateBuilder() + .WithCustomDimension("custom_dimension_key", "custom_dimension_value") + .Build(); + + var metricsHook = new MetricsHook(metricHookOptions); + + using var collector = new MetricCollector(metricsHook._evaluationSuccessCounter); + + var evaluationContext = EvaluationContext.Empty; + var ctx = new HookContext("my-flag", "foo", Constant.FlagValueType.String, + new ClientMetadata("my-client", "1.0"), new Metadata("my-provider"), evaluationContext); + + // Act + await metricsHook.AfterAsync(ctx, + new FlagEvaluationDetails("my-flag", "foo", Constant.ErrorType.None, "STATIC", "default"), + new Dictionary()).ConfigureAwait(true); + + var measurements = collector.LastMeasurement; + + // Assert + Assert.NotNull(measurements); + + Assert.Equal("my-flag", measurements.Tags["feature_flag.key"]); + Assert.Equal("my-provider", measurements.Tags["feature_flag.provider.name"]); + Assert.Equal("STATIC", measurements.Tags["feature_flag.result.reason"]); + Assert.Equal("custom_dimension_value", measurements.Tags["custom_dimension_key"]); + } + + [Fact] + public async Task With_FlagMetadataCallback_After_Test() + { + // Arrange + var metricHookOptions = MetricsHookOptions.CreateBuilder() + .WithFlagEvaluationMetadata("bool", m => m.GetBool("bool")) + .Build(); + + var metricsHook = new MetricsHook(metricHookOptions); + + using var collector = new MetricCollector(metricsHook._evaluationSuccessCounter); + + var evaluationContext = EvaluationContext.Empty; + var ctx = new HookContext("my-flag", "foo", Constant.FlagValueType.String, + new ClientMetadata("my-client", "1.0"), new Metadata("my-provider"), evaluationContext); + + var flagMetadata = new ImmutableMetadata(new Dictionary + { + { "bool", true } + }); + + // Act + await metricsHook.AfterAsync(ctx, + new FlagEvaluationDetails("my-flag", "foo", Constant.ErrorType.None, "STATIC", "default", errorMessage: null, flagMetadata), + new Dictionary()).ConfigureAwait(true); + + var measurements = collector.LastMeasurement; + + // Assert + Assert.NotNull(measurements); + + Assert.Equal("my-flag", measurements.Tags["feature_flag.key"]); + Assert.Equal("my-provider", measurements.Tags["feature_flag.provider.name"]); + Assert.Equal("STATIC", measurements.Tags["feature_flag.result.reason"]); + Assert.Equal(true, measurements.Tags["bool"]); + } + [Fact] public async Task Error_Test() { From e0fcf273bb745280a099f4cc85716ccb43282bfb Mon Sep 17 00:00:00 2001 From: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> Date: Tue, 8 Jul 2025 20:15:18 +0100 Subject: [PATCH 6/7] Add Custom Dimensions to all Metrics in Metrics hook Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> --- src/OpenFeature/Hooks/MetricsHook.cs | 49 ++-- .../Hooks/MetricsHookTests.cs | 261 +++++++++++++----- 2 files changed, 229 insertions(+), 81 deletions(-) diff --git a/src/OpenFeature/Hooks/MetricsHook.cs b/src/OpenFeature/Hooks/MetricsHook.cs index 87ef41150..6852b47c6 100644 --- a/src/OpenFeature/Hooks/MetricsHook.cs +++ b/src/OpenFeature/Hooks/MetricsHook.cs @@ -19,10 +19,10 @@ public class MetricsHook : Hook private static readonly string InstrumentationVersion = AssemblyName.Version?.ToString() ?? "1.0.0"; private static readonly Meter Meter = new(InstrumentationName, InstrumentationVersion); - private readonly UpDownCounter _evaluationActiveUpDownCounter; - private readonly Counter _evaluationRequestCounter; + internal readonly UpDownCounter _evaluationActiveUpDownCounter; + internal readonly Counter _evaluationRequestCounter; internal readonly Counter _evaluationSuccessCounter; - private readonly Counter _evaluationErrorCounter; + internal readonly Counter _evaluationErrorCounter; private readonly MetricsHookOptions _options; @@ -48,6 +48,8 @@ public override ValueTask BeforeAsync(HookContext conte { TelemetryConstants.Provider, context.ProviderMetadata.Name } }; + this.AddCustomDimensions(ref tagList); + this._evaluationActiveUpDownCounter.Add(1, tagList); this._evaluationRequestCounter.Add(1, tagList); @@ -65,19 +67,8 @@ public override ValueTask AfterAsync(HookContext context, FlagEvaluationDe { TelemetryConstants.Reason, details.Reason ?? Reason.Unknown.ToString() } }; - foreach (var customDimension in this._options.CustomDimensions) - { - tagList.Add(customDimension.Key, customDimension.Value); - } - - var metadata = details.FlagMetadata ?? new ImmutableMetadata(); - foreach (var item in this._options.FlagMetadataCallbacks) - { - var flagMetadataCallback = item.Value; - var value = flagMetadataCallback(metadata); - - tagList.Add(item.Key, value); - } + this.AddCustomDimensions(ref tagList); + this.AddFlagMetadataDimensions(details.FlagMetadata, ref tagList); this._evaluationSuccessCounter.Add(1, tagList); @@ -94,6 +85,8 @@ public override ValueTask ErrorAsync(HookContext context, Exception error, { MetricsConstants.ExceptionAttr, error.Message } }; + this.AddCustomDimensions(ref tagList); + this._evaluationErrorCounter.Add(1, tagList); return base.ErrorAsync(context, error, hints, cancellationToken); @@ -111,8 +104,32 @@ public override ValueTask FinallyAsync(HookContext context, { TelemetryConstants.Provider, context.ProviderMetadata.Name } }; + this.AddCustomDimensions(ref tagList); + this.AddFlagMetadataDimensions(evaluationDetails.FlagMetadata, ref tagList); + this._evaluationActiveUpDownCounter.Add(-1, tagList); return base.FinallyAsync(context, evaluationDetails, hints, cancellationToken); } + + private void AddCustomDimensions(ref TagList tagList) + { + foreach (var customDimension in this._options.CustomDimensions) + { + tagList.Add(customDimension.Key, customDimension.Value); + } + } + + private void AddFlagMetadataDimensions(ImmutableMetadata? flagMetadata, ref TagList tagList) + { + flagMetadata ??= new ImmutableMetadata(); + + foreach (var item in this._options.FlagMetadataCallbacks) + { + var flagMetadataCallback = item.Value; + var value = flagMetadataCallback(flagMetadata); + + tagList.Add(item.Key, value); + } + } } diff --git a/test/OpenFeature.Tests/Hooks/MetricsHookTests.cs b/test/OpenFeature.Tests/Hooks/MetricsHookTests.cs index 0e82b47e1..f1c3be3ad 100644 --- a/test/OpenFeature.Tests/Hooks/MetricsHookTests.cs +++ b/test/OpenFeature.Tests/Hooks/MetricsHookTests.cs @@ -1,63 +1,64 @@ using Microsoft.Extensions.Diagnostics.Metrics.Testing; using OpenFeature.Hooks; using OpenFeature.Model; -using OpenTelemetry; -using OpenTelemetry.Metrics; -using OpenTelemetry.Resources; namespace OpenFeature.Tests.Hooks; [CollectionDefinition(nameof(MetricsHookTest), DisableParallelization = true)] -public class MetricsHookTest : IDisposable +public class MetricsHookTest { - private readonly List _exportedItems; - private readonly MeterProvider _meterProvider; - - public MetricsHookTest() + [Fact] + public async Task After_Test() { - // Arrange metrics collector - this._exportedItems = []; - this._meterProvider = Sdk.CreateMeterProviderBuilder() - .AddMeter("OpenFeature") - .ConfigureResource(r => r.AddService("open-feature")) - .AddInMemoryExporter(this._exportedItems, - option => option.PeriodicExportingMetricReaderOptions = - new PeriodicExportingMetricReaderOptions { ExportIntervalMilliseconds = 100 }) - .Build(); - } + // Arrange + var metricsHook = new MetricsHook(); -#pragma warning disable CA1816 - public void Dispose() - { - this._meterProvider.Shutdown(); + using var collector = new MetricCollector(metricsHook._evaluationSuccessCounter); + + var evaluationContext = EvaluationContext.Empty; + var ctx = new HookContext("my-flag", "foo", Constant.FlagValueType.String, + new ClientMetadata("my-client", "1.0"), new Metadata("my-provider"), evaluationContext); + + // Act + await metricsHook.AfterAsync(ctx, + new FlagEvaluationDetails("my-flag", "foo", Constant.ErrorType.None, "STATIC", "default"), + new Dictionary()).ConfigureAwait(true); + + var measurements = collector.LastMeasurement; + + // Assert + Assert.NotNull(measurements); + + Assert.Equal("my-flag", measurements.Tags["feature_flag.key"]); + Assert.Equal("my-provider", measurements.Tags["feature_flag.provider.name"]); + Assert.Equal("STATIC", measurements.Tags["feature_flag.result.reason"]); } -#pragma warning restore CA1816 [Fact] - public async Task After_Test() + public async Task Without_Reason_After_Test_Defaults_To_Unknown() { // Arrange - const string metricName = "feature_flag.evaluation_success_total"; var metricsHook = new MetricsHook(); + + using var collector = new MetricCollector(metricsHook._evaluationSuccessCounter); + var evaluationContext = EvaluationContext.Empty; var ctx = new HookContext("my-flag", "foo", Constant.FlagValueType.String, new ClientMetadata("my-client", "1.0"), new Metadata("my-provider"), evaluationContext); // Act await metricsHook.AfterAsync(ctx, - new FlagEvaluationDetails("my-flag", "foo", Constant.ErrorType.None, "STATIC", "default"), + new FlagEvaluationDetails("my-flag", "foo", Constant.ErrorType.None, reason: null, "default"), new Dictionary()).ConfigureAwait(true); - this._meterProvider.ForceFlush(); - // Assert metrics - Assert.NotEmpty(this._exportedItems); + var measurements = collector.LastMeasurement; - // check if the metric is present in the exported items - var metric = this._exportedItems.FirstOrDefault(m => m.Name == metricName); - Assert.NotNull(metric); + // Assert + Assert.NotNull(measurements); - var noOtherMetric = this._exportedItems.All(m => m.Name == metricName); - Assert.True(noOtherMetric); + Assert.Equal("my-flag", measurements.Tags["feature_flag.key"]); + Assert.Equal("my-provider", measurements.Tags["feature_flag.provider.name"]); + Assert.Equal("UNKNOWN", measurements.Tags["feature_flag.result.reason"]); } [Fact] @@ -86,6 +87,7 @@ await metricsHook.AfterAsync(ctx, // Assert Assert.NotNull(measurements); + Assert.Equal(1, measurements.Value); Assert.Equal("my-flag", measurements.Tags["feature_flag.key"]); Assert.Equal("my-provider", measurements.Tags["feature_flag.provider.name"]); Assert.Equal("STATIC", measurements.Tags["feature_flag.result.reason"]); @@ -133,33 +135,69 @@ await metricsHook.AfterAsync(ctx, public async Task Error_Test() { // Arrange - const string metricName = "feature_flag.evaluation_error_total"; var metricsHook = new MetricsHook(); + + using var collector = new MetricCollector(metricsHook._evaluationErrorCounter); + + var evaluationContext = EvaluationContext.Empty; + var ctx = new HookContext("my-flag", "foo", Constant.FlagValueType.String, + new ClientMetadata("my-client", "1.0"), new Metadata("my-provider"), evaluationContext); + + var errorMessage = "An error occurred during evaluation"; + + // Act + await metricsHook.ErrorAsync(ctx, new Exception(errorMessage), new Dictionary()).ConfigureAwait(true); + + var measurements = collector.LastMeasurement; + + // Assert + Assert.NotNull(measurements); + + Assert.Equal("my-flag", measurements.Tags["feature_flag.key"]); + Assert.Equal("my-provider", measurements.Tags["feature_flag.provider.name"]); + Assert.Equal(errorMessage, measurements.Tags["exception"]); + } + + [Fact] + public async Task With_CustomDimensions_Error_Test() + { + // Arrange + var metricHookOptions = MetricsHookOptions.CreateBuilder() + .WithCustomDimension("custom_dimension_key", "custom_dimension_value") + .Build(); + + var metricsHook = new MetricsHook(metricHookOptions); + + using var collector = new MetricCollector(metricsHook._evaluationErrorCounter); + var evaluationContext = EvaluationContext.Empty; var ctx = new HookContext("my-flag", "foo", Constant.FlagValueType.String, new ClientMetadata("my-client", "1.0"), new Metadata("my-provider"), evaluationContext); + var errorMessage = "An error occurred during evaluation"; + // Act - await metricsHook.ErrorAsync(ctx, new Exception(), new Dictionary()).ConfigureAwait(true); - this._meterProvider.ForceFlush(); + await metricsHook.ErrorAsync(ctx, new Exception(errorMessage), new Dictionary()).ConfigureAwait(true); - // Assert metrics - Assert.NotEmpty(this._exportedItems); + var measurements = collector.LastMeasurement; - // check if the metric is present in the exported items - var metric = this._exportedItems.FirstOrDefault(m => m.Name == metricName); - Assert.NotNull(metric); + // Assert + Assert.NotNull(measurements); - var noOtherMetric = this._exportedItems.All(m => m.Name == metricName); - Assert.True(noOtherMetric); + Assert.Equal("my-flag", measurements.Tags["feature_flag.key"]); + Assert.Equal("my-provider", measurements.Tags["feature_flag.provider.name"]); + Assert.Equal(errorMessage, measurements.Tags["exception"]); + Assert.Equal("custom_dimension_value", measurements.Tags["custom_dimension_key"]); } [Fact] public async Task Finally_Test() { // Arrange - const string metricName = "feature_flag.evaluation_active_count"; var metricsHook = new MetricsHook(); + + using var collector = new MetricCollector(metricsHook._evaluationActiveUpDownCounter); + var evaluationContext = EvaluationContext.Empty; var ctx = new HookContext("my-flag", "foo", Constant.FlagValueType.String, new ClientMetadata("my-client", "1.0"), new Metadata("my-provider"), evaluationContext); @@ -167,45 +205,138 @@ public async Task Finally_Test() // Act await metricsHook.FinallyAsync(ctx, evaluationDetails, new Dictionary()).ConfigureAwait(true); - this._meterProvider.ForceFlush(); - // Assert metrics - Assert.NotEmpty(this._exportedItems); + var measurements = collector.LastMeasurement; - // check if the metric feature_flag.evaluation_success_total is present in the exported items - var metric = this._exportedItems.FirstOrDefault(m => m.Name == metricName); - Assert.NotNull(metric); + // Assert + Assert.NotNull(measurements); - var noOtherMetric = this._exportedItems.All(m => m.Name == metricName); - Assert.True(noOtherMetric); + Assert.Equal(-1, measurements.Value); + Assert.Equal("my-flag", measurements.Tags["feature_flag.key"]); + Assert.Equal("my-provider", measurements.Tags["feature_flag.provider.name"]); + } + + [Fact] + public async Task With_CustomDimensions_Finally_Test() + { + // Arrange + var metricHookOptions = MetricsHookOptions.CreateBuilder() + .WithCustomDimension("custom_dimension_key", "custom_dimension_value") + .Build(); + + var metricsHook = new MetricsHook(metricHookOptions); + + using var collector = new MetricCollector(metricsHook._evaluationActiveUpDownCounter); + + var evaluationContext = EvaluationContext.Empty; + var ctx = new HookContext("my-flag", "foo", Constant.FlagValueType.String, + new ClientMetadata("my-client", "1.0"), new Metadata("my-provider"), evaluationContext); + var evaluationDetails = new FlagEvaluationDetails("my-flag", "foo", Constant.ErrorType.None, "STATIC", "default"); + + // Act + await metricsHook.FinallyAsync(ctx, evaluationDetails, new Dictionary()).ConfigureAwait(true); + + var measurements = collector.LastMeasurement; + + // Assert + Assert.NotNull(measurements); + + Assert.Equal(-1, measurements.Value); + Assert.Equal("my-flag", measurements.Tags["feature_flag.key"]); + Assert.Equal("my-provider", measurements.Tags["feature_flag.provider.name"]); + Assert.Equal("custom_dimension_value", measurements.Tags["custom_dimension_key"]); + } + + [Fact] + public async Task With_FlagMetadataCallback_Finally_Test() + { + // Arrange + var metricHookOptions = MetricsHookOptions.CreateBuilder() + .WithFlagEvaluationMetadata("status_code", m => m.GetInt("status_code")) + .Build(); + + var metricsHook = new MetricsHook(metricHookOptions); + + using var collector = new MetricCollector(metricsHook._evaluationActiveUpDownCounter); + + var flagMetadata = new ImmutableMetadata(new Dictionary + { + { "status_code", 1521 } + }); + + var evaluationContext = EvaluationContext.Empty; + var ctx = new HookContext("my-flag", "foo", Constant.FlagValueType.String, + new ClientMetadata("my-client", "1.0"), new Metadata("my-provider"), evaluationContext); + var evaluationDetails = new FlagEvaluationDetails("my-flag", "foo", Constant.ErrorType.None, "STATIC", "default", flagMetadata: flagMetadata); + + // Act + await metricsHook.FinallyAsync(ctx, evaluationDetails, new Dictionary()).ConfigureAwait(true); + + var measurements = collector.LastMeasurement; + + // Assert + Assert.NotNull(measurements); + + Assert.Equal(-1, measurements.Value); + Assert.Equal("my-flag", measurements.Tags["feature_flag.key"]); + Assert.Equal("my-provider", measurements.Tags["feature_flag.provider.name"]); + Assert.Equal(1521, measurements.Tags["status_code"]); } [Fact] public async Task Before_Test() { // Arrange - const string metricName1 = "feature_flag.evaluation_active_count"; - const string metricName2 = "feature_flag.evaluation_requests_total"; var metricsHook = new MetricsHook(); + + using var collector1 = new MetricCollector(metricsHook._evaluationActiveUpDownCounter); + using var collector2 = new MetricCollector(metricsHook._evaluationRequestCounter); + var evaluationContext = EvaluationContext.Empty; var ctx = new HookContext("my-flag", "foo", Constant.FlagValueType.String, new ClientMetadata("my-client", "1.0"), new Metadata("my-provider"), evaluationContext); // Act await metricsHook.BeforeAsync(ctx, new Dictionary()).ConfigureAwait(true); - this._meterProvider.ForceFlush(); - // Assert metrics - Assert.NotEmpty(this._exportedItems); + var measurements = collector1.LastMeasurement; - // check if the metric is present in the exported items - var metric1 = this._exportedItems.FirstOrDefault(m => m.Name == metricName1); - Assert.NotNull(metric1); + // Assert + Assert.NotNull(measurements); - var metric2 = this._exportedItems.FirstOrDefault(m => m.Name == metricName2); - Assert.NotNull(metric2); + Assert.Equal(1, measurements.Value); + Assert.Equal("my-flag", measurements.Tags["feature_flag.key"]); + Assert.Equal("my-provider", measurements.Tags["feature_flag.provider.name"]); + } - var noOtherMetric = this._exportedItems.All(m => m.Name == metricName1 || m.Name == metricName2); - Assert.True(noOtherMetric); + [Fact] + public async Task With_CustomDimensions_Before_Test() + { + // Arrange + var metricHookOptions = MetricsHookOptions.CreateBuilder() + .WithCustomDimension("custom_dimension_key", "custom_dimension_value") + .Build(); + + var metricsHook = new MetricsHook(metricHookOptions); + + using var collector1 = new MetricCollector(metricsHook._evaluationActiveUpDownCounter); + using var collector2 = new MetricCollector(metricsHook._evaluationRequestCounter); + + var evaluationContext = EvaluationContext.Empty; + var ctx = new HookContext("my-flag", "foo", Constant.FlagValueType.String, + new ClientMetadata("my-client", "1.0"), new Metadata("my-provider"), evaluationContext); + + // Act + await metricsHook.BeforeAsync(ctx, new Dictionary()).ConfigureAwait(true); + + var measurements = collector1.LastMeasurement; + + // Assert + Assert.NotNull(measurements); + + Assert.Equal(1, measurements.Value); + Assert.Equal("my-flag", measurements.Tags["feature_flag.key"]); + Assert.Equal("my-provider", measurements.Tags["feature_flag.provider.name"]); + Assert.Equal("custom_dimension_value", measurements.Tags["custom_dimension_key"]); } } From a2785010e345f76b8b0868fa87f198af630da50d Mon Sep 17 00:00:00 2001 From: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> Date: Tue, 8 Jul 2025 20:19:27 +0100 Subject: [PATCH 7/7] Tweak README after Hook update Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 7125c73cc..da93d3311 100644 --- a/README.md +++ b/README.md @@ -663,7 +663,7 @@ namespace OpenFeatureTestApp After running this example, you should be able to see some metrics being generated into the console. -You can specify custom dimensions on the `feature_flag.evaluation_success_total` metric by providing `MetricsHookOptions` when adding the hook: +You can specify custom dimensions on all instruments by the `MetricsHook` by providing `MetricsHookOptions` when adding the hook: ```csharp var options = MetricsHookOptions.CreateBuilder()