diff --git a/README.md b/README.md index 66a3d620c..da93d3311 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 all instruments by the `MetricsHook` by providing `MetricsHookOptions` when adding the hook: + +```csharp +var options = MetricsHookOptions.CreateBuilder() + .WithCustomDimension("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..e09213076 100644 --- a/samples/AspNetCore/Program.cs +++ b/samples/AspNetCore/Program.cs @@ -26,9 +26,14 @@ builder.Services.AddOpenFeature(featureBuilder => { + var metricsHookOptions = MetricsHookOptions.CreateBuilder() + .WithCustomDimension("custom_dimension_key", "custom_dimension_value") + .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 2f2314f0d..6852b47c6 100644 --- a/src/OpenFeature/Hooks/MetricsHook.cs +++ b/src/OpenFeature/Hooks/MetricsHook.cs @@ -19,20 +19,24 @@ 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; - private readonly Counter _evaluationSuccessCounter; - private readonly Counter _evaluationErrorCounter; + internal readonly UpDownCounter _evaluationActiveUpDownCounter; + internal readonly Counter _evaluationRequestCounter; + internal readonly Counter _evaluationSuccessCounter; + internal 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; } /// @@ -44,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); @@ -61,6 +67,9 @@ public override ValueTask AfterAsync(HookContext context, FlagEvaluationDe { TelemetryConstants.Reason, details.Reason ?? Reason.Unknown.ToString() } }; + this.AddCustomDimensions(ref tagList); + this.AddFlagMetadataDimensions(details.FlagMetadata, ref tagList); + this._evaluationSuccessCounter.Add(1, tagList); return base.AfterAsync(context, details, hints, cancellationToken); @@ -76,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); @@ -93,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/src/OpenFeature/Hooks/MetricsHookOptions.cs b/src/OpenFeature/Hooks/MetricsHookOptions.cs new file mode 100644 index 000000000..553431496 --- /dev/null +++ b/src/OpenFeature/Hooks/MetricsHookOptions.cs @@ -0,0 +1,91 @@ +using OpenFeature.Model; + +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; } + + /// + /// + /// + internal IReadOnlyCollection>> FlagMetadataCallbacks { 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. + /// + internal MetricsHookOptions(IReadOnlyCollection>? customDimensions = null, + IReadOnlyCollection>>? flagMetadataSelectors = null) + { + this.CustomDimensions = customDimensions ?? []; + this.FlagMetadataCallbacks = flagMetadataSelectors ?? []; + } + + /// + /// 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>(); + private readonly List>> _flagMetadataExpressions = 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; + } + + /// + /// 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, Func flagMetadataCallback) + { + var kvp = new KeyValuePair>(key, flagMetadataCallback); + + this._flagMetadataExpressions.Add(kvp); + + return this; + } + + /// + /// Builds the instance. + /// + public MetricsHookOptions Build() + { + return new MetricsHookOptions(this._customDimensions.AsReadOnly(), this._flagMetadataExpressions.AsReadOnly()); + } + } +} 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..f1c3be3ad 100644 --- a/test/OpenFeature.Tests/Hooks/MetricsHookTests.cs +++ b/test/OpenFeature.Tests/Hooks/MetricsHookTests.cs @@ -1,43 +1,78 @@ +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(); + + 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 disable CA1816 - public void Dispose() + [Fact] + public async Task Without_Reason_After_Test_Defaults_To_Unknown() { - this._meterProvider.Shutdown(); + // Arrange + 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, reason: null, "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("UNKNOWN", measurements.Tags["feature_flag.result.reason"]); } -#pragma warning restore CA1816 [Fact] - public async Task After_Test() + public async Task With_CustomDimensions_After_Test() { // Arrange - const string metricName = "feature_flag.evaluation_success_total"; - var metricsHook = new MetricsHook(); + 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); @@ -46,50 +81,123 @@ public async Task After_Test() await metricsHook.AfterAsync(ctx, new FlagEvaluationDetails("my-flag", "foo", Constant.ErrorType.None, "STATIC", "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(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"]); + 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() { // 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); @@ -97,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; + + // 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"]); + } + + [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; - // 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"]); + 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; + + // 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"]); + } + + [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); - // check if the metric is present in the exported items - var metric1 = this._exportedItems.FirstOrDefault(m => m.Name == metricName1); - Assert.NotNull(metric1); + var measurements = collector1.LastMeasurement; - var metric2 = this._exportedItems.FirstOrDefault(m => m.Name == metricName2); - Assert.NotNull(metric2); + // Assert + Assert.NotNull(measurements); - var noOtherMetric = this._exportedItems.All(m => m.Name == metricName1 || m.Name == metricName2); - 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"]); + Assert.Equal("custom_dimension_value", measurements.Tags["custom_dimension_key"]); } }