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.
Files changed (64) hide show
  1. {ibm_appconfiguration_python_sdk-0.4.4/ibm_appconfiguration_python_sdk.egg-info → ibm_appconfiguration_python_sdk-0.5.0}/PKG-INFO +1 -1
  2. {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/ibm_appconfiguration/appconfiguration.py +10 -1
  3. {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/ibm_appconfiguration/configurations/configuration_handler.py +133 -30
  4. {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/ibm_appconfiguration/configurations/internal/common/config_constants.py +1 -0
  5. ibm_appconfiguration_python_sdk-0.5.0/ibm_appconfiguration/configurations/internal/utils/analytics.py +214 -0
  6. ibm_appconfiguration_python_sdk-0.5.0/ibm_appconfiguration/configurations/internal/utils/analytics_record.py +156 -0
  7. {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/ibm_appconfiguration/configurations/internal/utils/metering.py +2 -2
  8. {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/ibm_appconfiguration/configurations/internal/utils/parser.py +42 -0
  9. {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/ibm_appconfiguration/configurations/internal/utils/url_builder.py +13 -1
  10. {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/ibm_appconfiguration/configurations/models/feature.py +65 -3
  11. {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/ibm_appconfiguration/configurations/models/segment_rules.py +5 -0
  12. {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/ibm_appconfiguration/version.py +1 -1
  13. {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0/ibm_appconfiguration_python_sdk.egg-info}/PKG-INFO +1 -1
  14. {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/ibm_appconfiguration_python_sdk.egg-info/SOURCES.txt +5 -0
  15. {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/setup.py +1 -1
  16. ibm_appconfiguration_python_sdk-0.5.0/unit_tests/configurations/test_guarded_rollout.py +680 -0
  17. ibm_appconfiguration_python_sdk-0.5.0/unit_tests/configurations/utils/test_analytics.py +318 -0
  18. ibm_appconfiguration_python_sdk-0.5.0/unit_tests/configurations/utils/test_analytics_record.py +264 -0
  19. {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/LICENSE +0 -0
  20. {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/README.md +0 -0
  21. {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/examples/__init__.py +0 -0
  22. {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/examples/sample_app.py +0 -0
  23. {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/examples/server_sample.py +0 -0
  24. {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/ibm_appconfiguration/__init__.py +0 -0
  25. {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/ibm_appconfiguration/configurations/__init__.py +0 -0
  26. {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/ibm_appconfiguration/configurations/internal/__init__.py +0 -0
  27. {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/ibm_appconfiguration/configurations/internal/common/__init__.py +0 -0
  28. {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/ibm_appconfiguration/configurations/internal/common/config_messages.py +0 -0
  29. {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/ibm_appconfiguration/configurations/internal/utils/__init__.py +0 -0
  30. {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/ibm_appconfiguration/configurations/internal/utils/api_manager.py +0 -0
  31. {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/ibm_appconfiguration/configurations/internal/utils/compute_percentage.py +0 -0
  32. {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/ibm_appconfiguration/configurations/internal/utils/connectivity.py +0 -0
  33. {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/ibm_appconfiguration/configurations/internal/utils/file_manager.py +0 -0
  34. {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/ibm_appconfiguration/configurations/internal/utils/logger.py +0 -0
  35. {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/ibm_appconfiguration/configurations/internal/utils/rollout_utils.py +0 -0
  36. {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/ibm_appconfiguration/configurations/internal/utils/socket.py +0 -0
  37. {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/ibm_appconfiguration/configurations/internal/utils/validators.py +0 -0
  38. {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/ibm_appconfiguration/configurations/models/__init__.py +0 -0
  39. {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/ibm_appconfiguration/configurations/models/configuration_type.py +0 -0
  40. {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/ibm_appconfiguration/configurations/models/property.py +0 -0
  41. {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/ibm_appconfiguration/configurations/models/rule.py +0 -0
  42. {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/ibm_appconfiguration/configurations/models/segment.py +0 -0
  43. {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
  44. {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/ibm_appconfiguration_python_sdk.egg-info/requires.txt +0 -0
  45. {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
  46. {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/integration_tests/__init__.py +0 -0
  47. {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/integration_tests/test_integration.py +0 -0
  48. {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/setup.cfg +0 -0
  49. {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/unit_tests/__init__.py +0 -0
  50. {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/unit_tests/configurations/__init__.py +0 -0
  51. {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/unit_tests/configurations/models/__init__.py +0 -0
  52. {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/unit_tests/configurations/models/test_feature.py +0 -0
  53. {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/unit_tests/configurations/models/test_property.py +0 -0
  54. {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/unit_tests/configurations/models/test_rule.py +0 -0
  55. {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/unit_tests/configurations/models/test_segment.py +0 -0
  56. {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/unit_tests/configurations/models/test_segment_rules.py +0 -0
  57. {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/unit_tests/configurations/test_configuration_handler.py +0 -0
  58. {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/unit_tests/configurations/utils/__init__.py +0 -0
  59. {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/unit_tests/configurations/utils/test_api_manager.py +0 -0
  60. {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/unit_tests/configurations/utils/test_file_manager.py +0 -0
  61. {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/unit_tests/configurations/utils/test_metering.py +0 -0
  62. {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/unit_tests/configurations/utils/test_socket.py +0 -0
  63. {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/unit_tests/configurations/utils/test_url_builder.py +0 -0
  64. {ibm_appconfiguration_python_sdk-0.4.4 → ibm_appconfiguration_python_sdk-0.5.0}/unit_tests/test_appconfiguration.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ibm-appconfiguration-python-sdk
3
- Version: 0.4.4
3
+ Version: 0.5.0
4
4
  Summary: IBM Cloud App Configuration Python SDK
5
5
  Home-page: https://github.com/IBM/appconfiguration-python-sdk
6
6
  Author: IBM
@@ -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
- rollout_percentage = None
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
- if rollout_percentage == 100 or (get_normalized_value(
401
- entity_id + ":" + feature.get_feature_id()) < rollout_percentage):
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
- if segment_rule.get_rollout_configuration() is not None or segment_rule.get_rollout_type() == config_constants.PROGRESSIVE:
436
- # Determine which rollout map to use
437
- if segment_rule.get_rollout_percentage() == config_constants.DEFAULT_ROLLOUT_PERCENTAGE:
438
- # Use feature-level rollout
439
- rollout_map = self.__rollout_config_map.get(feature.get_feature_id())
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
- # Use segment-level rollout
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
- if segment_rollout_percentage == 100 or (get_normalized_value(
462
- entity_id + ":" + feature.get_feature_id())) < segment_rollout_percentage:
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
- rollout_percentage = None
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
- if rollout_percentage == 100 or get_normalized_value(
495
- entity_id + ":" + feature.get_feature_id()) < rollout_percentage:
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()
@@ -29,4 +29,5 @@ CUSTOM_SOCKET_CLOSE_REASON_CODE = 4001
29
29
 
30
30
  MANUAL = 'MANUAL'
31
31
  PROGRESSIVE = 'PROGRESSIVE'
32
+ GUARDED = 'GUARDED'
32
33
  DELIMITER = '\u001F'
@@ -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.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
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