Skip to content

Commit 6c44db9

Browse files
authored
feat: Add OTEL compatible telemetry object builder (#397)
<!-- Please use this template for your pull request. --> <!-- Please use the sections that you need and delete other sections --> ## This PR This pull request introduces a feature for generating telemetry events to track feature flag evaluations. It adds a new `EvaluationEvent` class, a builder for creating these events, constants for telemetry attributes, and metadata definitions. It also includes comprehensive unit tests to ensure the correctness of the implementation. ### New Feature: Telemetry Event Generation * **`EvaluationEvent` class**: Added a new class to represent evaluation events for feature flags, including properties for the event name and attributes. (`src/OpenFeature/Telemetry/EvaluationEvent.cs`) * **`EvaluationEventBuilder` class**: Introduced a static builder class to construct `EvaluationEvent` instances using flag evaluation details and hook context. (`src/OpenFeature/Telemetry/EvaluationEventBuilder.cs`) ### Supporting Components * **Telemetry constants**: Defined a set of constants in `TelemetryConstants` to standardize attribute keys for OpenTelemetry-compliant feature flag events. (`src/OpenFeature/Telemetry/TelemetryConstants.cs`) * **Flag metadata attributes**: Added `TelemetryFlagMetadata` to define well-known metadata attributes for telemetry events, such as `contextId`, `flagSetId`, and `version`. (`src/OpenFeature/Telemetry/TelemetryFlagMetadata.cs`) ### Unit Tests * **`EvaluationEventBuilderTests`**: Added unit tests to validate the behavior of `EvaluationEventBuilder`, including scenarios for handling errors, missing metadata, and missing attributes. (`test/OpenFeature.Tests/Telemetry/EvaluationEventBuilderTests.cs`) ### Related Issues <!-- add here the GitHub issue that this PR resolves if applicable --> Fixes #381 --------- Signed-off-by: André Silva <[email protected]>
1 parent fbcf3a4 commit 6c44db9

File tree

5 files changed

+346
-0
lines changed

5 files changed

+346
-0
lines changed
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
using System.Collections.Generic;
2+
3+
namespace OpenFeature.Telemetry;
4+
5+
/// <summary>
6+
/// Represents an evaluation event for feature flags.
7+
/// </summary>
8+
public class EvaluationEvent
9+
{
10+
/// <summary>
11+
/// Initializes a new instance of the <see cref="EvaluationEvent"/> class.
12+
/// </summary>
13+
/// <param name="name">The name of the event.</param>
14+
/// <param name="attributes">The attributes of the event.</param>
15+
public EvaluationEvent(string name, IDictionary<string, object?> attributes)
16+
{
17+
Name = name;
18+
Attributes = new Dictionary<string, object?>(attributes);
19+
}
20+
21+
/// <summary>
22+
/// Gets the name of the event.
23+
/// </summary>
24+
public string Name { get; }
25+
26+
/// <summary>
27+
/// Gets the attributes of the event.
28+
/// </summary>
29+
public IReadOnlyDictionary<string, object?> Attributes { get; }
30+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
using System.Collections.Generic;
2+
using OpenFeature.Constant;
3+
using OpenFeature.Model;
4+
5+
namespace OpenFeature.Telemetry;
6+
7+
/// <summary>
8+
/// Class for creating evaluation events for feature flags.
9+
/// </summary>
10+
public sealed class EvaluationEventBuilder
11+
{
12+
private const string EventName = "feature_flag.evaluation";
13+
14+
/// <summary>
15+
/// Gets the default instance of the <see cref="EvaluationEventBuilder"/>.
16+
/// </summary>
17+
public static EvaluationEventBuilder Default { get; } = new();
18+
19+
/// <summary>
20+
/// Creates an evaluation event based on the provided hook context and flag evaluation details.
21+
/// </summary>
22+
/// <param name="hookContext">The context of the hook containing flag key and provider metadata.</param>
23+
/// <param name="details">The details of the flag evaluation including reason, variant, and metadata.</param>
24+
/// <returns>An instance of <see cref="EvaluationEvent"/> containing the event name, attributes, and body.</returns>
25+
public EvaluationEvent Build(HookContext<Value> hookContext, FlagEvaluationDetails<Value> details)
26+
{
27+
var attributes = new Dictionary<string, object?>
28+
{
29+
{ TelemetryConstants.Key, hookContext.FlagKey },
30+
{ TelemetryConstants.Provider, hookContext.ProviderMetadata.Name }
31+
};
32+
33+
attributes[TelemetryConstants.Reason] = !string.IsNullOrWhiteSpace(details.Reason)
34+
? details.Reason?.ToLowerInvariant()
35+
: Reason.Unknown.ToLowerInvariant();
36+
attributes[TelemetryConstants.Variant] = details.Variant;
37+
attributes[TelemetryConstants.Value] = details.Value;
38+
39+
if (details.FlagMetadata != null)
40+
{
41+
attributes[TelemetryConstants.ContextId] = details.FlagMetadata.GetString(TelemetryFlagMetadata.ContextId);
42+
attributes[TelemetryConstants.FlagSetId] = details.FlagMetadata.GetString(TelemetryFlagMetadata.FlagSetId);
43+
attributes[TelemetryConstants.Version] = details.FlagMetadata.GetString(TelemetryFlagMetadata.Version);
44+
}
45+
46+
if (details.ErrorType != ErrorType.None)
47+
{
48+
attributes[TelemetryConstants.ErrorCode] = details.ErrorType.ToString().ToLowerInvariant();
49+
50+
if (!string.IsNullOrWhiteSpace(details.ErrorMessage))
51+
{
52+
attributes[TelemetryConstants.ErrorMessage] = details.ErrorMessage;
53+
}
54+
}
55+
56+
return new EvaluationEvent(EventName, attributes);
57+
}
58+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
namespace OpenFeature.Telemetry;
2+
3+
/// <summary>
4+
/// The attributes of an OpenTelemetry compliant event for flag evaluation.
5+
/// <see href="https://opentelemetry.io/docs/specs/semconv/feature-flags/feature-flags-logs/"/>
6+
/// </summary>
7+
public static class TelemetryConstants
8+
{
9+
/// <summary>
10+
/// The lookup key of the feature flag.
11+
/// </summary>
12+
public const string Key = "feature_flag.key";
13+
14+
/// <summary>
15+
/// Describes a class of error the operation ended with.
16+
/// </summary>
17+
public const string ErrorCode = "error.type";
18+
19+
/// <summary>
20+
/// A message explaining the nature of an error occurring during flag evaluation.
21+
/// </summary>
22+
public const string ErrorMessage = "error.message";
23+
24+
/// <summary>
25+
/// A semantic identifier for an evaluated flag value.
26+
/// </summary>
27+
public const string Variant = "feature_flag.result.variant";
28+
29+
/// <summary>
30+
/// The evaluated value of the feature flag.
31+
/// </summary>
32+
public const string Value = "feature_flag.result.value";
33+
34+
/// <summary>
35+
/// The unique identifier for the flag evaluation context. For example, the targeting key.
36+
/// </summary>
37+
public const string ContextId = "feature_flag.context.id";
38+
39+
/// <summary>
40+
/// The reason code which shows how a feature flag value was determined.
41+
/// </summary>
42+
public const string Reason = "feature_flag.result.reason";
43+
44+
/// <summary>
45+
/// Describes a class of error the operation ended with.
46+
/// </summary>
47+
public const string Provider = "feature_flag.provider.name";
48+
49+
/// <summary>
50+
/// The identifier of the flag set to which the feature flag belongs.
51+
/// </summary>
52+
public const string FlagSetId = "feature_flag.set.id";
53+
54+
/// <summary>
55+
/// The version of the ruleset used during the evaluation. This may be any stable value which uniquely identifies the ruleset.
56+
/// </summary>
57+
public const string Version = "feature_flag.version";
58+
59+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
namespace OpenFeature.Telemetry;
2+
3+
/// <summary>
4+
/// Well-known flag metadata attributes for telemetry events.
5+
/// <remarks>See also: https://openfeature.dev/specification/appendix-d#flag-metadata</remarks>
6+
/// </summary>
7+
public static class TelemetryFlagMetadata
8+
{
9+
/// <summary>
10+
/// The context identifier returned in the flag metadata uniquely identifies
11+
/// the subject of the flag evaluation. If not available, the targeting key
12+
/// should be used.
13+
/// </summary>
14+
public const string ContextId = "contextId";
15+
16+
/// <summary>
17+
/// ///A logical identifier for the flag set.
18+
/// </summary>
19+
public const string FlagSetId = "flagSetId";
20+
21+
/// <summary>
22+
/// A version string (format unspecified) for the flag or flag set.
23+
/// </summary>
24+
public const string Version = "version";
25+
}
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
using System.Collections.Generic;
2+
using OpenFeature.Constant;
3+
using OpenFeature.Model;
4+
using OpenFeature.Telemetry;
5+
using Xunit;
6+
7+
namespace OpenFeature.Tests.Telemetry;
8+
9+
public class EvaluationEventBuilderTests
10+
{
11+
private readonly EvaluationEventBuilder _builder = EvaluationEventBuilder.Default;
12+
13+
[Fact]
14+
public void Build_ShouldReturnEventWithCorrectAttributes()
15+
{
16+
// Arrange
17+
var clientMetadata = new ClientMetadata("client", "1.0.0");
18+
var providerMetadata = new Metadata("provider");
19+
var hookContext = new HookContext<Value>("flagKey", new Value(), FlagValueType.Object, clientMetadata,
20+
providerMetadata, EvaluationContext.Empty);
21+
var metadata = new Dictionary<string, object>
22+
{
23+
{ "flagSetId", "flagSetId" }, { "contextId", "contextId" }, { "version", "version" }
24+
};
25+
var flagMetadata = new ImmutableMetadata(metadata);
26+
var details = new FlagEvaluationDetails<Value>("flagKey", new Value("value"), ErrorType.None,
27+
reason: "reason", variant: "variant", flagMetadata: flagMetadata);
28+
29+
// Act
30+
var evaluationEvent = _builder.Build(hookContext, details);
31+
32+
// Assert
33+
Assert.Equal("feature_flag.evaluation", evaluationEvent.Name);
34+
Assert.Equal("flagKey", evaluationEvent.Attributes[TelemetryConstants.Key]);
35+
Assert.Equal("provider", evaluationEvent.Attributes[TelemetryConstants.Provider]);
36+
Assert.Equal("reason", evaluationEvent.Attributes[TelemetryConstants.Reason]);
37+
Assert.Equal("variant", evaluationEvent.Attributes[TelemetryConstants.Variant]);
38+
Assert.Equal("contextId", evaluationEvent.Attributes[TelemetryConstants.ContextId]);
39+
Assert.Equal("flagSetId", evaluationEvent.Attributes[TelemetryConstants.FlagSetId]);
40+
Assert.Equal("version", evaluationEvent.Attributes[TelemetryConstants.Version]);
41+
}
42+
43+
[Fact]
44+
public void Build_ShouldHandleErrorDetails()
45+
{
46+
// Arrange
47+
var clientMetadata = new ClientMetadata("client", "1.0.0");
48+
var providerMetadata = new Metadata("provider");
49+
var hookContext = new HookContext<Value>("flagKey", new Value(), FlagValueType.Object, clientMetadata,
50+
providerMetadata, EvaluationContext.Empty);
51+
var metadata = new Dictionary<string, object>
52+
{
53+
{ "flagSetId", "flagSetId" }, { "contextId", "contextId" }, { "version", "version" }
54+
};
55+
var flagMetadata = new ImmutableMetadata(metadata);
56+
var details = new FlagEvaluationDetails<Value>("flagKey", new Value("value"), ErrorType.General,
57+
errorMessage: "errorMessage", reason: "reason", variant: "variant", flagMetadata: flagMetadata);
58+
59+
// Act
60+
var evaluationEvent = _builder.Build(hookContext, details);
61+
62+
// Assert
63+
Assert.Equal("general", evaluationEvent.Attributes[TelemetryConstants.ErrorCode]);
64+
Assert.Equal("errorMessage", evaluationEvent.Attributes[TelemetryConstants.ErrorMessage]);
65+
}
66+
67+
[Fact]
68+
public void Build_ShouldHandleMissingVariant()
69+
{
70+
// Arrange
71+
var clientMetadata = new ClientMetadata("client", "1.0.0");
72+
var providerMetadata = new Metadata("provider");
73+
var hookContext = new HookContext<Value>("flagKey", new Value("value"), FlagValueType.Object, clientMetadata,
74+
providerMetadata, EvaluationContext.Empty);
75+
var metadata = new Dictionary<string, object>
76+
{
77+
{ "flagSetId", "flagSetId" }, { "contextId", "contextId" }, { "version", "version" }
78+
};
79+
var flagMetadata = new ImmutableMetadata(metadata);
80+
var details = new FlagEvaluationDetails<Value>("flagKey", new Value("value"), ErrorType.None,
81+
reason: "reason", variant: null, flagMetadata: flagMetadata);
82+
83+
// Act
84+
var evaluationEvent = _builder.Build(hookContext, details);
85+
86+
// Assert
87+
Assert.Null(evaluationEvent.Attributes[TelemetryConstants.Variant]);
88+
}
89+
90+
[Fact]
91+
public void Build_ShouldHandleMissingFlagMetadata()
92+
{
93+
// Arrange
94+
var clientMetadata = new ClientMetadata("client", "1.0.0");
95+
var providerMetadata = new Metadata("provider");
96+
var hookContext = new HookContext<Value>("flagKey", new Value("value"), FlagValueType.Object, clientMetadata,
97+
providerMetadata, EvaluationContext.Empty);
98+
var flagMetadata = new ImmutableMetadata();
99+
var details = new FlagEvaluationDetails<Value>("flagKey", new Value("value"), ErrorType.None,
100+
reason: "reason", variant: "", flagMetadata: flagMetadata);
101+
102+
// Act
103+
var evaluationEvent = _builder.Build(hookContext, details);
104+
105+
// Assert
106+
Assert.Null(evaluationEvent.Attributes[TelemetryConstants.ContextId]);
107+
Assert.Null(evaluationEvent.Attributes[TelemetryConstants.FlagSetId]);
108+
Assert.Null(evaluationEvent.Attributes[TelemetryConstants.Version]);
109+
}
110+
111+
[Theory]
112+
[InlineData(null)]
113+
[InlineData("")]
114+
[InlineData(" ")]
115+
public void Build_ShouldHandleMissingReason(string? reason)
116+
{
117+
// Arrange
118+
var clientMetadata = new ClientMetadata("client", "1.0.0");
119+
var providerMetadata = new Metadata("provider");
120+
var hookContext = new HookContext<Value>("flagKey", new Value("value"), FlagValueType.Object, clientMetadata,
121+
providerMetadata, EvaluationContext.Empty);
122+
var flagMetadata = new ImmutableMetadata();
123+
var details = new FlagEvaluationDetails<Value>("flagKey", new Value("value"), ErrorType.None,
124+
reason: reason, variant: "", flagMetadata: flagMetadata);
125+
126+
// Act
127+
var evaluationEvent = _builder.Build(hookContext, details);
128+
129+
// Assert
130+
Assert.Equal(Reason.Unknown.ToLowerInvariant(), evaluationEvent.Attributes[TelemetryConstants.Reason]);
131+
}
132+
133+
[Theory]
134+
[InlineData(null)]
135+
[InlineData("")]
136+
[InlineData(" ")]
137+
public void Build_ShouldHandleErrorWithEmptyErrorMessage(string? errorMessage)
138+
{
139+
// Arrange
140+
var clientMetadata = new ClientMetadata("client", "1.0.0");
141+
var providerMetadata = new Metadata("provider");
142+
var hookContext = new HookContext<Value>("flagKey", new Value("value"), FlagValueType.Object, clientMetadata,
143+
providerMetadata, EvaluationContext.Empty);
144+
var flagMetadata = new ImmutableMetadata();
145+
var details = new FlagEvaluationDetails<Value>("flagKey", new Value("value"), ErrorType.General,
146+
errorMessage: errorMessage, reason: "reason", variant: "", flagMetadata: flagMetadata);
147+
148+
// Act
149+
var evaluationEvent = _builder.Build(hookContext, details);
150+
151+
// Assert
152+
Assert.Equal("general", evaluationEvent.Attributes[TelemetryConstants.ErrorCode]);
153+
Assert.False(evaluationEvent.Attributes.ContainsKey(TelemetryConstants.ErrorMessage));
154+
}
155+
156+
[Fact]
157+
public void Build_ShouldIncludeValueAttributeInEvent()
158+
{
159+
// Arrange
160+
var clientMetadata = new ClientMetadata("client", "1.0.0");
161+
var providerMetadata = new Metadata("provider");
162+
var hookContext = new HookContext<Value>("flagKey", new Value(), FlagValueType.Object, clientMetadata,
163+
providerMetadata, EvaluationContext.Empty);
164+
var testValue = new Value("test-value");
165+
var details = new FlagEvaluationDetails<Value>("flagKey", testValue, ErrorType.None,
166+
reason: "reason", variant: "variant", flagMetadata: new ImmutableMetadata());
167+
168+
// Act
169+
var evaluationEvent = _builder.Build(hookContext, details);
170+
171+
// Assert
172+
Assert.Equal(testValue, evaluationEvent.Attributes[TelemetryConstants.Value]);
173+
}
174+
}

0 commit comments

Comments
 (0)