Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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();
```

<!-- x-hide-in-docs-start -->

## ⭐️ Support the project
Expand Down
7 changes: 6 additions & 1 deletion samples/AspNetCore/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ILogger<LoggingHook>>()))
.AddHook<MetricsHook>()
.AddHook(_ => new MetricsHook(metricsHookOptions))
.AddHook<TraceEnricherHook>()
.AddInMemoryProvider("InMemory", _ => new Dictionary<string, Flag>()
{
Expand Down
45 changes: 40 additions & 5 deletions src/OpenFeature/Hooks/MetricsHook.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<long> _evaluationActiveUpDownCounter;
private readonly Counter<long> _evaluationRequestCounter;
private readonly Counter<long> _evaluationSuccessCounter;
private readonly Counter<long> _evaluationErrorCounter;
internal readonly UpDownCounter<long> _evaluationActiveUpDownCounter;
internal readonly Counter<long> _evaluationRequestCounter;
internal readonly Counter<long> _evaluationSuccessCounter;
internal readonly Counter<long> _evaluationErrorCounter;

private readonly MetricsHookOptions _options;

/// <summary>
/// Initializes a new instance of the <see cref="MetricsHook"/> class.
/// </summary>
public MetricsHook()
/// <param name="options">Optional configuration for the metrics hook.</param>
public MetricsHook(MetricsHookOptions? options = null)
{
this._evaluationActiveUpDownCounter = Meter.CreateUpDownCounter<long>(MetricsConstants.ActiveCountName, description: MetricsConstants.ActiveDescription);
this._evaluationRequestCounter = Meter.CreateCounter<long>(MetricsConstants.RequestsTotalName, "{request}", MetricsConstants.RequestsDescription);
this._evaluationSuccessCounter = Meter.CreateCounter<long>(MetricsConstants.SuccessTotalName, "{impression}", MetricsConstants.SuccessDescription);
this._evaluationErrorCounter = Meter.CreateCounter<long>(MetricsConstants.ErrorTotalName, description: MetricsConstants.ErrorDescription);
this._options = options ?? MetricsHookOptions.Default;
}

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

this.AddCustomDimensions(ref tagList);

this._evaluationActiveUpDownCounter.Add(1, tagList);
this._evaluationRequestCounter.Add(1, tagList);

Expand All @@ -61,6 +67,9 @@ public override ValueTask AfterAsync<T>(HookContext<T> 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);
Expand All @@ -76,6 +85,8 @@ public override ValueTask ErrorAsync<T>(HookContext<T> context, Exception error,
{ MetricsConstants.ExceptionAttr, error.Message }
};

this.AddCustomDimensions(ref tagList);

this._evaluationErrorCounter.Add(1, tagList);

return base.ErrorAsync(context, error, hints, cancellationToken);
Expand All @@ -93,8 +104,32 @@ public override ValueTask FinallyAsync<T>(HookContext<T> 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);
}
}
}
91 changes: 91 additions & 0 deletions src/OpenFeature/Hooks/MetricsHookOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
using OpenFeature.Model;

namespace OpenFeature.Hooks;

/// <summary>
/// Configuration options for the <see cref="MetricsHook"/>.
/// </summary>
public sealed class MetricsHookOptions
{
/// <summary>
/// The default options for the <see cref="MetricsHook"/>.
/// </summary>
public static MetricsHookOptions Default { get; } = new MetricsHookOptions();

/// <summary>
/// Custom dimensions or tags to be associated with Meters in <see cref="MetricsHook"/>.
/// </summary>
public IReadOnlyCollection<KeyValuePair<string, object?>> CustomDimensions { get; }

/// <summary>
///
/// </summary>
internal IReadOnlyCollection<KeyValuePair<string, Func<ImmutableMetadata, object?>>> FlagMetadataCallbacks { get; }

/// <summary>
/// Initializes a new instance of the <see cref="MetricsHookOptions"/> class with default values.
/// </summary>
private MetricsHookOptions() : this(null, null)
{
}

/// <summary>
/// Initializes a new instance of the <see cref="MetricsHookOptions"/> class.
/// </summary>
/// <param name="customDimensions">Optional custom dimensions to tag Counter increments with.</param>
/// <param name="flagMetadataSelectors"></param>
internal MetricsHookOptions(IReadOnlyCollection<KeyValuePair<string, object?>>? customDimensions = null,
IReadOnlyCollection<KeyValuePair<string, Func<ImmutableMetadata, object?>>>? flagMetadataSelectors = null)
{
this.CustomDimensions = customDimensions ?? [];
this.FlagMetadataCallbacks = flagMetadataSelectors ?? [];
}

/// <summary>
/// Creates a new builder for <see cref="MetricsHookOptions"/>.
/// </summary>
public static MetricsHookOptionsBuilder CreateBuilder() => new MetricsHookOptionsBuilder();

/// <summary>
/// A builder for constructing <see cref="MetricsHookOptions"/> instances.
/// </summary>
public sealed class MetricsHookOptionsBuilder
{
private readonly List<KeyValuePair<string, object?>> _customDimensions = new List<KeyValuePair<string, object?>>();
private readonly List<KeyValuePair<string, Func<ImmutableMetadata, object?>>> _flagMetadataExpressions = new List<KeyValuePair<string, Func<ImmutableMetadata, object?>>>();

/// <summary>
/// Adds a custom dimension.
/// </summary>
/// <param name="key">The key for the custom dimension.</param>
/// <param name="value">The value for the custom dimension.</param>
public MetricsHookOptionsBuilder WithCustomDimension(string key, object? value)
{
this._customDimensions.Add(new KeyValuePair<string, object?>(key, value));
return this;
}

/// <summary>
/// Provide a callback to evaluate flag metadata for a specific flag key.
/// </summary>
/// <param name="key">The key for the custom dimension.</param>
/// <param name="flagMetadataCallback">The callback to retrieve the value to tag successful flag evaluations.</param>
/// <returns></returns>
public MetricsHookOptionsBuilder WithFlagEvaluationMetadata(string key, Func<ImmutableMetadata, object?> flagMetadataCallback)
{
var kvp = new KeyValuePair<string, Func<ImmutableMetadata, object?>>(key, flagMetadataCallback);

this._flagMetadataExpressions.Add(kvp);

return this;
}

/// <summary>
/// Builds the <see cref="MetricsHookOptions"/> instance.
/// </summary>
public MetricsHookOptions Build()
{
return new MetricsHookOptions(this._customDimensions.AsReadOnly(), this._flagMetadataExpressions.AsReadOnly());
}
}
}
84 changes: 84 additions & 0 deletions test/OpenFeature.Tests/Hooks/MetricsHookOptionsTests.cs
Original file line number Diff line number Diff line change
@@ -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<MetricsHookOptions.MetricsHookOptionsBuilder>(builder);
}

[Fact]
public void Build_Should_Return_Options()
{
// Arrange
var builder = MetricsHookOptions.CreateBuilder();

// Act
var options = builder.Build();

// Assert
Assert.NotNull(options);
Assert.IsType<MetricsHookOptions>(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);
}
}
Loading