Skip to content

Commit 2da1a5e

Browse files
feat: add analytics_url to send flag analytics to a different host (#215)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 896390d commit 2da1a5e

4 files changed

Lines changed: 134 additions & 12 deletions

File tree

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: 22 additions & 9 deletions
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
@@ -172,14 +178,12 @@ def __init__(
172178
self.session.proxies.update(proxies or {})
173179
retries = retries or Retry(total=3, backoff_factor=0.1)
174180

175-
api_url = api_url or DEFAULT_API_URL
176-
self.api_url = api_url if api_url.endswith("/") else f"{api_url}/"
177-
178-
realtime_api_url = realtime_api_url or DEFAULT_REALTIME_API_URL
179-
self.realtime_api_url = (
180-
realtime_api_url
181-
if realtime_api_url.endswith("/")
182-
else f"{realtime_api_url}/"
181+
self.api_url = self._ensure_trailing_slash(api_url or DEFAULT_API_URL)
182+
self.realtime_api_url = self._ensure_trailing_slash(
183+
realtime_api_url or DEFAULT_REALTIME_API_URL
184+
)
185+
self.analytics_url = (
186+
self._ensure_trailing_slash(analytics_url) if analytics_url else None
183187
)
184188

185189
self.request_timeout_seconds = request_timeout_seconds
@@ -201,21 +205,30 @@ def __init__(
201205
self._initialise_analytics(
202206
environment_key=environment_key,
203207
enable_analytics=enable_analytics,
208+
analytics_url=self.analytics_url,
204209
)
205210
self._initialise_events(
206211
environment_key=environment_key,
207212
enable_events=enable_events,
208213
event_processor_config=event_processor_config,
209214
)
210215

216+
@staticmethod
217+
def _ensure_trailing_slash(url: str) -> str:
218+
return url if url.endswith("/") else f"{url}/"
219+
211220
def _initialise_analytics(
212221
self,
213222
environment_key: str,
214223
enable_analytics: bool,
224+
analytics_url: typing.Optional[str] = None,
215225
) -> None:
216226
if enable_analytics:
217227
self._analytics_processor = AnalyticsProcessor(
218-
environment_key, self.api_url, timeout=self.request_timeout_seconds
228+
environment_key,
229+
self.api_url,
230+
timeout=self.request_timeout_seconds,
231+
analytics_url=analytics_url,
219232
)
220233

221234
def _initialise_events(

tests/test_analytics.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@
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 (
6+
ANALYTICS_ENDPOINT,
7+
ANALYTICS_TIMER,
8+
AnalyticsProcessor,
9+
)
610

711

812
def test_analytics_processor_track_feature_updates_analytics_data(
@@ -36,6 +40,9 @@ def test_analytics_processor_flush_post_request_data_match_ananlytics_data(
3640
# Then
3741
session.post.assert_called()
3842
post_call = session.mock_calls[0]
43+
# When analytics_url is unset, the POST falls back to base_api_url + ANALYTICS_ENDPOINT.
44+
# Locks the default in so a future refactor cannot silently break it.
45+
assert post_call[1][0] == "http://test_url" + ANALYTICS_ENDPOINT
3946
assert {"my_feature_1": 1, "my_feature_2": 1} == json.loads(post_call[2]["data"])
4047

4148

@@ -66,3 +73,22 @@ def test_analytics_processor_calling_track_feature_calls_flush_when_timer_runs_o
6673

6774
# Then
6875
session.post.assert_called()
76+
77+
78+
def test_analytics_processor_posts_to_analytics_url_when_set() -> None:
79+
# Given
80+
processor = AnalyticsProcessor(
81+
environment_key="test_key",
82+
base_api_url="http://edge-proxy/",
83+
analytics_url="http://core-api/analytics/flags/",
84+
)
85+
86+
# When
87+
with mock.patch("flagsmith.analytics.session") as session:
88+
processor.track_feature("my_feature")
89+
processor.flush()
90+
91+
# Then
92+
session.post.assert_called_once()
93+
assert session.post.call_args[0][0] == "http://core-api/analytics/flags/"
94+
assert "edge-proxy" not in session.post.call_args[0][0]

tests/test_flagsmith.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1186,6 +1186,80 @@ def test_get_experiment_flag_falls_back_to_value_without_variant(
11861186
)
11871187

11881188

1189+
@responses.activate()
1190+
def test_flagsmith_posts_analytics_to_analytics_url_when_set(
1191+
api_key: str, flags_json: str, mocker: MockerFixture
1192+
) -> None:
1193+
# Given
1194+
mocker.patch("flagsmith.analytics.session", requests.Session())
1195+
flagsmith = Flagsmith(
1196+
environment_key=api_key,
1197+
api_url="http://edge-proxy.internal/api/v1/",
1198+
analytics_url="http://core-api.flagsmith.com/api/v1/analytics/flags",
1199+
enable_analytics=True,
1200+
)
1201+
1202+
expected_analytics_url = "http://core-api.flagsmith.com/api/v1/analytics/flags/"
1203+
responses.add(method="GET", url=flagsmith.environment_flags_url, body=flags_json)
1204+
responses.add(method="POST", url=expected_analytics_url, status=200)
1205+
1206+
# When
1207+
flags = flagsmith.get_environment_flags()
1208+
assert flags.is_feature_enabled("some_feature") is True
1209+
assert flagsmith._analytics_processor is not None
1210+
flagsmith._analytics_processor.flush()
1211+
1212+
# Then
1213+
analytics_posts = [
1214+
call
1215+
for call in responses.calls
1216+
if call.request.method == "POST" and call.request.url == expected_analytics_url
1217+
]
1218+
assert len(analytics_posts) == 1
1219+
request = analytics_posts[0].request
1220+
assert request.body is not None
1221+
assert json.loads(request.body) == {"some_feature": 1}
1222+
assert request.headers["X-Environment-Key"] == api_key
1223+
assert not [
1224+
call
1225+
for call in responses.calls
1226+
if call.request.method == "POST"
1227+
and call.request.url
1228+
and "edge-proxy" in call.request.url
1229+
]
1230+
1231+
1232+
@responses.activate()
1233+
def test_flagsmith_posts_analytics_to_api_url_when_analytics_url_unset(
1234+
api_key: str, flags_json: str, mocker: MockerFixture
1235+
) -> None:
1236+
# Given
1237+
mocker.patch("flagsmith.analytics.session", requests.Session())
1238+
flagsmith = Flagsmith(
1239+
environment_key=api_key,
1240+
api_url="http://core-api.flagsmith.com/api/v1/",
1241+
enable_analytics=True,
1242+
)
1243+
1244+
expected_analytics_url = "http://core-api.flagsmith.com/api/v1/analytics/flags/"
1245+
responses.add(method="GET", url=flagsmith.environment_flags_url, body=flags_json)
1246+
responses.add(method="POST", url=expected_analytics_url, status=200)
1247+
1248+
# When
1249+
flags = flagsmith.get_environment_flags()
1250+
assert flags.is_feature_enabled("some_feature") is True
1251+
assert flagsmith._analytics_processor is not None
1252+
flagsmith._analytics_processor.flush()
1253+
1254+
# Then
1255+
analytics_posts = [
1256+
call
1257+
for call in responses.calls
1258+
if call.request.method == "POST" and call.request.url == expected_analytics_url
1259+
]
1260+
assert len(analytics_posts) == 1
1261+
1262+
11891263
def test_get_experiment_flag_skips_exposure_for_disabled_feature(
11901264
mocker: MockerFixture, api_key: str
11911265
) -> None:

0 commit comments

Comments
 (0)