Skip to content

Commit 77f6e1b

Browse files
authored
feat: Move OTEL hooks to the SDK (#338)
<!-- Please use this template for your pull request. --> <!-- Please use the sections that you need and delete other sections --> ## Move OTEL hooks to the SDK <!-- add the description of the PR here --> This pull request introduces telemetry enhancements to the OpenFeature .NET SDK by adding new hooks for tracing and metrics, updating dependencies, and providing examples and tests. The most significant changes include the addition of the `TraceEnricherHook` and `MetricsHook` classes, integration of OpenTelemetry in the ASP.NET Core sample, and updates to dependencies to support telemetry features. ### Telemetry Enhancements * **Trace Enricher Hook**: Added `TraceEnricherHook` to enrich telemetry traces with feature flag evaluation details, including tags and events for tracing purposes. This hook integrates with the current `Activity` and supports error handling. * **Metrics Hook**: Introduced `MetricsHook` for capturing metrics such as evaluation requests, successes, errors, and active evaluations. Metrics are collected using OpenTelemetry's `Meter` API. [[1]](diffhunk://#diff-912b71a06f9a65012af403b04269f68f3cb9ee580d0dd62e1d6afc99bd433d31R1-R100) [[2]](diffhunk://#diff-9c7590e55694b19483ea6d802e6a1bb34418366a8f66d6f35b57ae81adb5bf17R1-R16) ### Dependency Updates * **OpenTelemetry Dependencies**: Added `OpenTelemetry.Extensions.Hosting`, `OpenTelemetry.Instrumentation.AspNetCore`, and `OpenTelemetry.Exporter.OpenTelemetryProtocol` to the ASP.NET Core sample project to support telemetry features. [[1]](diffhunk://#diff-40299025547d304b834a53acda1eb9a8a6f49f2ec679b6bcebd3449211497c56R3-R18) [[2]](diffhunk://#diff-711ea17cbdebe419375c7684c8c39a1423d2bebcf8976ddd7bdd78deaab65b21R12) * **DiagnosticSource Dependency**: Included `System.Diagnostics.DiagnosticSource` in the main project and centralized dependency management. [[1]](diffhunk://#diff-5baf5f9e448ad54ab25a091adee0da05d4d228481c9200518fcb1b53a65d4156R19) [[2]](diffhunk://#diff-5baf5f9e448ad54ab25a091adee0da05d4d228481c9200518fcb1b53a65d4156R33-R34) ### Documentation and Examples * **README Updates**: Added documentation for `TraceEnricherHook` and `MetricsHook`, including detailed descriptions, examples, and usage instructions for integrating these hooks with OpenTelemetry. * **ASP.NET Core Sample**: Updated the sample application to demonstrate the use of `TraceEnricherHook` and `MetricsHook` with OpenTelemetry tracing and metrics. [[1]](diffhunk://#diff-41550b31d77b5898b38a3280f8ffbc5d2531fc4d4884079ebf3c5e953a85075dR6-R32) [[2]](diffhunk://#diff-40299025547d304b834a53acda1eb9a8a6f49f2ec679b6bcebd3449211497c56R3-R18) ### Testing * **MetricsHook Tests**: Added unit tests for `MetricsHook` to verify metrics collection during different stages of feature flag evaluation (e.g., `BeforeAsync`, `AfterAsync`, `ErrorAsync`, `FinallyAsync`). ### Related Issues <!-- add here the GitHub issue that this PR resolves if applicable --> Fixes #175 ### Notes * In this PR I made some changes to the `samples` application. This allow us to see the metrics and traces in any OTEL tool. Check the screenshots below: ![Screenshot 2025-06-25 at 18 58 59](https://github.com/user-attachments/assets/c5ce5e1d-6506-4e4b-a95c-707e65a1d5f6) ### 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 --> - We should remove any reference in the other repository and mark the Nuget package as deprecated. --------- Signed-off-by: André Silva <[email protected]>
1 parent 39f884d commit 77f6e1b

File tree

11 files changed

+558
-10
lines changed

11 files changed

+558
-10
lines changed

Directory.Packages.props

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="$(MicrosoftExtensionsVersion)" />
1717
<PackageVersion Include="Microsoft.Extensions.Options" Version="$(MicrosoftExtensionsVersion)" />
1818
<PackageVersion Include="System.Collections.Immutable" Version="$(MicrosoftExtensionsVersion)" />
19+
<PackageVersion Include="System.Diagnostics.DiagnosticSource" Version="$(MicrosoftExtensionsVersion)" />
1920
<PackageVersion Include="System.Threading.Channels" Version="$(MicrosoftExtensionsVersion)" />
2021
<PackageVersion Include="System.ValueTuple" Version="4.6.1" />
2122
</ItemGroup>
@@ -29,6 +30,8 @@
2930
<PackageVersion Include="Microsoft.Extensions.Diagnostics.Testing" Version="9.3.0" />
3031
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
3132
<PackageVersion Include="NSubstitute" Version="5.3.0" />
33+
<PackageVersion Include="OpenTelemetry" Version="1.11.2" />
34+
<PackageVersion Include="OpenTelemetry.Exporter.InMemory" Version="1.11.2" />
3235
<PackageVersion Include="Reqnroll.xUnit" Version="2.4.1" />
3336
<PackageVersion Include="xunit" Version="2.9.3" />
3437
<PackageVersion Include="xunit.runner.visualstudio" Version="2.8.2" />
@@ -39,4 +42,4 @@
3942
<PackageVersion Include="Microsoft.NETFramework.ReferenceAssemblies" Version="1.0.3" />
4043
</ItemGroup>
4144

42-
</Project>
45+
</Project>

README.md

Lines changed: 138 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -79,23 +79,23 @@ public async Task Example()
7979

8080
The [`samples/`](./samples) folder contains example applications demonstrating how to use OpenFeature in different .NET scenarios.
8181

82-
| Sample Name | Description |
83-
|---------------------------------------------------|----------------------------------------------------------------|
84-
| [AspNetCore](/samples/AspNetCore/README.md) | Feature flags in an ASP.NET Core Web API. |
82+
| Sample Name | Description |
83+
| ------------------------------------------- | ----------------------------------------- |
84+
| [AspNetCore](/samples/AspNetCore/README.md) | Feature flags in an ASP.NET Core Web API. |
8585

8686
**Getting Started with a Sample:**
8787

8888
1. Navigate to the sample directory
8989

90-
```shell
91-
cd samples/AspNetCore
92-
```
90+
```shell
91+
cd samples/AspNetCore
92+
```
9393

9494
2. Restore dependencies and run
9595

96-
```shell
97-
dotnet run
98-
```
96+
```shell
97+
dotnet run
98+
```
9999

100100
Want to contribute a new sample? See our [CONTRIBUTING](CONTRIBUTING.md) guide!
101101

@@ -534,6 +534,135 @@ services.AddOpenFeature(builder =>
534534
});
535535
```
536536
537+
### Trace Enricher Hook
538+
539+
The `TraceEnricherHook` enriches telemetry traces with additional information during the feature flag evaluation lifecycle. This hook adds relevant flag evaluation details as tags and events to the current `Activity` for tracing purposes.
540+
541+
For this hook to function correctly, an active span must be set in the current `Activity`, otherwise the hook will no-op.
542+
543+
Below are the tags added to the trace event:
544+
545+
| Tag Name | Description | Source |
546+
| --------------------------- | ---------------------------------------------------------------------------- | ----------------------------- |
547+
| feature_flag.key | The lookup key of the feature flag | Hook context flag key |
548+
| feature_flag.provider.name | The name of the feature flag provider | Provider metadata |
549+
| feature_flag.result.reason | The reason code which shows how a feature flag value was determined | Evaluation details |
550+
| feature_flag.result.variant | A semantic identifier for an evaluated flag value | Evaluation details |
551+
| feature_flag.result.value | The evaluated value of the feature flag | Evaluation details |
552+
| feature_flag.context.id | The unique identifier for the flag evaluation context | Flag metadata (if available) |
553+
| feature_flag.set.id | The identifier of the flag set to which the feature flag belongs | Flag metadata (if available) |
554+
| feature_flag.version | The version of the ruleset used during the evaluation | Flag metadata (if available) |
555+
| error.type | Describes a class of error the operation ended with | Evaluation details (if error) |
556+
| error.message | A message explaining the nature of an error occurring during flag evaluation | Evaluation details (if error) |
557+
558+
#### Example
559+
560+
The following example demonstrates the use of the `TraceEnricherHook` with the `OpenFeature dotnet-sdk`. The traces are sent to a `jaeger` OTLP collector running at `localhost:4317`.
561+
562+
```csharp
563+
using OpenFeature.Contrib.Providers.Flagd;
564+
using OpenFeature.Hooks;
565+
using OpenTelemetry.Exporter;
566+
using OpenTelemetry.Resources;
567+
using OpenTelemetry;
568+
using OpenTelemetry.Trace;
569+
570+
namespace OpenFeatureTestApp
571+
{
572+
class Hello {
573+
static void Main(string[] args) {
574+
575+
// set up the OpenTelemetry OTLP exporter
576+
var tracerProvider = Sdk.CreateTracerProviderBuilder()
577+
.AddSource("my-tracer")
578+
.ConfigureResource(r => r.AddService("jaeger-test"))
579+
.AddOtlpExporter(o =>
580+
{
581+
o.ExportProcessorType = ExportProcessorType.Simple;
582+
})
583+
.Build();
584+
585+
// add the TraceEnricherHook to the OpenFeature instance
586+
OpenFeature.Api.Instance.AddHooks(new TraceEnricherHook());
587+
588+
var flagdProvider = new FlagdProvider(new Uri("http://localhost:8013"));
589+
590+
// Set the flagdProvider as the provider for the OpenFeature SDK
591+
OpenFeature.Api.Instance.SetProvider(flagdProvider);
592+
593+
var client = OpenFeature.Api.Instance.GetClient("my-app");
594+
595+
var val = client.GetBooleanValueAsync("myBoolFlag", false, null);
596+
597+
// Print the value of the 'myBoolFlag' feature flag
598+
System.Console.WriteLine(val.Result.ToString());
599+
}
600+
}
601+
}
602+
```
603+
604+
After running this example, you will be able to see the traces, including the events sent by the hook in your Jaeger UI.
605+
606+
### Metrics Hook
607+
608+
For this hook to function correctly a global `MeterProvider` must be set.
609+
`MetricsHook` performs metric collection by tapping into various hook stages.
610+
611+
Below are the metrics extracted by this hook and dimensions they carry:
612+
613+
| Metric key | Description | Unit | Dimensions |
614+
| -------------------------------------- | ------------------------------- | ------------ | ----------------------------- |
615+
| feature_flag.evaluation_requests_total | Number of evaluation requests | {request} | key, provider name |
616+
| feature_flag.evaluation_success_total | Flag evaluation successes | {impression} | key, provider name, reason |
617+
| feature_flag.evaluation_error_total | Flag evaluation errors | 1 | key, provider name, exception |
618+
| feature_flag.evaluation_active_count | Active flag evaluations counter | 1 | key, provider name |
619+
620+
Consider the following code example for usage.
621+
622+
#### Example
623+
624+
The following example demonstrates the use of the `MetricsHook` with the `OpenFeature dotnet-sdk`. The metrics are sent to the `console`.
625+
626+
```csharp
627+
using OpenFeature.Contrib.Providers.Flagd;
628+
using OpenFeature;
629+
using OpenFeature.Hooks;
630+
using OpenTelemetry;
631+
using OpenTelemetry.Metrics;
632+
633+
namespace OpenFeatureTestApp
634+
{
635+
class Hello {
636+
static void Main(string[] args) {
637+
638+
// set up the OpenTelemetry OTLP exporter
639+
var meterProvider = Sdk.CreateMeterProviderBuilder()
640+
.AddMeter("OpenFeature")
641+
.ConfigureResource(r => r.AddService("openfeature-test"))
642+
.AddConsoleExporter()
643+
.Build();
644+
645+
// add the MetricsHook to the OpenFeature instance
646+
OpenFeature.Api.Instance.AddHooks(new MetricsHook());
647+
648+
var flagdProvider = new FlagdProvider(new Uri("http://localhost:8013"));
649+
650+
// Set the flagdProvider as the provider for the OpenFeature SDK
651+
OpenFeature.Api.Instance.SetProvider(flagdProvider);
652+
653+
var client = OpenFeature.Api.Instance.GetClient("my-app");
654+
655+
var val = client.GetBooleanValueAsync("myBoolFlag", false, null);
656+
657+
// Print the value of the 'myBoolFlag' feature flag
658+
System.Console.WriteLine(val.Result.ToString());
659+
}
660+
}
661+
}
662+
```
663+
664+
After running this example, you should be able to see some metrics being generated into the console.
665+
537666
<!-- x-hide-in-docs-start -->
538667
539668
## ⭐️ Support the project

samples/AspNetCore/Program.cs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,33 @@
33
using OpenFeature.DependencyInjection.Providers.Memory;
44
using OpenFeature.Hooks;
55
using OpenFeature.Providers.Memory;
6+
using OpenTelemetry.Logs;
7+
using OpenTelemetry.Metrics;
8+
using OpenTelemetry.Resources;
9+
using OpenTelemetry.Trace;
610

711
var builder = WebApplication.CreateBuilder(args);
812

913
// Add services to the container.
1014
builder.Services.AddProblemDetails();
1115

16+
// Configure OpenTelemetry
17+
builder.Services.AddOpenTelemetry()
18+
.ConfigureResource(resource => resource.AddService("openfeature-aspnetcore-sample"))
19+
.WithTracing(tracing => tracing
20+
.AddAspNetCoreInstrumentation()
21+
.AddOtlpExporter())
22+
.WithMetrics(metrics => metrics
23+
.AddAspNetCoreInstrumentation()
24+
.AddMeter("OpenFeature")
25+
.AddOtlpExporter());
26+
1227
builder.Services.AddOpenFeature(featureBuilder =>
1328
{
1429
featureBuilder.AddHostedFeatureLifecycle()
1530
.AddHook(sp => new LoggingHook(sp.GetRequiredService<ILogger<LoggingHook>>()))
31+
.AddHook<MetricsHook>()
32+
.AddHook<TraceEnricherHook>()
1633
.AddInMemoryProvider("InMemory", _ => new Dictionary<string, Flag>()
1734
{
1835
{
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,19 @@
11
<Project Sdk="Microsoft.NET.Sdk.Web">
22

3+
<PropertyGroup>
4+
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
5+
</PropertyGroup>
6+
37
<ItemGroup>
48
<ProjectReference Include="..\..\src\OpenFeature.DependencyInjection\OpenFeature.DependencyInjection.csproj" />
59
<ProjectReference Include="..\..\src\OpenFeature.Hosting\OpenFeature.Hosting.csproj" />
610
<ProjectReference Include="..\..\src\OpenFeature\OpenFeature.csproj" />
711
</ItemGroup>
812

13+
<ItemGroup>
14+
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.9.0" />
15+
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.9.0" />
16+
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.9.0" />
17+
</ItemGroup>
18+
919
</Project>
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
namespace OpenFeature.Hooks;
2+
3+
internal static class MetricsConstants
4+
{
5+
internal const string ActiveCountName = "feature_flag.evaluation_active_count";
6+
internal const string RequestsTotalName = "feature_flag.evaluation_requests_total";
7+
internal const string SuccessTotalName = "feature_flag.evaluation_success_total";
8+
internal const string ErrorTotalName = "feature_flag.evaluation_error_total";
9+
10+
internal const string ActiveDescription = "active flag evaluations counter";
11+
internal const string RequestsDescription = "feature flag evaluation request counter";
12+
internal const string SuccessDescription = "feature flag evaluation success counter";
13+
internal const string ErrorDescription = "feature flag evaluation error counter";
14+
15+
internal const string ExceptionAttr = "exception";
16+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
using System.Diagnostics;
2+
using System.Diagnostics.Metrics;
3+
using System.Reflection;
4+
using OpenFeature.Constant;
5+
using OpenFeature.Model;
6+
using OpenFeature.Telemetry;
7+
8+
namespace OpenFeature.Hooks;
9+
10+
/// <summary>
11+
/// Represents a hook for capturing metrics related to flag evaluations.
12+
/// The meter instrumentation name is "OpenFeature".
13+
/// </summary>
14+
/// <remarks> This is still experimental and subject to change. </remarks>
15+
public class MetricsHook : Hook
16+
{
17+
private static readonly AssemblyName AssemblyName = typeof(MetricsHook).Assembly.GetName();
18+
private static readonly string InstrumentationName = AssemblyName.Name ?? "OpenFeature";
19+
private static readonly string InstrumentationVersion = AssemblyName.Version?.ToString() ?? "1.0.0";
20+
private static readonly Meter Meter = new(InstrumentationName, InstrumentationVersion);
21+
22+
private readonly UpDownCounter<long> _evaluationActiveUpDownCounter;
23+
private readonly Counter<long> _evaluationRequestCounter;
24+
private readonly Counter<long> _evaluationSuccessCounter;
25+
private readonly Counter<long> _evaluationErrorCounter;
26+
27+
/// <summary>
28+
/// Initializes a new instance of the <see cref="MetricsHook"/> class.
29+
/// </summary>
30+
public MetricsHook()
31+
{
32+
this._evaluationActiveUpDownCounter = Meter.CreateUpDownCounter<long>(MetricsConstants.ActiveCountName, description: MetricsConstants.ActiveDescription);
33+
this._evaluationRequestCounter = Meter.CreateCounter<long>(MetricsConstants.RequestsTotalName, "{request}", MetricsConstants.RequestsDescription);
34+
this._evaluationSuccessCounter = Meter.CreateCounter<long>(MetricsConstants.SuccessTotalName, "{impression}", MetricsConstants.SuccessDescription);
35+
this._evaluationErrorCounter = Meter.CreateCounter<long>(MetricsConstants.ErrorTotalName, description: MetricsConstants.ErrorDescription);
36+
}
37+
38+
/// <inheritdoc/>
39+
public override ValueTask<EvaluationContext> BeforeAsync<T>(HookContext<T> context, IReadOnlyDictionary<string, object>? hints = null, CancellationToken cancellationToken = default)
40+
{
41+
var tagList = new TagList
42+
{
43+
{ TelemetryConstants.Key, context.FlagKey },
44+
{ TelemetryConstants.Provider, context.ProviderMetadata.Name }
45+
};
46+
47+
this._evaluationActiveUpDownCounter.Add(1, tagList);
48+
this._evaluationRequestCounter.Add(1, tagList);
49+
50+
return base.BeforeAsync(context, hints, cancellationToken);
51+
}
52+
53+
54+
/// <inheritdoc/>
55+
public override ValueTask AfterAsync<T>(HookContext<T> context, FlagEvaluationDetails<T> details, IReadOnlyDictionary<string, object>? hints = null, CancellationToken cancellationToken = default)
56+
{
57+
var tagList = new TagList
58+
{
59+
{ TelemetryConstants.Key, context.FlagKey },
60+
{ TelemetryConstants.Provider, context.ProviderMetadata.Name },
61+
{ TelemetryConstants.Reason, details.Reason ?? Reason.Unknown.ToString() }
62+
};
63+
64+
this._evaluationSuccessCounter.Add(1, tagList);
65+
66+
return base.AfterAsync(context, details, hints, cancellationToken);
67+
}
68+
69+
/// <inheritdoc/>
70+
public override ValueTask ErrorAsync<T>(HookContext<T> context, Exception error, IReadOnlyDictionary<string, object>? hints = null, CancellationToken cancellationToken = default)
71+
{
72+
var tagList = new TagList
73+
{
74+
{ TelemetryConstants.Key, context.FlagKey },
75+
{ TelemetryConstants.Provider, context.ProviderMetadata.Name },
76+
{ MetricsConstants.ExceptionAttr, error.Message }
77+
};
78+
79+
this._evaluationErrorCounter.Add(1, tagList);
80+
81+
return base.ErrorAsync(context, error, hints, cancellationToken);
82+
}
83+
84+
/// <inheritdoc/>
85+
public override ValueTask FinallyAsync<T>(HookContext<T> context,
86+
FlagEvaluationDetails<T> evaluationDetails,
87+
IReadOnlyDictionary<string, object>? hints = null,
88+
CancellationToken cancellationToken = default)
89+
{
90+
var tagList = new TagList
91+
{
92+
{ TelemetryConstants.Key, context.FlagKey },
93+
{ TelemetryConstants.Provider, context.ProviderMetadata.Name }
94+
};
95+
96+
this._evaluationActiveUpDownCounter.Add(-1, tagList);
97+
98+
return base.FinallyAsync(context, evaluationDetails, hints, cancellationToken);
99+
}
100+
}

0 commit comments

Comments
 (0)