diff --git a/.github/component_owners.yml b/.github/component_owners.yml
index db652298..3bb488e5 100644
--- a/.github/component_owners.yml
+++ b/.github/component_owners.yml
@@ -4,6 +4,7 @@ components:
src/OpenFeature.Contrib.Hooks.Otel:
- bacherfl
- toddbaert
+ - askpt
src/OpenFeature.Contrib.Providers.Flagd:
- bacherfl
- toddbaert
@@ -17,6 +18,7 @@ components:
test/OpenFeature.Contrib.Hooks.Otel.Test:
- bacherfl
- toddbaert
+ - askpt
test/OpenFeature.Contrib.Providers.Flagd.Test:
- bacherfl
- toddbaert
@@ -27,4 +29,4 @@ components:
- matthewelwell
ignored-authors:
- - renovate-bot
\ No newline at end of file
+ - renovate-bot
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 00000000..79fae382
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,3 @@
+{
+ "dotnet.defaultSolution": "DotnetSdkContrib.sln"
+}
diff --git a/src/OpenFeature.Contrib.Hooks.Otel/MetricsConstants.cs b/src/OpenFeature.Contrib.Hooks.Otel/MetricsConstants.cs
new file mode 100644
index 00000000..dcbffe99
--- /dev/null
+++ b/src/OpenFeature.Contrib.Hooks.Otel/MetricsConstants.cs
@@ -0,0 +1,21 @@
+namespace OpenFeature.Contrib.Hooks.Otel
+{
+ internal static class MetricsConstants
+ {
+ internal const string ActiveCountName = "feature_flag.evaluation_active_count";
+ internal const string RequestsTotalName = "feature_flag.evaluation_requests_total";
+ internal const string SuccessTotalName = "feature_flag.evaluation_success_total";
+ internal const string ErrorTotalName = "feature_flag.evaluation_error_total";
+
+ internal const string ActiveDescription = "active flag evaluations counter";
+ internal const string RequestsDescription = "feature flag evaluation request counter";
+ internal const string SuccessDescription = "feature flag evaluation success counter";
+ internal const string ErrorDescription = "feature flag evaluation error counter";
+
+ internal const string KeyAttr = "key";
+ internal const string ProviderNameAttr = "provider_name";
+ internal const string VariantAttr = "variant";
+ internal const string ReasonAttr = "reason";
+ internal const string ExceptionAttr = "exception";
+ }
+}
diff --git a/src/OpenFeature.Contrib.Hooks.Otel/MetricsHook.cs b/src/OpenFeature.Contrib.Hooks.Otel/MetricsHook.cs
new file mode 100644
index 00000000..f5fd56f9
--- /dev/null
+++ b/src/OpenFeature.Contrib.Hooks.Otel/MetricsHook.cs
@@ -0,0 +1,134 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Diagnostics.Metrics;
+using System.Reflection;
+using System.Threading.Tasks;
+using OpenFeature.Model;
+
+namespace OpenFeature.Contrib.Hooks.Otel
+{
+ ///
+ /// Represents a hook for capturing metrics related to flag evaluations.
+ /// The meter name is "OpenFeature.Contrib.Hooks.Otel".
+ ///
+ public class MetricsHook : Hook
+ {
+ private static readonly AssemblyName AssemblyName = typeof(MetricsHook).Assembly.GetName();
+ private static readonly string InstrumentationName = AssemblyName.Name;
+ private static readonly string InstrumentationVersion = AssemblyName.Version?.ToString();
+
+ private readonly UpDownCounter _evaluationActiveUpDownCounter;
+ private readonly Counter _evaluationRequestCounter;
+ private readonly Counter _evaluationSuccessCounter;
+ private readonly Counter _evaluationErrorCounter;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public MetricsHook()
+ {
+ var meter = new Meter(InstrumentationName, InstrumentationVersion);
+
+ _evaluationActiveUpDownCounter = meter.CreateUpDownCounter(MetricsConstants.ActiveCountName, description: MetricsConstants.ActiveDescription);
+ _evaluationRequestCounter = meter.CreateCounter(MetricsConstants.RequestsTotalName, "{request}", MetricsConstants.RequestsDescription);
+ _evaluationSuccessCounter = meter.CreateCounter(MetricsConstants.SuccessTotalName, "{impression}", MetricsConstants.SuccessDescription);
+ _evaluationErrorCounter = meter.CreateCounter(MetricsConstants.ErrorTotalName, description: MetricsConstants.ErrorDescription);
+ }
+
+ ///
+ /// Executes before the flag evaluation and captures metrics related to the evaluation.
+ /// The metrics are captured in the following order:
+ /// 1. The active count is incremented. (feature_flag.evaluation_active_count)
+ /// 2. The request count is incremented. (feature_flag.evaluation_requests_total)
+ ///
+ /// The type of the flag value.
+ /// The hook context.
+ /// The optional hints.
+ /// The evaluation context.
+ public override Task Before(HookContext context, IReadOnlyDictionary hints = null)
+ {
+ var tagList = new TagList
+ {
+ { MetricsConstants.KeyAttr, context.FlagKey },
+ { MetricsConstants.ProviderNameAttr, context.ProviderMetadata.Name }
+ };
+
+ _evaluationActiveUpDownCounter.Add(1, tagList);
+ _evaluationRequestCounter.Add(1, tagList);
+
+ return base.Before(context, hints);
+ }
+
+
+ ///
+ /// Executes after the flag evaluation and captures metrics related to the evaluation.
+ /// The metrics are captured in the following order:
+ /// 1. The success count is incremented. (feature_flag.evaluation_success_total)
+ ///
+ /// The type of the flag value.
+ /// The hook context.
+ /// The flag evaluation details.
+ /// The optional hints.
+ /// The evaluation context.
+ public override Task After(HookContext context, FlagEvaluationDetails details, IReadOnlyDictionary hints = null)
+ {
+ var tagList = new TagList
+ {
+ { MetricsConstants.KeyAttr, context.FlagKey },
+ { MetricsConstants.ProviderNameAttr, context.ProviderMetadata.Name },
+ { MetricsConstants.VariantAttr, details.Variant ?? details.Value?.ToString() },
+ { MetricsConstants.ReasonAttr, details.Reason ?? "UNKNOWN" }
+ };
+
+ _evaluationSuccessCounter.Add(1, tagList);
+
+ return base.After(context, details, hints);
+ }
+
+ ///
+ /// Executes when an error occurs during flag evaluation and captures metrics related to the error.
+ /// The metrics are captured in the following order:
+ /// 1. The error count is incremented. (feature_flag.evaluation_error_total)
+ ///
+ /// The type of the flag value.
+ /// The hook context.
+ /// The exception that occurred.
+ /// The optional hints.
+ /// The evaluation context.
+ public override Task Error(HookContext context, Exception error, IReadOnlyDictionary hints = null)
+ {
+ var tagList = new TagList
+ {
+ { MetricsConstants.KeyAttr, context.FlagKey },
+ { MetricsConstants.ProviderNameAttr, context.ProviderMetadata.Name },
+ { MetricsConstants.ExceptionAttr, error?.Message ?? "Unknown error" }
+ };
+
+ _evaluationErrorCounter.Add(1, tagList);
+
+ return base.Error(context, error, hints);
+ }
+
+ ///
+ /// Executes after the flag evaluation is complete and captures metrics related to the evaluation.
+ /// The active count is decremented. (feature_flag.evaluation_active_count)
+ ///
+ /// The type of the flag value.
+ /// The hook context.
+ /// The optional hints.
+ /// The evaluation context.
+ public override Task Finally(HookContext context, IReadOnlyDictionary hints = null)
+ {
+ var tagList = new TagList
+ {
+ { MetricsConstants.KeyAttr, context.FlagKey },
+ { MetricsConstants.ProviderNameAttr, context.ProviderMetadata.Name }
+ };
+
+ _evaluationActiveUpDownCounter.Add(-1, tagList);
+
+ return base.Finally(context, hints);
+ }
+ }
+}
diff --git a/src/OpenFeature.Contrib.Hooks.Otel/README.md b/src/OpenFeature.Contrib.Hooks.Otel/README.md
index d4e6a784..c15b5806 100644
--- a/src/OpenFeature.Contrib.Hooks.Otel/README.md
+++ b/src/OpenFeature.Contrib.Hooks.Otel/README.md
@@ -4,7 +4,7 @@
- open-feature/dotnet-sdk >= v1.0
-## Usage
+## Usage - Traces
For this hook to function correctly a global `TracerProvider` must be set, an example of how to do this can be found below.
@@ -32,7 +32,7 @@ namespace OpenFeatureTestApp
var tracerProvider = Sdk.CreateTracerProviderBuilder()
.AddSource("my-tracer")
.ConfigureResource(r => r.AddService("jaeger-test"))
- .AddOtlpExporter(o =>
+ .AddOtlpExporter(o =>
{
o.ExportProcessorType = ExportProcessorType.Simple;
})
@@ -65,6 +65,66 @@ In case something went wrong during a feature flag evaluation, you will see an e
![](./assets/otlp-error.png)
+## Usage - Metrics
+
+For this hook to function correctly a global `MeterProvider` must be set.
+`MetricsHook` performs metric collection by tapping into various hook stages.
+
+Below are the metrics extracted by this hook and dimensions they carry:
+
+| Metric key | Description | Unit | Dimensions |
+| -------------------------------------- | ------------------------------- | ------------ | ----------------------------------- |
+| feature_flag.evaluation_requests_total | Number of evaluation requests | {request} | key, provider name |
+| feature_flag.evaluation_success_total | Flag evaluation successes | {impression} | key, provider name, reason, variant |
+| feature_flag.evaluation_error_total | Flag evaluation errors | Counter | key, provider name |
+| feature_flag.evaluation_active_count | Active flag evaluations counter | Counter | key |
+
+Consider the following code example for usage.
+
+### Example
+
+The following example demonstrates the use of the `OpenTelemetry hook` with the `OpenFeature dotnet-sdk`. The metrics are sent to the `console`.
+
+```csharp
+using OpenFeature.Contrib.Providers.Flagd;
+using OpenFeature;
+using OpenFeature.Contrib.Hooks.Otel;
+using OpenTelemetry;
+using OpenTelemetry.Metrics;
+
+namespace OpenFeatureTestApp
+{
+ class Hello {
+ static void Main(string[] args) {
+
+ // set up the OpenTelemetry OTLP exporter
+ var meterProvider = Sdk.CreateMeterProviderBuilder()
+ .AddMeter("OpenFeature.Contrib.Hooks.Otel")
+ .ConfigureResource(r => r.AddService("openfeature-test"))
+ .AddConsoleExporter()
+ .Build();
+
+ // add the Otel Hook to the OpenFeature instance
+ OpenFeature.Api.Instance.AddHooks(new MetricsHook());
+
+ var flagdProvider = new FlagdProvider(new Uri("http://localhost:8013"));
+
+ // Set the flagdProvider as the provider for the OpenFeature SDK
+ OpenFeature.Api.Instance.SetProvider(flagdProvider);
+
+ var client = OpenFeature.Api.Instance.GetClient("my-app");
+
+ var val = client.GetBooleanValue("myBoolFlag", false, null);
+
+ // Print the value of the 'myBoolFlag' feature flag
+ System.Console.WriteLine(val.Result.ToString());
+ }
+ }
+}
+```
+
+After running this example, you should be able to see some metrics being generated into the console.
+
## License
Apache 2.0 - See [LICENSE](./../../LICENSE) for more information.
diff --git a/test/OpenFeature.Contrib.Hooks.Otel.Test/MetricsHookTest.cs b/test/OpenFeature.Contrib.Hooks.Otel.Test/MetricsHookTest.cs
new file mode 100644
index 00000000..ffc83b2c
--- /dev/null
+++ b/test/OpenFeature.Contrib.Hooks.Otel.Test/MetricsHookTest.cs
@@ -0,0 +1,163 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using OpenFeature.Model;
+using OpenTelemetry;
+using OpenTelemetry.Metrics;
+using OpenTelemetry.Resources;
+using Xunit;
+
+namespace OpenFeature.Contrib.Hooks.Otel.Test
+{
+ public class MetricsHookTest
+ {
+ [Fact]
+ public void After_Test()
+ {
+ // Arrange metrics collector
+ var exportedItems = new List();
+ Sdk.CreateMeterProviderBuilder()
+ .AddMeter("*")
+ .ConfigureResource(r => r.AddService("openfeature"))
+ .AddInMemoryExporter(exportedItems, option => option.PeriodicExportingMetricReaderOptions = new PeriodicExportingMetricReaderOptions { ExportIntervalMilliseconds = 100 })
+ .Build();
+
+ // Arrange
+ const string metricName = "feature_flag.evaluation_success_total";
+ var otelHook = new MetricsHook();
+ 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
+ var hookTask = otelHook.After(ctx, new FlagEvaluationDetails("my-flag", "foo", Constant.ErrorType.None, "STATIC", "default"), new Dictionary());
+ // Wait for the metrics to be exported
+ Thread.Sleep(150);
+
+ // Assert
+ Assert.True(hookTask.IsCompleted);
+
+ // Assert metrics
+ Assert.NotEmpty(exportedItems);
+
+ // check if the metric is present in the exported items
+ var metric = exportedItems.FirstOrDefault(m => m.Name == metricName);
+ Assert.NotNull(metric);
+
+ var noOtherMetric = exportedItems.All(m => m.Name == metricName);
+ Assert.True(noOtherMetric);
+ }
+
+ [Fact]
+ public void Error_Test()
+ {
+ // Arrange metrics collector
+ var exportedItems = new List();
+ Sdk.CreateMeterProviderBuilder()
+ .AddMeter("*")
+ .ConfigureResource(r => r.AddService("openfeature"))
+ .AddInMemoryExporter(exportedItems, option => option.PeriodicExportingMetricReaderOptions = new PeriodicExportingMetricReaderOptions { ExportIntervalMilliseconds = 100 })
+ .Build();
+
+ // Arrange
+ const string metricName = "feature_flag.evaluation_error_total";
+ var otelHook = new MetricsHook();
+ 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
+ var hookTask = otelHook.Error(ctx, new Exception(), new Dictionary());
+ // Wait for the metrics to be exported
+ Thread.Sleep(150);
+
+ // Assert
+ Assert.True(hookTask.IsCompleted);
+
+ // Assert metrics
+ Assert.NotEmpty(exportedItems);
+
+ // check if the metric is present in the exported items
+ var metric = exportedItems.FirstOrDefault(m => m.Name == metricName);
+ Assert.NotNull(metric);
+
+ var noOtherMetric = exportedItems.All(m => m.Name == metricName);
+ Assert.True(noOtherMetric);
+ }
+
+ [Fact]
+ public void Finally_Test()
+ {
+ // Arrange metrics collector
+ var exportedItems = new List();
+ Sdk.CreateMeterProviderBuilder()
+ .AddMeter("*")
+ .ConfigureResource(r => r.AddService("openfeature"))
+ .AddInMemoryExporter(exportedItems, option => option.PeriodicExportingMetricReaderOptions = new PeriodicExportingMetricReaderOptions { ExportIntervalMilliseconds = 100 })
+ .Build();
+
+ // Arrange
+ const string metricName = "feature_flag.evaluation_active_count";
+ var otelHook = new MetricsHook();
+ 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
+ var hookTask = otelHook.Finally(ctx, new Dictionary());
+ // Wait for the metrics to be exported
+ Thread.Sleep(150);
+
+ // Assert
+ Assert.True(hookTask.IsCompleted);
+
+ // Assert metrics
+ Assert.NotEmpty(exportedItems);
+
+ // check if the metric feature_flag.evaluation_success_total is present in the exported items
+ var metric = exportedItems.FirstOrDefault(m => m.Name == metricName);
+ Assert.NotNull(metric);
+
+ var noOtherMetric = exportedItems.All(m => m.Name == metricName);
+ Assert.True(noOtherMetric);
+ }
+
+ [Fact]
+ public void Before_Test()
+ {
+ // Arrange metrics collector
+ var exportedItems = new List();
+ Sdk.CreateMeterProviderBuilder()
+ .AddMeter("*")
+ .ConfigureResource(r => r.AddService("openfeature"))
+ .AddInMemoryExporter(exportedItems, option => option.PeriodicExportingMetricReaderOptions = new PeriodicExportingMetricReaderOptions { ExportIntervalMilliseconds = 100 })
+ .Build();
+
+ // Arrange
+ const string metricName1 = "feature_flag.evaluation_active_count";
+ const string metricName2 = "feature_flag.evaluation_requests_total";
+ var otelHook = new MetricsHook();
+ 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
+ var hookTask = otelHook.Before(ctx, new Dictionary());
+ // Wait for the metrics to be exported
+ Thread.Sleep(150);
+
+ // Assert
+ Assert.True(hookTask.IsCompleted);
+
+ // Assert metrics
+ Assert.NotEmpty(exportedItems);
+
+ // check if the metric is present in the exported items
+ var metric1 = exportedItems.FirstOrDefault(m => m.Name == metricName1);
+ Assert.NotNull(metric1);
+
+ var metric2 = exportedItems.FirstOrDefault(m => m.Name == metricName2);
+ Assert.NotNull(metric2);
+
+ var noOtherMetric = exportedItems.All(m => m.Name == metricName1 || m.Name == metricName2);
+ Assert.True(noOtherMetric);
+ }
+ }
+}