diff --git a/src/OpenFeature.Providers.MultiProvider/MultiProvider.cs b/src/OpenFeature.Providers.MultiProvider/MultiProvider.cs index 9737198f0..e05ce9128 100644 --- a/src/OpenFeature.Providers.MultiProvider/MultiProvider.cs +++ b/src/OpenFeature.Providers.MultiProvider/MultiProvider.cs @@ -113,6 +113,43 @@ public override Task> ResolveStringValueAsync(string f public override Task> ResolveStructureValueAsync(string flagKey, Value defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) => this.EvaluateAsync(flagKey, defaultValue, context, cancellationToken); + /// + public override void Track(string trackingEventName, EvaluationContext? evaluationContext = default, TrackingEventDetails? trackingEventDetails = default) + { + if (this._disposed == 1) + { + throw new ObjectDisposedException(nameof(MultiProvider)); + } + + if (string.IsNullOrWhiteSpace(trackingEventName)) + { + this.LogErrorTrackingEventEmptyName(); + return; + } + + foreach (var registeredProvider in this._registeredProviders) + { + var providerContext = new StrategyPerProviderContext( + registeredProvider.Provider, + registeredProvider.Name, + registeredProvider.Status, + string.Empty); // Tracking operations are not flag-specific, so the flag key is intentionally set to an empty string + + if (this._evaluationStrategy.ShouldTrackWithThisProvider(providerContext, evaluationContext, trackingEventName, trackingEventDetails)) + { + try + { + registeredProvider.Provider.Track(trackingEventName, evaluationContext, trackingEventDetails); + } + catch (Exception ex) + { + // Log tracking errors but don't throw - tracking should not disrupt application flow + this.LogErrorTrackingEvent(registeredProvider.Name, trackingEventName, ex); + } + } + } + } + /// public override async Task InitializeAsync(EvaluationContext context, CancellationToken cancellationToken = default) { @@ -638,4 +675,10 @@ internal void SetStatus(ProviderStatus providerStatus) [LoggerMessage(EventId = 1, Level = LogLevel.Debug, Message = "Provider {ProviderName} is already being listened to")] private partial void LogProviderAlreadyBeingListenedTo(string providerName); + + [LoggerMessage(EventId = 2, Level = LogLevel.Error, Message = "Error tracking event {TrackingEventName} with provider {ProviderName}")] + private partial void LogErrorTrackingEvent(string providerName, string trackingEventName, Exception exception); + + [LoggerMessage(EventId = 3, Level = LogLevel.Error, Message = "Tracking event with empty name is not allowed")] + private partial void LogErrorTrackingEventEmptyName(); } diff --git a/src/OpenFeature.Providers.MultiProvider/Strategies/BaseEvaluationStrategy.cs b/src/OpenFeature.Providers.MultiProvider/Strategies/BaseEvaluationStrategy.cs index f31b2c4ab..2b1646036 100644 --- a/src/OpenFeature.Providers.MultiProvider/Strategies/BaseEvaluationStrategy.cs +++ b/src/OpenFeature.Providers.MultiProvider/Strategies/BaseEvaluationStrategy.cs @@ -58,6 +58,20 @@ public virtual bool ShouldEvaluateNextProvider(StrategyPerProviderContext /// The final evaluation result. public abstract FinalResult DetermineFinalResult(StrategyEvaluationContext strategyContext, string key, T defaultValue, EvaluationContext? evaluationContext, List> resolutions); + /// + /// Determines whether a specific provider should receive tracking events. + /// + /// Context information about the provider. + /// The evaluation context for the tracking event. + /// The name of the tracking event. + /// The tracking event details. + /// True if the provider should receive tracking events, false otherwise. + public virtual bool ShouldTrackWithThisProvider(StrategyPerProviderContext strategyContext, EvaluationContext? evaluationContext, string trackingEventName, TrackingEventDetails? trackingEventDetails) + { + // By default, track with providers that are ready + return strategyContext.ProviderStatus == ProviderStatus.Ready; + } + /// /// Checks if a resolution result represents an error. /// diff --git a/test/OpenFeature.Providers.MultiProvider.Tests/MultiProviderTrackingTests.cs b/test/OpenFeature.Providers.MultiProvider.Tests/MultiProviderTrackingTests.cs new file mode 100644 index 000000000..d0d9d3d54 --- /dev/null +++ b/test/OpenFeature.Providers.MultiProvider.Tests/MultiProviderTrackingTests.cs @@ -0,0 +1,299 @@ +using NSubstitute; +using OpenFeature.Constant; +using OpenFeature.Model; +using OpenFeature.Providers.MultiProvider.Models; +using OpenFeature.Providers.MultiProvider.Strategies; +using OpenFeature.Providers.MultiProvider.Strategies.Models; +using OpenFeature.Providers.MultiProvider.Tests.Utils; + +namespace OpenFeature.Providers.MultiProvider.Tests; + +public class MultiProviderTrackingTests +{ + private const string TestTrackingEventName = "test-event"; + private const string Provider1Name = "provider1"; + private const string Provider2Name = "provider2"; + private const string Provider3Name = "provider3"; + + private readonly TestProvider _testProvider1 = new(Provider1Name); + private readonly TestProvider _testProvider2 = new(Provider2Name); + private readonly TestProvider _testProvider3 = new(Provider3Name); + private readonly EvaluationContext _evaluationContext = EvaluationContext.Builder().Build(); + + [Fact] + public async Task Track_WithMultipleReadyProviders_CallsTrackOnAllReadyProviders() + { + // Arrange + var providerEntries = new List + { + new(this._testProvider1, Provider1Name), + new(this._testProvider2, Provider2Name), + new(this._testProvider3, Provider3Name) + }; + + var multiProvider = new MultiProvider(providerEntries, new FirstMatchStrategy()); + await multiProvider.InitializeAsync(this._evaluationContext); + + var trackingDetails = TrackingEventDetails.Builder().SetValue(99.99).Build(); + + // Act + multiProvider.Track(TestTrackingEventName, this._evaluationContext, trackingDetails); + + // Assert + var provider1Invocations = this._testProvider1.GetTrackingInvocations(); + var provider2Invocations = this._testProvider2.GetTrackingInvocations(); + var provider3Invocations = this._testProvider3.GetTrackingInvocations(); + + Assert.Single(provider1Invocations); + Assert.Single(provider2Invocations); + Assert.Single(provider3Invocations); + + Assert.Equal(TestTrackingEventName, provider1Invocations[0].EventName); + Assert.Equal(TestTrackingEventName, provider2Invocations[0].EventName); + Assert.Equal(TestTrackingEventName, provider3Invocations[0].EventName); + + Assert.Equal(trackingDetails.Value, provider1Invocations[0].TrackingEventDetails?.Value); + Assert.Equal(trackingDetails.Value, provider2Invocations[0].TrackingEventDetails?.Value); + Assert.Equal(trackingDetails.Value, provider3Invocations[0].TrackingEventDetails?.Value); + } + + [Fact] + public async Task Track_WithNullEvaluationContext_CallsTrackWithNullContext() + { + // Arrange + var providerEntries = new List + { + new(this._testProvider1, Provider1Name), + new(this._testProvider2, Provider2Name) + }; + + var multiProvider = new MultiProvider(providerEntries, new FirstMatchStrategy()); + await multiProvider.InitializeAsync(this._evaluationContext); + + // Act + multiProvider.Track(TestTrackingEventName); + + // Assert + var provider1Invocations = this._testProvider1.GetTrackingInvocations(); + var provider2Invocations = this._testProvider2.GetTrackingInvocations(); + + Assert.Single(provider1Invocations); + Assert.Single(provider2Invocations); + + Assert.Equal(TestTrackingEventName, provider1Invocations[0].EventName); + Assert.Equal(TestTrackingEventName, provider2Invocations[0].EventName); + } + + [Fact] + public async Task Track_WithNullTrackingDetails_CallsTrackWithNullDetails() + { + // Arrange + var providerEntries = new List + { + new(this._testProvider1, Provider1Name), + new(this._testProvider2, Provider2Name) + }; + + var multiProvider = new MultiProvider(providerEntries, new FirstMatchStrategy()); + await multiProvider.InitializeAsync(this._evaluationContext); + + // Act + multiProvider.Track(TestTrackingEventName, this._evaluationContext); + + // Assert + var provider1Invocations = this._testProvider1.GetTrackingInvocations(); + var provider2Invocations = this._testProvider2.GetTrackingInvocations(); + + Assert.Single(provider1Invocations); + Assert.Single(provider2Invocations); + + Assert.Equal(TestTrackingEventName, provider1Invocations[0].EventName); + Assert.Null(provider1Invocations[0].TrackingEventDetails); + + Assert.Equal(TestTrackingEventName, provider2Invocations[0].EventName); + Assert.Null(provider2Invocations[0].TrackingEventDetails); + } + + [Fact] + public async Task Track_WhenProviderThrowsException_ContinuesWithOtherProviders() + { + // Arrange + var throwingProvider = Substitute.For(); + throwingProvider.GetMetadata().Returns(new Metadata(Provider2Name)); + throwingProvider.When(x => x.Track(Arg.Any(), Arg.Any(), Arg.Any())) + .Do(_ => throw new InvalidOperationException("Test exception")); + + var providerEntries = new List + { + new(this._testProvider1, Provider1Name), + new(throwingProvider, Provider2Name), + new(this._testProvider3, Provider3Name) + }; + + var multiProvider = new MultiProvider(providerEntries, new FirstMatchStrategy()); + await multiProvider.InitializeAsync(this._evaluationContext); + + // Manually set all providers to Ready status + throwingProvider.Status.Returns(ProviderStatus.Ready); + + var trackingDetails = TrackingEventDetails.Builder().SetValue(99.99).Build(); + + // Act + multiProvider.Track(TestTrackingEventName, this._evaluationContext, trackingDetails); + + // Assert - should not throw and should continue with other providers + var provider1Invocations = this._testProvider1.GetTrackingInvocations(); + var provider3Invocations = this._testProvider3.GetTrackingInvocations(); + + Assert.Single(provider1Invocations); + Assert.Single(provider3Invocations); + + throwingProvider.Received(1).Track(TestTrackingEventName, Arg.Any(), trackingDetails); + } + + [Fact] + public async Task Track_WhenDisposed_ThrowsObjectDisposedException() + { + // Arrange + var providerEntries = new List + { + new(this._testProvider1, Provider1Name) + }; + + var multiProvider = new MultiProvider(providerEntries, new FirstMatchStrategy()); + await multiProvider.InitializeAsync(this._evaluationContext); + await multiProvider.DisposeAsync(); + + // Act & Assert + Assert.Throws(() => multiProvider.Track(TestTrackingEventName, this._evaluationContext)); + } + + [Fact] + public async Task Track_WithCustomStrategy_RespectsStrategyDecision() + { + // Arrange + var customStrategy = Substitute.For(); + customStrategy.RunMode.Returns(RunMode.Sequential); + + // Only allow tracking with the first provider + customStrategy.ShouldTrackWithThisProvider( + Arg.Is>(ctx => ctx.ProviderName == Provider1Name), + Arg.Any(), + Arg.Any(), + Arg.Any() + ).Returns(true); + + customStrategy.ShouldTrackWithThisProvider( + Arg.Is>(ctx => ctx.ProviderName != Provider1Name), + Arg.Any(), + Arg.Any(), + Arg.Any() + ).Returns(false); + + var providerEntries = new List + { + new(this._testProvider1, Provider1Name), + new(this._testProvider2, Provider2Name), + new(this._testProvider3, Provider3Name) + }; + + var multiProvider = new MultiProvider(providerEntries, customStrategy); + await multiProvider.InitializeAsync(this._evaluationContext); + + var trackingDetails = TrackingEventDetails.Builder().SetValue(99.99).Build(); + + // Act + multiProvider.Track(TestTrackingEventName, this._evaluationContext, trackingDetails); + + // Assert - only provider1 should receive the tracking call + var provider1Invocations = this._testProvider1.GetTrackingInvocations(); + var provider2Invocations = this._testProvider2.GetTrackingInvocations(); + var provider3Invocations = this._testProvider3.GetTrackingInvocations(); + + Assert.Single(provider1Invocations); + Assert.Empty(provider2Invocations); + Assert.Empty(provider3Invocations); + + customStrategy.Received(3).ShouldTrackWithThisProvider( + Arg.Any>(), + Arg.Any(), + TestTrackingEventName, + trackingDetails + ); + } + + [Fact] + public async Task Track_WithComplexTrackingDetails_PropagatesAllDetails() + { + // Arrange + var providerEntries = new List + { + new(this._testProvider1, Provider1Name), + new(this._testProvider2, Provider2Name) + }; + + var multiProvider = new MultiProvider(providerEntries, new FirstMatchStrategy()); + await multiProvider.InitializeAsync(this._evaluationContext); + + var trackingDetails = TrackingEventDetails.Builder() + .SetValue(199.99) + .Set("currency", new Value("USD")) + .Set("productId", new Value("prod-123")) + .Set("quantity", new Value(5)) + .Build(); + + // Act + multiProvider.Track(TestTrackingEventName, this._evaluationContext, trackingDetails); + + // Assert + var provider1Invocations = this._testProvider1.GetTrackingInvocations(); + var provider2Invocations = this._testProvider2.GetTrackingInvocations(); + + Assert.Single(provider1Invocations); + Assert.Single(provider2Invocations); + + var details1 = provider1Invocations[0].TrackingEventDetails; + var details2 = provider2Invocations[0].TrackingEventDetails; + + Assert.NotNull(details1); + Assert.NotNull(details2); + + Assert.Equal(199.99, details1.Value); + Assert.Equal(199.99, details2.Value); + + Assert.Equal("USD", details1.GetValue("currency").AsString); + Assert.Equal("USD", details2.GetValue("currency").AsString); + + Assert.Equal("prod-123", details1.GetValue("productId").AsString); + Assert.Equal("prod-123", details2.GetValue("productId").AsString); + + Assert.Equal(5, details1.GetValue("quantity").AsInteger); + Assert.Equal(5, details2.GetValue("quantity").AsInteger); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task Track_WhenInvalidTrackingEventName_DoesNotCallProviders(string? trackingEventName) + { + // Arrange + var providerEntries = new List + { + new(this._testProvider1, Provider1Name), + new(this._testProvider2, Provider2Name) + }; + + var multiProvider = new MultiProvider(providerEntries, new FirstMatchStrategy()); + await multiProvider.InitializeAsync(this._evaluationContext); + + // Act & Assert + multiProvider.Track(trackingEventName!, this._evaluationContext, TrackingEventDetails.Empty); + + var provider1Invocations = this._testProvider1.GetTrackingInvocations(); + var provider2Invocations = this._testProvider2.GetTrackingInvocations(); + + Assert.Empty(provider1Invocations); + Assert.Empty(provider2Invocations); + } +} diff --git a/test/OpenFeature.Providers.MultiProvider.Tests/Utils/TestProvider.cs b/test/OpenFeature.Providers.MultiProvider.Tests/Utils/TestProvider.cs index 883cd6582..0bfd7bb01 100644 --- a/test/OpenFeature.Providers.MultiProvider.Tests/Utils/TestProvider.cs +++ b/test/OpenFeature.Providers.MultiProvider.Tests/Utils/TestProvider.cs @@ -1,7 +1,25 @@ +using OpenFeature.Constant; using OpenFeature.Model; namespace OpenFeature.Providers.MultiProvider.Tests.Utils; +/// +/// Represents a tracking invocation for testing purposes. +/// +public class TrackingInvocation +{ + public string EventName { get; } + public EvaluationContext? EvaluationContext { get; } + public TrackingEventDetails? TrackingEventDetails { get; } + + public TrackingInvocation(string eventName, EvaluationContext? evaluationContext, TrackingEventDetails? trackingEventDetails) + { + this.EventName = eventName; + this.EvaluationContext = evaluationContext; + this.TrackingEventDetails = trackingEventDetails; + } +} + /// /// A test implementation of FeatureProvider for MultiProvider testing. /// @@ -10,6 +28,7 @@ public class TestProvider : FeatureProvider private readonly string _name; private readonly Exception? _initException; private readonly Exception? _shutdownException; + private readonly List _trackingInvocations = new(); public TestProvider(string name, Exception? initException = null, Exception? shutdownException = null) { @@ -18,6 +37,10 @@ public TestProvider(string name, Exception? initException = null, Exception? shu this._shutdownException = shutdownException; } + public IReadOnlyList GetTrackingInvocations() => this._trackingInvocations.AsReadOnly(); + + public void ResetTrackingInvocations() => this._trackingInvocations.Clear(); + public override Metadata GetMetadata() => new(this._name); public override async Task InitializeAsync(EvaluationContext context, CancellationToken cancellationToken = default) @@ -59,4 +82,23 @@ public override Task> ResolveDoubleValueAsync(string f public override Task> ResolveStructureValueAsync(string flagKey, Value defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) => Task.FromResult(new ResolutionDetails(flagKey, defaultValue)); + + public override void Track(string trackingEventName, EvaluationContext? evaluationContext = default, TrackingEventDetails? trackingEventDetails = default) + { + this._trackingInvocations.Add(new TrackingInvocation(trackingEventName, evaluationContext, trackingEventDetails)); + } + + /// + /// Sends a provider event to simulate status changes. + /// + public async Task SendProviderEventAsync(ProviderEventTypes eventType, ErrorType? errorType = null, CancellationToken cancellationToken = default) + { + var payload = new ProviderEventPayload + { + Type = eventType, + ProviderName = this._name, + ErrorType = errorType + }; + await this.EventChannel.Writer.WriteAsync(payload, cancellationToken); + } }