Skip to content

Commit cbe92b5

Browse files
authored
feat: added OTel hook (#44)
Signed-off-by: Florian Bacher <[email protected]>
1 parent 9679fe4 commit cbe92b5

File tree

9 files changed

+338
-27
lines changed

9 files changed

+338
-27
lines changed

src/OpenFeature.Contrib.Hooks.Otel/Class1.cs

Lines changed: 0 additions & 12 deletions
This file was deleted.

src/OpenFeature.Contrib.Hooks.Otel/OpenFeature.Contrib.Hooks.Otel.csproj

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,12 @@
99
<Description>Open Telemetry Hook for .NET</Description>
1010
<PackageProjectUrl>https://openfeature.dev</PackageProjectUrl>
1111
<RepositoryUrl>https://github.com/open-feature/dotnet-sdk-contrib</RepositoryUrl>
12-
<Authors>Todd Baert</Authors>
12+
<Authors>Florian Bacher</Authors>
1313
</PropertyGroup>
1414

15+
<ItemGroup>
16+
<PackageReference Include="OpenTelemetry" Version="1.4.0" />
17+
<PackageReference Include="OpenTelemetry.Exporter.Console" Version="1.4.0" />
18+
</ItemGroup>
19+
1520
</Project>
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
using OpenFeature;
2+
using OpenFeature.Model;
3+
using System.Threading.Tasks;
4+
using System.Collections.Generic;
5+
using OpenTelemetry.Context.Propagation;
6+
using OpenTelemetry.Trace;
7+
8+
namespace OpenFeature.Contrib.Hooks.Otel
9+
10+
{
11+
/// <summary>
12+
/// Stub.
13+
/// </summary>
14+
public class OtelHook : Hook
15+
{
16+
17+
/// <summary>
18+
/// After is executed after a feature flag has been evaluated.
19+
/// </summary>
20+
/// <param name="context">The hook context</param>
21+
/// <param name="details">The result of the feature flag evaluation</param>
22+
/// <param name="hints">Hints for the feature flag evaluation</param>
23+
/// <returns>An awaitable Task object</returns>
24+
public override Task After<T>(HookContext<T> context, FlagEvaluationDetails<T> details,
25+
IReadOnlyDictionary<string, object> hints = null)
26+
{
27+
var span = Tracer.CurrentSpan;
28+
if (span != null)
29+
{
30+
var attributes = new Dictionary<string, object>
31+
{
32+
{"feature_flag.key", details.FlagKey},
33+
{"feature_flag.variant", details.Variant},
34+
{"feature_flag.provider_name", context.ProviderMetadata.Name}
35+
};
36+
span.AddEvent("feature_flag", new SpanAttributes(attributes));
37+
}
38+
39+
return Task.CompletedTask;
40+
}
41+
42+
/// <summary>
43+
/// Error is executed when an error during a feature flag evaluation occured.
44+
/// </summary>
45+
/// <param name="context">The hook context</param>
46+
/// <param name="error">The exception thrown by feature flag provider</param>
47+
/// <param name="hints">Hints for the feature flag evaluation</param>
48+
/// <returns>An awaitable Task object</returns>
49+
public override Task Error<T>(HookContext<T> context, System.Exception error,
50+
IReadOnlyDictionary<string, object> hints = null)
51+
{
52+
var span = Tracer.CurrentSpan;
53+
if (span != null)
54+
{
55+
span.RecordException(error);
56+
}
57+
58+
return Task.CompletedTask;
59+
}
60+
61+
}
62+
}
63+
64+
Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,69 @@
11
# OpenFeature OpenTelemetry hook for .NET
22

3-
Coming soon!
3+
### Requirements
4+
5+
- open-feature/dotnet-sdk >= v1.0
6+
7+
## Usage
8+
9+
For this hook to function correctly a global `TracerProvider` must be set, an example of how to do this can be found below.
10+
11+
The `open telemetry hook` taps into the after and error methods of the hook lifecycle to write `events` and `attributes` to an existing `span`.
12+
For this, an active span must be set in the `Tracer`, otherwise the hook will no-op.
13+
14+
### Example
15+
The following example demonstrates the use of the `OpenTelemetry hook` with the `OpenFeature dotnet-sdk`. The traces are sent to a `jaeger` OTLP collector running at `localhost:4317`.
16+
17+
```csharp
18+
using OpenFeature.Contrib.Providers.Flagd;
19+
using OpenFeature.Contrib.Hooks.Otel;
20+
using OpenTelemetry.Exporter;
21+
using OpenTelemetry.Resources;
22+
using OpenTelemetry;
23+
using OpenTelemetry.Trace;
24+
25+
namespace OpenFeatureTestApp
26+
{
27+
class Hello {
28+
static void Main(string[] args) {
29+
30+
// set up the OpenTelemetry OTLP exporter
31+
var tracerProvider = Sdk.CreateTracerProviderBuilder()
32+
.AddSource("my-tracer")
33+
.ConfigureResource(r => r.AddService("jaeger-test"))
34+
.AddOtlpExporter(o =>
35+
{
36+
o.ExportProcessorType = ExportProcessorType.Simple;
37+
})
38+
.Build();
39+
40+
// add the Otel Hook to the OpenFeature instance
41+
OpenFeature.Api.Instance.AddHooks(new OtelHook());
42+
43+
var flagdProvider = new FlagdProvider(new Uri("http://localhost:8013"));
44+
45+
// Set the flagdProvider as the provider for the OpenFeature SDK
46+
OpenFeature.Api.Instance.SetProvider(flagdProvider);
47+
48+
var client = OpenFeature.Api.Instance.GetClient("my-app");
49+
50+
var val = client.GetBooleanValue("myBoolFlag", false, null);
51+
52+
// Print the value of the 'myBoolFlag' feature flag
53+
System.Console.WriteLine(val.Result.ToString());
54+
}
55+
}
56+
}
57+
```
58+
59+
After running this example, you will be able to see the traces, including the events sent by the hook in your Jaeger UI:
60+
61+
![](./assets/otlp-success.png)
62+
63+
In case something went wrong during a feature flag evaluation, you will see an event containing error details in the span:
64+
65+
![](./assets/otlp-error.png)
66+
67+
## License
68+
69+
Apache 2.0 - See [LICENSE](./../../LICENSE) for more information.
259 KB
Loading
109 KB
Loading
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,12 @@
11
<Project Sdk="Microsoft.NET.Sdk">
22

3+
<ItemGroup>
4+
<ProjectReference Include="..\..\src\OpenFeature.Contrib.Hooks.Otel\OpenFeature.Contrib.Hooks.Otel.csproj" />
5+
</ItemGroup>
6+
7+
<ItemGroup>
8+
<PackageReference Include="OpenTelemetry" Version="1.4.0" />
9+
<PackageReference Include="OpenTelemetry.Exporter.InMemory" Version="1.4.0" />
10+
</ItemGroup>
11+
312
</Project>
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
using Xunit;
2+
using System.Diagnostics;
3+
using OpenFeature.Model;
4+
using OpenTelemetry;
5+
using OpenTelemetry.Resources;
6+
using OpenTelemetry.Trace;
7+
using System.Collections.Generic;
8+
using OpenTelemetry.Exporter;
9+
using System.Linq;
10+
11+
namespace OpenFeature.Contrib.Hooks.Otel.Test
12+
{
13+
public class OtelHookTest
14+
{
15+
[Fact]
16+
public void TestAfter()
17+
{
18+
// List that will be populated with the traces by InMemoryExporter
19+
var exportedItems = new List<Activity>();
20+
21+
// Create a new in-memory exporter
22+
var exporter = new InMemoryExporter<Activity>(exportedItems);
23+
24+
var tracerProvider = Sdk.CreateTracerProviderBuilder()
25+
.AddSource("my-tracer")
26+
.ConfigureResource(r => r.AddService("inmemory-test"))
27+
.AddInMemoryExporter(exportedItems)
28+
.Build();
29+
30+
31+
var tracer = tracerProvider.GetTracer("my-tracer");
32+
33+
var span = tracer.StartActiveSpan("my-span");
34+
35+
var otelHook = new OtelHook();
36+
37+
var evaluationContext = OpenFeature.Model.EvaluationContext.Empty;
38+
39+
var ctx = new HookContext<string>("my-flag", "foo", Constant.FlagValueType.String, new ClientMetadata("my-client", "1.0"), new Metadata("my-provider"), evaluationContext);
40+
41+
var hookTask = otelHook.After<string>(ctx, new FlagEvaluationDetails<string>("my-flag", "foo", Constant.ErrorType.None, "STATIC", "default"), new Dictionary<string, object>());
42+
43+
Assert.True(hookTask.IsCompleted);
44+
45+
span.End();
46+
47+
Assert.Single(exportedItems);
48+
49+
var rootSpan = exportedItems[0];
50+
51+
Assert.Single(rootSpan.Events);
52+
53+
var eventsEnum = rootSpan.Events.GetEnumerator();
54+
eventsEnum.MoveNext();
55+
56+
ActivityEvent ev = (ActivityEvent)eventsEnum.Current;
57+
Assert.Equal("feature_flag", ev.Name);
58+
59+
var tagsEnum = ev.Tags.GetEnumerator();
60+
61+
Assert.True(
62+
Enumerable.Contains<KeyValuePair<string, object>>(
63+
ev.Tags, new KeyValuePair<string, object>("feature_flag.key", "my-flag")
64+
)
65+
);
66+
Assert.True(
67+
Enumerable.Contains<KeyValuePair<string, object>>(
68+
ev.Tags, new KeyValuePair<string, object>("feature_flag.variant", "default")
69+
)
70+
);
71+
Assert.True(
72+
Enumerable.Contains<KeyValuePair<string, object>>(
73+
ev.Tags, new KeyValuePair<string, object>("feature_flag.provider_name", "my-provider")
74+
)
75+
);
76+
}
77+
78+
[Fact]
79+
public void TestAfterNoSpan()
80+
{
81+
// List that will be populated with the traces by InMemoryExporter
82+
var exportedItems = new List<Activity>();
83+
84+
// Create a new in-memory exporter
85+
var exporter = new InMemoryExporter<Activity>(exportedItems);
86+
87+
var tracerProvider = Sdk.CreateTracerProviderBuilder()
88+
.AddSource("my-tracer")
89+
.ConfigureResource(r => r.AddService("inmemory-test"))
90+
.AddInMemoryExporter(exportedItems)
91+
.Build();
92+
93+
94+
var tracer = tracerProvider.GetTracer("my-tracer");
95+
96+
var otelHook = new OtelHook();
97+
98+
var evaluationContext = OpenFeature.Model.EvaluationContext.Empty;
99+
100+
var ctx = new HookContext<string>("my-flag", "foo", Constant.FlagValueType.String, new ClientMetadata("my-client", "1.0"), new Metadata("my-provider"), evaluationContext);
101+
102+
var hookTask = otelHook.After<string>(ctx, new FlagEvaluationDetails<string>("my-flag", "foo", Constant.ErrorType.None, "STATIC", "default"), new Dictionary<string, object>());
103+
104+
Assert.True(hookTask.IsCompleted);
105+
106+
Assert.Empty(exportedItems);
107+
}
108+
109+
[Fact]
110+
public void TestError()
111+
{
112+
// List that will be populated with the traces by InMemoryExporter
113+
var exportedItems = new List<Activity>();
114+
115+
// Create a new in-memory exporter
116+
var exporter = new InMemoryExporter<Activity>(exportedItems);
117+
118+
var tracerProvider = Sdk.CreateTracerProviderBuilder()
119+
.AddSource("my-tracer")
120+
.ConfigureResource(r => r.AddService("inmemory-test"))
121+
.AddInMemoryExporter(exportedItems)
122+
.Build();
123+
124+
125+
var tracer = tracerProvider.GetTracer("my-tracer");
126+
127+
var span = tracer.StartActiveSpan("my-span");
128+
129+
var otelHook = new OtelHook();
130+
131+
var evaluationContext = OpenFeature.Model.EvaluationContext.Empty;
132+
133+
var ctx = new HookContext<string>("my-flag", "foo", Constant.FlagValueType.String, new ClientMetadata("my-client", "1.0"), new Metadata("my-provider"), evaluationContext);
134+
135+
var hookTask = otelHook.Error<string>(ctx, new System.Exception("unexpected error"), new Dictionary<string, object>());
136+
137+
Assert.True(hookTask.IsCompleted);
138+
139+
span.End();
140+
141+
Assert.Single(exportedItems);
142+
143+
var rootSpan = exportedItems[0];
144+
145+
Assert.Single(rootSpan.Events);
146+
147+
var enumerator = rootSpan.Events.GetEnumerator();
148+
enumerator.MoveNext();
149+
var ev = (ActivityEvent)enumerator.Current;
150+
151+
Assert.Equal("exception", ev.Name);
152+
153+
Assert.True(
154+
Enumerable.Contains<KeyValuePair<string, object>>(
155+
ev.Tags, new KeyValuePair<string, object>("exception.message", "unexpected error")
156+
)
157+
);
158+
}
159+
160+
[Fact]
161+
public void TestErrorNoSpan()
162+
{
163+
// List that will be populated with the traces by InMemoryExporter
164+
var exportedItems = new List<Activity>();
165+
166+
// Create a new in-memory exporter
167+
var exporter = new InMemoryExporter<Activity>(exportedItems);
168+
169+
var tracerProvider = Sdk.CreateTracerProviderBuilder()
170+
.AddSource("my-tracer")
171+
.ConfigureResource(r => r.AddService("inmemory-test"))
172+
.AddInMemoryExporter(exportedItems)
173+
.Build();
174+
175+
176+
var tracer = tracerProvider.GetTracer("my-tracer");
177+
178+
var otelHook = new OtelHook();
179+
180+
var evaluationContext = OpenFeature.Model.EvaluationContext.Empty;
181+
182+
var ctx = new HookContext<string>("my-flag", "foo", Constant.FlagValueType.String, new ClientMetadata("my-client", "1.0"), new Metadata("my-provider"), evaluationContext);
183+
184+
var hookTask = otelHook.Error<string>(ctx, new System.Exception("unexpected error"), new Dictionary<string, object>());
185+
186+
Assert.True(hookTask.IsCompleted);
187+
188+
Assert.Empty(exportedItems);
189+
}
190+
}
191+
}
192+

test/OpenFeature.Contrib.Hooks.Otel.Test/UnitTest1.cs

Lines changed: 0 additions & 13 deletions
This file was deleted.

0 commit comments

Comments
 (0)