Skip to content
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
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()
.SetSampler(new AlwaysOnSampler())
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Maybe we should add AddSource("OpenFeature") to collect OpenFeature activity?

.AddOtlpExporter())
.WithMetrics(metrics => metrics
.AddAspNetCoreInstrumentation()
Expand Down
48 changes: 48 additions & 0 deletions src/OpenFeature/OpenFeatureActivitySource.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
using System.Diagnostics;
using System.Reflection;
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);

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

static string GetLibraryVersion()
=> typeof(OpenFeatureActivitySource).Assembly
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?
.InformationalVersion ?? "UNKNOWN";
Comment on lines +44 to +47
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Could you please remove the +<sha> in the version. You can see it in the screenshots you posted.

Also I wonder if we should "cache" the version string somewhere to prevent doing GetCustomAttribute all the time.

}
31 changes: 31 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,15 @@ private async Task<FlagEvaluationDetails<T>> EvaluateFlagAsync<T>(
var resolveValueDelegate = providerInfo.Item1;
var provider = providerInfo.Item2;

var activity = OpenFeatureActivitySource.StartActivity("feature_flag.evaluation");
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Copy link
Copy Markdown
Contributor

@WeihanLi WeihanLi May 13, 2026

Choose a reason for hiding this comment

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

Wonder if we should set it as Client

personally think it's fine to keep it using default Internal

Copy link
Copy Markdown
Contributor

@WeihanLi WeihanLi May 13, 2026

Choose a reason for hiding this comment

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

Suggested change
var activity = OpenFeatureActivitySource.StartActivity("feature_flag.evaluation");
using var activity = OpenFeatureActivitySource.StartActivity("feature_flag.evaluation");

maybe we could add using for this

activity?.SetTag("feature_flag.key", flagKey);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

for the SetTag/SetStatus, think we could add tags in one place and add tags only when IsAllDataRequested

https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.activity.isalldatarequested?view=net-10.0


var providerMetadata = provider.GetMetadata();
if (providerMetadata != null)
{
activity?.SetTag("feature_flag.provider.name", providerMetadata.Name);
Comment thread
kylejuliandev marked this conversation as resolved.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Would we like to keep these tagName/activityName to be constants in a separated place?

}

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

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

activity?.SetTag("feature_flag.result.reason", OpenFeatureActivitySource.GetFlagEvaluationReasonDescription(evaluation.Reason));
Comment thread
kylejuliandev marked this conversation as resolved.

if (evaluation.ErrorType == ErrorType.None)
{
activity?.SetTag("feature_flag.result.value", evaluation.Value);
activity?.SetTag("feature_flag.result.variant", evaluation.Variant);
Comment thread
kylejuliandev marked this conversation as resolved.

await hookRunner.TriggerAfterHooksAsync(
evaluation,
options?.HookHints,
Expand All @@ -271,6 +286,10 @@ await hookRunner.TriggerAfterHooksAsync(
}
else
{
activity?.SetStatus(ActivityStatusCode.Error);
activity?.AddTag("error.type", OpenFeatureActivitySource.GetFlagEvaluationErrorDescription(evaluation.ErrorType));
activity?.SetTag("feature_flag.error.message", evaluation.ErrorMessage);
Comment thread
kylejuliandev marked this conversation as resolved.

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 +301,11 @@ 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?.AddTag("error.type", OpenFeatureActivitySource.GetFlagEvaluationErrorDescription(evaluation.ErrorType));
activity?.SetTag("feature_flag.error.message", evaluation.ErrorMessage);
Comment thread
kylejuliandev marked this conversation as resolved.

await hookRunner.TriggerErrorHooksAsync(ex, options?.HookHints, cancellationToken)
.ConfigureAwait(false);
}
Expand All @@ -290,6 +314,11 @@ 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?.AddTag("error.type", OpenFeatureActivitySource.GetFlagEvaluationErrorDescription(evaluation.ErrorType));
activity?.SetTag("feature_flag.error.message", evaluation.ErrorMessage);
Comment thread
kylejuliandev marked this conversation as resolved.

await hookRunner.TriggerErrorHooksAsync(ex, options?.HookHints, cancellationToken)
.ConfigureAwait(false);
}
Expand All @@ -302,6 +331,8 @@ await hookRunner.TriggerFinallyHooksAsync(evaluation, options?.HookHints, cancel
.ConfigureAwait(false);
}

activity?.Dispose();

return evaluation;
}

Expand Down
62 changes: 62 additions & 0 deletions test/OpenFeature.Tests/OpenFeatureActivitySourceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
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);

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