Skip to content

Commit 8c05d1d

Browse files
feat: Add Metric Hook Custom Attributes (#512)
<!-- Please use this template for your pull request. --> <!-- Please use the sections that you need and delete other sections --> ## This PR <!-- add the description of the PR here --> Adds new `MetricsHookOptions` and `MetricsHookOptionsBuilder` to optionally configure custom attributes that can be tagged on the `feature_flag.evaluation_success_total` metric. Example usage: ```csharp var options = MetricsHookOptions.CreateBuilder() .WithCustomDimension("custom_dimension_key", "custom_dimension_value") .WithFlagEvaluationMetadata("boolean", s => s.GetBool("boolean")) .Build(); OpenFeature.Api.Instance.AddHooks(new MetricsHook(options)); ``` Screenshot below shows the AspNetCore sample application tagging the `feature_flag.evaluation_success_total` counter with the specified dimensions. ![image](https://github.com/user-attachments/assets/e8cda7d8-404a-4d54-96a5-066188d5c18a) ### Related Issues <!-- add here the GitHub issue that this PR resolves if applicable --> Fixes #509 Fixes #514 ### Notes <!-- any additional notes for this PR --> ### Follow-up Tasks <!-- anything that is related to this PR but not done here should be noted under this section --> <!-- if there is a need for a new issue, please link it here --> ### How to test <!-- if applicable, add testing instructions under this section --> --------- Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com>
1 parent 18705c7 commit 8c05d1d

File tree

6 files changed

+503
-69
lines changed

6 files changed

+503
-69
lines changed

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -664,6 +664,24 @@ namespace OpenFeatureTestApp
664664
665665
After running this example, you should be able to see some metrics being generated into the console.
666666
667+
You can specify custom dimensions on all instruments by the `MetricsHook` by providing `MetricsHookOptions` when adding the hook:
668+
669+
```csharp
670+
var options = MetricsHookOptions.CreateBuilder()
671+
.WithCustomDimension("custom_dimension_key", "custom_dimension_value")
672+
.Build();
673+
674+
OpenFeature.Api.Instance.AddHooks(new MetricsHook(options));
675+
```
676+
677+
You can also write your own extraction logic against the Flag metadata by providing a callback to `WithFlagEvaluationMetadata`.
678+
679+
```csharp
680+
var options = MetricsHookOptions.CreateBuilder()
681+
.WithFlagEvaluationMetadata("boolean", s => s.GetBool("boolean"))
682+
.Build();
683+
```
684+
667685
<!-- x-hide-in-docs-start -->
668686
669687
## ⭐️ Support the project

samples/AspNetCore/Program.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,14 @@
2626

2727
builder.Services.AddOpenFeature(featureBuilder =>
2828
{
29+
var metricsHookOptions = MetricsHookOptions.CreateBuilder()
30+
.WithCustomDimension("custom_dimension_key", "custom_dimension_value")
31+
.WithFlagEvaluationMetadata("boolean", s => s.GetBool("boolean"))
32+
.Build();
33+
2934
featureBuilder.AddHostedFeatureLifecycle()
3035
.AddHook(sp => new LoggingHook(sp.GetRequiredService<ILogger<LoggingHook>>()))
31-
.AddHook<MetricsHook>()
36+
.AddHook(_ => new MetricsHook(metricsHookOptions))
3237
.AddHook<TraceEnricherHook>()
3338
.AddInMemoryProvider("InMemory", _ => new Dictionary<string, Flag>()
3439
{

src/OpenFeature/Hooks/MetricsHook.cs

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,20 +19,24 @@ public class MetricsHook : Hook
1919
private static readonly string InstrumentationVersion = AssemblyName.Version?.ToString() ?? "1.0.0";
2020
private static readonly Meter Meter = new(InstrumentationName, InstrumentationVersion);
2121

22-
private readonly UpDownCounter<long> _evaluationActiveUpDownCounter;
23-
private readonly Counter<long> _evaluationRequestCounter;
24-
private readonly Counter<long> _evaluationSuccessCounter;
25-
private readonly Counter<long> _evaluationErrorCounter;
22+
internal readonly UpDownCounter<long> _evaluationActiveUpDownCounter;
23+
internal readonly Counter<long> _evaluationRequestCounter;
24+
internal readonly Counter<long> _evaluationSuccessCounter;
25+
internal readonly Counter<long> _evaluationErrorCounter;
26+
27+
private readonly MetricsHookOptions _options;
2628

2729
/// <summary>
2830
/// Initializes a new instance of the <see cref="MetricsHook"/> class.
2931
/// </summary>
30-
public MetricsHook()
32+
/// <param name="options">Optional configuration for the metrics hook.</param>
33+
public MetricsHook(MetricsHookOptions? options = null)
3134
{
3235
this._evaluationActiveUpDownCounter = Meter.CreateUpDownCounter<long>(MetricsConstants.ActiveCountName, description: MetricsConstants.ActiveDescription);
3336
this._evaluationRequestCounter = Meter.CreateCounter<long>(MetricsConstants.RequestsTotalName, "{request}", MetricsConstants.RequestsDescription);
3437
this._evaluationSuccessCounter = Meter.CreateCounter<long>(MetricsConstants.SuccessTotalName, "{impression}", MetricsConstants.SuccessDescription);
3538
this._evaluationErrorCounter = Meter.CreateCounter<long>(MetricsConstants.ErrorTotalName, description: MetricsConstants.ErrorDescription);
39+
this._options = options ?? MetricsHookOptions.Default;
3640
}
3741

3842
/// <inheritdoc/>
@@ -44,6 +48,8 @@ public override ValueTask<EvaluationContext> BeforeAsync<T>(HookContext<T> conte
4448
{ TelemetryConstants.Provider, context.ProviderMetadata.Name }
4549
};
4650

51+
this.AddCustomDimensions(ref tagList);
52+
4753
this._evaluationActiveUpDownCounter.Add(1, tagList);
4854
this._evaluationRequestCounter.Add(1, tagList);
4955

@@ -61,6 +67,9 @@ public override ValueTask AfterAsync<T>(HookContext<T> context, FlagEvaluationDe
6167
{ TelemetryConstants.Reason, details.Reason ?? Reason.Unknown.ToString() }
6268
};
6369

70+
this.AddCustomDimensions(ref tagList);
71+
this.AddFlagMetadataDimensions(details.FlagMetadata, ref tagList);
72+
6473
this._evaluationSuccessCounter.Add(1, tagList);
6574

6675
return base.AfterAsync(context, details, hints, cancellationToken);
@@ -76,6 +85,8 @@ public override ValueTask ErrorAsync<T>(HookContext<T> context, Exception error,
7685
{ MetricsConstants.ExceptionAttr, error.Message }
7786
};
7887

88+
this.AddCustomDimensions(ref tagList);
89+
7990
this._evaluationErrorCounter.Add(1, tagList);
8091

8192
return base.ErrorAsync(context, error, hints, cancellationToken);
@@ -93,8 +104,32 @@ public override ValueTask FinallyAsync<T>(HookContext<T> context,
93104
{ TelemetryConstants.Provider, context.ProviderMetadata.Name }
94105
};
95106

107+
this.AddCustomDimensions(ref tagList);
108+
this.AddFlagMetadataDimensions(evaluationDetails.FlagMetadata, ref tagList);
109+
96110
this._evaluationActiveUpDownCounter.Add(-1, tagList);
97111

98112
return base.FinallyAsync(context, evaluationDetails, hints, cancellationToken);
99113
}
114+
115+
private void AddCustomDimensions(ref TagList tagList)
116+
{
117+
foreach (var customDimension in this._options.CustomDimensions)
118+
{
119+
tagList.Add(customDimension.Key, customDimension.Value);
120+
}
121+
}
122+
123+
private void AddFlagMetadataDimensions(ImmutableMetadata? flagMetadata, ref TagList tagList)
124+
{
125+
flagMetadata ??= new ImmutableMetadata();
126+
127+
foreach (var item in this._options.FlagMetadataCallbacks)
128+
{
129+
var flagMetadataCallback = item.Value;
130+
var value = flagMetadataCallback(flagMetadata);
131+
132+
tagList.Add(item.Key, value);
133+
}
134+
}
100135
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
using OpenFeature.Model;
2+
3+
namespace OpenFeature.Hooks;
4+
5+
/// <summary>
6+
/// Configuration options for the <see cref="MetricsHook"/>.
7+
/// </summary>
8+
public sealed class MetricsHookOptions
9+
{
10+
/// <summary>
11+
/// The default options for the <see cref="MetricsHook"/>.
12+
/// </summary>
13+
public static MetricsHookOptions Default { get; } = new MetricsHookOptions();
14+
15+
/// <summary>
16+
/// Custom dimensions or tags to be associated with Meters in <see cref="MetricsHook"/>.
17+
/// </summary>
18+
public IReadOnlyCollection<KeyValuePair<string, object?>> CustomDimensions { get; }
19+
20+
/// <summary>
21+
///
22+
/// </summary>
23+
internal IReadOnlyCollection<KeyValuePair<string, Func<ImmutableMetadata, object?>>> FlagMetadataCallbacks { get; }
24+
25+
/// <summary>
26+
/// Initializes a new instance of the <see cref="MetricsHookOptions"/> class with default values.
27+
/// </summary>
28+
private MetricsHookOptions() : this(null, null)
29+
{
30+
}
31+
32+
/// <summary>
33+
/// Initializes a new instance of the <see cref="MetricsHookOptions"/> class.
34+
/// </summary>
35+
/// <param name="customDimensions">Optional custom dimensions to tag Counter increments with.</param>
36+
/// <param name="flagMetadataSelectors"></param>
37+
internal MetricsHookOptions(IReadOnlyCollection<KeyValuePair<string, object?>>? customDimensions = null,
38+
IReadOnlyCollection<KeyValuePair<string, Func<ImmutableMetadata, object?>>>? flagMetadataSelectors = null)
39+
{
40+
this.CustomDimensions = customDimensions ?? [];
41+
this.FlagMetadataCallbacks = flagMetadataSelectors ?? [];
42+
}
43+
44+
/// <summary>
45+
/// Creates a new builder for <see cref="MetricsHookOptions"/>.
46+
/// </summary>
47+
public static MetricsHookOptionsBuilder CreateBuilder() => new MetricsHookOptionsBuilder();
48+
49+
/// <summary>
50+
/// A builder for constructing <see cref="MetricsHookOptions"/> instances.
51+
/// </summary>
52+
public sealed class MetricsHookOptionsBuilder
53+
{
54+
private readonly List<KeyValuePair<string, object?>> _customDimensions = new List<KeyValuePair<string, object?>>();
55+
private readonly List<KeyValuePair<string, Func<ImmutableMetadata, object?>>> _flagMetadataExpressions = new List<KeyValuePair<string, Func<ImmutableMetadata, object?>>>();
56+
57+
/// <summary>
58+
/// Adds a custom dimension.
59+
/// </summary>
60+
/// <param name="key">The key for the custom dimension.</param>
61+
/// <param name="value">The value for the custom dimension.</param>
62+
public MetricsHookOptionsBuilder WithCustomDimension(string key, object? value)
63+
{
64+
this._customDimensions.Add(new KeyValuePair<string, object?>(key, value));
65+
return this;
66+
}
67+
68+
/// <summary>
69+
/// Provide a callback to evaluate flag metadata for a specific flag key.
70+
/// </summary>
71+
/// <param name="key">The key for the custom dimension.</param>
72+
/// <param name="flagMetadataCallback">The callback to retrieve the value to tag successful flag evaluations.</param>
73+
/// <returns></returns>
74+
public MetricsHookOptionsBuilder WithFlagEvaluationMetadata(string key, Func<ImmutableMetadata, object?> flagMetadataCallback)
75+
{
76+
var kvp = new KeyValuePair<string, Func<ImmutableMetadata, object?>>(key, flagMetadataCallback);
77+
78+
this._flagMetadataExpressions.Add(kvp);
79+
80+
return this;
81+
}
82+
83+
/// <summary>
84+
/// Builds the <see cref="MetricsHookOptions"/> instance.
85+
/// </summary>
86+
public MetricsHookOptions Build()
87+
{
88+
return new MetricsHookOptions(this._customDimensions.AsReadOnly(), this._flagMetadataExpressions.AsReadOnly());
89+
}
90+
}
91+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
using OpenFeature.Hooks;
2+
using OpenFeature.Model;
3+
4+
namespace OpenFeature.Tests.Hooks;
5+
6+
public class MetricsHookOptionsTests
7+
{
8+
[Fact]
9+
public void Default_Options_Should_Be_Initialized_Correctly()
10+
{
11+
// Arrange & Act
12+
var options = MetricsHookOptions.Default;
13+
14+
// Assert
15+
Assert.NotNull(options);
16+
Assert.Empty(options.CustomDimensions);
17+
Assert.Empty(options.FlagMetadataCallbacks);
18+
}
19+
20+
[Fact]
21+
public void CreateBuilder_Should_Return_New_Builder_Instance()
22+
{
23+
// Arrange & Act
24+
var builder = MetricsHookOptions.CreateBuilder();
25+
26+
// Assert
27+
Assert.NotNull(builder);
28+
Assert.IsType<MetricsHookOptions.MetricsHookOptionsBuilder>(builder);
29+
}
30+
31+
[Fact]
32+
public void Build_Should_Return_Options()
33+
{
34+
// Arrange
35+
var builder = MetricsHookOptions.CreateBuilder();
36+
37+
// Act
38+
var options = builder.Build();
39+
40+
// Assert
41+
Assert.NotNull(options);
42+
Assert.IsType<MetricsHookOptions>(options);
43+
}
44+
45+
[Theory]
46+
[InlineData("custom_dimension_value")]
47+
[InlineData(1.0)]
48+
[InlineData(2025)]
49+
[InlineData(null)]
50+
[InlineData(true)]
51+
public void Builder_Should_Allow_Adding_Custom_Dimensions(object? value)
52+
{
53+
// Arrange
54+
var builder = MetricsHookOptions.CreateBuilder();
55+
var key = "custom_dimension_key";
56+
57+
// Act
58+
builder.WithCustomDimension(key, value);
59+
var options = builder.Build();
60+
61+
// Assert
62+
Assert.Single(options.CustomDimensions);
63+
Assert.Equal(key, options.CustomDimensions.First().Key);
64+
Assert.Equal(value, options.CustomDimensions.First().Value);
65+
}
66+
67+
[Fact]
68+
public void Builder_Should_Allow_Adding_Flag_Metadata_Expressions()
69+
{
70+
// Arrange
71+
var builder = MetricsHookOptions.CreateBuilder();
72+
var key = "flag_metadata_key";
73+
static object? expression(ImmutableMetadata m) => m.GetString("flag_metadata_key");
74+
75+
// Act
76+
builder.WithFlagEvaluationMetadata(key, expression);
77+
var options = builder.Build();
78+
79+
// Assert
80+
Assert.Single(options.FlagMetadataCallbacks);
81+
Assert.Equal(key, options.FlagMetadataCallbacks.First().Key);
82+
Assert.Equal(expression, options.FlagMetadataCallbacks.First().Value);
83+
}
84+
}

0 commit comments

Comments
 (0)