ibm-appconfiguration-python-sdk 0.4.4__tar.gz → 0.5.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {ibm_appconfiguration_python_sdk-0.4.4/ibm_appconfiguration_python_sdk.egg-info → ibm_appconfiguration_python_sdk-0.5.0}/PKG-INFO +1 -1
- {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/ibm_appconfiguration/appconfiguration.py +10 -1
- {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/ibm_appconfiguration/configurations/configuration_handler.py +133 -30
- {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/ibm_appconfiguration/configurations/internal/common/config_constants.py +1 -0
- ibm_appconfiguration_python_sdk-0.5.0/ibm_appconfiguration/configurations/internal/utils/analytics.py +214 -0
- ibm_appconfiguration_python_sdk-0.5.0/ibm_appconfiguration/configurations/internal/utils/analytics_record.py +156 -0
- {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/ibm_appconfiguration/configurations/internal/utils/metering.py +2 -2
- {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/ibm_appconfiguration/configurations/internal/utils/parser.py +42 -0
- {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/ibm_appconfiguration/configurations/internal/utils/url_builder.py +13 -1
- {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/ibm_appconfiguration/configurations/models/feature.py +65 -3
- {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/ibm_appconfiguration/configurations/models/segment_rules.py +5 -0
- {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/ibm_appconfiguration/version.py +1 -1
- {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0/ibm_appconfiguration_python_sdk.egg-info}/PKG-INFO +1 -1
- {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/ibm_appconfiguration_python_sdk.egg-info/SOURCES.txt +5 -0
- {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/setup.py +1 -1
- ibm_appconfiguration_python_sdk-0.5.0/unit_tests/configurations/test_guarded_rollout.py +680 -0
- ibm_appconfiguration_python_sdk-0.5.0/unit_tests/configurations/utils/test_analytics.py +318 -0
- ibm_appconfiguration_python_sdk-0.5.0/unit_tests/configurations/utils/test_analytics_record.py +264 -0
- {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/LICENSE +0 -0
- {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/README.md +0 -0
- {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/examples/__init__.py +0 -0
- {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/examples/sample_app.py +0 -0
- {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/examples/server_sample.py +0 -0
- {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/ibm_appconfiguration/__init__.py +0 -0
- {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/ibm_appconfiguration/configurations/__init__.py +0 -0
- {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/ibm_appconfiguration/configurations/internal/__init__.py +0 -0
- {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/ibm_appconfiguration/configurations/internal/common/__init__.py +0 -0
- {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/ibm_appconfiguration/configurations/internal/common/config_messages.py +0 -0
- {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/ibm_appconfiguration/configurations/internal/utils/__init__.py +0 -0
- {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/ibm_appconfiguration/configurations/internal/utils/api_manager.py +0 -0
- {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/ibm_appconfiguration/configurations/internal/utils/compute_percentage.py +0 -0
- {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/ibm_appconfiguration/configurations/internal/utils/connectivity.py +0 -0
- {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/ibm_appconfiguration/configurations/internal/utils/file_manager.py +0 -0
- {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/ibm_appconfiguration/configurations/internal/utils/logger.py +0 -0
- {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/ibm_appconfiguration/configurations/internal/utils/rollout_utils.py +0 -0
- {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/ibm_appconfiguration/configurations/internal/utils/socket.py +0 -0
- {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/ibm_appconfiguration/configurations/internal/utils/validators.py +0 -0
- {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/ibm_appconfiguration/configurations/models/__init__.py +0 -0
- {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/ibm_appconfiguration/configurations/models/configuration_type.py +0 -0
- {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/ibm_appconfiguration/configurations/models/property.py +0 -0
- {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/ibm_appconfiguration/configurations/models/rule.py +0 -0
- {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/ibm_appconfiguration/configurations/models/segment.py +0 -0
- {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/ibm_appconfiguration_python_sdk.egg-info/dependency_links.txt +0 -0
- {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/ibm_appconfiguration_python_sdk.egg-info/requires.txt +0 -0
- {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/ibm_appconfiguration_python_sdk.egg-info/top_level.txt +0 -0
- {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/integration_tests/__init__.py +0 -0
- {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/integration_tests/test_integration.py +0 -0
- {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/setup.cfg +0 -0
- {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/unit_tests/__init__.py +0 -0
- {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/unit_tests/configurations/__init__.py +0 -0
- {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/unit_tests/configurations/models/__init__.py +0 -0
- {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/unit_tests/configurations/models/test_feature.py +0 -0
- {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/unit_tests/configurations/models/test_property.py +0 -0
- {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/unit_tests/configurations/models/test_rule.py +0 -0
- {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/unit_tests/configurations/models/test_segment.py +0 -0
- {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/unit_tests/configurations/models/test_segment_rules.py +0 -0
- {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/unit_tests/configurations/test_configuration_handler.py +0 -0
- {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/unit_tests/configurations/utils/__init__.py +0 -0
- {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/unit_tests/configurations/utils/test_api_manager.py +0 -0
- {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/unit_tests/configurations/utils/test_file_manager.py +0 -0
- {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/unit_tests/configurations/utils/test_metering.py +0 -0
- {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/unit_tests/configurations/utils/test_socket.py +0 -0
- {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/unit_tests/configurations/utils/test_url_builder.py +0 -0
- {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/unit_tests/test_appconfiguration.py +0 -0
|
@@ -290,4 +290,13 @@ class AppConfiguration:
|
|
|
290
290
|
"""
|
|
291
291
|
if self.__configuration_handler_instance is None:
|
|
292
292
|
return False
|
|
293
|
-
return self.__configuration_handler_instance.is_connected()
|
|
293
|
+
return self.__configuration_handler_instance.is_connected()
|
|
294
|
+
|
|
295
|
+
def flush_records(self):
|
|
296
|
+
"""Immediately sends all pending analytics data (guarded rollout
|
|
297
|
+
evaluations and metric events) to the App Configuration service,
|
|
298
|
+
without waiting for the next scheduled flush. Safe to call at any
|
|
299
|
+
time, including on shutdown.
|
|
300
|
+
"""
|
|
301
|
+
if self.__is_initialized and self.__is_initialized_configuration:
|
|
302
|
+
self.__configuration_handler_instance.flush_analytics()
|
|
@@ -20,6 +20,8 @@ import os
|
|
|
20
20
|
from typing import Dict, List, Any
|
|
21
21
|
from threading import Timer, Thread
|
|
22
22
|
from ibm_appconfiguration.configurations.internal.common import config_messages, config_constants
|
|
23
|
+
from .internal.utils.analytics import Analytics
|
|
24
|
+
from .internal.utils.analytics_record import EventType
|
|
23
25
|
from .internal.utils.logger import Logger
|
|
24
26
|
from .internal.utils.parser import extract_configurations, format_config
|
|
25
27
|
from .internal.utils.validators import Validators
|
|
@@ -122,6 +124,10 @@ class ConfigurationHandler:
|
|
|
122
124
|
override_service_url=self.__override_service_url,
|
|
123
125
|
use_private_endpoint=self.__use_private_endpoint)
|
|
124
126
|
Metering.get_instance().set_metering_url(URLBuilder.get_metering_path())
|
|
127
|
+
analytics = Analytics.get_instance()
|
|
128
|
+
analytics.set_context(environment_id=environment_id, collection_id=collection_id)
|
|
129
|
+
analytics.set_analytics_url(URLBuilder.get_analytics_path())
|
|
130
|
+
analytics.start()
|
|
125
131
|
self.__api_manager = APIManager.get_instance()
|
|
126
132
|
self.__live_config_update_enabled = options['live_config_update_enabled']
|
|
127
133
|
self.__bootstrap_file = options['bootstrap_file']
|
|
@@ -357,6 +363,97 @@ class ConfigurationHandler:
|
|
|
357
363
|
self.record_valuation(property_id=property_id, feature_id=None, entity_id=entity_id,
|
|
358
364
|
evaluated_segment_id=result_dict['evaluated_segment_id'])
|
|
359
365
|
|
|
366
|
+
@staticmethod
|
|
367
|
+
def get_feature_normalised_value(feature: Feature, entity_id: str) -> int:
|
|
368
|
+
colon = ':'
|
|
369
|
+
match feature.get_rollout_type():
|
|
370
|
+
case config_constants.MANUAL:
|
|
371
|
+
key = colon.join([entity_id, feature.get_feature_id()])
|
|
372
|
+
case config_constants.PROGRESSIVE:
|
|
373
|
+
key = colon.join([
|
|
374
|
+
entity_id,
|
|
375
|
+
feature.get_feature_id(),
|
|
376
|
+
feature.get_rollout_configuration().get('start_at', '')
|
|
377
|
+
if feature.get_rollout_configuration() is not None else ''
|
|
378
|
+
])
|
|
379
|
+
case config_constants.GUARDED:
|
|
380
|
+
key = colon.join([
|
|
381
|
+
entity_id,
|
|
382
|
+
feature.get_feature_id(),
|
|
383
|
+
feature.get_rollout_id()
|
|
384
|
+
])
|
|
385
|
+
case _:
|
|
386
|
+
key = entity_id
|
|
387
|
+
return get_normalized_value(key)
|
|
388
|
+
|
|
389
|
+
@staticmethod
|
|
390
|
+
def get_rule_normalised_value(rule: SegmentRules, feature_id: str, entity_id: str, start_at: str = None):
|
|
391
|
+
colon = ':'
|
|
392
|
+
match rule.get_rollout_type():
|
|
393
|
+
case config_constants.MANUAL:
|
|
394
|
+
key = colon.join([entity_id, feature_id])
|
|
395
|
+
case config_constants.PROGRESSIVE:
|
|
396
|
+
key = colon.join([
|
|
397
|
+
entity_id,
|
|
398
|
+
feature_id,
|
|
399
|
+
start_at if start_at is not None else ''
|
|
400
|
+
])
|
|
401
|
+
case config_constants.GUARDED:
|
|
402
|
+
key = colon.join([
|
|
403
|
+
entity_id,
|
|
404
|
+
feature_id,
|
|
405
|
+
rule.get_rollout_id()
|
|
406
|
+
])
|
|
407
|
+
case _:
|
|
408
|
+
key = entity_id
|
|
409
|
+
return get_normalized_value(key)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
@staticmethod
|
|
413
|
+
def add_guarded_evaluation_entry(rollout_id: str, entity_id: str, rollout_percentage: int, normalised_value: int):
|
|
414
|
+
metric = {
|
|
415
|
+
'rollout_id': rollout_id,
|
|
416
|
+
'entity_id': entity_id,
|
|
417
|
+
}
|
|
418
|
+
if 0 <= normalised_value < rollout_percentage:
|
|
419
|
+
# enabled value was served to user and needs to be tracked
|
|
420
|
+
metric['value_served'] = 'target'
|
|
421
|
+
elif normalised_value >= 100 - rollout_percentage:
|
|
422
|
+
# disabled value was served to user and needs to be tracked
|
|
423
|
+
metric['value_served'] = 'original'
|
|
424
|
+
else:
|
|
425
|
+
# this evaluation should not be tracked as it is not part of control group
|
|
426
|
+
return
|
|
427
|
+
Analytics.get_instance().add_metric(metric, EventType.GUARDED_EVALUATION)
|
|
428
|
+
|
|
429
|
+
@staticmethod
|
|
430
|
+
def add_guarded_metric_entry(
|
|
431
|
+
entity_id: str,
|
|
432
|
+
feature_id: str,
|
|
433
|
+
rollout_id: str,
|
|
434
|
+
event_key: str,
|
|
435
|
+
metrics: list[dict[str, str]],
|
|
436
|
+
rollout_percentage: int
|
|
437
|
+
):
|
|
438
|
+
metric = {
|
|
439
|
+
'rollout_id': rollout_id,
|
|
440
|
+
'entity_id': entity_id,
|
|
441
|
+
'event_key': event_key,
|
|
442
|
+
'metrics': metrics
|
|
443
|
+
}
|
|
444
|
+
normalised_value = get_normalized_value(':'.join([entity_id, feature_id, rollout_id]))
|
|
445
|
+
if 0 <= normalised_value < rollout_percentage:
|
|
446
|
+
# enabled value was served to user and needs to be tracked
|
|
447
|
+
metric['value_served'] = 'target'
|
|
448
|
+
elif normalised_value >= 100 - rollout_percentage:
|
|
449
|
+
# disabled value was served to user and needs to be tracked
|
|
450
|
+
metric['value_served'] = 'original'
|
|
451
|
+
else:
|
|
452
|
+
# this evaluation should not be tracked as it is not part of control group
|
|
453
|
+
return
|
|
454
|
+
Analytics.get_instance().add_metric(metric, EventType.GUARDED_METRIC)
|
|
455
|
+
|
|
456
|
+
|
|
360
457
|
def feature_evaluation(self, feature: Feature, is_enabled: bool, entity_id: str,
|
|
361
458
|
entity_attributes: dict = None) -> Any:
|
|
362
459
|
"""Feature evaluation method
|
|
@@ -386,19 +483,20 @@ class ConfigurationHandler:
|
|
|
386
483
|
return result_dict['value'], result_dict['is_enabled']
|
|
387
484
|
|
|
388
485
|
# Check feature-level rollout
|
|
389
|
-
|
|
390
|
-
if feature.get_rollout_configuration() is not None:
|
|
486
|
+
if feature.get_rollout_type() == config_constants.PROGRESSIVE and feature.get_rollout_configuration() is not None:
|
|
391
487
|
rollout_map = self.__rollout_config_map.get(feature.get_feature_id())
|
|
392
488
|
if rollout_map:
|
|
393
|
-
entity_id += feature.get_rollout_configuration().get('start_at')
|
|
394
489
|
rollout_percentage = get_current_rollout_percentage(rollout_map)
|
|
395
490
|
else:
|
|
396
491
|
rollout_percentage = 0
|
|
397
492
|
else:
|
|
398
493
|
rollout_percentage = feature.get_rollout_percentage() if feature.get_rollout_percentage() is not None else 100
|
|
399
494
|
|
|
400
|
-
|
|
401
|
-
|
|
495
|
+
normalised_value = ConfigurationHandler.get_feature_normalised_value(feature, entity_id)
|
|
496
|
+
if feature.get_rollout_type() == config_constants.GUARDED:
|
|
497
|
+
# if guarded rollout add evaluation entry
|
|
498
|
+
self.add_guarded_evaluation_entry(feature.get_rollout_id(), entity_id, rollout_percentage, normalised_value)
|
|
499
|
+
if rollout_percentage == 100 or normalised_value < rollout_percentage:
|
|
402
500
|
return feature.get_enabled_value(), True
|
|
403
501
|
return feature.get_disabled_value(), False
|
|
404
502
|
return feature.get_disabled_value(), False
|
|
@@ -429,25 +527,18 @@ class ConfigurationHandler:
|
|
|
429
527
|
result_dict['evaluated_segment_id'] = segment_key
|
|
430
528
|
if feature is not None:
|
|
431
529
|
# evaluate_rules was called for feature flag
|
|
432
|
-
segment_rollout_percentage = None
|
|
433
|
-
|
|
434
530
|
# Check if segment rule has progressive rollout
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
531
|
+
start_at = None
|
|
532
|
+
if segment_rule.get_rollout_type() == config_constants.PROGRESSIVE:
|
|
533
|
+
# Use segment-level rollout
|
|
534
|
+
start_at = segment_rule.get_rollout_configuration().get('start_at', '')
|
|
535
|
+
rule_id = segment_rule.get_rule_id()
|
|
536
|
+
if rule_id:
|
|
537
|
+
key = feature.get_feature_id() + config_constants.DELIMITER + rule_id
|
|
538
|
+
rollout_map = self.__rollout_config_map.get(key)
|
|
440
539
|
else:
|
|
441
|
-
|
|
442
|
-
rule_id = segment_rule.get_rule_id()
|
|
443
|
-
if rule_id:
|
|
444
|
-
key = feature.get_feature_id() + config_constants.DELIMITER + rule_id
|
|
445
|
-
rollout_map = self.__rollout_config_map.get(key)
|
|
446
|
-
else:
|
|
447
|
-
rollout_map = None
|
|
448
|
-
|
|
540
|
+
rollout_map = None
|
|
449
541
|
if rollout_map:
|
|
450
|
-
entity_id += segment_rule.get_rollout_configuration().get('start_at')
|
|
451
542
|
segment_rollout_percentage = get_current_rollout_percentage(rollout_map)
|
|
452
543
|
else:
|
|
453
544
|
segment_rollout_percentage = 0
|
|
@@ -457,9 +548,16 @@ class ConfigurationHandler:
|
|
|
457
548
|
segment_rollout_percentage = feature.get_rollout_percentage() if feature.get_rollout_percentage() is not None else 100
|
|
458
549
|
else:
|
|
459
550
|
segment_rollout_percentage = segment_rule.get_rollout_percentage() if segment_rule.get_rollout_percentage() is not None else 100
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
551
|
+
|
|
552
|
+
normalised_value = ConfigurationHandler.get_rule_normalised_value(segment_rule, feature.get_feature_id(), entity_id, start_at)
|
|
553
|
+
if segment_rule.get_rollout_type() == config_constants.GUARDED:
|
|
554
|
+
ConfigurationHandler.add_guarded_evaluation_entry(
|
|
555
|
+
segment_rule.get_rollout_id(),
|
|
556
|
+
entity_id,
|
|
557
|
+
segment_rollout_percentage,
|
|
558
|
+
normalised_value
|
|
559
|
+
)
|
|
560
|
+
if segment_rollout_percentage == 100 or normalised_value < segment_rollout_percentage:
|
|
463
561
|
if segment_rule.get_value() == config_constants.DEFAULT_FEATURE_VALUE:
|
|
464
562
|
result_dict['value'] = feature.get_enabled_value()
|
|
465
563
|
else:
|
|
@@ -480,19 +578,20 @@ class ConfigurationHandler:
|
|
|
480
578
|
|
|
481
579
|
if feature is not None:
|
|
482
580
|
# Check feature-level rollout
|
|
483
|
-
|
|
484
|
-
if feature.get_rollout_configuration() is not None:
|
|
581
|
+
if feature.get_rollout_type() == config_constants.PROGRESSIVE and feature.get_rollout_configuration() is not None:
|
|
485
582
|
rollout_map = self.__rollout_config_map.get(feature.get_feature_id())
|
|
486
583
|
if rollout_map:
|
|
487
|
-
entity_id += feature.get_rollout_configuration().get('start_at')
|
|
488
584
|
rollout_percentage = get_current_rollout_percentage(rollout_map)
|
|
489
585
|
else:
|
|
490
586
|
rollout_percentage = 0
|
|
491
587
|
else:
|
|
492
588
|
rollout_percentage = feature.get_rollout_percentage() if feature.get_rollout_percentage() is not None else 100
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
589
|
+
|
|
590
|
+
normalised_value = ConfigurationHandler.get_feature_normalised_value(feature, entity_id)
|
|
591
|
+
if feature.get_rollout_type() == config_constants.GUARDED:
|
|
592
|
+
# if guarded rollout add evaluation entry
|
|
593
|
+
self.add_guarded_evaluation_entry(feature.get_rollout_id(), entity_id, rollout_percentage, normalised_value)
|
|
594
|
+
if rollout_percentage == 100 or normalised_value < rollout_percentage:
|
|
496
595
|
result_dict['value'] = feature.get_enabled_value()
|
|
497
596
|
result_dict['is_enabled'] = True
|
|
498
597
|
else:
|
|
@@ -601,3 +700,7 @@ class ConfigurationHandler:
|
|
|
601
700
|
Returns: boolean indicating connection status
|
|
602
701
|
"""
|
|
603
702
|
return self.__socket.is_connected()
|
|
703
|
+
|
|
704
|
+
def flush_analytics(self):
|
|
705
|
+
"""Immediately flush all pending analytics data."""
|
|
706
|
+
Analytics.get_instance().flush()
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# Copyright 2021 IBM All Rights Reserved.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""
|
|
16
|
+
This module provides methods that perform analytics related operations.
|
|
17
|
+
"""
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
from threading import Event, Lock, Thread
|
|
20
|
+
from time import time
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from ibm_appconfiguration.configurations.internal.common import config_messages
|
|
24
|
+
from ibm_appconfiguration.configurations.internal.utils.analytics_record import AnalyticsRecord, EventType
|
|
25
|
+
from ibm_appconfiguration.configurations.internal.utils.api_manager import APIManager
|
|
26
|
+
from ibm_appconfiguration.configurations.internal.utils.logger import Logger
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Analytics:
|
|
30
|
+
"""Class to send the Analytic data.
|
|
31
|
+
|
|
32
|
+
Thread-safety model
|
|
33
|
+
-------------------
|
|
34
|
+
One lock (``__record_lock``) serialises all reads and writes on
|
|
35
|
+
``__record``. ``AnalyticsRecord`` itself is intentionally NOT
|
|
36
|
+
thread-safe — the lock lives here so we can do atomic check-then-act
|
|
37
|
+
sequences (e.g. *exceeds_limit → split_head*) without TOCTOU gaps.
|
|
38
|
+
|
|
39
|
+
A second lock (``__flush_lock``) ensures that at most one thread is
|
|
40
|
+
running the drain/flush pipeline at a time. This prevents the
|
|
41
|
+
background job and a concurrent ``flush()`` call from both splitting
|
|
42
|
+
the same data and sending it twice.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
__send_interval = 5 * 60 # mandatory flush every 5 minutes
|
|
46
|
+
__batch_size = 30 # max usages the server accepts per request
|
|
47
|
+
__instance = None
|
|
48
|
+
|
|
49
|
+
@staticmethod
|
|
50
|
+
def get_instance():
|
|
51
|
+
""" Static access method. """
|
|
52
|
+
if Analytics.__instance is None:
|
|
53
|
+
return Analytics()
|
|
54
|
+
return Analytics.__instance
|
|
55
|
+
|
|
56
|
+
def __init__(self):
|
|
57
|
+
""" Virtually private constructor. """
|
|
58
|
+
if Analytics.__instance is not None:
|
|
59
|
+
raise Exception("Analytics " + config_messages.SINGLETON_EXCEPTION)
|
|
60
|
+
self.__environment_id = None
|
|
61
|
+
self.__collection_id = None
|
|
62
|
+
self.__analytics_url = None
|
|
63
|
+
# Guards all access to __record.
|
|
64
|
+
self.__record_lock = Lock()
|
|
65
|
+
self.__record = AnalyticsRecord()
|
|
66
|
+
self.__job_flag = False
|
|
67
|
+
# Wakes the background flush thread early (batch-full or stop).
|
|
68
|
+
self.__flush_event = Event()
|
|
69
|
+
# Serialises concurrent drain/flush pipelines (background vs flush()).
|
|
70
|
+
self.__flush_lock = Lock()
|
|
71
|
+
Analytics.__instance = self
|
|
72
|
+
|
|
73
|
+
def set_context(self, environment_id: str, collection_id: str):
|
|
74
|
+
self.__environment_id = environment_id
|
|
75
|
+
self.__collection_id = collection_id
|
|
76
|
+
|
|
77
|
+
def set_analytics_url(self, url: str):
|
|
78
|
+
"""Set the analytics url."""
|
|
79
|
+
self.__analytics_url = url
|
|
80
|
+
|
|
81
|
+
def add_metric(self, metric: dict[str, Any], metric_type: EventType):
|
|
82
|
+
metric["timestamp"] = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
83
|
+
with self.__record_lock:
|
|
84
|
+
self.__record.add(metric, metric_type)
|
|
85
|
+
if self.__record.exceeds_limit(Analytics.__batch_size):
|
|
86
|
+
self.__flush_event.set()
|
|
87
|
+
|
|
88
|
+
def __send_to_server(self, record: AnalyticsRecord, previous_delay: int = 0) -> int:
|
|
89
|
+
"""POST *record* to the server. Returns 0 on success or seconds to
|
|
90
|
+
wait before retrying."""
|
|
91
|
+
api_manager = APIManager.get_instance()
|
|
92
|
+
response = api_manager.prepare_api_request(
|
|
93
|
+
method="POST",
|
|
94
|
+
url=self.__analytics_url,
|
|
95
|
+
data=record.get_request_body(self.__environment_id, self.__collection_id)
|
|
96
|
+
)
|
|
97
|
+
status_code = response.get_status_code()
|
|
98
|
+
if 200 <= status_code < 300:
|
|
99
|
+
Logger.info("Successfully posted analytics data")
|
|
100
|
+
return 0
|
|
101
|
+
if status_code == 429:
|
|
102
|
+
Logger.warning("Analytics endpoint has been rate-limited, retrying after 30 sec")
|
|
103
|
+
return 30
|
|
104
|
+
next_delay = min(600, previous_delay * 2 if previous_delay > 0 else 60)
|
|
105
|
+
Logger.error(f"Error while posting analytics data, retrying after {next_delay}sec")
|
|
106
|
+
return next_delay
|
|
107
|
+
|
|
108
|
+
def __send_batch(self, batch: AnalyticsRecord):
|
|
109
|
+
"""Send *batch* with retry + merge-back on failure.
|
|
110
|
+
|
|
111
|
+
On each failure:
|
|
112
|
+
1. Merge the failed batch back into the live record (under lock)
|
|
113
|
+
so no data is stranded while we sleep.
|
|
114
|
+
2. Sleep for the back-off delay (woken early by flush_event).
|
|
115
|
+
3. Re-split from the now-merged (and possibly larger) live record
|
|
116
|
+
so any entries added during the sleep are included next time.
|
|
117
|
+
"""
|
|
118
|
+
if len(batch) == 0:
|
|
119
|
+
return
|
|
120
|
+
previous_delay = 0
|
|
121
|
+
while True:
|
|
122
|
+
delay = self.__send_to_server(batch, previous_delay)
|
|
123
|
+
if delay == 0:
|
|
124
|
+
return
|
|
125
|
+
# Merge back before sleeping so data is never orphaned.
|
|
126
|
+
with self.__record_lock:
|
|
127
|
+
self.__record.merge(batch)
|
|
128
|
+
previous_delay = delay
|
|
129
|
+
self.__flush_event.wait(timeout=delay)
|
|
130
|
+
self.__flush_event.clear()
|
|
131
|
+
# Re-split including any entries that arrived during the sleep.
|
|
132
|
+
# Guard: if another flush() drained the record while we slept,
|
|
133
|
+
# there is nothing left to send — exit cleanly.
|
|
134
|
+
with self.__record_lock:
|
|
135
|
+
if len(self.__record) == 0:
|
|
136
|
+
return
|
|
137
|
+
batch = self.__record.split_head(Analytics.__batch_size)
|
|
138
|
+
|
|
139
|
+
def __drain_chunks(self):
|
|
140
|
+
"""Send all full batches (≥ batch_size) one at a time, oldest first.
|
|
141
|
+
|
|
142
|
+
Must be called while holding ``__flush_lock``.
|
|
143
|
+
Leaves any remainder below batch_size in the live record.
|
|
144
|
+
"""
|
|
145
|
+
while True:
|
|
146
|
+
with self.__record_lock:
|
|
147
|
+
if not self.__record.exceeds_limit(Analytics.__batch_size):
|
|
148
|
+
break
|
|
149
|
+
batch = self.__record.split_head(Analytics.__batch_size)
|
|
150
|
+
self.__send_batch(batch)
|
|
151
|
+
|
|
152
|
+
def __flush_remainder(self):
|
|
153
|
+
"""Send whatever is left in the record (< batch_size entries).
|
|
154
|
+
|
|
155
|
+
Must be called while holding ``__flush_lock``.
|
|
156
|
+
"""
|
|
157
|
+
with self.__record_lock:
|
|
158
|
+
n = len(self.__record)
|
|
159
|
+
if n == 0:
|
|
160
|
+
return
|
|
161
|
+
batch = self.__record.split_head(n)
|
|
162
|
+
self.__send_batch(batch)
|
|
163
|
+
|
|
164
|
+
def __send_analytic_job(self):
|
|
165
|
+
last_flush = time()
|
|
166
|
+
|
|
167
|
+
while self.__job_flag:
|
|
168
|
+
remaining = Analytics.__send_interval - (time() - last_flush)
|
|
169
|
+
# Skip sleep entirely when the record is already at the limit.
|
|
170
|
+
with self.__record_lock:
|
|
171
|
+
already_full = self.__record.exceeds_limit(Analytics.__batch_size)
|
|
172
|
+
if remaining > 0 and not already_full:
|
|
173
|
+
self.__flush_event.wait(timeout=remaining)
|
|
174
|
+
self.__flush_event.clear()
|
|
175
|
+
|
|
176
|
+
if not self.__job_flag:
|
|
177
|
+
break
|
|
178
|
+
|
|
179
|
+
five_min_due = (time() - last_flush) >= Analytics.__send_interval
|
|
180
|
+
|
|
181
|
+
# Re-check under lock — state may have changed while we slept.
|
|
182
|
+
with self.__record_lock:
|
|
183
|
+
is_full = self.__record.exceeds_limit(Analytics.__batch_size)
|
|
184
|
+
has_data = len(self.__record) > 0
|
|
185
|
+
|
|
186
|
+
if is_full:
|
|
187
|
+
with self.__flush_lock:
|
|
188
|
+
self.__drain_chunks()
|
|
189
|
+
elif five_min_due and has_data:
|
|
190
|
+
with self.__flush_lock:
|
|
191
|
+
self.__flush_remainder()
|
|
192
|
+
last_flush = time()
|
|
193
|
+
# Woken early but record < batch_size and 5-min not due: loop
|
|
194
|
+
# back and sleep for the remaining interval.
|
|
195
|
+
|
|
196
|
+
def start(self):
|
|
197
|
+
if self.__job_flag:
|
|
198
|
+
return
|
|
199
|
+
self.__job_flag = True
|
|
200
|
+
Thread(target=self.__send_analytic_job, daemon=True).start()
|
|
201
|
+
|
|
202
|
+
def stop(self):
|
|
203
|
+
self.__job_flag = False
|
|
204
|
+
self.__flush_event.set() # unblock any active sleep
|
|
205
|
+
|
|
206
|
+
def flush(self):
|
|
207
|
+
"""Immediately flush all pending analytics data.
|
|
208
|
+
|
|
209
|
+
Safe to call concurrently with the background job — ``__flush_lock``
|
|
210
|
+
ensures only one drain/flush pipeline runs at a time.
|
|
211
|
+
"""
|
|
212
|
+
with self.__flush_lock:
|
|
213
|
+
self.__drain_chunks()
|
|
214
|
+
self.__flush_remainder()
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# Copyright 2021 IBM All Rights Reserved.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""
|
|
16
|
+
This module provides methods that perform analytics related operations.
|
|
17
|
+
"""
|
|
18
|
+
from copy import deepcopy
|
|
19
|
+
from enum import Enum
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from ibm_appconfiguration.configurations.internal.common.config_constants import DELIMITER
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class EventType(Enum):
|
|
26
|
+
GUARDED_METRIC = 1
|
|
27
|
+
GUARDED_EVALUATION = 2
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class AnalyticsRecord:
|
|
31
|
+
"""In-memory store for analytics usages.
|
|
32
|
+
|
|
33
|
+
NOT thread-safe on its own. All concurrent access must be serialised
|
|
34
|
+
by the caller (Analytics) holding its own lock before calling any method.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self):
|
|
38
|
+
self._usages: list[dict[str, Any]] = []
|
|
39
|
+
self._history_map: dict[str, int] = {}
|
|
40
|
+
|
|
41
|
+
@staticmethod
|
|
42
|
+
def _build_key(*args) -> str:
|
|
43
|
+
return DELIMITER.join(args)
|
|
44
|
+
|
|
45
|
+
@staticmethod
|
|
46
|
+
def _build_metric_key(metric: dict[str, Any], metric_type: EventType) -> str:
|
|
47
|
+
match metric_type:
|
|
48
|
+
case EventType.GUARDED_EVALUATION:
|
|
49
|
+
return AnalyticsRecord._build_key(
|
|
50
|
+
metric["rollout_id"],
|
|
51
|
+
metric["entity_id"],
|
|
52
|
+
metric["value_served"],
|
|
53
|
+
)
|
|
54
|
+
case EventType.GUARDED_METRIC:
|
|
55
|
+
return AnalyticsRecord._build_key(
|
|
56
|
+
metric["rollout_id"],
|
|
57
|
+
metric["entity_id"],
|
|
58
|
+
metric["value_served"],
|
|
59
|
+
metric["event_key"],
|
|
60
|
+
)
|
|
61
|
+
case _:
|
|
62
|
+
raise ValueError(f"Unsupported metric type: {metric_type}")
|
|
63
|
+
|
|
64
|
+
def _insert_or_create_record(self, index: int, metric: dict[str, Any], metric_type: EventType):
|
|
65
|
+
# Attach the metadata block.
|
|
66
|
+
match metric_type:
|
|
67
|
+
case EventType.GUARDED_EVALUATION:
|
|
68
|
+
metric["metadata"] = {"feature": "guarded", "event_type": "evaluation"}
|
|
69
|
+
case EventType.GUARDED_METRIC:
|
|
70
|
+
metric["metadata"] = {"feature": "guarded", "event_type": "metric"}
|
|
71
|
+
|
|
72
|
+
if index == -1:
|
|
73
|
+
# New entry.
|
|
74
|
+
if metric_type == EventType.GUARDED_METRIC:
|
|
75
|
+
metric["count"] = 1
|
|
76
|
+
self._usages.append(metric)
|
|
77
|
+
self._history_map[self._build_metric_key(metric, metric_type)] = len(self._usages) - 1
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
# Existing entry — only metric types are aggregated (count + latest timestamp).
|
|
81
|
+
if metric_type == EventType.GUARDED_METRIC:
|
|
82
|
+
self._usages[index]["count"] += 1
|
|
83
|
+
self._usages[index]["timestamp"] = max(self._usages[index]["timestamp"], metric["timestamp"])
|
|
84
|
+
|
|
85
|
+
def add(self, metric: dict[str, Any], metric_type: EventType):
|
|
86
|
+
key = self._build_metric_key(metric, metric_type)
|
|
87
|
+
self._insert_or_create_record(self._history_map.get(key, -1), metric, metric_type)
|
|
88
|
+
|
|
89
|
+
def get_request_body(self, environment_id: str, collection_id: str) -> dict[str, Any]:
|
|
90
|
+
usages = []
|
|
91
|
+
for usage in self._usages:
|
|
92
|
+
usage_copy = deepcopy(usage)
|
|
93
|
+
usage_copy.pop('event_key', None)
|
|
94
|
+
usages.append(usage_copy)
|
|
95
|
+
return {
|
|
96
|
+
"environment_id": environment_id,
|
|
97
|
+
"collection_id": collection_id,
|
|
98
|
+
"usages": usages,
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
def __len__(self) -> int:
|
|
102
|
+
return len(self._usages)
|
|
103
|
+
|
|
104
|
+
def get_record_length(self) -> int:
|
|
105
|
+
return len(self._usages)
|
|
106
|
+
|
|
107
|
+
def exceeds_limit(self, limit: int) -> bool:
|
|
108
|
+
return len(self._usages) >= limit
|
|
109
|
+
|
|
110
|
+
def split_head(self, n: int) -> 'AnalyticsRecord':
|
|
111
|
+
"""Remove and return the first *n* usages as a new AnalyticsRecord.
|
|
112
|
+
|
|
113
|
+
The caller must hold the outer Analytics lock before calling this.
|
|
114
|
+
Insertion order is preserved — oldest entries are returned first.
|
|
115
|
+
"""
|
|
116
|
+
head = AnalyticsRecord()
|
|
117
|
+
head._usages = self._usages[:n]
|
|
118
|
+
for i, usage in enumerate(head._usages):
|
|
119
|
+
metric_type = AnalyticsRecord._get_metric_type(usage)
|
|
120
|
+
key = AnalyticsRecord._build_metric_key(usage, metric_type)
|
|
121
|
+
head._history_map[key] = i
|
|
122
|
+
|
|
123
|
+
self._usages = self._usages[n:]
|
|
124
|
+
self._history_map = {}
|
|
125
|
+
for i, usage in enumerate(self._usages):
|
|
126
|
+
metric_type = AnalyticsRecord._get_metric_type(usage)
|
|
127
|
+
key = AnalyticsRecord._build_metric_key(usage, metric_type)
|
|
128
|
+
self._history_map[key] = i
|
|
129
|
+
|
|
130
|
+
return head
|
|
131
|
+
|
|
132
|
+
@staticmethod
|
|
133
|
+
def _get_metric_type(metric: dict[str, Any]) -> EventType:
|
|
134
|
+
feature = metric["metadata"]["feature"]
|
|
135
|
+
event_type = metric["metadata"]["event_type"]
|
|
136
|
+
return EventType.GUARDED_EVALUATION if event_type == "evaluation" else EventType.GUARDED_METRIC
|
|
137
|
+
|
|
138
|
+
def merge(self, other: 'AnalyticsRecord'):
|
|
139
|
+
"""Merge all usages from *other* into self.
|
|
140
|
+
|
|
141
|
+
The caller must hold the outer Analytics lock before calling this.
|
|
142
|
+
"""
|
|
143
|
+
if not isinstance(other, AnalyticsRecord):
|
|
144
|
+
return
|
|
145
|
+
for usage in other._usages:
|
|
146
|
+
metric_type = AnalyticsRecord._get_metric_type(usage)
|
|
147
|
+
key = self._build_metric_key(usage, metric_type)
|
|
148
|
+
index = self._history_map.get(key, -1)
|
|
149
|
+
if index == -1:
|
|
150
|
+
self._usages.append(usage)
|
|
151
|
+
self._history_map[key] = len(self._usages) - 1
|
|
152
|
+
elif metric_type == EventType.GUARDED_METRIC:
|
|
153
|
+
self._usages[index]["count"] += usage["count"]
|
|
154
|
+
self._usages[index]["timestamp"] = max(
|
|
155
|
+
self._usages[index]["timestamp"], usage["timestamp"]
|
|
156
|
+
)
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
This module provides methods that perform metering and usage related operations.
|
|
17
17
|
"""
|
|
18
18
|
from threading import Lock, Timer
|
|
19
|
-
from datetime import datetime
|
|
19
|
+
from datetime import datetime, timezone
|
|
20
20
|
from .api_manager import APIManager
|
|
21
21
|
from .logger import Logger
|
|
22
22
|
from ..common import config_messages, config_constants
|
|
@@ -80,7 +80,7 @@ class Metering:
|
|
|
80
80
|
|
|
81
81
|
self.__lock.acquire()
|
|
82
82
|
try:
|
|
83
|
-
time = datetime.
|
|
83
|
+
time = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
84
84
|
feature_json = {
|
|
85
85
|
'count': 1,
|
|
86
86
|
'evaluation_time': time
|