From 6230d7073737135ecb6972c1f6d832dcacb39582 Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Tue, 18 Aug 2026 09:26:07 +0100 Subject: [PATCH] feat: Allow configuring analytics API endpoint separate from flags API Currently, we only support having the analytics API on the same host as the flags/identities endpoints. This is not the case when using Edge Proxy, which does not accept analytics requests. This change adds `FlagsmithConfiguration.AnalyticsUri` to configure an arbitrary analytics endpoint URI, and adds an `AnalyticsProcessor` constructor overload taking that full URI, so it can also be used directly to manage analytics manually. The existing `baseApiUrl` constructor and `GetInstance` are unchanged. Ports Flagsmith/flagsmith-nodejs-client#168. Co-Authored-By: Claude Opus 5 --- .../AnalyticsProcessorTest.cs | 4 +- Flagsmith.Client.Test/AnalyticsTests.cs | 79 +++++++++++++++++-- Flagsmith.Client.Test/Fixtures.cs | 4 +- Flagsmith.Client.Test/UserAgentTest.cs | 2 +- .../AnalyticsProcessor.cs | 11 ++- Flagsmith.FlagsmithClient/FlagsmithClient.cs | 2 +- .../FlagsmithConfiguration.cs | 8 ++ 7 files changed, 95 insertions(+), 15 deletions(-) diff --git a/Flagsmith.Client.Test/AnalyticsProcessorTest.cs b/Flagsmith.Client.Test/AnalyticsProcessorTest.cs index fd4c23d..577b78a 100644 --- a/Flagsmith.Client.Test/AnalyticsProcessorTest.cs +++ b/Flagsmith.Client.Test/AnalyticsProcessorTest.cs @@ -13,8 +13,8 @@ internal class AnalyticsProcessorTest : AnalyticsProcessor /// /// This class provides some extra functionality to help particularly in unit testing. /// - public AnalyticsProcessorTest(HttpClient httpClient, string environmentKey, string baseApiUrl, int timeOut = 3) - : base(httpClient, environmentKey, baseApiUrl, timeOut: timeOut) + public AnalyticsProcessorTest(HttpClient httpClient, string environmentKey, Uri analyticsUri, int timeOut = 3) + : base(httpClient, environmentKey, analyticsUri, timeOut: timeOut) { } /// diff --git a/Flagsmith.Client.Test/AnalyticsTests.cs b/Flagsmith.Client.Test/AnalyticsTests.cs index 2c4ebb8..e5b608d 100644 --- a/Flagsmith.Client.Test/AnalyticsTests.cs +++ b/Flagsmith.Client.Test/AnalyticsTests.cs @@ -14,7 +14,8 @@ namespace Flagsmith.FlagsmithClientTest { public class AnalyticsTests { - const string _defaultApiUrl = "https://edge.api.flagsmith.com/api/v1/"; + static readonly Uri _defaultApiUri = new Uri("https://edge.api.flagsmith.com/api/v1/"); + static readonly Uri _defaultAnalyticsUri = new Uri(_defaultApiUri, "analytics/flags/"); [Fact] public async Task TestAnalyticsProcessorDoesNotThrowUnderLoad() @@ -27,7 +28,7 @@ public async Task TestAnalyticsProcessorDoesNotThrowUnderLoad() HttpMocker.PayloadsSubmitted = new System.Collections.Concurrent.ConcurrentBag(); var mockHttpClient = HttpMocker.MockHttpResponse(System.Net.HttpStatusCode.OK, null, true); - var analyticsProcessor = new AnalyticsProcessorTest(mockHttpClient.Object, null, null); + var analyticsProcessor = new AnalyticsProcessorTest(mockHttpClient.Object, null, _defaultAnalyticsUri); var token = new CancellationToken(); await Parallel.ForEachAsync(Enumerable.Range(1, numberOfThreads), token, async (item, token) => { @@ -69,7 +70,7 @@ public async Task TestAnalyticsProcessorFlushClearsAnalyticsData() { StatusCode = System.Net.HttpStatusCode.OK, }); - var analyticsProcessor = new AnalyticsProcessorTest(mockHttpClient.Object, null, null); + var analyticsProcessor = new AnalyticsProcessorTest(mockHttpClient.Object, null, _defaultAnalyticsUri); await analyticsProcessor.TrackFeature("TestAnalyticsProcessorFlushClearsAnalyticsDataFeature"); await analyticsProcessor.Flush(); Assert.False(analyticsProcessor.HasTrackingItemsInCache()); @@ -81,7 +82,7 @@ public async void TestAnalyticsProcessorFlushPostRequestDataMatchAnanlyticsData( { StatusCode = System.Net.HttpStatusCode.OK, }); - var analyticsProcessor = new AnalyticsProcessorTest(mockHttpClient.Object, null, baseApiUrl: _defaultApiUrl); + var analyticsProcessor = new AnalyticsProcessorTest(mockHttpClient.Object, null, analyticsUri: _defaultAnalyticsUri); await analyticsProcessor.TrackFeature("TestAnalyticsProcessorFlushPostRequestDataMatchAnanlyticsDataFeature1"); await analyticsProcessor.TrackFeature("TestAnalyticsProcessorFlushPostRequestDataMatchAnanlyticsDataFeature2"); var jObject = JObject.Parse(analyticsProcessor.ToString()); @@ -91,13 +92,77 @@ public async void TestAnalyticsProcessorFlushPostRequestDataMatchAnanlyticsData( Assert.Equal(1, jObject["TestAnalyticsProcessorFlushPostRequestDataMatchAnanlyticsDataFeature2"].Value()); } [Fact] + public async Task TestAnalyticsProcessorPostsToConfiguredAnalyticsUri() + { + var mockHttpClient = HttpMocker.MockHttpResponse(new HttpResponseMessage + { + StatusCode = System.Net.HttpStatusCode.OK, + }); + var analyticsUri = new Uri("https://analytics.example.com/api/v1/analytics/flags/"); + var analyticsProcessor = new AnalyticsProcessorTest(mockHttpClient.Object, Fixtures.ApiKey, analyticsUri); + await analyticsProcessor.TrackFeature("some_feature"); + await analyticsProcessor.Flush(); + mockHttpClient.Verify(x => x.SendAsync( + It.Is(req => req.Method == HttpMethod.Post && req.RequestUri == analyticsUri), + It.IsAny()), Times.Once); + } + [Fact] + public async Task TestAnalyticsProcessorBaseApiUrlConstructorPostsToAnalyticsEndpoint() + { + var mockHttpClient = HttpMocker.MockHttpResponse(new HttpResponseMessage + { + StatusCode = System.Net.HttpStatusCode.OK, + }); + var analyticsProcessor = new AnalyticsProcessor(mockHttpClient.Object, Fixtures.ApiKey, _defaultApiUri.ToString()); + await analyticsProcessor.TrackFeature("some_feature"); + await analyticsProcessor.Flush(); + VerifyAnalyticsPostedTo(mockHttpClient, _defaultAnalyticsUri); + } + [Fact] + public async Task TestFlagsmithClientPostsAnalyticsToExpectedUri() + { + var customAnalyticsUri = new Uri("https://analytics.example.com/api/v1/analytics/flags/"); + // A client using the default analytics endpoint, relative to the API URI... + var (defaultUriMock, defaultUriFlags) = await GetClientFlags(analyticsUri: null); + // ...and a client with an analytics endpoint configured separately from the API URI + var (customUriMock, customUriFlags) = await GetClientFlags(customAnalyticsUri); + await Task.Delay(11 * 1000); + await defaultUriFlags.IsFeatureEnabled("some_feature"); + await customUriFlags.IsFeatureEnabled("some_feature"); + // Tracking a feature does not await the flush it triggers, so give the requests time to be made + await Task.Delay(2 * 1000); + VerifyAnalyticsPostedTo(defaultUriMock, _defaultAnalyticsUri); + VerifyAnalyticsPostedTo(customUriMock, customAnalyticsUri); + } + private static async Task<(Mock, IFlags)> GetClientFlags(Uri analyticsUri) + { + var mockHttpClient = HttpMocker.MockHttpResponse(new HttpResponseMessage + { + StatusCode = System.Net.HttpStatusCode.OK, + Content = new StringContent(Fixtures.ApiFlagResponse) + }); + var client = new FlagsmithClient(new FlagsmithConfiguration + { + EnvironmentKey = Fixtures.ApiKey, + ApiUri = _defaultApiUri, + AnalyticsUri = analyticsUri, + HttpClient = mockHttpClient.Object, + EnableAnalytics = true + }); + return (mockHttpClient, await client.GetEnvironmentFlags()); + } + private static void VerifyAnalyticsPostedTo(Mock mockHttpClient, Uri expectedUri) + => mockHttpClient.Verify(x => x.SendAsync( + It.Is(req => req.Method == HttpMethod.Post && req.RequestUri == expectedUri), + It.IsAny()), Times.Once); + [Fact] public async Task TestAnalyticsProcessorFlushEarlyExitIfAnalyticsDataIsEmpty() { var mockHttpClient = HttpMocker.MockHttpResponse(new HttpResponseMessage { StatusCode = System.Net.HttpStatusCode.OK, }); - var analyticsProcessor = new AnalyticsProcessorTest(mockHttpClient.Object, null, baseApiUrl: _defaultApiUrl); + var analyticsProcessor = new AnalyticsProcessorTest(mockHttpClient.Object, null, analyticsUri: _defaultAnalyticsUri); await analyticsProcessor.Flush(); mockHttpClient.Verify(x => x.SendAsync(It.IsAny(), It.IsAny()), Times.Never); } @@ -108,7 +173,7 @@ public async Task TestAnalyticsProcessorCallingTrackFeatureCallsFlushWhenTimerRu { StatusCode = System.Net.HttpStatusCode.OK, }); - var analyticsProcessor = new AnalyticsProcessorTest(mockHttpClient.Object, null, baseApiUrl: _defaultApiUrl); + var analyticsProcessor = new AnalyticsProcessorTest(mockHttpClient.Object, null, analyticsUri: _defaultAnalyticsUri); await Task.Delay(12 * 1000); await analyticsProcessor.TrackFeature("TestAnalyticsProcessorCallingTrackFeatureCallsFlushWhenTimerRunsOutFeature"); mockHttpClient.VerifyHttpRequest(HttpMethod.Post, "/api/v1/analytics/flags/", Times.Once); @@ -122,7 +187,7 @@ public async Task TestAnalyticsProcessorDataConcurrentAccess() { StatusCode = System.Net.HttpStatusCode.OK, }); - var analyticsProcessor = new AnalyticsProcessorTest(mockHttpClient.Object, null, null); + var analyticsProcessor = new AnalyticsProcessorTest(mockHttpClient.Object, null, _defaultAnalyticsUri); const int numberOfCalls = 100000; // When diff --git a/Flagsmith.Client.Test/Fixtures.cs b/Flagsmith.Client.Test/Fixtures.cs index e7f4f05..f267f28 100644 --- a/Flagsmith.Client.Test/Fixtures.cs +++ b/Flagsmith.Client.Test/Fixtures.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Net.Http; using FlagsmithEngine.Environment.Models; @@ -10,7 +11,8 @@ internal class Fixtures { public static string ApiKey => "ser.test_key"; public static string ApiUrl => "http://test_url/"; - public static AnalyticsProcessorTest GetAnalyticalProcessorTest() => new(new HttpClient(), ApiKey, ApiUrl); + public static Uri AnalyticsUri => new Uri(ApiUrl + "analytics/flags/"); + public static AnalyticsProcessorTest GetAnalyticalProcessorTest() => new(new HttpClient(), ApiKey, AnalyticsUri); public static JObject JsonObject = JObject.Parse(@"{ 'api_key': 'test_key', 'name': 'Test Environment', diff --git a/Flagsmith.Client.Test/UserAgentTest.cs b/Flagsmith.Client.Test/UserAgentTest.cs index 14b2346..6cca703 100644 --- a/Flagsmith.Client.Test/UserAgentTest.cs +++ b/Flagsmith.Client.Test/UserAgentTest.cs @@ -113,7 +113,7 @@ public async Task TestUserAgentHeaderIsSentInAnalyticsFlush() var analyticsProcessor = new AnalyticsProcessor( httpClientMock.Object, Fixtures.ApiKey, - Fixtures.ApiUrl + Fixtures.AnalyticsUri ); // When diff --git a/Flagsmith.FlagsmithClient/AnalyticsProcessor.cs b/Flagsmith.FlagsmithClient/AnalyticsProcessor.cs index 99f4201..b874c4f 100644 --- a/Flagsmith.FlagsmithClient/AnalyticsProcessor.cs +++ b/Flagsmith.FlagsmithClient/AnalyticsProcessor.cs @@ -18,7 +18,7 @@ public class AnalyticsProcessor : IAnalyticsProcessor private int _FlushIntervalSeconds = 10; private readonly DisposableLock _lock = new DisposableLock(); - private readonly string _AnalyticsEndPoint; + private readonly Uri _AnalyticsUri; private readonly string _EnvironmentKey; private readonly int _TimeOut; private DateTime _LastFlushed; @@ -28,9 +28,14 @@ public class AnalyticsProcessor : IAnalyticsProcessor private ConcurrentDictionary> AnalyticsDataThreads; public AnalyticsProcessor(HttpClient httpClient, string environmentKey, string baseApiUrl, ILogger logger = null, Dictionary customHeaders = null, int timeOut = 3, int flushIntervalSeconds = 10) + : this(httpClient, environmentKey, new Uri(baseApiUrl + "analytics/flags/", UriKind.RelativeOrAbsolute), logger, customHeaders, timeOut, flushIntervalSeconds) + { + } + + public AnalyticsProcessor(HttpClient httpClient, string environmentKey, Uri analyticsUri, ILogger logger = null, Dictionary customHeaders = null, int timeOut = 3, int flushIntervalSeconds = 10) { _EnvironmentKey = environmentKey; - _AnalyticsEndPoint = baseApiUrl + "analytics/flags/"; + _AnalyticsUri = analyticsUri; _TimeOut = timeOut; _LastFlushed = DateTime.UtcNow; _HttpClient = httpClient; @@ -71,7 +76,7 @@ private async Task FlushWithoutLock() try { var analyticsJson = JsonConvert.SerializeObject(GetAggregatedAnalyticsWithoutLock()); - var request = new HttpRequestMessage(HttpMethod.Post, _AnalyticsEndPoint) + var request = new HttpRequestMessage(HttpMethod.Post, _AnalyticsUri) { Headers = { diff --git a/Flagsmith.FlagsmithClient/FlagsmithClient.cs b/Flagsmith.FlagsmithClient/FlagsmithClient.cs index 9ad6bc5..f819447 100644 --- a/Flagsmith.FlagsmithClient/FlagsmithClient.cs +++ b/Flagsmith.FlagsmithClient/FlagsmithClient.cs @@ -70,7 +70,7 @@ private void Initialise() throw new Exception("ValueError: environmentKey is required"); } if (_config.EnableAnalytics) - _analyticsProcessor = new AnalyticsProcessor(_config.HttpClient, _config.EnvironmentKey, _config.ApiUri.ToString(), _config.Logger, _config.CustomHeaders); + _analyticsProcessor = new AnalyticsProcessor(_config.HttpClient, _config.EnvironmentKey, _config.AnalyticsUri ?? new Uri(_config.ApiUri, "analytics/flags/"), _config.Logger, _config.CustomHeaders); if (_config.EnableLocalEvaluation) { diff --git a/Flagsmith.FlagsmithClient/FlagsmithConfiguration.cs b/Flagsmith.FlagsmithClient/FlagsmithConfiguration.cs index 29ad442..66436ef 100644 --- a/Flagsmith.FlagsmithClient/FlagsmithConfiguration.cs +++ b/Flagsmith.FlagsmithClient/FlagsmithConfiguration.cs @@ -19,6 +19,14 @@ public class FlagsmithConfiguration /// public Uri ApiUri { get; set; } = DefaultApiUri; + /// + /// URI of the Flagsmith analytics events API endpoint. Set this when analytics are not served by the same + /// API as flags, e.g. when using Edge Proxy. Defaults to the analytics/flags/ endpoint relative to + /// . + /// new Uri("https://flagsmith.example.com/api/v1/analytics/flags/") + /// + public Uri? AnalyticsUri { get; set; } + /// /// The environment key obtained from Flagsmith interface. ///