Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
3b609d9
Add OpenTelemetry log exporting and ensure traces reliably sample
kylejuliandev May 6, 2026
703798f
Add code to start activity and initial unit tests
kylejuliandev May 6, 2026
73ee185
Add additional unit tests and ensure tags are added
kylejuliandev May 7, 2026
24ea6b6
Fix build issue
kylejuliandev May 7, 2026
3dce87e
Address gemini code review comments
kylejuliandev May 8, 2026
a6b8ec3
Add tests to improve code coverage of OpenFeatureActivitySource
kylejuliandev May 8, 2026
7e99375
Set activity kind to internal
kylejuliandev May 8, 2026
3b8eac5
Ensure major.minor.patch version is included in trace
kylejuliandev May 14, 2026
6e943fa
Move tag keys to string constants in separate class
kylejuliandev May 14, 2026
6d64f4f
Remove duplicate activity disposal
kylejuliandev May 14, 2026
21c9a1c
Add internal extension method for adding tags to activities
kylejuliandev May 20, 2026
23d0839
Merge branch 'main' into kylej/improve-tracing
kylejuliandev May 20, 2026
06fc54b
Remove null check on Activity and create constant for error.type
kylejuliandev May 21, 2026
6f51561
docs: Add notice to README on how to get new traces from OpenFeature
kylejuliandev Jun 18, 2026
870b7c0
Merge branch 'main' into kylej/improve-tracing
kylejuliandev Jun 18, 2026
1ba2ef9
Address coderabbit review comments
kylejuliandev Jun 18, 2026
4fff690
Merge branch 'main' into kylej/improve-tracing
kylejuliandev Jun 18, 2026
ca5b1e6
Ensure `feature_flag.result.reason` is added in exception branches
kylejuliandev Jun 18, 2026
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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -639,6 +639,9 @@ services.AddOpenFeature(builder =>

### Trace Enricher Hook

> [!WARNING]
> As of v2.14, the OpenFeature SDK emits native OpenTelemetry spans for every flag evaluation without requiring any hooks. Use `TraceEnricherHook` when you want activities/spans **outside** of OpenFeature to be enriched with flag evaluation data. If you are already using `TraceEnricherHook`, you may see **new spans** appear in your observability platform after upgrading to v2.14.

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.

For this hook to function correctly, an active span must be set in the current `Activity`, otherwise the hook will no-op.
Expand All @@ -658,6 +661,9 @@ Below are the tags added to the trace event:
| error.type | Describes a class of error the operation ended with | Evaluation details (if error) |
| error.message | A message explaining the nature of an error occurring during flag evaluation | Evaluation details (if error) |

> [!NOTE]
> To receive OpenTelemetry traces emitted natively by the OpenFeature SDK, add `.AddSource("OpenFeature")` to your `TracerProviderBuilder`. No hooks are required.

#### Example

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`.
Expand All @@ -678,6 +684,7 @@ namespace OpenFeatureTestApp

// set up the OpenTelemetry OTLP exporter
var tracerProvider = Sdk.CreateTracerProviderBuilder()
.AddSource("OpenFeature") // receive traces from OpenFeature SDK
.AddSource("my-tracer")
.ConfigureResource(r => r.AddService("jaeger-test"))
.AddOtlpExporter(o =>
Expand Down
14 changes: 14 additions & 0 deletions samples/AspNetCore/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
using OpenFeature.Providers.MultiProvider.DependencyInjection;
using OpenFeature.Providers.MultiProvider.Models;
using OpenFeature.Providers.MultiProvider.Strategies;
using OpenTelemetry.Logs;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
Expand All @@ -25,10 +26,23 @@
builder.Services.AddProblemDetails();

// Configure OpenTelemetry
builder.Logging.AddOpenTelemetry(options =>
{
options
.SetResourceBuilder(
ResourceBuilder.CreateDefault()
.AddService("openfeature-aspnetcore-sample"))
.AddOtlpExporter();

options.IncludeScopes = true;
options.IncludeFormattedMessage = true;
});

builder.Services.AddOpenTelemetry()
.ConfigureResource(resource => resource.AddService("openfeature-aspnetcore-sample"))
.WithTracing(tracing => tracing
.AddAspNetCoreInstrumentation()
.AddSource("OpenFeature")
.AddOtlpExporter())
.WithMetrics(metrics => metrics
.AddAspNetCoreInstrumentation()
Expand Down
70 changes: 70 additions & 0 deletions src/OpenFeature/OpenFeatureActivitySource.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
using System.Diagnostics;
using OpenFeature.Constant;

namespace OpenFeature;

static class OpenFeatureActivitySource
{
static readonly ActivitySource Source = new("OpenFeature", GetLibraryVersion());

internal static Activity? StartActivity(string name)
=> Source.StartActivity(name, ActivityKind.Internal);

internal const string EvaluationActivityName = "feature_flag.evaluation";
internal const string FeatureFlagKeyName = "feature_flag.key";
internal const string FeatureFlagProviderName = "feature_flag.provider.name";
Comment thread
kylejuliandev marked this conversation as resolved.
internal const string FeatureFlagReasonName = "feature_flag.result.reason";
internal const string FeatureFlagValueName = "feature_flag.result.value";
internal const string FeatureFlagVariantName = "feature_flag.result.variant";
Comment thread
kylejuliandev marked this conversation as resolved.
internal const string FeatureFlagErrorMessageName = "feature_flag.error.message";

internal const string ErrorTypeName = "error.type";

// Mapped to standard `error.types` https://opentelemetry.io/docs/specs/semconv/feature-flags/feature-flags-events/#evaluation-event
internal static string GetFlagEvaluationErrorDescription(this ErrorType errorType) =>
errorType switch
{
ErrorType.ProviderNotReady => "provider_not_ready",
ErrorType.FlagNotFound => "flag_not_found",
ErrorType.ParseError => "parse_error",
ErrorType.TypeMismatch => "type_mismatch",
ErrorType.General => "general",
ErrorType.InvalidContext => "invalid_context",
ErrorType.TargetingKeyMissing => "targeting_key_missing",
ErrorType.ProviderFatal => "provider_fatal",
_ => "_OTHER"
};

// Mapped to standard `feature_flag.result.reason` https://opentelemetry.io/docs/specs/semconv/feature-flags/feature-flags-events/#evaluation-event
internal static string? GetFlagEvaluationReasonDescription(string? reason) =>
reason switch
{
Reason.TargetingMatch => "targeting_match",
Reason.Split => "split",
Reason.Disabled => "disabled",
Reason.Default => "default",
Reason.Static => "static",
Reason.Cached => "cached",
Reason.Unknown => "unknown",
Reason.Error => "error",
_ => reason
Comment thread
kylejuliandev marked this conversation as resolved.
};

static string GetLibraryVersion()
{
var version = typeof(OpenFeatureActivitySource).Assembly
.GetName()
.Version;

// "3" = major.minor.patch only
return version?.ToString(3) ?? "UNKNOWN";
}

internal static void AddTagIfRequested(this Activity activity, string tagName, object? value)
{
if (!activity.IsAllDataRequested)
return;

activity.AddTag(tagName, value);
}
}
32 changes: 32 additions & 0 deletions src/OpenFeature/OpenFeatureClient.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Collections.Concurrent;
using System.Collections.Immutable;
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using OpenFeature.Constant;
Expand Down Expand Up @@ -211,6 +212,16 @@ private async Task<FlagEvaluationDetails<T>> EvaluateFlagAsync<T>(
var resolveValueDelegate = providerInfo.Item1;
var provider = providerInfo.Item2;

using var activity = OpenFeatureActivitySource.StartActivity(OpenFeatureActivitySource.EvaluationActivityName);

activity?.AddTagIfRequested(OpenFeatureActivitySource.FeatureFlagKeyName, flagKey);

var providerMetadata = provider.GetMetadata();
if (providerMetadata != null)
{
activity?.AddTagIfRequested(OpenFeatureActivitySource.FeatureFlagProviderName, providerMetadata.Name);
}

// New up an evaluation context if one was not provided.
context ??= EvaluationContext.Empty;

Expand Down Expand Up @@ -261,8 +272,13 @@ private async Task<FlagEvaluationDetails<T>> EvaluateFlagAsync<T>(
.ConfigureAwait(false))
.ToFlagEvaluationDetails();

activity?.AddTagIfRequested(OpenFeatureActivitySource.FeatureFlagReasonName, OpenFeatureActivitySource.GetFlagEvaluationReasonDescription(evaluation.Reason));

if (evaluation.ErrorType == ErrorType.None)
{
activity?.AddTagIfRequested(OpenFeatureActivitySource.FeatureFlagValueName, evaluation.Value);
activity?.AddTagIfRequested(OpenFeatureActivitySource.FeatureFlagVariantName, evaluation.Variant);

await hookRunner.TriggerAfterHooksAsync(
evaluation,
options?.HookHints,
Expand All @@ -271,6 +287,10 @@ await hookRunner.TriggerAfterHooksAsync(
}
else
{
activity?.SetStatus(ActivityStatusCode.Error);
activity?.AddTagIfRequested(OpenFeatureActivitySource.ErrorTypeName, OpenFeatureActivitySource.GetFlagEvaluationErrorDescription(evaluation.ErrorType));
activity?.AddTagIfRequested(OpenFeatureActivitySource.FeatureFlagErrorMessageName, evaluation.ErrorMessage);

var exception = new FeatureProviderException(evaluation.ErrorType, evaluation.ErrorMessage);
this.FlagEvaluationErrorWithDescription(flagKey, evaluation.ErrorType.GetDescription(), exception);
await hookRunner.TriggerErrorHooksAsync(exception, options?.HookHints, cancellationToken)
Expand All @@ -282,6 +302,12 @@ await hookRunner.TriggerErrorHooksAsync(exception, options?.HookHints, cancellat
this.FlagEvaluationErrorWithDescription(flagKey, ex.ErrorType.GetDescription(), ex);
evaluation = new FlagEvaluationDetails<T>(flagKey, defaultValue, ex.ErrorType, Reason.Error,
string.Empty, ex.Message);

activity?.SetStatus(ActivityStatusCode.Error);
activity?.AddTagIfRequested(OpenFeatureActivitySource.FeatureFlagReasonName, OpenFeatureActivitySource.GetFlagEvaluationReasonDescription(evaluation.Reason));
activity?.AddTagIfRequested(OpenFeatureActivitySource.ErrorTypeName, OpenFeatureActivitySource.GetFlagEvaluationErrorDescription(evaluation.ErrorType));
activity?.AddTagIfRequested(OpenFeatureActivitySource.FeatureFlagErrorMessageName, evaluation.ErrorMessage);

await hookRunner.TriggerErrorHooksAsync(ex, options?.HookHints, cancellationToken)
.ConfigureAwait(false);
}
Expand All @@ -290,6 +316,12 @@ await hookRunner.TriggerErrorHooksAsync(ex, options?.HookHints, cancellationToke
var errorCode = ex is InvalidCastException ? ErrorType.TypeMismatch : ErrorType.General;
evaluation = new FlagEvaluationDetails<T>(flagKey, defaultValue, errorCode, Reason.Error, string.Empty,
ex.Message);

activity?.SetStatus(ActivityStatusCode.Error);
activity?.AddTagIfRequested(OpenFeatureActivitySource.FeatureFlagReasonName, OpenFeatureActivitySource.GetFlagEvaluationReasonDescription(evaluation.Reason));
activity?.AddTagIfRequested(OpenFeatureActivitySource.ErrorTypeName, OpenFeatureActivitySource.GetFlagEvaluationErrorDescription(evaluation.ErrorType));
activity?.AddTagIfRequested(OpenFeatureActivitySource.FeatureFlagErrorMessageName, evaluation.ErrorMessage);

await hookRunner.TriggerErrorHooksAsync(ex, options?.HookHints, cancellationToken)
.ConfigureAwait(false);
}
Expand Down
111 changes: 111 additions & 0 deletions test/OpenFeature.Tests/OpenFeatureActivitySourceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
using System.Diagnostics;
using OpenFeature.Constant;

namespace OpenFeature.Tests;

public class OpenFeatureActivitySourceTests
{
[Fact]
public void StartActivity_ReturnsActivityWithCorrectName()
{
using var activityListener = new ActivityListener()
{
ShouldListenTo = source => true,
Sample = (ref _) => ActivitySamplingResult.AllDataAndRecorded
};

ActivitySource.AddActivityListener(activityListener);

using var activity = OpenFeatureActivitySource.StartActivity("test_activity");

Assert.NotNull(activity);
Assert.Equal("test_activity", activity.OperationName);
Assert.Equal("OpenFeature", activity.Source.Name);
Assert.NotNull(activity.Source.Version);
Assert.NotEmpty(activity.Source.Version);
Assert.Equal(ActivityKind.Internal, activity.Kind);
}

[Theory]
[InlineData(ErrorType.ProviderNotReady, "provider_not_ready")]
[InlineData(ErrorType.FlagNotFound, "flag_not_found")]
[InlineData(ErrorType.ParseError, "parse_error")]
[InlineData(ErrorType.TypeMismatch, "type_mismatch")]
[InlineData(ErrorType.General, "general")]
[InlineData(ErrorType.InvalidContext, "invalid_context")]
[InlineData(ErrorType.TargetingKeyMissing, "targeting_key_missing")]
[InlineData(ErrorType.ProviderFatal, "provider_fatal")]
[InlineData((ErrorType)999, "_OTHER")]
public void GetFlagEvaluationErrorDescription_ReturnsCorrectDescription(ErrorType errorType, string expectedDescription)
{
var actual = errorType.GetFlagEvaluationErrorDescription();

Assert.Equal(expectedDescription, actual);
}

[Theory]
[InlineData("TARGETING_MATCH", "targeting_match")]
[InlineData("SPLIT", "split")]
[InlineData("DISABLED", "disabled")]
[InlineData("DEFAULT", "default")]
[InlineData("STATIC", "static")]
[InlineData("CACHED", "cached")]
[InlineData("UNKNOWN", "unknown")]
[InlineData("ERROR", "error")]
[InlineData("OTHER", "OTHER")]
public void GetFlagEvaluationReasonDescription(string? reason, string expectedDescription)
{
var actual = OpenFeatureActivitySource.GetFlagEvaluationReasonDescription(reason);

Assert.Equal(expectedDescription, actual);
}

[Fact]
public void SetTagIfRequested_AddsTag()
{
var exportedActivities = new List<Activity>();
using var activityListener = new ActivityListener()
{
ShouldListenTo = source => source.Name == "OpenFeature",
Sample = (ref _) => ActivitySamplingResult.AllDataAndRecorded,
ActivityStopped = activity => exportedActivities.Add(activity)
};

ActivitySource.AddActivityListener(activityListener);

using (var activity = OpenFeatureActivitySource.StartActivity("set_tag_if_requested"))
{
activity?.AddTagIfRequested("custom_tag_name", true);
}

Assert.Single(exportedActivities);

var actualActivity = exportedActivities.First();
Assert.Equal("custom_tag_name", actualActivity.TagObjects.First().Key);
Assert.Equal(true, actualActivity.TagObjects.First().Value);
}

[Fact]
public void SetTagIfRequested_WhenDataNotRequested_DoesNotAddTag()
{
var exportedActivities = new List<Activity>();
using var activityListener = new ActivityListener()
{
ShouldListenTo = source => source.Name == "OpenFeature",
Sample = (ref _) => ActivitySamplingResult.PropagationData,
ActivityStopped = activity => exportedActivities.Add(activity)
};

ActivitySource.AddActivityListener(activityListener);

using (var activity = OpenFeatureActivitySource.StartActivity("set_tag_if_requested"))
{
activity?.AddTagIfRequested("custom_tag_name", true);
}

Assert.Single(exportedActivities);

var actualActivity = exportedActivities.First();
Assert.Empty(actualActivity.TagObjects);
}
}
Loading