Skip to content

Commit 186b357

Browse files
authored
feat: Add tracking to multi-provider (#612)
* feat: Add tracking event handling to MultiProvider and evaluation strategy Signed-off-by: André Silva <[email protected]> * feat: Add validation for tracking event name in MultiProvider Signed-off-by: André Silva <[email protected]> * feat: Update ShouldTrackWithThisProvider to support generic provider types Signed-off-by: André Silva <[email protected]> * feat: Enhance TestProvider with tracking invocation methods and event simulation Signed-off-by: André Silva <[email protected]> * feat: Add comprehensive tracking tests for MultiProvider functionality Signed-off-by: André Silva <[email protected]> * refactor: Clarify tracking context handling and rename test for invalid tracking event name Signed-off-by: André Silva <[email protected]> * refactor: Change TrackingInvocation from record to class for enhanced flexibility Signed-off-by: André Silva <[email protected]> * fix: Change log level to Error for tracking event errors in MultiProvider Signed-off-by: André Silva <[email protected]> --------- Signed-off-by: André Silva <[email protected]>
1 parent 2576022 commit 186b357

File tree

4 files changed

+398
-0
lines changed

4 files changed

+398
-0
lines changed

src/OpenFeature.Providers.MultiProvider/MultiProvider.cs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,43 @@ public override Task<ResolutionDetails<string>> ResolveStringValueAsync(string f
113113
public override Task<ResolutionDetails<Value>> ResolveStructureValueAsync(string flagKey, Value defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) =>
114114
this.EvaluateAsync(flagKey, defaultValue, context, cancellationToken);
115115

116+
/// <inheritdoc/>
117+
public override void Track(string trackingEventName, EvaluationContext? evaluationContext = default, TrackingEventDetails? trackingEventDetails = default)
118+
{
119+
if (this._disposed == 1)
120+
{
121+
throw new ObjectDisposedException(nameof(MultiProvider));
122+
}
123+
124+
if (string.IsNullOrWhiteSpace(trackingEventName))
125+
{
126+
this.LogErrorTrackingEventEmptyName();
127+
return;
128+
}
129+
130+
foreach (var registeredProvider in this._registeredProviders)
131+
{
132+
var providerContext = new StrategyPerProviderContext<object>(
133+
registeredProvider.Provider,
134+
registeredProvider.Name,
135+
registeredProvider.Status,
136+
string.Empty); // Tracking operations are not flag-specific, so the flag key is intentionally set to an empty string
137+
138+
if (this._evaluationStrategy.ShouldTrackWithThisProvider(providerContext, evaluationContext, trackingEventName, trackingEventDetails))
139+
{
140+
try
141+
{
142+
registeredProvider.Provider.Track(trackingEventName, evaluationContext, trackingEventDetails);
143+
}
144+
catch (Exception ex)
145+
{
146+
// Log tracking errors but don't throw - tracking should not disrupt application flow
147+
this.LogErrorTrackingEvent(registeredProvider.Name, trackingEventName, ex);
148+
}
149+
}
150+
}
151+
}
152+
116153
/// <inheritdoc/>
117154
public override async Task InitializeAsync(EvaluationContext context, CancellationToken cancellationToken = default)
118155
{
@@ -638,4 +675,10 @@ internal void SetStatus(ProviderStatus providerStatus)
638675

639676
[LoggerMessage(EventId = 1, Level = LogLevel.Debug, Message = "Provider {ProviderName} is already being listened to")]
640677
private partial void LogProviderAlreadyBeingListenedTo(string providerName);
678+
679+
[LoggerMessage(EventId = 2, Level = LogLevel.Error, Message = "Error tracking event {TrackingEventName} with provider {ProviderName}")]
680+
private partial void LogErrorTrackingEvent(string providerName, string trackingEventName, Exception exception);
681+
682+
[LoggerMessage(EventId = 3, Level = LogLevel.Error, Message = "Tracking event with empty name is not allowed")]
683+
private partial void LogErrorTrackingEventEmptyName();
641684
}

src/OpenFeature.Providers.MultiProvider/Strategies/BaseEvaluationStrategy.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,20 @@ public virtual bool ShouldEvaluateNextProvider<T>(StrategyPerProviderContext<T>
5858
/// <returns>The final evaluation result.</returns>
5959
public abstract FinalResult<T> DetermineFinalResult<T>(StrategyEvaluationContext<T> strategyContext, string key, T defaultValue, EvaluationContext? evaluationContext, List<ProviderResolutionResult<T>> resolutions);
6060

61+
/// <summary>
62+
/// Determines whether a specific provider should receive tracking events.
63+
/// </summary>
64+
/// <param name="strategyContext">Context information about the provider.</param>
65+
/// <param name="evaluationContext">The evaluation context for the tracking event.</param>
66+
/// <param name="trackingEventName">The name of the tracking event.</param>
67+
/// <param name="trackingEventDetails">The tracking event details.</param>
68+
/// <returns>True if the provider should receive tracking events, false otherwise.</returns>
69+
public virtual bool ShouldTrackWithThisProvider<T>(StrategyPerProviderContext<T> strategyContext, EvaluationContext? evaluationContext, string trackingEventName, TrackingEventDetails? trackingEventDetails)
70+
{
71+
// By default, track with providers that are ready
72+
return strategyContext.ProviderStatus == ProviderStatus.Ready;
73+
}
74+
6175
/// <summary>
6276
/// Checks if a resolution result represents an error.
6377
/// </summary>
Lines changed: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,299 @@
1+
using NSubstitute;
2+
using OpenFeature.Constant;
3+
using OpenFeature.Model;
4+
using OpenFeature.Providers.MultiProvider.Models;
5+
using OpenFeature.Providers.MultiProvider.Strategies;
6+
using OpenFeature.Providers.MultiProvider.Strategies.Models;
7+
using OpenFeature.Providers.MultiProvider.Tests.Utils;
8+
9+
namespace OpenFeature.Providers.MultiProvider.Tests;
10+
11+
public class MultiProviderTrackingTests
12+
{
13+
private const string TestTrackingEventName = "test-event";
14+
private const string Provider1Name = "provider1";
15+
private const string Provider2Name = "provider2";
16+
private const string Provider3Name = "provider3";
17+
18+
private readonly TestProvider _testProvider1 = new(Provider1Name);
19+
private readonly TestProvider _testProvider2 = new(Provider2Name);
20+
private readonly TestProvider _testProvider3 = new(Provider3Name);
21+
private readonly EvaluationContext _evaluationContext = EvaluationContext.Builder().Build();
22+
23+
[Fact]
24+
public async Task Track_WithMultipleReadyProviders_CallsTrackOnAllReadyProviders()
25+
{
26+
// Arrange
27+
var providerEntries = new List<ProviderEntry>
28+
{
29+
new(this._testProvider1, Provider1Name),
30+
new(this._testProvider2, Provider2Name),
31+
new(this._testProvider3, Provider3Name)
32+
};
33+
34+
var multiProvider = new MultiProvider(providerEntries, new FirstMatchStrategy());
35+
await multiProvider.InitializeAsync(this._evaluationContext);
36+
37+
var trackingDetails = TrackingEventDetails.Builder().SetValue(99.99).Build();
38+
39+
// Act
40+
multiProvider.Track(TestTrackingEventName, this._evaluationContext, trackingDetails);
41+
42+
// Assert
43+
var provider1Invocations = this._testProvider1.GetTrackingInvocations();
44+
var provider2Invocations = this._testProvider2.GetTrackingInvocations();
45+
var provider3Invocations = this._testProvider3.GetTrackingInvocations();
46+
47+
Assert.Single(provider1Invocations);
48+
Assert.Single(provider2Invocations);
49+
Assert.Single(provider3Invocations);
50+
51+
Assert.Equal(TestTrackingEventName, provider1Invocations[0].EventName);
52+
Assert.Equal(TestTrackingEventName, provider2Invocations[0].EventName);
53+
Assert.Equal(TestTrackingEventName, provider3Invocations[0].EventName);
54+
55+
Assert.Equal(trackingDetails.Value, provider1Invocations[0].TrackingEventDetails?.Value);
56+
Assert.Equal(trackingDetails.Value, provider2Invocations[0].TrackingEventDetails?.Value);
57+
Assert.Equal(trackingDetails.Value, provider3Invocations[0].TrackingEventDetails?.Value);
58+
}
59+
60+
[Fact]
61+
public async Task Track_WithNullEvaluationContext_CallsTrackWithNullContext()
62+
{
63+
// Arrange
64+
var providerEntries = new List<ProviderEntry>
65+
{
66+
new(this._testProvider1, Provider1Name),
67+
new(this._testProvider2, Provider2Name)
68+
};
69+
70+
var multiProvider = new MultiProvider(providerEntries, new FirstMatchStrategy());
71+
await multiProvider.InitializeAsync(this._evaluationContext);
72+
73+
// Act
74+
multiProvider.Track(TestTrackingEventName);
75+
76+
// Assert
77+
var provider1Invocations = this._testProvider1.GetTrackingInvocations();
78+
var provider2Invocations = this._testProvider2.GetTrackingInvocations();
79+
80+
Assert.Single(provider1Invocations);
81+
Assert.Single(provider2Invocations);
82+
83+
Assert.Equal(TestTrackingEventName, provider1Invocations[0].EventName);
84+
Assert.Equal(TestTrackingEventName, provider2Invocations[0].EventName);
85+
}
86+
87+
[Fact]
88+
public async Task Track_WithNullTrackingDetails_CallsTrackWithNullDetails()
89+
{
90+
// Arrange
91+
var providerEntries = new List<ProviderEntry>
92+
{
93+
new(this._testProvider1, Provider1Name),
94+
new(this._testProvider2, Provider2Name)
95+
};
96+
97+
var multiProvider = new MultiProvider(providerEntries, new FirstMatchStrategy());
98+
await multiProvider.InitializeAsync(this._evaluationContext);
99+
100+
// Act
101+
multiProvider.Track(TestTrackingEventName, this._evaluationContext);
102+
103+
// Assert
104+
var provider1Invocations = this._testProvider1.GetTrackingInvocations();
105+
var provider2Invocations = this._testProvider2.GetTrackingInvocations();
106+
107+
Assert.Single(provider1Invocations);
108+
Assert.Single(provider2Invocations);
109+
110+
Assert.Equal(TestTrackingEventName, provider1Invocations[0].EventName);
111+
Assert.Null(provider1Invocations[0].TrackingEventDetails);
112+
113+
Assert.Equal(TestTrackingEventName, provider2Invocations[0].EventName);
114+
Assert.Null(provider2Invocations[0].TrackingEventDetails);
115+
}
116+
117+
[Fact]
118+
public async Task Track_WhenProviderThrowsException_ContinuesWithOtherProviders()
119+
{
120+
// Arrange
121+
var throwingProvider = Substitute.For<FeatureProvider>();
122+
throwingProvider.GetMetadata().Returns(new Metadata(Provider2Name));
123+
throwingProvider.When(x => x.Track(Arg.Any<string>(), Arg.Any<EvaluationContext>(), Arg.Any<TrackingEventDetails>()))
124+
.Do(_ => throw new InvalidOperationException("Test exception"));
125+
126+
var providerEntries = new List<ProviderEntry>
127+
{
128+
new(this._testProvider1, Provider1Name),
129+
new(throwingProvider, Provider2Name),
130+
new(this._testProvider3, Provider3Name)
131+
};
132+
133+
var multiProvider = new MultiProvider(providerEntries, new FirstMatchStrategy());
134+
await multiProvider.InitializeAsync(this._evaluationContext);
135+
136+
// Manually set all providers to Ready status
137+
throwingProvider.Status.Returns(ProviderStatus.Ready);
138+
139+
var trackingDetails = TrackingEventDetails.Builder().SetValue(99.99).Build();
140+
141+
// Act
142+
multiProvider.Track(TestTrackingEventName, this._evaluationContext, trackingDetails);
143+
144+
// Assert - should not throw and should continue with other providers
145+
var provider1Invocations = this._testProvider1.GetTrackingInvocations();
146+
var provider3Invocations = this._testProvider3.GetTrackingInvocations();
147+
148+
Assert.Single(provider1Invocations);
149+
Assert.Single(provider3Invocations);
150+
151+
throwingProvider.Received(1).Track(TestTrackingEventName, Arg.Any<EvaluationContext>(), trackingDetails);
152+
}
153+
154+
[Fact]
155+
public async Task Track_WhenDisposed_ThrowsObjectDisposedException()
156+
{
157+
// Arrange
158+
var providerEntries = new List<ProviderEntry>
159+
{
160+
new(this._testProvider1, Provider1Name)
161+
};
162+
163+
var multiProvider = new MultiProvider(providerEntries, new FirstMatchStrategy());
164+
await multiProvider.InitializeAsync(this._evaluationContext);
165+
await multiProvider.DisposeAsync();
166+
167+
// Act & Assert
168+
Assert.Throws<ObjectDisposedException>(() => multiProvider.Track(TestTrackingEventName, this._evaluationContext));
169+
}
170+
171+
[Fact]
172+
public async Task Track_WithCustomStrategy_RespectsStrategyDecision()
173+
{
174+
// Arrange
175+
var customStrategy = Substitute.For<BaseEvaluationStrategy>();
176+
customStrategy.RunMode.Returns(RunMode.Sequential);
177+
178+
// Only allow tracking with the first provider
179+
customStrategy.ShouldTrackWithThisProvider(
180+
Arg.Is<StrategyPerProviderContext<object>>(ctx => ctx.ProviderName == Provider1Name),
181+
Arg.Any<EvaluationContext>(),
182+
Arg.Any<string>(),
183+
Arg.Any<TrackingEventDetails>()
184+
).Returns(true);
185+
186+
customStrategy.ShouldTrackWithThisProvider(
187+
Arg.Is<StrategyPerProviderContext<object>>(ctx => ctx.ProviderName != Provider1Name),
188+
Arg.Any<EvaluationContext>(),
189+
Arg.Any<string>(),
190+
Arg.Any<TrackingEventDetails>()
191+
).Returns(false);
192+
193+
var providerEntries = new List<ProviderEntry>
194+
{
195+
new(this._testProvider1, Provider1Name),
196+
new(this._testProvider2, Provider2Name),
197+
new(this._testProvider3, Provider3Name)
198+
};
199+
200+
var multiProvider = new MultiProvider(providerEntries, customStrategy);
201+
await multiProvider.InitializeAsync(this._evaluationContext);
202+
203+
var trackingDetails = TrackingEventDetails.Builder().SetValue(99.99).Build();
204+
205+
// Act
206+
multiProvider.Track(TestTrackingEventName, this._evaluationContext, trackingDetails);
207+
208+
// Assert - only provider1 should receive the tracking call
209+
var provider1Invocations = this._testProvider1.GetTrackingInvocations();
210+
var provider2Invocations = this._testProvider2.GetTrackingInvocations();
211+
var provider3Invocations = this._testProvider3.GetTrackingInvocations();
212+
213+
Assert.Single(provider1Invocations);
214+
Assert.Empty(provider2Invocations);
215+
Assert.Empty(provider3Invocations);
216+
217+
customStrategy.Received(3).ShouldTrackWithThisProvider(
218+
Arg.Any<StrategyPerProviderContext<object>>(),
219+
Arg.Any<EvaluationContext>(),
220+
TestTrackingEventName,
221+
trackingDetails
222+
);
223+
}
224+
225+
[Fact]
226+
public async Task Track_WithComplexTrackingDetails_PropagatesAllDetails()
227+
{
228+
// Arrange
229+
var providerEntries = new List<ProviderEntry>
230+
{
231+
new(this._testProvider1, Provider1Name),
232+
new(this._testProvider2, Provider2Name)
233+
};
234+
235+
var multiProvider = new MultiProvider(providerEntries, new FirstMatchStrategy());
236+
await multiProvider.InitializeAsync(this._evaluationContext);
237+
238+
var trackingDetails = TrackingEventDetails.Builder()
239+
.SetValue(199.99)
240+
.Set("currency", new Value("USD"))
241+
.Set("productId", new Value("prod-123"))
242+
.Set("quantity", new Value(5))
243+
.Build();
244+
245+
// Act
246+
multiProvider.Track(TestTrackingEventName, this._evaluationContext, trackingDetails);
247+
248+
// Assert
249+
var provider1Invocations = this._testProvider1.GetTrackingInvocations();
250+
var provider2Invocations = this._testProvider2.GetTrackingInvocations();
251+
252+
Assert.Single(provider1Invocations);
253+
Assert.Single(provider2Invocations);
254+
255+
var details1 = provider1Invocations[0].TrackingEventDetails;
256+
var details2 = provider2Invocations[0].TrackingEventDetails;
257+
258+
Assert.NotNull(details1);
259+
Assert.NotNull(details2);
260+
261+
Assert.Equal(199.99, details1.Value);
262+
Assert.Equal(199.99, details2.Value);
263+
264+
Assert.Equal("USD", details1.GetValue("currency").AsString);
265+
Assert.Equal("USD", details2.GetValue("currency").AsString);
266+
267+
Assert.Equal("prod-123", details1.GetValue("productId").AsString);
268+
Assert.Equal("prod-123", details2.GetValue("productId").AsString);
269+
270+
Assert.Equal(5, details1.GetValue("quantity").AsInteger);
271+
Assert.Equal(5, details2.GetValue("quantity").AsInteger);
272+
}
273+
274+
[Theory]
275+
[InlineData(null)]
276+
[InlineData("")]
277+
[InlineData(" ")]
278+
public async Task Track_WhenInvalidTrackingEventName_DoesNotCallProviders(string? trackingEventName)
279+
{
280+
// Arrange
281+
var providerEntries = new List<ProviderEntry>
282+
{
283+
new(this._testProvider1, Provider1Name),
284+
new(this._testProvider2, Provider2Name)
285+
};
286+
287+
var multiProvider = new MultiProvider(providerEntries, new FirstMatchStrategy());
288+
await multiProvider.InitializeAsync(this._evaluationContext);
289+
290+
// Act & Assert
291+
multiProvider.Track(trackingEventName!, this._evaluationContext, TrackingEventDetails.Empty);
292+
293+
var provider1Invocations = this._testProvider1.GetTrackingInvocations();
294+
var provider2Invocations = this._testProvider2.GetTrackingInvocations();
295+
296+
Assert.Empty(provider1Invocations);
297+
Assert.Empty(provider2Invocations);
298+
}
299+
}

0 commit comments

Comments
 (0)