Skip to content

Commit f3c71a0

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 9967efb commit f3c71a0

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
@@ -28,7 +28,11 @@ class AnalyticsProcessor:
2828
"""
2929

3030
def __init__(
31-
self, environment_key: str, base_api_url: str, timeout: typing.Optional[int] = 3
31+
self,
32+
environment_key: str,
33+
base_api_url: str,
34+
timeout: typing.Optional[int] = 3,
35+
analytics_url: typing.Optional[str] = None,
3236
):
3337
"""
3438
Initialise the AnalyticsProcessor to handle sending analytics on flag usage to
@@ -38,8 +42,13 @@ def __init__(
3842
:param base_api_url: base api url to override when using self hosted version
3943
:param timeout: used to tell requests to stop waiting for a response after a
4044
given number of seconds
45+
:param analytics_url: full URL of the flag analytics endpoint, used to override
46+
the default ``<base_api_url>/analytics/flags/``. Intended for deployments
47+
where flag evaluation traffic and analytics traffic must go to different
48+
hosts (for example, evaluating through the Edge Proxy while sending
49+
analytics to the core API).
4150
"""
42-
self.analytics_endpoint = base_api_url + ANALYTICS_ENDPOINT
51+
self.analytics_endpoint = analytics_url or (base_api_url + ANALYTICS_ENDPOINT)
4352
self.environment_key = environment_key
4453
self._last_flushed = datetime.now()
4554
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
@@ -68,6 +68,7 @@ def __init__(
6868
environment_key: typing.Optional[str] = None,
6969
api_url: typing.Optional[str] = None,
7070
realtime_api_url: typing.Optional[str] = None,
71+
analytics_url: typing.Optional[str] = None,
7172
custom_headers: typing.Optional[typing.Dict[str, typing.Any]] = None,
7273
request_timeout_seconds: typing.Optional[int] = 10,
7374
enable_local_evaluation: bool = False,
@@ -89,6 +90,11 @@ def __init__(
8990
Required unless offline_mode is True.
9091
:param api_url: Override the URL of the Flagsmith API to communicate with
9192
:param realtime_api_url: Override the URL of the Flagsmith real-time API
93+
:param analytics_url: Override the URL used for flag analytics requests when
94+
enable_analytics is True. When unset, analytics are posted to
95+
``<api_url>/analytics/flags/``. Set this when api_url points at a host that
96+
does not handle analytics (for example, the Edge Proxy) so analytics can be
97+
sent directly to the core Flagsmith API.
9298
:param custom_headers: Additional headers to add to requests made to the
9399
Flagsmith API
94100
:param request_timeout_seconds: Number of seconds to wait for a request to
@@ -170,6 +176,10 @@ def __init__(
170176
else f"{realtime_api_url}/"
171177
)
172178

179+
if analytics_url and not analytics_url.endswith("/"):
180+
analytics_url = f"{analytics_url}/"
181+
self.analytics_url = analytics_url
182+
173183
self.request_timeout_seconds = request_timeout_seconds
174184
self.session.mount(self.api_url, HTTPAdapter(max_retries=retries))
175185

@@ -190,17 +200,22 @@ def __init__(
190200
environment_key=environment_key,
191201
enable_analytics=enable_analytics,
192202
pipeline_analytics_config=pipeline_analytics_config,
203+
analytics_url=self.analytics_url,
193204
)
194205

195206
def _initialise_analytics(
196207
self,
197208
environment_key: str,
198209
enable_analytics: bool,
199210
pipeline_analytics_config: typing.Optional[PipelineAnalyticsConfig],
211+
analytics_url: typing.Optional[str] = None,
200212
) -> None:
201213
if enable_analytics:
202214
self._analytics_processor = AnalyticsProcessor(
203-
environment_key, self.api_url, timeout=self.request_timeout_seconds
215+
environment_key,
216+
self.api_url,
217+
timeout=self.request_timeout_seconds,
218+
analytics_url=analytics_url,
204219
)
205220
if pipeline_analytics_config:
206221
self._pipeline_analytics_processor = PipelineAnalyticsProcessor(

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
@@ -1038,3 +1038,56 @@ def test_identity_flags_records_evaluation_with_resolved_traits(
10381038
identity_identifier="user123",
10391039
traits={"plan": "premium"},
10401040
)
1041+
1042+
1043+
@responses.activate()
1044+
def test_flagsmith_posts_analytics_to_analytics_url_when_set(
1045+
api_key: str, flags_json: str, mocker: MockerFixture
1046+
) -> None:
1047+
# Given a Flagsmith client pointed at an Edge Proxy for flag evaluations,
1048+
# with analytics_url overriding the analytics endpoint to the core API.
1049+
# analytics_url is intentionally written without a trailing slash to
1050+
# exercise the constructor's normalisation.
1051+
#
1052+
# We swap the fire-and-forget FuturesSession for a plain requests.Session
1053+
# so the analytics POST happens synchronously on the test thread and is
1054+
# observable via responses.calls; without this swap the worker thread can
1055+
# race the assertions.
1056+
mocker.patch("flagsmith.analytics.session", requests.Session())
1057+
flagsmith = Flagsmith(
1058+
environment_key=api_key,
1059+
api_url="http://edge-proxy.internal/api/v1/",
1060+
analytics_url="http://core-api.flagsmith.com/api/v1/analytics/flags",
1061+
enable_analytics=True,
1062+
)
1063+
1064+
expected_analytics_url = (
1065+
"http://core-api.flagsmith.com/api/v1/analytics/flags/"
1066+
)
1067+
responses.add(
1068+
method="GET", url=flagsmith.environment_flags_url, body=flags_json
1069+
)
1070+
responses.add(method="POST", url=expected_analytics_url, status=200)
1071+
1072+
# When the customer-facing evaluation API is exercised. This is the path
1073+
# that triggers track_feature internally (Flags.get_flag in models.py).
1074+
flags = flagsmith.get_environment_flags()
1075+
assert flags.is_feature_enabled("some_feature") is True
1076+
1077+
# Force the flush deterministically rather than waiting on ANALYTICS_TIMER.
1078+
assert flagsmith._analytics_processor is not None
1079+
flagsmith._analytics_processor.flush()
1080+
1081+
# Then exactly one analytics POST landed on the override host (with the
1082+
# trailing slash applied), carried the tracked feature payload, and the
1083+
# Edge Proxy never received an analytics request.
1084+
analytics_calls = [
1085+
call for call in responses.calls if call.request.method == "POST"
1086+
]
1087+
assert len(analytics_calls) == 1
1088+
request = analytics_calls[0].request
1089+
assert request.url == expected_analytics_url
1090+
assert "edge-proxy" not in request.url
1091+
assert request.body is not None
1092+
assert json.loads(request.body) == {"some_feature": 1}
1093+
assert request.headers["X-Environment-Key"] == api_key

0 commit comments

Comments
 (0)