Skip to content

Commit 6317cdb

Browse files
gagantrivediMatthew Elwell
andauthored
feat(analytics-flags): Add support for feature flag analytics (#9)
* feat(flag-analytics): Add module to suppport flag analytics for more: https://docs.flagsmith.com/advanced-use/flag-analytics * Add test module for analytics * fix: mock call for py3.6-3.7 Co-authored-by: Matthew Elwell <matthewe@solidstategroup.com>
1 parent a068642 commit 6317cdb

File tree

6 files changed

+156
-23
lines changed

6 files changed

+156
-23
lines changed

.isort.cfg

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@ use_parentheses=true
33
multi_line_output=3
44
include_trailing_comma=true
55
line_length=79
6-
known_third_party = requests,setuptools
6+
known_third_party = pytest,requests,requests_futures,setuptools

flagsmith/analytics.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import json
2+
from datetime import datetime
3+
4+
from requests_futures.sessions import FuturesSession
5+
6+
ANALYTICS_ENDPOINT = "analytics/flags/"
7+
8+
# Used to control how often we send data(in seconds)
9+
ANALYTICS_TIMER = 10
10+
11+
session = FuturesSession(max_workers=4)
12+
13+
14+
class AnalyticsProcessor:
15+
"""
16+
AnalyticsProcessor is used to track how often individual Flags are evaluated within
17+
the Flagsmith SDK. Docs: https://docs.flagsmith.com/advanced-use/flag-analytics.
18+
"""
19+
20+
def __init__(self, environment_key: str, base_api_url: str, timeout: int = 3):
21+
"""
22+
Initialise the AnalyticsProcessor to handle sending analytics on flag usage to
23+
the Flagsmith API.
24+
25+
:param environment_key: environment key obtained from the Flagsmith UI
26+
:param base_api_url: base api url to override when using self hosted version
27+
:param timeout: used to tell requests to stop waiting for a response after a
28+
given number of seconds
29+
"""
30+
self.analytics_endpoint = base_api_url + ANALYTICS_ENDPOINT
31+
self.environment_key = environment_key
32+
self._last_flushed = datetime.now()
33+
self.analytics_data = {}
34+
self.timeout = timeout
35+
super().__init__()
36+
37+
def flush(self):
38+
"""
39+
Sends all the collected data to the api asynchronously and resets the timer
40+
"""
41+
42+
if not self.analytics_data:
43+
return
44+
session.post(
45+
self.analytics_endpoint,
46+
data=json.dumps(self.analytics_data),
47+
timeout=self.timeout,
48+
headers={
49+
"X-Environment-Key": self.environment_key,
50+
"Content-Type": "application/json",
51+
},
52+
)
53+
54+
self.analytics_data.clear()
55+
self._last_flushed = datetime.now()
56+
57+
def track_feature(self, feature_id: int):
58+
self.analytics_data[feature_id] = self.analytics_data.get(feature_id, 0) + 1
59+
if (datetime.now() - self._last_flushed).seconds > ANALYTICS_TIMER:
60+
self.flush()

flagsmith/flagsmith.py

Lines changed: 20 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
import requests
44

5+
from .analytics import AnalyticsProcessor
6+
57
logger = logging.getLogger(__name__)
68

79
SERVER_URL = "https://api.flagsmith.com/api/v1/"
@@ -19,12 +21,16 @@ def __init__(self, environment_id, api=SERVER_URL, request_timeout=None):
1921
:param api: (optional) api url to override when using self hosted version
2022
:param request_timeout: (optional) request timeout in seconds
2123
"""
24+
2225
self.environment_id = environment_id
2326
self.api = api
2427
self.flags_endpoint = api + FLAGS_ENDPOINT
2528
self.identities_endpoint = api + IDENTITY_ENDPOINT
2629
self.traits_endpoint = api + TRAIT_ENDPOINT
2730
self.request_timeout = request_timeout
31+
self._analytics_processor = AnalyticsProcessor(
32+
environment_id, api, self.request_timeout
33+
)
2834

2935
def get_flags(self, identity=None):
3036
"""
@@ -62,8 +68,9 @@ def has_feature(self, feature_name):
6268
:return: True if exists, False if not.
6369
"""
6470
data = self._get_flags_response(feature_name)
65-
6671
if data:
72+
feature_id = data["feature"]["id"]
73+
self._analytics_processor.track_feature(feature_id)
6774
return True
6875

6976
return False
@@ -72,7 +79,7 @@ def feature_enabled(self, feature_name, identity=None):
7279
"""
7380
Get enabled state of given feature for an environment.
7481
75-
:param feature_name: name of feature to determine if enabled (must match 'ID' on flagsmith.com)
82+
:param feature_name: name of feature to determine if enabled
7683
:param identity: (optional) application's unique identifier for the user to check feature state
7784
:return: True / False if feature exists. None otherwise.
7885
"""
@@ -81,21 +88,19 @@ def feature_enabled(self, feature_name, identity=None):
8188

8289
data = self._get_flags_response(feature_name, identity)
8390

84-
if data:
85-
if data.get("flags"):
86-
for flag in data.get("flags"):
87-
if flag["feature"]["name"] == feature_name:
88-
return flag["enabled"]
89-
else:
90-
return data["enabled"]
91-
else:
91+
if not data:
9292
return None
9393

94+
feature_id = data["feature"]["id"]
95+
self._analytics_processor.track_feature(feature_id)
96+
97+
return data["enabled"]
98+
9499
def get_value(self, feature_name, identity=None):
95100
"""
96101
Get value of given feature for an environment.
97102
98-
:param feature_name: name of feature to determine value of (must match 'ID' on flagsmith.com)
103+
:param feature_name: name of feature to determine value of
99104
:param identity: (optional) application's unique identifier for the user to check feature state
100105
:return: value of the feature state if feature exists, None otherwise
101106
"""
@@ -104,17 +109,11 @@ def get_value(self, feature_name, identity=None):
104109

105110
data = self._get_flags_response(feature_name, identity)
106111

107-
if data:
108-
# using new endpoints means that the flags come back in a list, filter this for the one we want and
109-
# return it's value
110-
if data.get("flags"):
111-
for flag in data.get("flags"):
112-
if flag["feature"]["name"] == feature_name:
113-
return flag["feature_state_value"]
114-
else:
115-
return data["feature_state_value"]
116-
else:
112+
if not data:
117113
return None
114+
feature_id = data["feature"]["id"]
115+
self._analytics_processor.track_feature(feature_id)
116+
return data["feature_state_value"]
118117

119118
def get_trait(self, trait_key, identity):
120119
"""

requirements.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1-
requests>=2.19.1
1+
requests>=2.19.1
2+
requests-futures==1.0.0

tests/conftest.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import pytest
2+
3+
from flagsmith.analytics import AnalyticsProcessor
4+
5+
6+
@pytest.fixture
7+
def analytics_processor():
8+
return AnalyticsProcessor(
9+
environment_key="test_key", base_api_url="http://test_url"
10+
)

tests/test_analytics.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import json
2+
from datetime import datetime, timedelta
3+
from unittest import mock
4+
5+
from flagsmith.analytics import ANALYTICS_TIMER, AnalyticsProcessor
6+
7+
8+
def test_analytics_processor_track_feature_updates_analytics_data(analytics_processor):
9+
# When
10+
analytics_processor.track_feature(1)
11+
assert analytics_processor.analytics_data[1] == 1
12+
13+
analytics_processor.track_feature(1)
14+
assert analytics_processor.analytics_data[1] == 2
15+
16+
17+
def test_analytics_processor_flush_clears_analytics_data(analytics_processor):
18+
analytics_processor.track_feature(1)
19+
analytics_processor.flush()
20+
assert analytics_processor.analytics_data == {}
21+
22+
23+
def test_analytics_processor_flush_post_request_data_match_ananlytics_data(
24+
analytics_processor,
25+
):
26+
# Given
27+
with mock.patch("flagsmith.analytics.session") as session:
28+
# When
29+
analytics_processor.track_feature(1)
30+
analytics_processor.track_feature(2)
31+
analytics_processor.flush()
32+
# Then
33+
session.post.assert_called()
34+
post_call = session.mock_calls[0]
35+
assert {"1": 1, "2": 1} == json.loads(post_call[2]["data"])
36+
37+
38+
def test_analytics_processor_flush_early_exit_if_analytics_data_is_empty(
39+
analytics_processor,
40+
):
41+
with mock.patch("flagsmith.analytics.session") as session:
42+
analytics_processor.flush()
43+
44+
# Then
45+
session.post.assert_not_called()
46+
47+
48+
def test_analytics_processor_calling_track_feature_calls_flush_when_timer_runs_out(
49+
analytics_processor,
50+
):
51+
# Given
52+
with mock.patch("flagsmith.analytics.datetime") as mocked_datetime, mock.patch(
53+
"flagsmith.analytics.session"
54+
) as session:
55+
# Let's move the time
56+
mocked_datetime.now.return_value = datetime.now() + timedelta(
57+
seconds=ANALYTICS_TIMER + 1
58+
)
59+
# When
60+
analytics_processor.track_feature(1)
61+
62+
# Then
63+
session.post.assert_called()

0 commit comments

Comments
 (0)