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); + } + } +}