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()
.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);
}
}
30 changes: 30 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,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?.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 +315,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?.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);

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
Loading