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

Draft
wants to merge 26 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
26 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
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.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doc doesn't appear correct.

/// </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