Skip to content

feat: Add OTEL compatible telemetry object builder #397

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

Open
wants to merge 21 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
00f75fc
feat: Add TelemetryAttributes for OpenTelemetry compliant event logging
askpt Mar 6, 2025
c6287eb
feat: Add TelemetryEvaluationData for feature flag event logging
askpt Mar 6, 2025
38a110a
feat: Add TelemetryFlagMetadata for flag metadata attributes
askpt Mar 6, 2025
75330fe
feat: Rename TelemetryAttributes to TelemetryConstants and add Evalua…
askpt Mar 6, 2025
783df4f
feat: Introduce EvaluationEvent class and refactor EvaluationEventFac…
askpt Mar 6, 2025
f94d83a
feat: Add EvaluationEventBuilder for creating evaluation events for f…
askpt Mar 7, 2025
f6f3d0f
test: Add unit tests for EvaluationEventBuilder to validate event cre…
askpt Mar 7, 2025
30949be
Merge branch 'main' into askpt/381-add-opentelemetry-compatible-telem…
askpt Mar 7, 2025
5369348
fix: Simplify variant assignment in EvaluationEventBuilder and enhanc…
askpt Mar 7, 2025
2d81ada
fix: Ensure proper handling of missing reason in EvaluationEventBuild…
askpt Mar 7, 2025
8a38d09
Merge branch 'main' into askpt/381-add-opentelemetry-compatible-telem…
askpt Mar 11, 2025
0d1a869
fix: Normalize error code to lowercase in EvaluationEventBuilder
askpt Mar 11, 2025
2a69a2a
Merge branch 'main' into askpt/381-add-opentelemetry-compatible-telem…
askpt Mar 12, 2025
81edc80
Merge branch 'main' into askpt/381-add-opentelemetry-compatible-telem…
askpt Apr 8, 2025
dd7793c
Merge branch 'main' into askpt/381-add-opentelemetry-compatible-telem…
askpt Apr 17, 2025
26d7dd5
fix: Correct telemetry constant values for feature flag attributes
askpt Apr 17, 2025
6a51440
Merge branch 'main' into askpt/381-add-opentelemetry-compatible-telem…
askpt Apr 17, 2025
5c11da2
Merge branch 'main' into askpt/381-add-opentelemetry-compatible-telem…
askpt Apr 22, 2025
b373d16
Merge branch 'main' into askpt/381-add-opentelemetry-compatible-telem…
askpt Apr 24, 2025
62f3b59
Merge branch 'main' into askpt/381-add-opentelemetry-compatible-telem…
askpt Apr 28, 2025
6b48487
refactor(tests): streamline EvaluationEventBuilderTests for clarity a…
askpt Apr 28, 2025
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
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()?.ToLowerInvariant();

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