Skip to content

Commit 7013e95

Browse files
feat: Implement Default Logging Hook (#308)
Signed-off-by: Kyle Julian <[email protected]>
1 parent 728ae47 commit 7013e95

File tree

5 files changed

+864
-1
lines changed

5 files changed

+864
-1
lines changed

Directory.Packages.props

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
<PackageVersion Include="coverlet.msbuild" Version="6.0.3" />
2424
<PackageVersion Include="FluentAssertions" Version="7.0.0" />
2525
<PackageVersion Include="GitHubActionsTestLogger" Version="2.4.1" />
26+
<PackageVersion Include="Microsoft.Extensions.Diagnostics.Testing" Version="9.0.0" />
2627
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
2728
<PackageVersion Include="NSubstitute" Version="5.3.0" />
2829
<PackageVersion Include="SpecFlow" Version="3.9.74" />

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,19 @@ var value = await client.GetBooleanValueAsync("boolFlag", false, context, new Fl
153153

154154
The .NET SDK uses Microsoft.Extensions.Logging. See the [manual](https://learn.microsoft.com/en-us/dotnet/core/extensions/logging?tabs=command-line) for complete documentation.
155155

156+
#### Logging Hook
157+
158+
The .NET SDK includes a LoggingHook, which logs detailed information at key points during flag evaluation, using Microsoft.Extensions.Logging structured logging API. This hook can be particularly helpful for troubleshooting and debugging; simply attach it at the global, client or invocation level and ensure your log level is set to "debug".
159+
160+
```csharp
161+
using var loggerFactory = LoggerFactory.Create(builder => builder.AddConsole());
162+
var logger = loggerFactory.CreateLogger("Program");
163+
164+
var client = Api.Instance.GetClient();
165+
client.AddHooks(new LoggingHook(logger));
166+
```
167+
See [hooks](#hooks) for more information on configuring hooks.
168+
156169
### Domains
157170

158171
Clients can be assigned to a domain.

src/OpenFeature/Hooks/LoggingHook.cs

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Text;
4+
using System.Threading;
5+
using System.Threading.Tasks;
6+
using Microsoft.Extensions.Logging;
7+
using OpenFeature.Model;
8+
9+
namespace OpenFeature.Hooks
10+
{
11+
/// <summary>
12+
/// The logging hook is a hook which logs messages during the flag evaluation life-cycle.
13+
/// </summary>
14+
public sealed partial class LoggingHook : Hook
15+
{
16+
private readonly ILogger _logger;
17+
private readonly bool _includeContext;
18+
19+
/// <summary>
20+
/// Initialise a <see cref="LoggingHook"/> with a <paramref name="logger"/> and optional Evaluation Context. <paramref name="includeContext"/> will
21+
/// include properties in the <see cref="HookContext{T}.EvaluationContext"/> to the generated logs.
22+
/// </summary>
23+
public LoggingHook(ILogger logger, bool includeContext = false)
24+
{
25+
this._logger = logger ?? throw new ArgumentNullException(nameof(logger));
26+
this._includeContext = includeContext;
27+
}
28+
29+
/// <inheritdoc/>
30+
public override ValueTask<EvaluationContext> BeforeAsync<T>(HookContext<T> context, IReadOnlyDictionary<string, object>? hints = null, CancellationToken cancellationToken = default)
31+
{
32+
var evaluationContext = this._includeContext ? context.EvaluationContext : null;
33+
34+
var content = new LoggingHookContent(
35+
context.ClientMetadata.Name,
36+
context.ProviderMetadata.Name,
37+
context.FlagKey,
38+
context.DefaultValue?.ToString(),
39+
evaluationContext);
40+
41+
this.HookBeforeStageExecuted(content);
42+
43+
return base.BeforeAsync(context, hints, cancellationToken);
44+
}
45+
46+
/// <inheritdoc/>
47+
public override ValueTask ErrorAsync<T>(HookContext<T> context, Exception error, IReadOnlyDictionary<string, object>? hints = null, CancellationToken cancellationToken = default)
48+
{
49+
var evaluationContext = this._includeContext ? context.EvaluationContext : null;
50+
51+
var content = new LoggingHookContent(
52+
context.ClientMetadata.Name,
53+
context.ProviderMetadata.Name,
54+
context.FlagKey,
55+
context.DefaultValue?.ToString(),
56+
evaluationContext);
57+
58+
this.HookErrorStageExecuted(content);
59+
60+
return base.ErrorAsync(context, error, hints, cancellationToken);
61+
}
62+
63+
/// <inheritdoc/>
64+
public override ValueTask AfterAsync<T>(HookContext<T> context, FlagEvaluationDetails<T> details, IReadOnlyDictionary<string, object>? hints = null, CancellationToken cancellationToken = default)
65+
{
66+
var evaluationContext = this._includeContext ? context.EvaluationContext : null;
67+
68+
var content = new LoggingHookContent(
69+
context.ClientMetadata.Name,
70+
context.ProviderMetadata.Name,
71+
context.FlagKey,
72+
context.DefaultValue?.ToString(),
73+
evaluationContext);
74+
75+
this.HookAfterStageExecuted(content);
76+
77+
return base.AfterAsync(context, details, hints, cancellationToken);
78+
}
79+
80+
[LoggerMessage(
81+
Level = LogLevel.Debug,
82+
Message = "Before Flag Evaluation {Content}")]
83+
partial void HookBeforeStageExecuted(LoggingHookContent content);
84+
85+
[LoggerMessage(
86+
Level = LogLevel.Error,
87+
Message = "Error during Flag Evaluation {Content}")]
88+
partial void HookErrorStageExecuted(LoggingHookContent content);
89+
90+
[LoggerMessage(
91+
Level = LogLevel.Debug,
92+
Message = "After Flag Evaluation {Content}")]
93+
partial void HookAfterStageExecuted(LoggingHookContent content);
94+
95+
/// <summary>
96+
/// Generates a log string with contents provided by the <see cref="LoggingHook"/>.
97+
/// <para>
98+
/// Specification for log contents found at https://github.com/open-feature/spec/blob/d261f68331b94fd8ed10bc72bc0485cfc72a51a8/specification/appendix-a-included-utilities.md#logging-hook
99+
/// </para>
100+
/// </summary>
101+
internal class LoggingHookContent
102+
{
103+
private readonly string _domain;
104+
private readonly string _providerName;
105+
private readonly string _flagKey;
106+
private readonly string _defaultValue;
107+
private readonly EvaluationContext? _evaluationContext;
108+
109+
public LoggingHookContent(string? domain, string? providerName, string flagKey, string? defaultValue, EvaluationContext? evaluationContext = null)
110+
{
111+
this._domain = string.IsNullOrEmpty(domain) ? "missing" : domain!;
112+
this._providerName = string.IsNullOrEmpty(providerName) ? "missing" : providerName!;
113+
this._flagKey = flagKey;
114+
this._defaultValue = string.IsNullOrEmpty(defaultValue) ? "missing" : defaultValue!;
115+
this._evaluationContext = evaluationContext;
116+
}
117+
118+
public override string ToString()
119+
{
120+
var stringBuilder = new StringBuilder();
121+
122+
stringBuilder.Append("Domain:");
123+
stringBuilder.AppendLine(this._domain);
124+
125+
stringBuilder.Append("ProviderName:");
126+
stringBuilder.AppendLine(this._providerName);
127+
128+
stringBuilder.Append("FlagKey:");
129+
stringBuilder.AppendLine(this._flagKey);
130+
131+
stringBuilder.Append("DefaultValue:");
132+
stringBuilder.AppendLine(this._defaultValue);
133+
134+
if (this._evaluationContext != null)
135+
{
136+
stringBuilder.AppendLine("Context:");
137+
foreach (var kvp in this._evaluationContext.AsDictionary())
138+
{
139+
stringBuilder.Append('\t');
140+
stringBuilder.Append(kvp.Key);
141+
stringBuilder.Append(':');
142+
stringBuilder.AppendLine(GetValueString(kvp.Value));
143+
}
144+
}
145+
146+
return stringBuilder.ToString();
147+
}
148+
149+
static string? GetValueString(Value value)
150+
{
151+
if (value.IsNull)
152+
return string.Empty;
153+
154+
if (value.IsString)
155+
return value.AsString;
156+
157+
if (value.IsBoolean)
158+
return value.AsBoolean.ToString();
159+
160+
if (value.IsNumber)
161+
{
162+
// Value.AsDouble will attempt to cast other numbers to double
163+
// There is an implicit conversation for int/long to double
164+
if (value.AsDouble != null) return value.AsDouble.ToString();
165+
}
166+
167+
if (value.IsDateTime)
168+
return value.AsDateTime?.ToString("O");
169+
170+
return value.ToString();
171+
}
172+
}
173+
}
174+
}

0 commit comments

Comments
 (0)