Skip to content

feat: Move OTEL hooks to the SDK #338

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 53 commits into from
Jun 27, 2025
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
e7d40c7
Added metrics hook.
askpt Dec 31, 2024
46e5711
Adding the traces hook.
askpt Dec 31, 2024
ced2d34
Adding Metrics Hook tests.
askpt Dec 31, 2024
633859c
Adding tracing hook tests.
askpt Dec 31, 2024
6d4378f
Fix typos.
askpt Dec 31, 2024
8a32ca9
Simplify code.
askpt Dec 31, 2024
629b900
Cleanup tracing hook tests.
askpt Dec 31, 2024
766caa5
More test cleanup.
askpt Dec 31, 2024
703f418
Cleanup tests.
askpt Dec 31, 2024
729bd65
Adding empty span.
askpt Dec 31, 2024
7f5af18
Adding .ConfigureAwait
askpt Dec 31, 2024
c0b6cc0
Fix dotnet format.
askpt Dec 31, 2024
d0157ae
Removed comma
askpt Dec 31, 2024
902a102
Adding README information.
askpt Dec 31, 2024
60813c5
Merge branch 'main' into askpt/175-promote-otel-hooks
askpt Dec 31, 2024
6a3d911
Using the new API.
askpt Dec 31, 2024
52b47da
Fix subscribed Meter.
askpt Dec 31, 2024
8e1e884
Merge branch 'main' into askpt/175-promote-otel-hooks
askpt Jan 6, 2025
1a0c16d
Merge branch 'main' into askpt/175-promote-otel-hooks
askpt Jan 7, 2025
41c915d
Fix breaking change.
askpt Jan 7, 2025
4bf70d2
Merge branch 'main' into askpt/175-promote-otel-hooks
askpt Feb 26, 2025
7b6ef96
Merge branch 'main' into askpt/175-promote-otel-hooks
askpt Mar 5, 2025
066706d
Merge branch 'main' into askpt/175-promote-otel-hooks
askpt Apr 8, 2025
5cfb99b
Merge branch 'main' into askpt/175-promote-otel-hooks
askpt Apr 8, 2025
a39ebbd
Merge branch 'main' into askpt/175-promote-otel-hooks
askpt Apr 28, 2025
415de4d
feat: add System.Diagnostics.DiagnosticSource package reference and u…
askpt Apr 28, 2025
4a3bacf
Merge branch 'main' into askpt/175-promote-otel-hooks
askpt May 12, 2025
dd86dd5
Merge branch 'main' into askpt/175-promote-otel-hooks
askpt May 19, 2025
94a1fcb
refactor: Replace MetricsConstants with TelemetryConstants in Metrics…
askpt May 19, 2025
a8789f4
refactor: Enhance documentation for TracingHook methods and clarify e…
askpt May 19, 2025
d34ede3
fix: Update return statements in TracingHook to call base methods for…
askpt May 19, 2025
345a164
fix: Correct documentation and update tag keys in TracingHook tests f…
askpt May 19, 2025
bc38b0c
refactor: Optimize Meter initialization in MetricsHook constructor fo…
askpt May 20, 2025
cd9811e
fix: Correct indentation in README.md for OpenTelemetry OTLP exporter…
askpt May 20, 2025
ed68e95
Merge branch 'main' into askpt/175-promote-otel-hooks
askpt May 21, 2025
fe05709
Merge branch 'main' into askpt/175-promote-otel-hooks
askpt Jun 4, 2025
3309850
chore: remove unused usings from hook files
askpt Jun 4, 2025
904fd44
chore: remove variant assignment from MetricsHook
askpt Jun 4, 2025
0200319
feat: add additional tags for flag evaluation details in TracingHook
askpt Jun 23, 2025
ed543dd
feat: add value and reason tags to telemetry in TracingHook
askpt Jun 23, 2025
5309232
fix: update remarks for MetricsHook and TracingHook to clarify experi…
askpt Jun 23, 2025
4359a37
refactor: rename AfterAsync to FinallyAsync in TracingHook and update…
askpt Jun 23, 2025
8038a69
Merge branch 'main' into askpt/175-promote-otel-hooks
askpt Jun 23, 2025
d3cf9d2
feat: add TraceEnricherHook and corresponding tests for telemetry tra…
askpt Jun 25, 2025
b8b3436
refactor: remove ErrorAsync method from TraceEnricherHook to streamli…
askpt Jun 25, 2025
46fcf38
refactor: update event name in TraceEnricherHook and remove unused Tr…
askpt Jun 25, 2025
cc5fde8
Merge branch 'main' into askpt/175-promote-otel-hooks
askpt Jun 25, 2025
4a3d3bc
refactor: streamline event tagging in TraceEnricherHook by using Eval…
askpt Jun 25, 2025
fea94e9
refactor: update event name and enhance assertions in TraceEnricherHo…
askpt Jun 25, 2025
78044d1
refactor: rename variable for clarity in TraceEnricherHookTests
askpt Jun 25, 2025
1a7f62a
docs: improve README formatting and enhance Trace Enricher Hook descr…
askpt Jun 25, 2025
a0319e4
feat: integrate OpenTelemetry for tracing and metrics in ASP.NET Core…
askpt Jun 25, 2025
2b4306a
Merge branch 'main' into askpt/175-promote-otel-hooks
askpt Jun 27, 2025
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
4 changes: 3 additions & 1 deletion Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
<PackageVersion Include="GitHubActionsTestLogger" Version="2.4.1" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageVersion Include="NSubstitute" Version="5.3.0" />
<PackageVersion Include="OpenTelemetry" Version="1.10.0" />
<PackageVersion Include="OpenTelemetry.Exporter.InMemory" Version="1.10.0" />
<PackageVersion Include="SpecFlow" Version="3.9.74" />
<PackageVersion Include="SpecFlow.Tools.MsBuild.Generation" Version="3.9.74" />
<PackageVersion Include="SpecFlow.xUnit" Version="3.9.74" />
Expand All @@ -37,4 +39,4 @@
<PackageVersion Include="Microsoft.NETFramework.ReferenceAssemblies" Version="1.0.3" />
</ItemGroup>

</Project>
</Project>
115 changes: 115 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,121 @@ services.AddOpenFeature()
});
```

### Trace Hook

For this hook to function correctly a global `TracerProvider` must be set, an example of how to do this can be found below.

The `open telemetry hook` taps into the after and error methods of the hook lifecycle to write `events` and `attributes` to an existing `span`.
For this, an active span must be set in the `Tracer`, otherwise the hook will no-op.

### Example

The following example demonstrates the use of the `OpenTelemetry hook` with the `OpenFeature dotnet-sdk`. The traces are sent to a `jaeger` OTLP collector running at `localhost:4317`.

```csharp
using OpenFeature.Contrib.Providers.Flagd;
using OpenFeature.Hooks;
using OpenTelemetry.Exporter;
using OpenTelemetry.Resources;
using OpenTelemetry;
using OpenTelemetry.Trace;

namespace OpenFeatureTestApp
{
class Hello {
static void Main(string[] args) {

// set up the OpenTelemetry OTLP exporter
var tracerProvider = Sdk.CreateTracerProviderBuilder()
.AddSource("my-tracer")
.ConfigureResource(r => r.AddService("jaeger-test"))
.AddOtlpExporter(o =>
{
o.ExportProcessorType = ExportProcessorType.Simple;
})
.Build();

// add the Otel Hook to the OpenFeature instance
OpenFeature.Api.Instance.AddHooks(new TracingHook());

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.GetBooleanValueAsync("myBoolFlag", false, null);

// Print the value of the 'myBoolFlag' feature flag
System.Console.WriteLine(val.Result.ToString());
}
}
}
```

After running this example, you will be able to see the traces, including the events sent by the hook in your Jaeger UI.

### Metrics Hook

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.Hooks;
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")
.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.GetBooleanValueAsync("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.

<!-- x-hide-in-docs-start -->
## ⭐️ Support the project

Expand Down
20 changes: 20 additions & 0 deletions src/OpenFeature/Hooks/MetricsConstants.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
namespace OpenFeature.Hooks;

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";
}
103 changes: 103 additions & 0 deletions src/OpenFeature/Hooks/MetricsHook.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.Metrics;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using OpenFeature.Model;

namespace OpenFeature.Hooks;

/// <summary>
/// Represents a hook for capturing metrics related to flag evaluations.
/// The meter instrumentation name is "OpenFeature".
/// </summary>
public class MetricsHook : Hook
{
private static readonly AssemblyName AssemblyName = typeof(MetricsHook).Assembly.GetName();
private static readonly string InstrumentationName = AssemblyName.Name ?? "OpenFeature";
private static readonly string InstrumentationVersion = AssemblyName.Version?.ToString() ?? "1.0.0";

private readonly UpDownCounter<long> _evaluationActiveUpDownCounter;
private readonly Counter<long> _evaluationRequestCounter;
private readonly Counter<long> _evaluationSuccessCounter;
private readonly Counter<long> _evaluationErrorCounter;

/// <summary>
/// Initializes a new instance of the <see cref="MetricsHook"/> class.
/// </summary>
public MetricsHook()
{
var meter = new Meter(InstrumentationName, InstrumentationVersion);

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

/// <inheritdoc/>
public override ValueTask<EvaluationContext> BeforeAsync<T>(HookContext<T> context, IReadOnlyDictionary<string, object>? hints = null, CancellationToken cancellationToken = default)
{
var tagList = new TagList
{
{ MetricsConstants.KeyAttr, context.FlagKey },
{ MetricsConstants.ProviderNameAttr, context.ProviderMetadata.Name }
};

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

return base.BeforeAsync(context, hints, cancellationToken);
}


/// <inheritdoc/>
public override ValueTask AfterAsync<T>(HookContext<T> context, FlagEvaluationDetails<T> details, IReadOnlyDictionary<string, object>? hints = null, CancellationToken cancellationToken = default)
{
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" }
};

this._evaluationSuccessCounter.Add(1, tagList);

return base.AfterAsync(context, details, hints, cancellationToken);
}

/// <inheritdoc/>
public override ValueTask ErrorAsync<T>(HookContext<T> context, Exception error, IReadOnlyDictionary<string, object>? hints = null, CancellationToken cancellationToken = default)
{
var tagList = new TagList
{
{ MetricsConstants.KeyAttr, context.FlagKey },
{ MetricsConstants.ProviderNameAttr, context.ProviderMetadata.Name },
{ MetricsConstants.ExceptionAttr, error.Message }
};

this._evaluationErrorCounter.Add(1, tagList);

return base.ErrorAsync(context, error, hints, cancellationToken);
}

/// <inheritdoc/>
public override ValueTask FinallyAsync<T>(HookContext<T> context,
FlagEvaluationDetails<T> evaluationDetails,
IReadOnlyDictionary<string, object>? hints = null,
CancellationToken cancellationToken = default)
{
var tagList = new TagList
{
{ MetricsConstants.KeyAttr, context.FlagKey },
{ MetricsConstants.ProviderNameAttr, context.ProviderMetadata.Name }
};

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

return base.FinallyAsync(context, evaluationDetails, hints, cancellationToken);
}
}
9 changes: 9 additions & 0 deletions src/OpenFeature/Hooks/TracingConstants.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace OpenFeature.Hooks;

internal static class TracingConstants
{
internal const string AttributeExceptionEventName = "exception";
internal const string AttributeExceptionType = "exception.type";
internal const string AttributeExceptionMessage = "exception.message";
internal const string AttributeExceptionStacktrace = "exception.stacktrace";
}
54 changes: 54 additions & 0 deletions src/OpenFeature/Hooks/TracingHook.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using OpenFeature.Model;

namespace OpenFeature.Hooks;

/// <summary>
/// Stub.
/// </summary>
public class TracingHook : Hook
{
/// <inheritdoc/>
public override ValueTask AfterAsync<T>(HookContext<T> context, FlagEvaluationDetails<T> details,
IReadOnlyDictionary<string, object>? hints = null, CancellationToken cancellationToken = default)
{
Activity.Current?
.SetTag("feature_flag.key", details.FlagKey)
.SetTag("feature_flag.variant", details.Variant)
.SetTag("feature_flag.provider_name", context.ProviderMetadata.Name)
.AddEvent(new ActivityEvent("feature_flag", tags: new ActivityTagsCollection
{
["feature_flag.key"] = details.FlagKey,
["feature_flag.variant"] = details.Variant,
["feature_flag.provider_name"] = context.ProviderMetadata.Name
}));

return default;
}

/// <inheritdoc/>
public override ValueTask ErrorAsync<T>(HookContext<T> context, System.Exception error,
IReadOnlyDictionary<string, object>? hints = null, CancellationToken cancellationToken = default)
{
#if NET9_0_OR_GREATER
// For dotnet9 we should use the new API https://learn.microsoft.com/en-gb/dotnet/api/system.diagnostics.activity.addexception?view=net-9.0
Activity.Current?.AddException(error);
#else
var tagsCollection = new ActivityTagsCollection
{
{ TracingConstants.AttributeExceptionType, error.GetType().FullName },
{ TracingConstants.AttributeExceptionStacktrace, error.ToString() },
};
if (!string.IsNullOrWhiteSpace(error.Message))
{
tagsCollection.Add(TracingConstants.AttributeExceptionMessage, error.Message);
}

Activity.Current?.AddEvent(new ActivityEvent(TracingConstants.AttributeExceptionEventName, default, tagsCollection));
#endif
return default;
}
}
Loading
Loading