Skip to content

Commit 751c12c

Browse files
authored
FFM-8300 Target metrics request batching (#76)
* FFM-8300 Begin refactoring to use batches for analytics * FFM-8300 Add additional loop for batching * FFM-8300 Check unique targets in all batches * FFM-8300 Fix bug with final batch * FFM-8300 Fix finally bug * FFM-8300 Temp set max number of batches to 200 * FFM-8300 Remove commented code * FFM-8300 Remove commented code * FFM-8300 Start batching requests * FFM-8300 Start batching requests * FFM-8300 Start batching requests * FFM-8300 Use futures to sesnd requests * FFM-8300 Use futures to sesnd requests * FFM-8300 Start from second element * FFM-8300 Fix exception if no other batches * FFM-8300 Fix exception if no other batches * FFM-8300 SDK Codes * FFM-8300 Fix bug in unpacking dict * FFM-8300 Exception handling for metrics request * FFM-8300 Comment * FFM-8300 Comment * FFM-8300 Remove whitespace from message * FFM-8300 Move metrics success message to top level * FFM-8300 Comment * FFM-8300 Comment * FFM-8300 Don't let metrics window be below 60 seconds * FFM-8300 Update docs * FFM-8300 Don't let metrics window be below 60 seconds FFM-8300 Fixup * FFM-8300 Log unique success codes * FFM-8300 1.2.0 release prep * FFM-8300 1.2.0 release prep * FFM-8300 Change eval to debug * FFM-8300 merge main
1 parent af29325 commit 751c12c

File tree

10 files changed

+161
-58
lines changed

10 files changed

+161
-58
lines changed

docs/further_reading.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ You can pass the configuration in as options when the SDK client is created.
1212
with_events_url("https://events.ff.harness.io/api/1.0"),
1313
with_stream_enabled(True),
1414
with_analytics_enabled(True),
15-
Config(pull_interval=60))
15+
config=Config(pull_interval=60))
1616
```
1717

1818
| Name | Config Option | Description | default |

featureflags/.DS_Store

-6 KB
Binary file not shown.

featureflags/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22

33
__author__ = """Enver Bisevac"""
44
__email__ = "enver.bisevac@harness.io"
5-
__version__ = '1.1.16'
5+
__version__ = '1.2.0'

featureflags/analytics.py

Lines changed: 133 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import time
22
from threading import Lock, Thread
33
from typing import Dict, List, Union
4+
import concurrent.futures
45

56
import attr
7+
import httpx
68

79
from featureflags.models.metrics_data_metrics_type import \
810
MetricsDataMetricsType
@@ -20,7 +22,8 @@
2022
from .models.unset import Unset
2123
from .sdk_logging_codes import info_metrics_thread_started, \
2224
info_metrics_success, warn_post_metrics_failed, \
23-
info_metrics_thread_existed, info_metrics_target_exceeded
25+
info_metrics_thread_existed, info_metrics_target_exceeded, \
26+
warn_post_metrics_target_batch_failed, info_metrics_target_batch_success
2427
from .util import log
2528

2629
FF_METRIC_TYPE = 'FFMETRICS'
@@ -62,7 +65,10 @@ def __init__(self, config: Config, client: AuthenticatedClient,
6265
self._client = client
6366
self._environment = environment
6467
self._data: Dict[str, AnalyticsEvent] = {}
65-
self._target_data: Dict[str, MetricTargetData] = {}
68+
self._target_data_batches: List[Dict[str, MetricTargetData]] = [{}]
69+
self._max_number_of_batches = 200
70+
self._max_batch_size = 1000
71+
self._current_batch_index = 0
6672
self.max_target_data_exceeded = False
6773

6874
self._running = False
@@ -89,37 +95,45 @@ def enqueue(self, target: Target, identifier: str,
8995
event.count = 1
9096
self._data[unique_evaluation_key] = event
9197

92-
# Store unique targets. If the target already exists
93-
# just ignore it.
98+
# Check if we're on our final batch - if we are, and we've
99+
# exceeded the max batch size just return early.
100+
if len(self._target_data_batches) >= self._max_number_of_batches:
101+
if len(self._target_data_batches[
102+
self._current_batch_index]) >= \
103+
self._max_batch_size:
104+
if not self.max_target_data_exceeded:
105+
self.max_target_data_exceeded = True
106+
info_metrics_target_exceeded()
107+
return
108+
94109
if event.target is not None and not event.target.anonymous:
95110
unique_target_key = self.get_target_key(event)
96-
if unique_target_key not in self._target_data:
97-
# Temporary workaround for FFM-8231 - limit max size of
98-
# target
99-
# metrics to 50k, which ff-server can process in around
100-
# 18 seconds. This possibly prevent some targets from
101-
# getting
102-
# registered and showing in the UI, but in theory, they
103-
# should get registered eventually on subsequent
104-
# evaluations.
105-
# We want to eventually use a batching solution
106-
# to avoid this.
107-
max_target_size = 50000
108-
if len(self._target_data) >= max_target_size:
109-
# Only log the info code once per interval
110-
if not self.max_target_data_exceeded:
111-
info_metrics_target_exceeded()
112-
self.max_target_data_exceeded = True
111+
112+
# Store unique targets. If the target already exists
113+
# in any of the batches, don't continue processing it
114+
for batch in self._target_data_batches:
115+
if unique_target_key in batch:
113116
return
114-
target_name = event.target.name
115-
# If the target has no name use the identifier
116-
if not target_name:
117-
target_name = event.target.identifier
118-
self._target_data[unique_target_key] = MetricTargetData(
117+
118+
# If we've exceeded the max batch size for the current
119+
# batch, then create a new batch and start using it.
120+
if len(self._target_data_batches[
121+
self._current_batch_index]) >= self._max_batch_size:
122+
self._target_data_batches.append({})
123+
self._current_batch_index += 1
124+
125+
target_name = event.target.name
126+
# If the target has no name use the identifier
127+
if not target_name:
128+
target_name = event.target.identifier
129+
self._target_data_batches[
130+
self._current_batch_index][unique_target_key] = \
131+
MetricTargetData(
119132
identifier=event.target.identifier,
120133
name=target_name,
121134
attributes=event.target.attributes
122135
)
136+
123137
finally:
124138
self._lock.release()
125139

@@ -177,35 +191,106 @@ def _send_data(self) -> None:
177191
attributes=metric_attributes
178192
)
179193
metrics_data.append(md)
180-
for _, unique_target in self._target_data.items():
181-
target_attributes: List[KeyValue] = []
182-
if not isinstance(unique_target.attributes, Unset):
183-
for key, value in unique_target.attributes.items():
184-
# Attribute values need to be sent as string to
185-
# ff-server so convert all values to strings.
186-
target_attributes.append(KeyValue(key, str(value)))
187-
td = TargetData(
188-
identifier=unique_target.identifier,
189-
name=unique_target.name,
190-
attributes=target_attributes
191-
)
192-
target_data.append(td)
194+
for _, unique_target in self._target_data_batches[0].items():
195+
self.process_target(target_data, unique_target)
196+
197+
target_data_batches: List[List[TargetData]] = []
198+
target_data_batch_index = 0
199+
# We've already accounted for the first batch, so start processing
200+
# from the second batch onwards
201+
for batch in self._target_data_batches[1:]:
202+
target_data_batches.append([])
203+
for _, unique_target in batch.items():
204+
self.process_target(
205+
target_data_batches[target_data_batch_index],
206+
unique_target)
207+
target_data_batch_index += 1
208+
209+
193210
finally:
194211
self._data = {}
195-
self._target_data = {}
212+
self._target_data_batches = [{}]
213+
self._current_batch_index = 0
196214
self.max_target_data_exceeded = False
197215
self._lock.release()
198216

199217
body: Metrics = Metrics(target_data=target_data,
200218
metrics_data=metrics_data)
201-
response = post_metrics(client=self._client,
202-
environment=self._environment, json_body=body)
203-
log.debug('Metrics server returns: %d', response.status_code)
204-
if response.status_code >= 400:
205-
warn_post_metrics_failed(response.status_code)
206-
return
207-
info_metrics_success()
208-
return
219+
try:
220+
response = post_metrics(client=self._client,
221+
environment=self._environment,
222+
json_body=body)
223+
224+
log.debug('Metrics server returns: %d', response.status_code)
225+
if response.status_code >= 400:
226+
warn_post_metrics_failed(response.status_code)
227+
return
228+
if len(target_data_batches) > 0:
229+
log.info('Sending %s target batches to metrics',
230+
len(target_data_batches))
231+
unique_responses_codes = {}
232+
233+
# Process batches concurrently
234+
with concurrent.futures.ThreadPoolExecutor() as executor:
235+
futures = []
236+
for batch in target_data_batches:
237+
# Staggering requests over 0.02 seconds mean that we
238+
# will send 200 requests every four seconds, so that
239+
# the backend isn't hit too hard.
240+
time.sleep(0.02)
241+
future = executor.submit(
242+
self.process_target_data_batch,
243+
batch)
244+
futures.append(future)
245+
246+
# Wait for all batches to complete
247+
concurrent.futures.wait(futures)
248+
249+
# Get unique status codes
250+
for future in futures:
251+
status_code = future.result()
252+
if status_code in unique_responses_codes:
253+
unique_responses_codes[status_code] += 1
254+
else:
255+
unique_responses_codes[status_code] = 1
256+
257+
# Log any error codes
258+
for unique_code, count in unique_responses_codes.items():
259+
if response.status_code >= 400:
260+
warn_post_metrics_target_batch_failed(
261+
f'{count} batches received code {unique_code}')
262+
info_metrics_target_batch_success(
263+
f'{count} batches successful')
264+
265+
266+
info_metrics_success()
267+
except httpx.RequestError as ex:
268+
warn_post_metrics_failed(ex)
269+
270+
271+
def process_target_data_batch(self, target_data_batch):
272+
batch_request_body: Metrics = Metrics(
273+
target_data=target_data_batch, metrics_data=[]
274+
)
275+
response = post_metrics(
276+
client=self._client, environment=self._environment,
277+
json_body=batch_request_body
278+
)
279+
return response.status_code
280+
281+
def process_target(self, target_data, unique_target):
282+
target_attributes: List[KeyValue] = []
283+
if not isinstance(unique_target.attributes, Unset):
284+
for key, value in unique_target.attributes.items():
285+
# Attribute values need to be sent as string to
286+
# ff-server so convert all values to strings.
287+
target_attributes.append(KeyValue(key, str(value)))
288+
td = TargetData(
289+
identifier=unique_target.identifier,
290+
name=unique_target.name,
291+
attributes=target_attributes
292+
)
293+
target_data.append(td)
209294

210295
def close(self) -> None:
211296
self._running = False

featureflags/config.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from .interface import Cache
77
from .lru_cache import LRUCache
8+
from .util import log
89

910
BASE_URL = "https://config.ff.harness.io/api/1.0"
1011
EVENTS_URL = "https://events.ff.harness.io/api/1.0"
@@ -32,7 +33,13 @@ def __init__(
3233
self.events_url = events_url
3334
self.pull_interval = pull_interval
3435
self.persist_interval = persist_interval
35-
self.events_sync_interval = events_sync_interval
36+
if events_sync_interval < EVENTS_SYNC_INTERVAL:
37+
log.warning("Metrics events sync interval cannot be lower than "
38+
"60 seconds. Default of 60 seconds will be used")
39+
self.events_sync_interval = EVENTS_SYNC_INTERVAL
40+
else:
41+
self.events_sync_interval = events_sync_interval
42+
3643
self.cache = cache
3744
if self.cache is None:
3845
self.cache = LRUCache()

featureflags/evaluations/variation.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ def bool(self, target: Target, flag_identifier: str,
2323
default: bool = False) -> bool:
2424
if self.value:
2525
result = self.value.lower() == "true"
26-
log.info(
26+
log.debug(
2727
"SDKCODE:6000: Evaluated bool variation successfully:"
2828
"%s", {"result": result, "flag identifier": flag_identifier,
2929
"target": target})
@@ -38,7 +38,7 @@ def string(self, target: Target, flag_identifier: str,
3838
default: str) -> str:
3939
if self.value:
4040
result = self.value
41-
log.info(
41+
log.debug(
4242
"SDKCODE:6000: Evaluated string variation successfully:"
4343
"%s", {"result": result, "flag identifier": flag_identifier,
4444
"target": target})
@@ -53,7 +53,7 @@ def number(self, target: Target, flag_identifier: str,
5353
default: float) -> float:
5454
if self.value:
5555
result = float(self.value)
56-
log.info(
56+
log.debug(
5757
"SDKCODE:6000: Evaluated number variation successfully:"
5858
"%s", {"result": result, "flag identifier": flag_identifier,
5959
"target": target})
@@ -68,7 +68,7 @@ def int(self, target: Target, flag_identifier: str,
6868
default: int) -> int:
6969
if self.value:
7070
result = int(self.value)
71-
log.info(
71+
log.debug(
7272
"SDKCODE:6000: Evaluated number variation successfully:"
7373
"%s", {"result": result, "flag identifier": flag_identifier,
7474
"target": target})
@@ -83,7 +83,7 @@ def json(self, target: Target, flag_identifier: str,
8383
default: dict) -> dict:
8484
if self.value:
8585
result = json.loads(self.value)
86-
log.info(
86+
log.debug(
8787
"SDKCODE:6000: Evaluated json variation successfully:"
8888
"%s", {"result": result, "flag identifier": flag_identifier,
8989
"target": target})

featureflags/sdk_logging_codes.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ def get_sdk_code_message(key):
3636
7003: "Metrics posted successfully",
3737
7004: "Target metrics exceeded max size, remaining targets for this "
3838
"analytics interval will not be sent",
39+
7005: "Target metrics batches succeeded:",
40+
7006: "Target metrics batch/batches failed:",
3941
}
4042
if key in sdk_codes:
4143
return sdk_codes[key]
@@ -105,6 +107,10 @@ def info_metrics_target_exceeded():
105107
log.info(sdk_err_msg(7004))
106108

107109

110+
def info_metrics_target_batch_success(message):
111+
log.info(sdk_err_msg(7005, message))
112+
113+
108114
def info_metrics_thread_existed():
109115
log.info(sdk_err_msg(7001))
110116

@@ -143,6 +149,10 @@ def warn_post_metrics_failed(reason):
143149
log.warning(sdk_err_msg(7002, reason))
144150

145151

152+
def warn_post_metrics_target_batch_failed(message):
153+
log.warning(sdk_err_msg(7006, message))
154+
155+
146156
def warn_default_variation_served(flag, target, default):
147157
log.warning(sdk_err_msg(6001,
148158
f"flag={flag}, "

setup.cfg

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[bumpversion]
2-
current_version = 1.1.16
2+
current_version = 1.2.0
33
commit = True
44
tag = True
55

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,6 @@
5757
test_suite="tests",
5858
tests_require=test_requirements,
5959
url="https://github.com/harness/ff-python-server-sdk",
60-
version='1.1.16',
60+
version='1.2.0',
6161
zip_safe=False,
6262
)

tests/unit/test_sdk_logging_codes.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,5 @@ def test_logs_dont_raise_exception():
2626
sdk_codes.warn_stream_disconnected("example reason")
2727
sdk_codes.warn_stream_retrying(5)
2828
sdk_codes.warn_post_metrics_failed("example reason")
29+
sdk_codes.warn_post_metrics_target_batch_failed("example reason")
2930
sdk_codes.warn_default_variation_served("identifier", target, "default")

0 commit comments

Comments
 (0)