diff --git a/.github/workflows/sdk-spec.yml b/.github/workflows/sdk-spec.yml index 9de7109..ee955bd 100644 --- a/.github/workflows/sdk-spec.yml +++ b/.github/workflows/sdk-spec.yml @@ -11,7 +11,7 @@ permissions: jobs: sdk-spec: # Keep the ref in `uses:` and `spec-ref` in sync. - uses: Flagsmith/sdk-spec/.github/workflows/run-spec.yml@v0.3.0 + uses: Flagsmith/sdk-spec/.github/workflows/run-spec.yml@v0.7.0 with: - spec-ref: v0.3.0 + spec-ref: v0.7.0 language: dotnet diff --git a/Flagsmith.Client.Test/EventsTests.cs b/Flagsmith.Client.Test/EventsTests.cs new file mode 100644 index 0000000..a2304f2 --- /dev/null +++ b/Flagsmith.Client.Test/EventsTests.cs @@ -0,0 +1,361 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using Newtonsoft.Json.Linq; +using Xunit; + +namespace Flagsmith.FlagsmithClientTest +{ + public class EventsTests + { + private static readonly string SdkVersionNumber = SdkVersion.GetUserAgent().Split('/')[1]; + + private static Mock MockFlagsmith(string identityResponse = null, HttpStatusCode eventsStatus = HttpStatusCode.Accepted) => + HttpMocker.MockHttpResponse(new Dictionary + { + { "/api/v1/identities/", new HttpResponseMessage { StatusCode = HttpStatusCode.OK, Content = new StringContent(identityResponse ?? Fixtures.ApiIdentityResponse) } }, + { "/v1/events", new HttpResponseMessage { StatusCode = eventsStatus } }, + }); + + private static FlagsmithConfiguration EventsConfiguration(Mock mockHttpClient) => new FlagsmithConfiguration + { + EnvironmentKey = Fixtures.ApiKey, + HttpClient = mockHttpClient.Object, + EnableEvents = true, + EventsApiUri = new Uri("http://events.test/"), + }; + + [Fact] + public async Task TestTrackedEventReachesTheEventsApi() + { + // Given + var mockHttpClient = MockFlagsmith(); + var flagsmith = new FlagsmithClient(EventsConfiguration(mockHttpClient)); + var traits = new List { new Trait("plan", "premium"), new Trait("email", "user@example.com", transient: true) }; + + // When + flagsmith.TrackEvent("checkout_completed", "user_123", 42, traits, new Dictionary { ["currency"] = "GBP" }); + await flagsmith.FlushEvents(); + + // Then + var batch = Assert.Single(mockHttpClient.PostedJson("/v1/events")); + var trackedEvent = (JObject)Assert.Single(batch["events"]); + Assert.Equal(JTokenType.Integer, trackedEvent["timestamp"].Type); + trackedEvent.Remove("timestamp"); + var expectedEvent = JObject.FromObject(new Dictionary + { + ["event"] = "checkout_completed", + ["feature_name"] = null, + ["identifier"] = "user_123", + ["value"] = "42", + ["traits"] = new Dictionary { ["plan"] = "premium" }, + ["metadata"] = new Dictionary { ["currency"] = "GBP", ["sdk_version"] = SdkVersionNumber }, + }); + Assert.True(JToken.DeepEquals(expectedEvent, trackedEvent), trackedEvent.ToString()); + mockHttpClient.Verify(x => x.SendAsync(It.Is(request => + request.RequestUri.ToString() == "http://events.test/v1/events" && + request.Headers.GetValues("X-Environment-Key").Single() == Fixtures.ApiKey && + request.Headers.GetValues("Flagsmith-SDK-User-Agent").Single() == SdkVersion.GetUserAgent()), It.IsAny()), Times.Once); + } + + [Fact] + public async Task TestTrackedExposureReachesTheEventsApiAsAFlagExposureEvent() + { + // Given + var mockHttpClient = MockFlagsmith(); + var flagsmith = new FlagsmithClient(EventsConfiguration(mockHttpClient)); + + // When + flagsmith.TrackExposureEvent("banner", "user_123", "control", metadata: new Dictionary { ["experiment_id"] = 1 }); + await flagsmith.FlushEvents(); + + // Then + var exposure = mockHttpClient.PostedJson("/v1/events").Single()["events"].Single(); + Assert.Equal("$flag_exposure", (string)exposure["event"]); + Assert.Equal("banner", (string)exposure["feature_name"]); + Assert.Equal("user_123", (string)exposure["identifier"]); + Assert.Equal("control", (string)exposure["value"]); + Assert.Equal(JTokenType.Null, exposure["traits"].Type); + Assert.Equal(1, (int)exposure["metadata"]["experiment_id"]); + } + + [Fact] + public async Task TestRepeatedExposureIsOnlySentOncePerBatch() + { + // Given + var mockHttpClient = MockFlagsmith(); + var flagsmith = new FlagsmithClient(EventsConfiguration(mockHttpClient)); + + // When + flagsmith.TrackExposureEvent("banner", "user_123", "control"); + flagsmith.TrackExposureEvent("banner", "user_123", "control"); + flagsmith.TrackExposureEvent("banner", "user_123", "treatment"); + await flagsmith.FlushEvents(); + flagsmith.TrackExposureEvent("banner", "user_123", "control"); + await flagsmith.FlushEvents(); + + // Then + var batches = mockHttpClient.PostedJson("/v1/events"); + Assert.Equal(new[] { "control", "treatment" }, batches[0]["events"].Select(exposure => (string)exposure["value"])); + Assert.Equal(new[] { "control" }, batches[1]["events"].Select(exposure => (string)exposure["value"])); + } + + [Fact] + public async Task TestExposureWithoutAnIdentityIsNotSent() + { + // Given + var mockHttpClient = MockFlagsmith(); + var flagsmith = new FlagsmithClient(EventsConfiguration(mockHttpClient)); + + // When + flagsmith.TrackExposureEvent("banner", "", "control"); + await flagsmith.FlushEvents(); + + // Then + Assert.Empty(mockHttpClient.PostedJson("/v1/events")); + } + + [Theory] + [InlineData("")] + [InlineData("$flag_exposure")] + [InlineData("$checkout")] + public void TestApplicationEventNamesCannotBeEmptyOrReserved(string eventName) + { + // Given + var flagsmith = new FlagsmithClient(EventsConfiguration(MockFlagsmith())); + + // When / Then + Assert.Throws(() => flagsmith.TrackEvent(eventName, "user_123")); + } + + [Fact] + public async Task TestTrackingRequiresEventsToBeEnabled() + { + // Given + var flagsmith = new FlagsmithClient(new FlagsmithConfiguration { EnvironmentKey = Fixtures.ApiKey, HttpClient = MockFlagsmith().Object }); + + // When / Then + Assert.Throws(() => flagsmith.TrackEvent("checkout_completed", "user_123")); + Assert.Throws(() => flagsmith.TrackExposureEvent("banner", "user_123", "control")); + await Assert.ThrowsAsync(() => flagsmith.GetExperimentFlag("banner", "user_123")); + await flagsmith.FlushEvents(); + } + + [Fact] + public void TestEventsCannotBeSentInOfflineMode() + { + // Given + var config = new FlagsmithConfiguration + { + OfflineMode = true, + OfflineHandler = new OfflineHandler.LocalFileHandler("../../../data/offline-environment.json"), + EnableEvents = true, + }; + + // When / Then + var exception = Assert.Throws(() => new FlagsmithClient(config)); + Assert.Equal("ValueError: Cannot send events in offline mode.", exception.Message); + } + + [Fact] + public void TestEventsAreSentAsSoonAsTheBufferIsFull() + { + // Given + var mockHttpClient = MockFlagsmith(); + var config = EventsConfiguration(mockHttpClient); + config.EventsMaxBufferItems = 2; + var flagsmith = new FlagsmithClient(config); + + // When + flagsmith.TrackEvent("signed_up", "user_123"); + var sentBeforeFull = mockHttpClient.PostedJson("/v1/events").Count; + flagsmith.TrackEvent("checkout_completed", "user_123"); + + // Then + Assert.Equal(0, sentBeforeFull); + Assert.Equal(2, mockHttpClient.PostedJson("/v1/events").Single()["events"].Count()); + } + + [Fact] + public async Task TestEventsAreSentOnTheFlushInterval() + { + // Given + var mockHttpClient = MockFlagsmith(); + var config = EventsConfiguration(mockHttpClient); + config.EventsFlushInterval = TimeSpan.FromMilliseconds(50); + var flagsmith = new FlagsmithClient(config); + + // When + flagsmith.TrackEvent("checkout_completed", "user_123"); + var deadline = DateTime.UtcNow.AddSeconds(10); + while (mockHttpClient.PostedJson("/v1/events").Count == 0 && DateTime.UtcNow < deadline) + { + await Task.Delay(20); + } + + // Then + Assert.Equal("checkout_completed", (string)mockHttpClient.PostedJson("/v1/events").Single()["events"].Single()["event"]); + } + + [Fact] + public async Task TestFlushWaitsForEventsAlreadyBeingSent() + { + // Given + var eventsResponse = new TaskCompletionSource(); + var mockHttpClient = new Mock(); + mockHttpClient.Setup(x => x.SendAsync(It.IsAny(), It.IsAny())).Returns(eventsResponse.Task); + var config = EventsConfiguration(mockHttpClient); + config.EventsMaxBufferItems = 1; + var flagsmith = new FlagsmithClient(config); + flagsmith.TrackEvent("checkout_completed", "user_123"); + + // When + var flush = flagsmith.FlushEvents(); + + // Then + Assert.Single(mockHttpClient.PostedJson("/v1/events")); + Assert.False(flush.IsCompleted); + eventsResponse.SetResult(new HttpResponseMessage { StatusCode = HttpStatusCode.Accepted }); + await flush; + } + + [Fact] + public async Task TestEventsThatCannotBeSerialisedAreDropped() + { + // Given + var mockHttpClient = MockFlagsmith(); + var flagsmith = new FlagsmithClient(EventsConfiguration(mockHttpClient)); + var metadata = new Dictionary(); + metadata["self"] = metadata; + flagsmith.TrackEvent("checkout_completed", "user_123", metadata: metadata); + + // When + await flagsmith.FlushEvents(); + + // Then + Assert.Empty(mockHttpClient.PostedJson("/v1/events")); + } + + [Fact] + public void TestEventsFlushIntervalMustBePositive() + { + // Given + var config = EventsConfiguration(MockFlagsmith()); + config.EventsFlushInterval = TimeSpan.Zero; + + // When / Then + Assert.Throws(() => new FlagsmithClient(config)); + } + + [Theory] + [InlineData(HttpStatusCode.InternalServerError, 2)] + [InlineData(HttpStatusCode.BadRequest, 1)] + public async Task TestEventsTheEventsApiDoesNotAcceptAreRetriedOnlyOnServerErrorsThenDropped(HttpStatusCode eventsStatus, int expectedAttempts) + { + // Given + var mockHttpClient = MockFlagsmith(eventsStatus: eventsStatus); + var flagsmith = new FlagsmithClient(EventsConfiguration(mockHttpClient)); + flagsmith.TrackEvent("checkout_completed", "user_123"); + + // When + await flagsmith.FlushEvents(); + await flagsmith.FlushEvents(); + + // Then + Assert.Equal(expectedAttempts, mockHttpClient.PostedJson("/v1/events").Count); + } + + [Fact] + public async Task TestEventsAreRetriedOnceWhenTheEventsApiIsUnreachable() + { + // Given + var mockHttpClient = HttpMocker.MockHttpThrowConnectionError(); + var flagsmith = new FlagsmithClient(EventsConfiguration(mockHttpClient)); + flagsmith.TrackEvent("checkout_completed", "user_123"); + + // When + await flagsmith.FlushEvents(); + + // Then + Assert.Equal(2, mockHttpClient.PostedJson("/v1/events").Count); + } + + [Fact] + public async Task TestIdentityInARunningExperimentIsExposedToTheVariantItWasServed() + { + // Given + var mockHttpClient = MockFlagsmith(@"{ + 'flags': [{ + 'feature': { 'id': 1, 'name': 'banner' }, + 'feature_state_value': 'hello treatment', + 'enabled': true, + 'variant': 'treatment', + 'metadata': { 'experiment': { 'id': 7, 'name': 'banner experiment', 'in_experiment': true } } + }] + }"); + var flagsmith = new FlagsmithClient(EventsConfiguration(mockHttpClient)); + var traits = new List { new Trait("plan", "premium") }; + + // When + var flag = (Flag)await flagsmith.GetExperimentFlag("banner", "user_123", traits); + await flagsmith.FlushEvents(); + + // Then + Assert.Equal("hello treatment", flag.Value); + Assert.Equal("treatment", flag.Variant); + Assert.Equal(7, flag.Experiment.Id); + Assert.Equal("banner experiment", flag.Experiment.Name); + Assert.True(flag.Experiment.InExperiment); + var exposure = mockHttpClient.PostedJson("/v1/events").Single()["events"].Single(); + Assert.Equal("$flag_exposure", (string)exposure["event"]); + Assert.Equal("banner", (string)exposure["feature_name"]); + Assert.Equal("user_123", (string)exposure["identifier"]); + Assert.Equal("treatment", (string)exposure["value"]); + Assert.True(JToken.DeepEquals(JObject.FromObject(new { plan = "premium" }), exposure["traits"]), exposure.ToString()); + Assert.Equal(7, (int)exposure["metadata"]["experiment_id"]); + } + + [Theory] + [InlineData("'enabled': true")] + [InlineData("'enabled': false, 'variant': 'treatment', 'metadata': { 'experiment': { 'id': 7, 'name': 'banner experiment', 'in_experiment': true } }")] + [InlineData("'enabled': true, 'variant': 'treatment', 'metadata': { 'experiment': { 'id': 7, 'name': 'banner experiment', 'in_experiment': false } }")] + [InlineData("'enabled': true, 'variant': null, 'metadata': { 'experiment': { 'id': 7, 'name': 'banner experiment', 'in_experiment': true } }")] + public async Task TestIdentityIsNotExposedUnlessServedAnEnabledVariantOfARunningExperiment(string flagState) + { + // Given + var mockHttpClient = MockFlagsmith($"{{ 'flags': [{{ 'feature': {{ 'id': 1, 'name': 'banner' }}, 'feature_state_value': 'hello', {flagState} }}] }}"); + var flagsmith = new FlagsmithClient(EventsConfiguration(mockHttpClient)); + + // When + var flag = await flagsmith.GetExperimentFlag("banner", "user_123"); + await flagsmith.FlushEvents(); + + // Then + Assert.Equal("hello", flag.Value); + Assert.Empty(mockHttpClient.PostedJson("/v1/events")); + } + + [Fact] + public async Task TestDefaultFlagIsNotExposed() + { + // Given + var mockHttpClient = MockFlagsmith(); + var config = EventsConfiguration(mockHttpClient); + config.DefaultFlagHandler = _ => new Flag(null, true, "default"); + var flagsmith = new FlagsmithClient(config); + + // When + var flag = await flagsmith.GetExperimentFlag("banner", "user_123"); + await flagsmith.FlushEvents(); + + // Then + Assert.Equal("default", flag.Value); + Assert.Empty(mockHttpClient.PostedJson("/v1/events")); + } + } +} diff --git a/Flagsmith.Client.Test/HttpMocker.cs b/Flagsmith.Client.Test/HttpMocker.cs index aa886d2..47ab8a1 100644 --- a/Flagsmith.Client.Test/HttpMocker.cs +++ b/Flagsmith.Client.Test/HttpMocker.cs @@ -2,12 +2,14 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Net; using System.Net.Http; using System.Threading; using System.Threading.Tasks; using System.Web; using Moq; +using Newtonsoft.Json.Linq; namespace Flagsmith.FlagsmithClientTest { @@ -97,6 +99,13 @@ public static void VerifyHttpRequest(this Mock mockHttpClient, HttpM (queryString == "" || QueryStringsMatch(req, queryString))), It.IsAny()), times); } + public static List PostedJson(this Mock mockHttpClient, string path) => + mockHttpClient.Invocations + .Select(invocation => (HttpRequestMessage)invocation.Arguments[0]) + .Where(request => request.Method == HttpMethod.Post && request.RequestUri.AbsolutePath == path) + .Select(request => JObject.Parse(request.Content.ReadAsStringAsync().Result)) + .ToList(); + private static bool QueryStringsMatch(HttpRequestMessage req, string expectedQueryString) { // We can't just compare the querystring directly, since .NET sometimes encodes `=` diff --git a/Flagsmith.FlagsmithClient/EventProcessor.cs b/Flagsmith.FlagsmithClient/EventProcessor.cs new file mode 100644 index 0000000..283f9e9 --- /dev/null +++ b/Flagsmith.FlagsmithClient/EventProcessor.cs @@ -0,0 +1,247 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; + +namespace Flagsmith +{ + /// + /// Buffers tracking and exposure events, and sends them to the Flagsmith events API in batches: + /// on a timer, and as soon as the buffer fills. Equal exposures are only sent once per batch. + /// + internal class EventProcessor : IDisposable + { + internal const string FlagExposureEvent = "$flag_exposure"; + + private const int MaxAttempts = 2; + private static readonly TimeSpan RetryBackoff = TimeSpan.FromSeconds(1); + + private readonly HttpClient _httpClient; + private readonly string _eventsEndpoint; + private readonly string _environmentKey; + private readonly int _maxBufferItems; + private readonly TimeSpan _requestTimeout; + private readonly ILogger? _logger; + private readonly Timer _timer; + private readonly object _lock = new object(); + private List _buffer = new List(); + private readonly HashSet<(string?, string?, string?, string?)> _bufferedExposures = new HashSet<(string?, string?, string?, string?)>(); + private readonly HashSet _pendingSends = new HashSet(); + + public EventProcessor(HttpClient httpClient, string environmentKey, Uri eventsApiUri, TimeSpan flushInterval, int maxBufferItems, TimeSpan requestTimeout, ILogger? logger) + { + if (maxBufferItems < 1) + { + throw new ArgumentOutOfRangeException(nameof(maxBufferItems), "The events buffer must hold at least one event."); + } + if (flushInterval <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(flushInterval), "The events flush interval must be positive."); + } + + _httpClient = httpClient; + _environmentKey = environmentKey; + _eventsEndpoint = eventsApiUri.ToString().AppendToUrl(false, "v1", "events"); + _maxBufferItems = maxBufferItems; + _requestTimeout = requestTimeout; + _logger = logger; + _timer = new Timer(_ => _ = Flush(), null, flushInterval, flushInterval); + } + + public void TrackEvent(string eventName, string? identifier, object? value, List? traits, Dictionary? metadata) => + Buffer(new Event(eventName, null, identifier, value, traits, metadata)); + + public void TrackExposureEvent(string featureName, string identifier, object? value, List? traits, Dictionary? metadata) => + Buffer(new Event(FlagExposureEvent, featureName, identifier, value, traits, metadata)); + + /// + /// Sends every event buffered so far, and waits for batches already being sent. + /// A batch the events API does not accept is logged and dropped. + /// + public Task Flush() + { + List? events = null; + TaskCompletionSource? sent = null; + Task[] pendingSends; + lock (_lock) + { + if (_buffer.Count > 0) + { + events = _buffer; + _buffer = new List(); + _bufferedExposures.Clear(); + // Registered before the lock is released, so a concurrent Flush always waits for this batch. + sent = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _pendingSends.Add(sent.Task); + } + pendingSends = _pendingSends.ToArray(); + } + + if (events != null) + { + _ = SendTracked(events, sent!); + } + return Task.WhenAll(pendingSends); + } + + public void Dispose() => _timer.Dispose(); + + private void Buffer(Event @event) + { + bool isFull; + lock (_lock) + { + if (@event.Name == FlagExposureEvent && !_bufferedExposures.Add(@event.ExposureKey)) + { + _logger?.LogDebug("Skipping duplicate {Event} event for feature {FeatureName} and identity {Identifier}", @event.Name, @event.FeatureName, @event.Identifier); + return; + } + _buffer.Add(@event); + isFull = _buffer.Count >= _maxBufferItems; + } + + if (isFull) + { + _ = Flush(); + } + } + + private async Task SendTracked(List events, TaskCompletionSource sent) + { + try + { + await Send(events).ConfigureAwait(false); + } + finally + { + lock (_lock) + { + _pendingSends.Remove(sent.Task); + } + sent.SetResult(true); + } + } + + private async Task Send(List events) + { + string body; + try + { + body = JsonConvert.SerializeObject(new { events }); + } + catch (Exception e) + { + _logger?.LogError(e, "Dropping {EventCount} events that could not be serialised", events.Count); + return; + } + + for (var attempt = 1; ; attempt++) + { + string failure; + bool retryable; + try + { + var request = new HttpRequestMessage(HttpMethod.Post, _eventsEndpoint) + { + Headers = + { + { "X-Environment-Key", _environmentKey }, + { "User-Agent", SdkVersion.GetUserAgent() }, + { "Flagsmith-SDK-User-Agent", SdkVersion.GetUserAgent() } + }, + Content = new StringContent(body, Encoding.UTF8, "application/json") + }; + using var cancellationTokenSource = new CancellationTokenSource(_requestTimeout); + var response = await _httpClient.SendAsync(request, cancellationTokenSource.Token).ConfigureAwait(false); + if (response.IsSuccessStatusCode) + { + return; + } + failure = $"HTTP {(int)response.StatusCode}"; + retryable = (int)response.StatusCode >= 500; + } + catch (Exception e) when (e is HttpRequestException || e is TaskCanceledException) + { + failure = e.Message; + retryable = true; + } + catch (Exception e) + { + failure = e.Message; + retryable = false; + } + + if (!retryable || attempt >= MaxAttempts) + { + _logger?.LogError("Dropping {EventCount} events the events API did not accept: {Failure}", events.Count, failure); + return; + } + _logger?.LogWarning("Retrying {EventCount} events the events API did not accept: {Failure}", events.Count, failure); + await Task.Delay(RetryBackoff).ConfigureAwait(false); + } + } + + private class Event + { + public Event(string name, string? featureName, string? identifier, object? value, List? traits, Dictionary? metadata) + { + Name = name; + FeatureName = featureName; + Identifier = identifier; + Value = AsEventValue(value); + Traits = AsEventTraits(traits); + Metadata = new Dictionary(metadata ?? new Dictionary()) { ["sdk_version"] = SdkVersion.Version }; + Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + } + + [JsonProperty("event")] + public string Name { get; } + + [JsonProperty("feature_name")] + public string? FeatureName { get; } + + [JsonProperty("identifier")] + public string? Identifier { get; } + + [JsonProperty("value")] + public string? Value { get; } + + [JsonProperty("traits")] + public Dictionary? Traits { get; } + + [JsonProperty("metadata")] + public Dictionary Metadata { get; } + + [JsonProperty("timestamp")] + public long Timestamp { get; } + + [JsonIgnore] + public (string?, string?, string?, string?) ExposureKey => + (FeatureName, Identifier, Value, Metadata.TryGetValue("experiment_id", out var experimentId) ? AsEventValue(experimentId) : null); + + private static string? AsEventValue(object? value) => value switch + { + null => null, + bool boolean => boolean ? "true" : "false", + IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture), + _ => value.ToString(), + }; + + private static Dictionary? AsEventTraits(List? traits) + { + var persisted = traits?.Where(trait => !trait.GetTransient()).ToList(); + return persisted == null || persisted.Count == 0 + ? null + : persisted.ToDictionary(trait => trait.GetTraitKey(), trait => (object?)trait.GetTraitValue()); + } + } + } +} diff --git a/Flagsmith.FlagsmithClient/ExperimentMetadata.cs b/Flagsmith.FlagsmithClient/ExperimentMetadata.cs new file mode 100644 index 0000000..09b6656 --- /dev/null +++ b/Flagsmith.FlagsmithClient/ExperimentMetadata.cs @@ -0,0 +1,22 @@ +using Newtonsoft.Json; + +namespace Flagsmith +{ + /// + /// The running experiment a flag was evaluated under. Only remote identity evaluation carries it. + /// + public class ExperimentMetadata + { + [JsonProperty("id")] + public int Id { get; private set; } + + [JsonProperty("name")] + public string Name { get; private set; } + + /// + /// Whether the identity is enrolled in the experiment. + /// + [JsonProperty("in_experiment")] + public bool InExperiment { get; private set; } + } +} diff --git a/Flagsmith.FlagsmithClient/Flag.cs b/Flagsmith.FlagsmithClient/Flag.cs index 7f1ff01..929f79b 100644 --- a/Flagsmith.FlagsmithClient/Flag.cs +++ b/Flagsmith.FlagsmithClient/Flag.cs @@ -24,6 +24,21 @@ public Flag(Feature feature, bool enabled, string value, int featureId = default [JsonProperty("feature_state_value")] public string Value { get; private set; } + /// + /// Key of the multivariate option the identity was served, if any. + /// + [JsonProperty("variant")] + public string Variant { get; private set; } + + [JsonProperty("metadata")] + private FlagMetadata Metadata { get; set; } + + /// + /// The running experiment the flag was evaluated under, if any. + /// + [JsonIgnore] + public ExperimentMetadata Experiment => Metadata?.Experiment; + public string GetFeatureName() { return this.Feature.Name; @@ -32,5 +47,11 @@ public override string ToString() { return JsonConvert.SerializeObject(this); } + + private class FlagMetadata + { + [JsonProperty("experiment")] + public ExperimentMetadata Experiment { get; set; } + } } } diff --git a/Flagsmith.FlagsmithClient/FlagsmithClient.cs b/Flagsmith.FlagsmithClient/FlagsmithClient.cs index 9ad6bc5..6337c1e 100644 --- a/Flagsmith.FlagsmithClient/FlagsmithClient.cs +++ b/Flagsmith.FlagsmithClient/FlagsmithClient.cs @@ -43,6 +43,7 @@ public class FlagsmithClient : IFlagsmithClient private readonly IEngine _engine = new Engine(); private PollingManager? _pollingManager; private AnalyticsProcessor? _analyticsProcessor; + private EventProcessor? _eventProcessor; private RegularFlagListCache? _regularFlagListCache; private ConcurrentDictionary? _flagListCacheDictionary; @@ -56,6 +57,10 @@ private void Initialise() { throw new Exception("ValueError: Cannot use both defaultFlagHandler and offlineHandler."); } + else if (_config.OfflineMode && _config.EnableEvents) + { + throw new Exception("ValueError: Cannot send events in offline mode."); + } if (_config.OfflineHandler != null) { @@ -72,6 +77,9 @@ private void Initialise() if (_config.EnableAnalytics) _analyticsProcessor = new AnalyticsProcessor(_config.HttpClient, _config.EnvironmentKey, _config.ApiUri.ToString(), _config.Logger, _config.CustomHeaders); + if (_config.EnableEvents) + _eventProcessor = new EventProcessor(_config.HttpClient, _config.EnvironmentKey, _config.EventsApiUri, _config.EventsFlushInterval, _config.EventsMaxBufferItems, TimeSpan.FromSeconds(_config.RequestTimeout ?? 5), _config.Logger); + if (_config.EnableLocalEvaluation) { if (!_config.EnvironmentKey!.StartsWith("ser.")) @@ -156,6 +164,71 @@ public async Task GetIdentityFlagsFromCorrectSource(IdentityWrapper iden return await GetIdentityFlagsFromApi(identityWrapper.Identifier, identityWrapper.Traits, identityWrapper.Transient).ConfigureAwait(false); } + /// + /// Get an identity's flag for a feature, and record which variant the identity was exposed to + /// when it is enrolled in a running experiment on the feature. Requires . + /// Only remote evaluation knows about experiments, so local evaluation records no exposure. + /// + public async Task GetExperimentFlag(string featureName, string identifier, List? traits = null) + { + RequireEventProcessor("get experiment flags"); + var flags = await GetIdentityFlags(identifier, traits).ConfigureAwait(false); + var flag = await flags.GetFlag(featureName).ConfigureAwait(false); + var experiment = (flag as Flag)?.Experiment; + var variant = (flag as Flag)?.Variant; + + if (!flag.Enabled) + _config.Logger?.LogDebug("Not sending {Event} for feature {FeatureName}: the flag is disabled", EventProcessor.FlagExposureEvent, featureName); + else if (experiment == null || !experiment.InExperiment) + _config.Logger?.LogDebug("Not sending {Event} for feature {FeatureName}: the identity is not in a running experiment", EventProcessor.FlagExposureEvent, featureName); + else if (variant == null) + _config.Logger?.LogDebug("Not sending {Event} for feature {FeatureName}: the identity was served no variant", EventProcessor.FlagExposureEvent, featureName); + else + TrackExposureEvent(featureName, identifier, variant, traits, new Dictionary { ["experiment_id"] = experiment.Id }); + + return flag; + } + + /// + /// Record an application event, such as a conversion, for experimentation. Requires . + /// + /// The event name is empty, or starts with the reserved "$". + public void TrackEvent(string eventName, string? identifier = null, object? value = null, List? traits = null, Dictionary? metadata = null) + { + var eventProcessor = RequireEventProcessor("track events"); + if (string.IsNullOrEmpty(eventName)) + throw new ArgumentException("The event name must not be empty.", nameof(eventName)); + if (eventName.StartsWith("$")) + throw new ArgumentException($"Event names starting with \"$\" are reserved: use TrackExposureEvent to record \"{EventProcessor.FlagExposureEvent}\".", nameof(eventName)); + + eventProcessor.TrackEvent(eventName, identifier, value, traits, metadata); + } + + /// + /// Record that an identity was exposed to a variant of a feature, as a "$flag_exposure" event. + /// Requires . + /// + public void TrackExposureEvent(string featureName, string identifier, object? value = null, List? traits = null, Dictionary? metadata = null) + { + var eventProcessor = RequireEventProcessor("track exposure events"); + if (string.IsNullOrEmpty(identifier)) + { + _config.Logger?.LogWarning("Not sending {Event} for feature {FeatureName}: an exposure needs an identifier to be reconciled with conversion events", EventProcessor.FlagExposureEvent, featureName); + return; + } + + eventProcessor.TrackExposureEvent(featureName, identifier, value, traits, metadata); + } + + /// + /// Send the buffered events now, rather than at the next . + /// Call it before the application exits, so that no events are lost. + /// + public Task FlushEvents() => _eventProcessor?.Flush() ?? Task.CompletedTask; + + private EventProcessor RequireEventProcessor(string action) => + _eventProcessor ?? throw new FlagsmithClientError($"Enable events to {action}."); + public List? GetIdentitySegments(string identifier) { return GetIdentitySegments(identifier, new List()); @@ -320,6 +393,10 @@ private IFlags GetIdentityFlagsFromLocalEvaluationContext(string identifier, Lis public Dictionary aggregatedAnalytics => _analyticsProcessor != null ? _analyticsProcessor.GetAggregatedAnalytics() : new Dictionary(); - ~FlagsmithClient() => _pollingManager?.StopPoll(); + ~FlagsmithClient() + { + _pollingManager?.StopPoll(); + _eventProcessor?.Dispose(); + } } } diff --git a/Flagsmith.FlagsmithClient/FlagsmithConfiguration.cs b/Flagsmith.FlagsmithClient/FlagsmithConfiguration.cs index 29ad442..6faae60 100644 --- a/Flagsmith.FlagsmithClient/FlagsmithConfiguration.cs +++ b/Flagsmith.FlagsmithClient/FlagsmithConfiguration.cs @@ -10,6 +10,7 @@ namespace Flagsmith public class FlagsmithConfiguration { private static readonly Uri DefaultApiUri = new Uri("https://edge.api.flagsmith.com/api/v1/"); + private static readonly Uri DefaultEventsApiUri = new Uri("https://events.api.flagsmith.com/"); private TimeSpan _timeout = TimeSpan.FromSeconds(5); /// @@ -49,6 +50,26 @@ public class FlagsmithConfiguration /// public bool EnableAnalytics { get; set; } + /// + /// If enabled, sends tracking and exposure events to the Flagsmith events API, powering experimentation. + /// + public bool EnableEvents { get; set; } + + /// + /// Base URI of the Flagsmith events API. Defaults to https://events.api.flagsmith.com/. + /// + public Uri EventsApiUri { get; set; } = DefaultEventsApiUri; + + /// + /// Interval between two sends of the buffered events. + /// + public TimeSpan EventsFlushInterval { get; set; } = TimeSpan.FromSeconds(10); + + /// + /// Number of buffered events that are sent without waiting for . + /// + public int EventsMaxBufferItems { get; set; } = 1000; + /// /// Number of seconds to wait for a request to complete before terminating the request /// diff --git a/Flagsmith.FlagsmithClient/IFlagsmithClient.cs b/Flagsmith.FlagsmithClient/IFlagsmithClient.cs index 949e68e..14bca2e 100644 --- a/Flagsmith.FlagsmithClient/IFlagsmithClient.cs +++ b/Flagsmith.FlagsmithClient/IFlagsmithClient.cs @@ -20,6 +20,27 @@ public interface IFlagsmithClient /// Task GetIdentityFlags(string identifier, List traits, bool transient = false); + /// + /// Get an identity's flag for a feature, and record which variant the identity was exposed to + /// when it is enrolled in a running experiment on the feature. + /// + Task GetExperimentFlag(string featureName, string identifier, List traits = null); + + /// + /// Record an application event, such as a conversion, for experimentation. + /// + void TrackEvent(string eventName, string identifier = null, object value = null, List traits = null, Dictionary metadata = null); + + /// + /// Record that an identity was exposed to a variant of a feature, as a "$flag_exposure" event. + /// + void TrackExposureEvent(string featureName, string identifier, object value = null, List traits = null, Dictionary metadata = null); + + /// + /// Send the buffered events now. + /// + Task FlushEvents(); + List GetIdentitySegments(string identifier); List GetIdentitySegments(string identifier, List traits); } diff --git a/Flagsmith.FlagsmithClient/SdkVersion.cs b/Flagsmith.FlagsmithClient/SdkVersion.cs index 80edbf0..4b48919 100644 --- a/Flagsmith.FlagsmithClient/SdkVersion.cs +++ b/Flagsmith.FlagsmithClient/SdkVersion.cs @@ -8,7 +8,7 @@ namespace Flagsmith public static class SdkVersion { // x-release-please-start-version - private const string Version = "9.0.0"; + internal const string Version = "9.0.0"; // x-release-please-end /// diff --git a/sdk-spec/steps.cs b/sdk-spec/steps.cs index c552874..089f8a7 100644 --- a/sdk-spec/steps.cs +++ b/sdk-spec/steps.cs @@ -11,6 +11,16 @@ public static class Steps public static FlagsmithClient CreateClient(string environmentKey, string apiUrl) => new(new FlagsmithConfiguration { EnvironmentKey = environmentKey, ApiUri = new Uri(apiUrl) }); + // Create the SDK client with events enabled + public static FlagsmithClient CreateClientWithEvents(string environmentKey, string apiUrl, string eventsApiUrl) => + new(new FlagsmithConfiguration + { + EnvironmentKey = environmentKey, + ApiUri = new Uri(apiUrl), + EnableEvents = true, + EventsApiUri = new Uri(eventsApiUrl), + }); + // When the application gets the environment flags public static async Task> GetEnvironmentFlags(FlagsmithClient flagsmith) => AsSpecFlags(await flagsmith.GetEnvironmentFlags()); @@ -18,11 +28,30 @@ public static async Task> GetEnvironmentFlags(FlagsmithClien // When the application gets the flags for identity "{identifier}" with traits: public static async Task> GetIdentityFlags( FlagsmithClient flagsmith, string identifier, Dictionary traits) => - AsSpecFlags(await flagsmith.GetIdentityFlags( - identifier, traits.Select(trait => (ITrait)new Trait(trait.Key, trait.Value)).ToList())); + AsSpecFlags(await flagsmith.GetIdentityFlags(identifier, AsTraits(traits))); + + // When the application gets the experiment flag "{feature}" for identity "{identifier}" with traits: + public static async Task> GetExperimentFlag( + FlagsmithClient flagsmith, string feature, string identifier, Dictionary traits) => + new[] { AsSpecFlag(await flagsmith.GetExperimentFlag(feature, identifier, AsTraits(traits))) }; + + // When the application tracks event "{event}" for identity "{identifier}" with value {value} and metadata: + public static void TrackEvent( + FlagsmithClient flagsmith, string eventName, string identifier, object? value, Dictionary metadata) => + flagsmith.TrackEvent(eventName, identifier, value, metadata: metadata); + + // When the application tracks an exposure of feature "{feature}" for identity "{identifier}" with value {value} and metadata: + public static void TrackExposureEvent( + FlagsmithClient flagsmith, string feature, string identifier, object? value, Dictionary metadata) => + flagsmith.TrackExposureEvent(feature, identifier, value, metadata: metadata); + + private static List AsTraits(Dictionary traits) => + traits.Select(trait => (ITrait)new Trait(trait.Key, trait.Value)).ToList(); + + private static IEnumerable AsSpecFlags(IFlags flags) => flags.AllFlags().Select(AsSpecFlag); - private static IEnumerable AsSpecFlags(IFlags flags) => - flags.AllFlags().Select(flag => new { feature = flag.GetFeatureName(), enabled = flag.Enabled, value = AsSpecValue(flag.Value) }); + private static object AsSpecFlag(IFlag flag) => + new { feature = flag.GetFeatureName(), enabled = flag.Enabled, value = AsSpecValue(flag.Value) }; // The SDK exposes flag values as strings (42 -> "42", true -> "true"), so restore numbers and booleans. private static object? AsSpecValue(string? value) =>