Skip to content
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

feat: Move OTEL hooks to the SDK #338

Open
wants to merge 20 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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