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: Add OTEL compatible telemetry object builder #397

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
Open
37 changes: 37 additions & 0 deletions src/OpenFeature/Telemetry/EvaluationEvent.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
using System.Collections.Generic;

namespace OpenFeature.Telemetry;

/// <summary>
/// Represents an evaluation event for feature flags.
/// </summary>
public class EvaluationEvent
{
/// <summary>
/// Initializes a new instance of the <see cref="EvaluationEvent"/> class.
/// </summary>
/// <param name="name">The name of the event.</param>
/// <param name="attributes">The attributes of the event.</param>
/// <param name="body">The body of the event.</param>
public EvaluationEvent(string name, Dictionary<string, object?> attributes, Dictionary<string, object> body)
{
this.Name = name;
this.Attributes = attributes;
this.Body = body;
}

/// <summary>
/// Gets or sets the name of the event.
/// </summary>
public string Name { get; set; }

/// <summary>
/// Gets or sets the attributes of the event.
/// </summary>
public Dictionary<string, object?> Attributes { get; set; }

/// <summary>
/// Gets or sets the body of the event.
/// </summary>
public Dictionary<string, object> Body { get; set; }
}
49 changes: 49 additions & 0 deletions src/OpenFeature/Telemetry/EvaluationEventBuilder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
using System.Collections.Generic;
using OpenFeature.Constant;
using OpenFeature.Model;

namespace OpenFeature.Telemetry;

/// <summary>
/// Class for creating evaluation events for feature flags.
/// </summary>
public static class EvaluationEventBuilder
{
private const string EventName = "feature_flag.evaluation";

/// <summary>
/// Creates an evaluation event based on the provided hook context and flag evaluation details.
/// </summary>
/// <param name="hookContext">The context of the hook containing flag key and provider metadata.</param>
/// <param name="details">The details of the flag evaluation including reason, variant, and metadata.</param>
/// <returns>An instance of <see cref="EvaluationEvent"/> containing the event name, attributes, and body.</returns>
public static EvaluationEvent Build(HookContext<Value> hookContext, FlagEvaluationDetails<Value> details)
{
var attributes = new Dictionary<string, object?>
{
{ TelemetryConstants.Key, hookContext.FlagKey },
{ TelemetryConstants.Provider, hookContext.ProviderMetadata.Name }
};


var body = new Dictionary<string, object>();

attributes[TelemetryConstants.Reason] = !string.IsNullOrWhiteSpace(details.Reason) ? details.Reason?.ToLowerInvariant() : Reason.Unknown;
attributes[TelemetryConstants.Variant] = details.Variant;
attributes[TelemetryFlagMetadata.ContextId] = details.FlagMetadata?.GetString(TelemetryFlagMetadata.ContextId);
attributes[TelemetryFlagMetadata.FlagSetId] = details.FlagMetadata?.GetString(TelemetryFlagMetadata.FlagSetId);
attributes[TelemetryFlagMetadata.Version] = details.FlagMetadata?.GetString(TelemetryFlagMetadata.Version);

if (details.ErrorType != ErrorType.None)
{
attributes[TelemetryConstants.ErrorCode] = details.ErrorType.ToString();

if (!string.IsNullOrWhiteSpace(details.ErrorMessage))
{
attributes[TelemetryConstants.ErrorMessage] = details.ErrorMessage ?? "N/A";
}
}

return new EvaluationEvent(EventName, attributes, body);
}
}
53 changes: 53 additions & 0 deletions src/OpenFeature/Telemetry/TelemetryConstants.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
namespace OpenFeature.Telemetry;

/// <summary>
/// The attributes of an OpenTelemetry compliant event for flag evaluation.
/// <see href="https://opentelemetry.io/docs/specs/semconv/feature-flags/feature-flags-logs/"/>
/// </summary>
public static class TelemetryConstants
{
/// <summary>
/// The lookup key of the feature flag.
/// </summary>
public const string Key = "feature_flag.key";

/// <summary>
/// Describes a class of error the operation ended with.
/// </summary>
public const string ErrorCode = "error.type";

/// <summary>
/// A semantic identifier for an evaluated flag value.
/// </summary>
public const string Variant = "feature_flag.variant";

/// <summary>
/// The unique identifier for the flag evaluation context. For example, the targeting key.
/// </summary>
public const string ContextId = "feature_flag.context.id";

/// <summary>
/// A message explaining the nature of an error occurring during flag evaluation.
/// </summary>
public const string ErrorMessage = "feature_flag.evaluation.error.message";

/// <summary>
/// The reason code which shows how a feature flag value was determined.
/// </summary>
public const string Reason = "feature_flag.evaluation.reason";

/// <summary>
/// Describes a class of error the operation ended with.
/// </summary>
public const string Provider = "feature_flag.provider_name";

/// <summary>
/// The identifier of the flag set to which the feature flag belongs.
/// </summary>
public const string FlagSetId = "feature_flag.set.id";

/// <summary>
/// The version of the ruleset used during the evaluation. This may be any stable value which uniquely identifies the ruleset.
/// </summary>
public const string Version = "feature_flag.version";
}
20 changes: 20 additions & 0 deletions src/OpenFeature/Telemetry/TelemetryEvaluationData.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
namespace OpenFeature.Telemetry;

/**
* Event data, sometimes referred to as "body", is specific to a specific event.
* In this case, the event is `feature_flag.evaluation`. That's why the prefix
* is omitted from the values.
* @see https://opentelemetry.io/docs/specs/semconv/feature-flags/feature-flags-logs/
*/
public static class TelemetryEvaluationData
{
/**
* The evaluated value of the feature flag.
*
* - type: `undefined`
* - requirement level: `conditionally required`
* - condition: variant is not defined on the evaluation details
* - example: `#ff0000`; `1`; `true`
*/
public const string Value = "value";
}
25 changes: 25 additions & 0 deletions src/OpenFeature/Telemetry/TelemetryFlagMetadata.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
namespace OpenFeature.Telemetry;

/**
* Well-known flag metadata attributes for telemetry events.
* @see https://openfeature.dev/specification/appendix-d#flag-metadata
*/
public static class TelemetryFlagMetadata
{
/**
* The context identifier returned in the flag metadata uniquely identifies
* the subject of the flag evaluation. If not available, the targeting key
* should be used.
*/
public const string ContextId = "contextId";

/**
* A logical identifier for the flag set.
*/
public const string FlagSetId = "flagSetId";

/**
* A version string (format unspecified) for the flag or flag set.
*/
public const string Version = "version";
}
131 changes: 131 additions & 0 deletions test/OpenFeature.Tests/Telemetry/EvaluationEventBuilderTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
using System.Collections.Generic;
using OpenFeature.Constant;
using OpenFeature.Model;
using OpenFeature.Telemetry;
using Xunit;

namespace OpenFeature.Tests.Telemetry
{
public class EvaluationEventBuilderTests
{
[Fact]
public void Build_ShouldReturnEventWithCorrectAttributes()
{
// Arrange
var clientMetadata = new ClientMetadata("client", "1.0.0");
var providerMetadata = new Metadata("provider");
var hookContext = new HookContext<Value>("flagKey", new Value(), FlagValueType.Object, clientMetadata,
providerMetadata, EvaluationContext.Empty);
var metadata = new Dictionary<string, object>
{
{ "flagSetId", "flagSetId" }, { "contextId", "contextId" }, { "version", "version" }
};
var flagMetadata = new ImmutableMetadata(metadata);
var details = new FlagEvaluationDetails<Value>("flagKey", new Value("value"), ErrorType.None,
reason: "reason", variant: "variant", flagMetadata: flagMetadata);

// Act
var evaluationEvent = EvaluationEventBuilder.Build(hookContext, details);

// Assert
Assert.Equal("feature_flag.evaluation", evaluationEvent.Name);
Assert.Equal("flagKey", evaluationEvent.Attributes[TelemetryConstants.Key]);
Assert.Equal("provider", evaluationEvent.Attributes[TelemetryConstants.Provider]);
Assert.Equal("reason", evaluationEvent.Attributes[TelemetryConstants.Reason]);
Assert.Equal("variant", evaluationEvent.Attributes[TelemetryConstants.Variant]);
Assert.Equal("contextId", evaluationEvent.Attributes[TelemetryFlagMetadata.ContextId]);
Assert.Equal("flagSetId", evaluationEvent.Attributes[TelemetryFlagMetadata.FlagSetId]);
Assert.Equal("version", evaluationEvent.Attributes[TelemetryFlagMetadata.Version]);
}

[Fact]
public void Build_ShouldHandleErrorDetails()
{
// Arrange
var clientMetadata = new ClientMetadata("client", "1.0.0");
var providerMetadata = new Metadata("provider");
var hookContext = new HookContext<Value>("flagKey", new Value(), FlagValueType.Object, clientMetadata,
providerMetadata, EvaluationContext.Empty);
var metadata = new Dictionary<string, object>
{
{ "flagSetId", "flagSetId" }, { "contextId", "contextId" }, { "version", "version" }
};
var flagMetadata = new ImmutableMetadata(metadata);
var details = new FlagEvaluationDetails<Value>("flagKey", new Value("value"), ErrorType.General,
errorMessage: "errorMessage", reason: "reason", variant: "variant", flagMetadata: flagMetadata);

// Act
var evaluationEvent = EvaluationEventBuilder.Build(hookContext, details);

// Assert
Assert.Equal("General", evaluationEvent.Attributes[TelemetryConstants.ErrorCode]);
Assert.Equal("errorMessage", evaluationEvent.Attributes[TelemetryConstants.ErrorMessage]);
}

[Fact]
public void Build_ShouldHandleMissingVariant()
{
// Arrange
var clientMetadata = new ClientMetadata("client", "1.0.0");
var providerMetadata = new Metadata("provider");
var hookContext = new HookContext<Value>("flagKey", new Value("value"), FlagValueType.Object, clientMetadata,
providerMetadata, EvaluationContext.Empty);
var metadata = new Dictionary<string, object>
{
{ "flagSetId", "flagSetId" }, { "contextId", "contextId" }, { "version", "version" }
};
var flagMetadata = new ImmutableMetadata(metadata);
var details = new FlagEvaluationDetails<Value>("flagKey", new Value("value"), ErrorType.None,
reason: "reason", variant: null, flagMetadata: flagMetadata);

// Act
var evaluationEvent = EvaluationEventBuilder.Build(hookContext, details);

// Assert
Assert.Null(evaluationEvent.Attributes[TelemetryConstants.Variant]);
}

[Fact]
public void Build_ShouldHandleMissingFlagMetadata()
{
// Arrange
var clientMetadata = new ClientMetadata("client", "1.0.0");
var providerMetadata = new Metadata("provider");
var hookContext = new HookContext<Value>("flagKey", new Value("value"), FlagValueType.Object, clientMetadata,
providerMetadata, EvaluationContext.Empty);
var flagMetadata = new ImmutableMetadata();
var details = new FlagEvaluationDetails<Value>("flagKey", new Value("value"), ErrorType.None,
reason: "reason", variant: "", flagMetadata: flagMetadata);

// Act
var evaluationEvent = EvaluationEventBuilder.Build(hookContext, details);

// Assert
Assert.Null(evaluationEvent.Attributes[TelemetryFlagMetadata.ContextId]);
Assert.Null(evaluationEvent.Attributes[TelemetryFlagMetadata.FlagSetId]);
Assert.Null(evaluationEvent.Attributes[TelemetryFlagMetadata.Version]);
}

[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Build_ShouldHandleMissingReason(string? reason)
{
// Arrange
var clientMetadata = new ClientMetadata("client", "1.0.0");
var providerMetadata = new Metadata("provider");
var hookContext = new HookContext<Value>("flagKey", new Value("value"), FlagValueType.Object, clientMetadata,
providerMetadata, EvaluationContext.Empty);
var flagMetadata = new ImmutableMetadata();
var details = new FlagEvaluationDetails<Value>("flagKey", new Value("value"), ErrorType.None,
reason: reason, variant: "", flagMetadata: flagMetadata);

// Act
var evaluationEvent = EvaluationEventBuilder.Build(hookContext, details);

// Assert
Assert.Equal(Reason.Unknown, evaluationEvent.Attributes[TelemetryConstants.Reason]);
}
}
}