Skip to content

Commit dfa15c5

Browse files
committed
feat: add analytics_url to send flag analytics to a different host
When the SDK evaluates flags through an Edge Proxy, the analytics POST built from api_url hits a path the proxy does not handle and events are silently dropped. Setting analytics_url overrides just the analytics endpoint, so analytics can be sent to the core Flagsmith API while evaluations keep going through the proxy. When analytics_url is unset, the SDK still derives the endpoint from api_url + /analytics/flags/, so existing setups are unaffected. Closes #213
1 parent 83a76d0 commit dfa15c5

5 files changed

Lines changed: 126 additions & 4 deletions

File tree

‎README.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,27 @@ The SDK for Python applications for [https://www.flagsmith.com/](https://www.fla
1010
For full documentation visit
1111
[https://docs.flagsmith.com/clients/server-side?language=python](https://docs.flagsmith.com/clients/server-side?language=python).
1212

13+
### Sending flag analytics to a different host than evaluations
14+
15+
When evaluating flags through an Edge Proxy (or another host that does not handle
16+
the analytics endpoint), pass `analytics_url` to send flag analytics directly to the
17+
core Flagsmith API while keeping flag evaluations on the proxy:
18+
19+
```python
20+
from flagsmith import Flagsmith
21+
22+
flagsmith = Flagsmith(
23+
environment_key="<your API key>",
24+
api_url="https://edge-proxy.internal/api/v1/",
25+
analytics_url="https://edge.api.flagsmith.com/api/v1/analytics/flags/",
26+
enable_local_evaluation=True,
27+
enable_analytics=True,
28+
)
29+
```
30+
31+
When `analytics_url` is unset, analytics continue to post to
32+
`<api_url>/analytics/flags/` as before.
33+
1334
## Contributing
1435

1536
Please read [CONTRIBUTING.md](CONTRIBUTING.md) for details on our code of conduct, and the process for submitting pull

‎flagsmith/analytics.py‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,11 @@ class AnalyticsProcessor:
3131
"""
3232

3333
def __init__(
34-
self, environment_key: str, base_api_url: str, timeout: typing.Optional[int] = 3
34+
self,
35+
environment_key: str,
36+
base_api_url: str,
37+
timeout: typing.Optional[int] = 3,
38+
analytics_url: typing.Optional[str] = None,
3539
):
3640
"""
3741
Initialise the AnalyticsProcessor to handle sending analytics on flag usage to
@@ -41,8 +45,13 @@ def __init__(
4145
:param base_api_url: base api url to override when using self hosted version
4246
:param timeout: used to tell requests to stop waiting for a response after a
4347
given number of seconds
48+
:param analytics_url: full URL of the flag analytics endpoint, used to override
49+
the default ``<base_api_url>/analytics/flags/``. Intended for deployments
50+
where flag evaluation traffic and analytics traffic must go to different
51+
hosts (for example, evaluating through the Edge Proxy while sending
52+
analytics to the core API).
4453
"""
45-
self.analytics_endpoint = base_api_url + ANALYTICS_ENDPOINT
54+
self.analytics_endpoint = analytics_url or (base_api_url + ANALYTICS_ENDPOINT)
4655
self.environment_key = environment_key
4756
self._last_flushed = datetime.now()
4857
self.analytics_data: typing.MutableMapping[str, typing.Any] = {}

‎flagsmith/flagsmith.py‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ def __init__(
7070
environment_key: typing.Optional[str] = None,
7171
api_url: typing.Optional[str] = None,
7272
realtime_api_url: typing.Optional[str] = None,
73+
analytics_url: typing.Optional[str] = None,
7374
custom_headers: typing.Optional[typing.Dict[str, typing.Any]] = None,
7475
request_timeout_seconds: typing.Optional[int] = 10,
7576
enable_local_evaluation: bool = False,
@@ -92,6 +93,11 @@ def __init__(
9293
Required unless offline_mode is True.
9394
:param api_url: Override the URL of the Flagsmith API to communicate with
9495
:param realtime_api_url: Override the URL of the Flagsmith real-time API
96+
:param analytics_url: Override the URL used for flag analytics requests when
97+
enable_analytics is True. When unset, analytics are posted to
98+
``<api_url>/analytics/flags/``. Set this when api_url points at a host that
99+
does not handle analytics (for example, the Edge Proxy) so analytics can be
100+
sent directly to the core Flagsmith API.
95101
:param custom_headers: Additional headers to add to requests made to the
96102
Flagsmith API
97103
:param request_timeout_seconds: Number of seconds to wait for a request to
@@ -182,6 +188,10 @@ def __init__(
182188
else f"{realtime_api_url}/"
183189
)
184190

191+
if analytics_url and not analytics_url.endswith("/"):
192+
analytics_url = f"{analytics_url}/"
193+
self.analytics_url = analytics_url
194+
185195
self.request_timeout_seconds = request_timeout_seconds
186196
self.session.mount(self.api_url, HTTPAdapter(max_retries=retries))
187197

@@ -201,6 +211,7 @@ def __init__(
201211
self._initialise_analytics(
202212
environment_key=environment_key,
203213
enable_analytics=enable_analytics,
214+
analytics_url=self.analytics_url,
204215
)
205216
self._initialise_events(
206217
environment_key=environment_key,
@@ -212,10 +223,14 @@ def _initialise_analytics(
212223
self,
213224
environment_key: str,
214225
enable_analytics: bool,
226+
analytics_url: typing.Optional[str] = None,
215227
) -> None:
216228
if enable_analytics:
217229
self._analytics_processor = AnalyticsProcessor(
218-
environment_key, self.api_url, timeout=self.request_timeout_seconds
230+
environment_key,
231+
self.api_url,
232+
timeout=self.request_timeout_seconds,
233+
analytics_url=analytics_url,
219234
)
220235

221236
def _initialise_events(

‎tests/test_analytics.py‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from datetime import datetime, timedelta
33
from unittest import mock
44

5-
from flagsmith.analytics import ANALYTICS_TIMER, AnalyticsProcessor
5+
from flagsmith.analytics import ANALYTICS_ENDPOINT, ANALYTICS_TIMER, AnalyticsProcessor
66

77

88
def test_analytics_processor_track_feature_updates_analytics_data(
@@ -36,6 +36,9 @@ def test_analytics_processor_flush_post_request_data_match_ananlytics_data(
3636
# Then
3737
session.post.assert_called()
3838
post_call = session.mock_calls[0]
39+
# When analytics_url is unset, the POST falls back to base_api_url + ANALYTICS_ENDPOINT.
40+
# Locks the default in so a future refactor cannot silently break it.
41+
assert post_call[1][0] == "http://test_url" + ANALYTICS_ENDPOINT
3942
assert {"my_feature_1": 1, "my_feature_2": 1} == json.loads(post_call[2]["data"])
4043

4144

@@ -66,3 +69,24 @@ def test_analytics_processor_calling_track_feature_calls_flush_when_timer_runs_o
6669

6770
# Then
6871
session.post.assert_called()
72+
73+
74+
def test_analytics_processor_posts_to_analytics_url_when_set() -> None:
75+
# Given an AnalyticsProcessor configured to send analytics to a host
76+
# that is different from base_api_url (e.g. base_api_url points at an
77+
# Edge Proxy that does not handle analytics)
78+
processor = AnalyticsProcessor(
79+
environment_key="test_key",
80+
base_api_url="http://edge-proxy/",
81+
analytics_url="http://core-api/analytics/flags/",
82+
)
83+
84+
# When the processor flushes
85+
with mock.patch("flagsmith.analytics.session") as session:
86+
processor.track_feature("my_feature")
87+
processor.flush()
88+
89+
# Then the POST goes to analytics_url and never to the edge-proxy host
90+
session.post.assert_called_once()
91+
assert session.post.call_args[0][0] == "http://core-api/analytics/flags/"
92+
assert "edge-proxy" not in session.post.call_args[0][0]

‎tests/test_flagsmith.py‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1178,3 +1178,56 @@ def test_get_experiment_flag_falls_back_to_value_without_variant(
11781178
traits=None,
11791179
metadata=None,
11801180
)
1181+
1182+
1183+
@responses.activate()
1184+
def test_flagsmith_posts_analytics_to_analytics_url_when_set(
1185+
api_key: str, flags_json: str, mocker: MockerFixture
1186+
) -> None:
1187+
# Given a Flagsmith client pointed at an Edge Proxy for flag evaluations,
1188+
# with analytics_url overriding the analytics endpoint to the core API.
1189+
# analytics_url is intentionally written without a trailing slash to
1190+
# exercise the constructor's normalisation.
1191+
#
1192+
# We swap the fire-and-forget FuturesSession for a plain requests.Session
1193+
# so the analytics POST happens synchronously on the test thread and is
1194+
# observable via responses.calls; without this swap the worker thread can
1195+
# race the assertions.
1196+
mocker.patch("flagsmith.analytics.session", requests.Session())
1197+
flagsmith = Flagsmith(
1198+
environment_key=api_key,
1199+
api_url="http://edge-proxy.internal/api/v1/",
1200+
analytics_url="http://core-api.flagsmith.com/api/v1/analytics/flags",
1201+
enable_analytics=True,
1202+
)
1203+
1204+
expected_analytics_url = (
1205+
"http://core-api.flagsmith.com/api/v1/analytics/flags/"
1206+
)
1207+
responses.add(
1208+
method="GET", url=flagsmith.environment_flags_url, body=flags_json
1209+
)
1210+
responses.add(method="POST", url=expected_analytics_url, status=200)
1211+
1212+
# When the customer-facing evaluation API is exercised. This is the path
1213+
# that triggers track_feature internally (Flags.get_flag in models.py).
1214+
flags = flagsmith.get_environment_flags()
1215+
assert flags.is_feature_enabled("some_feature") is True
1216+
1217+
# Force the flush deterministically rather than waiting on ANALYTICS_TIMER.
1218+
assert flagsmith._analytics_processor is not None
1219+
flagsmith._analytics_processor.flush()
1220+
1221+
# Then exactly one analytics POST landed on the override host (with the
1222+
# trailing slash applied), carried the tracked feature payload, and the
1223+
# Edge Proxy never received an analytics request.
1224+
analytics_calls = [
1225+
call for call in responses.calls if call.request.method == "POST"
1226+
]
1227+
assert len(analytics_calls) == 1
1228+
request = analytics_calls[0].request
1229+
assert request.url == expected_analytics_url
1230+
assert "edge-proxy" not in request.url
1231+
assert request.body is not None
1232+
assert json.loads(request.body) == {"some_feature": 1}
1233+
assert request.headers["X-Environment-Key"] == api_key

0 commit comments

Comments
 (0)