diff --git a/demo.py b/demo.py index 8c675ce..370104c 100644 --- a/demo.py +++ b/demo.py @@ -1,6 +1,8 @@ import logging import featureprobe as fp +import random +import time logging.basicConfig(level=logging.WARNING) @@ -14,19 +16,17 @@ start_wait=5) # Server Side SDK Key for your project and environment - SDK_KEY = 'server-8ed48815ef044428826787e9a238b9c6a479f98c' + SDK_KEY = 'server-9e53c5db4fd75049a69df8881f3bc90edd58fb06' with fp.Client(SDK_KEY, config) as client: - if client.initialized(): - print("SDK successfully initialized!") - else: + if not client.initialized(): print("SDK failed to initialize!") - exit() # Create one user # "userId" is used in rules, should be filled in. user = fp.User().with_attr('userId', '00001') # Get toggle result for this user. - TOGGLE_KEY = 'campaign_allow_list' + #TOGGLE_KEY = 'campaign_allow_list' + TOGGLE_KEY = 'Event_Analysis' # Get toggle result for this user is_open = client.value(TOGGLE_KEY, user, default=False) @@ -36,3 +36,13 @@ is_open_detail = client.value_detail(TOGGLE_KEY, user, default=False) print('detail: ' + str(is_open_detail.reason)) print('rule index: ' + str(is_open_detail.rule_index)) + + # Simulate conversion rate of 1000 users for a new feature + #YOU_EVENT_NAME = "new_feature_conversion"; + YOU_EVENT_NAME = "multi_feature" + for i in range(1000): + event_user = fp.User().stable_rollout(str(time.time_ns() + i)) + new_feature = client.value(TOGGLE_KEY, event_user, '1') + client.track(YOU_EVENT_NAME, event_user, random.randint(0, 99)) + print("New feature conversion.") + time.sleep(0.2) diff --git a/featureprobe/__init__.py b/featureprobe/__init__.py index b137154..65dea92 100644 --- a/featureprobe/__init__.py +++ b/featureprobe/__init__.py @@ -27,7 +27,7 @@ from featureprobe.access_recorder import ( AccessCounter, - AccessRecorder, + AccessSummaryRecorder, ) from featureprobe.config import Config @@ -64,7 +64,7 @@ # featureprobe 'AccessCounter', - 'AccessRecorder', + 'AccessSummaryRecorder', 'Client', 'Config', 'Context', diff --git a/featureprobe/access_recorder.py b/featureprobe/access_recorder.py index f45f1d2..d4fd79a 100644 --- a/featureprobe/access_recorder.py +++ b/featureprobe/access_recorder.py @@ -21,7 +21,7 @@ class AccessCounter: - def __init__(self, value: str, version: int, index: int): + def __init__(self, value: object, version: int, index: int): self._VALUE = value self._VERSION = version self._INDEX = index @@ -59,12 +59,11 @@ def increment(self): self._count += 1 def is_group(self, event: "AccessEvent"): - return self._VALUE == event.value \ - and self._VERSION == event.version \ - and self._INDEX == event.index + return self._VERSION == event.version \ + and self._INDEX == event.variation_index -class AccessRecorder: +class AccessSummaryRecorder: def __init__(self): self._counters = {} # Dict[str, List[AccessCounter]] self._start_time = 0 @@ -102,13 +101,13 @@ def add(self, _event: "AccessEvent"): # sourcery skip: use-named-expression AccessCounter( _event.value, _event.version, - _event.index)) + _event.variation_index)) else: groups = [ AccessCounter( _event.value, _event.version, - _event.index)] + _event.variation_index)] self._counters[_event.key] = groups def snapshot(self): diff --git a/featureprobe/client.py b/featureprobe/client.py index c44d598..9cdf973 100644 --- a/featureprobe/client.py +++ b/featureprobe/client.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +from typing import Optional import logging import time from threading import Event @@ -20,7 +21,7 @@ from featureprobe.config import Config from featureprobe.context import Context from featureprobe.detail import Detail -from featureprobe.event import AccessEvent +from featureprobe.event import AccessEvent, CustomEvent from featureprobe.internal.empty_str import empty_str from featureprobe.user import User @@ -105,12 +106,17 @@ def value(self, toggle_key: str, user: User, default) -> Any: return default eval_result = toggle.eval(user, segments, default) - access_event = AccessEvent(timestamp=int(time.time() * 1000), - user=user, - key=toggle_key, - value=str(eval_result.value), - version=eval_result.version, - index=eval_result.variation_index) + access_event = AccessEvent( + timestamp=int( + time.time() * 1000), + user=user, + key=toggle_key, + value=eval_result.value, + version=eval_result.version, + variation_index=eval_result.variation_index, + rule_index=eval_result.rule_index, + reason=eval_result.reason, + track_access_events=toggle.track_access_events) self._event_processor.push(access_event) return eval_result.value @@ -140,11 +146,35 @@ def value_detail(self, toggle_key: str, user: User, default) -> Detail: reason=eval_result.reason, rule_index=eval_result.rule_index, version=eval_result.version) - access_event = AccessEvent(timestamp=int(time.time() * 1000), - user=user, - key=toggle_key, - value=eval_result.value, - version=eval_result.version, - index=eval_result.variation_index) + access_event = AccessEvent( + timestamp=int( + time.time() * 1000), + user=user, + key=toggle_key, + value=eval_result.value, + version=eval_result.version, + variation_index=eval_result.variation_index, + rule_index=eval_result.rule_index, + reason=eval_result.reason, + track_access_events=toggle.track_access_events) self._event_processor.push(access_event) return detail + + def track( + self, + event_name: str, + user: User, + value: Optional[float] = None): + """Tracks that a custom defined event + + :param event_name: the name of the event. + :param user: :obj:`~featureprobe.User` to be evaluated. + :param value: a numeric value(Optional). + """ + self._event_processor.push( + CustomEvent( + timestamp=int( + time.time() * 1000), + name=event_name, + user=user, + value=value)) diff --git a/featureprobe/default_event_processor.py b/featureprobe/default_event_processor.py index 1577b81..7c89aff 100644 --- a/featureprobe/default_event_processor.py +++ b/featureprobe/default_event_processor.py @@ -13,6 +13,7 @@ # limitations under the License. import contextlib +import json import logging import queue import threading @@ -26,9 +27,9 @@ from apscheduler.schedulers.background import BackgroundScheduler from requests import Session, HTTPError -from featureprobe.access_recorder import AccessRecorder +from featureprobe.access_recorder import AccessSummaryRecorder from featureprobe.context import Context -from featureprobe.event import Event, AccessEvent +from featureprobe.event import CustomEvent, Event, AccessEvent from featureprobe.event_processor import EventProcessor @@ -46,13 +47,13 @@ def __init__(self, _type: Type, event: Optional[Event]): class EventRepository: def __init__(self): self.events = [] - self.access = AccessRecorder() + self.access = AccessSummaryRecorder() @classmethod def _clone( cls, events: List[Event], - access: AccessRecorder) -> "EventRepository": + access: AccessSummaryRecorder) -> "EventRepository": repo = cls() repo.events = events.copy() repo.access = access.snapshot() @@ -73,6 +74,10 @@ def is_empty(self): def add(self, event: Event): if isinstance(event, AccessEvent): self.access.add(event) + if event.track_access_events: + self.events.append(event) + elif isinstance(event, CustomEvent): + self.events.append(event) def snapshot(self): return EventRepository._clone(self.events, self.access) @@ -185,7 +190,9 @@ def _send_events(self, repositories: List[EventRepository]): json=repositories, timeout=self._timeout) # sourcery skip: replace-interpolation-with-fstring - self._logger.debug('Http response: %s' % resp.text) + self._logger.debug( + 'Http request %s, response %s' % + (repositories, resp)) try: resp.raise_for_status() except HTTPError as e: diff --git a/featureprobe/event.py b/featureprobe/event.py index 89ed19d..2619eb6 100644 --- a/featureprobe/event.py +++ b/featureprobe/event.py @@ -12,21 +12,23 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Optional if TYPE_CHECKING: from featureprobe.user import User class Event: - def __init__(self, created_time: int, user: "User"): + def __init__(self, kind: str, created_time: int, user: "User"): self._created_time = created_time self._user = user + self._kind = kind def to_dict(self) -> dict: return { - 'createdTime': self._created_time, - 'user': self._user.to_dict(), + 'kind': self.kind, + 'time': self._created_time, + 'user': self._user.key } @property @@ -37,6 +39,10 @@ def created_time(self) -> int: def user(self) -> "User": return self._user + @property + def kind(self) -> str: + return self._kind + class AccessEvent(Event): def __init__( @@ -44,14 +50,32 @@ def __init__( timestamp: int, user: "User", key: str, - value: str, + value: object, version: int, - index: int): - super().__init__(timestamp, user) + variation_index: int, + rule_index: int, + reason: str, + track_access_events: bool): + super().__init__("access", timestamp, user) self._key = key self._value = value self._version = version - self._index = index + self._variation_index = variation_index + self._rule_index = rule_index + self._reason = reason + self._track_access_events = track_access_events + + def to_dict(self) -> dict: + values = super().to_dict() + values.update({ + 'key': self._key, + 'value': self._value, + 'version': self._version, + 'variationIndex': self._variation_index, + 'ruleIndex': self._rule_index, + 'reason': self._reason + }) + return values @property def key(self): @@ -66,5 +90,44 @@ def version(self): return self._version @property - def index(self): - return self._index + def variation_index(self): + return self._variation_index + + @property + def rule_index(self): + return self._rule_index + + @property + def reason(self): + return self._reason + + @property + def track_access_events(self): + return self._track_access_events + + +class CustomEvent(Event): + def __init__( + self, + timestamp: int, + user: "User", + name: str, + value: Optional[float] = None): + super().__init__("custom", timestamp, user) + self._name = name + self._value = value + + def to_dict(self) -> dict: + values = super().to_dict() + values['name'] = self._name + if self._value is not None: + values['value'] = self._value + return values + + @property + def name(self): + return self._name + + @property + def value(self): + return self._value diff --git a/featureprobe/model/toggle.py b/featureprobe/model/toggle.py index bab9940..f2f95b5 100644 --- a/featureprobe/model/toggle.py +++ b/featureprobe/model/toggle.py @@ -34,7 +34,9 @@ def __init__(self, default_serve: "Serve", rules: List["Rule"], variations: list, - for_client: bool): + for_client: bool, + track_access_events: bool, + last_modified: int): self._key = key self._enabled = enabled self._version = version @@ -43,6 +45,8 @@ def __init__(self, self._rules = rules self._variations = variations self._for_client = for_client + self._track_access_events = track_access_events + self._last_modified = last_modified @classmethod @json_decoder @@ -55,6 +59,9 @@ def from_json(cls, json: dict) -> "Toggle": rules = [Rule.from_json(r) for r in json.get('rules', [])] variations = json.get('variations', []) for_client = json.get('forClient', False) + track_access_events = json.get('trackAccessEvents', False) + last_modified = json.get('lastModified', None) + return cls( key, enabled, @@ -63,7 +70,9 @@ def from_json(cls, json: dict) -> "Toggle": default_serve, rules, variations, - for_client) + for_client, + track_access_events, + last_modified) @property def key(self) -> str: @@ -125,6 +134,10 @@ def variations(self, value: List[str]): def for_client(self) -> bool: return self._for_client + @property + def track_access_events(self) -> bool: + return self._track_access_events + @for_client.setter def for_client(self, value: bool): self._for_client = value diff --git a/tests/access_recorder_test.py b/tests/access_recorder_test.py index 7f2a149..534e4e1 100644 --- a/tests/access_recorder_test.py +++ b/tests/access_recorder_test.py @@ -23,11 +23,15 @@ def _timestamp(): def setup_function(): global recorder, event # noqa - recorder = fp.AccessRecorder() + recorder = fp.AccessSummaryRecorder() user = fp.User().stable_rollout('test_user') event = fp.AccessEvent(_timestamp(), user, key='test_toggle', value='true', - version=1, index=0) + version=1, + variation_index=1, + rule_index=0, + track_access_events=True, + reason='') def test_add_event(): @@ -37,7 +41,7 @@ def test_add_event(): assert recorder.counters.get('test_toggle')[0].value == 'true' assert recorder.counters.get('test_toggle')[0].count == 1 assert recorder.counters.get('test_toggle')[0].version == 1 - assert recorder.counters.get('test_toggle')[0].index == 0 + assert recorder.counters.get('test_toggle')[0].index == 1 def test_get_snapshot():