-
Notifications
You must be signed in to change notification settings - Fork 2
FF-3143 feat: add caching logger for assignment and bandit events #68
Changes from all commits
67006f5
6412fa2
2b59a0d
c4c5b16
a9e5918
9b3599a
14126c6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,13 +1,59 @@ | ||
| from typing import Dict | ||
| from eppo_client.base_model import BaseModel | ||
| from pydantic import ConfigDict | ||
| from typing import Dict, Optional, Tuple, MutableMapping | ||
|
|
||
|
|
||
| class AssignmentLogger(BaseModel): | ||
| model_config = ConfigDict(arbitrary_types_allowed=True) | ||
|
|
||
| class AssignmentLogger: | ||
| def log_assignment(self, assignment_event: Dict): | ||
| pass | ||
|
|
||
| def log_bandit_action(self, bandit_event: Dict): | ||
| pass | ||
|
|
||
|
|
||
| class AssignmentCacheLogger(AssignmentLogger): | ||
| def __init__( | ||
| self, | ||
| inner: AssignmentLogger, | ||
| *, | ||
| assignment_cache: Optional[MutableMapping] = None, | ||
| bandit_cache: Optional[MutableMapping] = None, | ||
| ): | ||
| self.__inner = inner | ||
| self.__assignment_cache = assignment_cache | ||
| self.__bandit_cache = bandit_cache | ||
|
|
||
| def log_assignment(self, event: Dict): | ||
| _cache_or_call( | ||
| self.__assignment_cache, | ||
| *AssignmentCacheLogger.__assignment_cache_keyvalue(event), | ||
| lambda: self.__inner.log_assignment(event), | ||
| ) | ||
|
|
||
| def log_bandit_action(self, event: Dict): | ||
| _cache_or_call( | ||
| self.__bandit_cache, | ||
| *AssignmentCacheLogger.__bandit_cache_keyvalue(event), | ||
| lambda: self.__inner.log_bandit_action(event), | ||
| ) | ||
|
|
||
| @staticmethod | ||
| def __assignment_cache_keyvalue(event: Dict) -> Tuple[Tuple, Tuple]: | ||
| key = (event["featureFlag"], event["subject"]) | ||
| value = (event["allocation"], event["variation"]) | ||
| return key, value | ||
|
|
||
| @staticmethod | ||
| def __bandit_cache_keyvalue(event: Dict) -> Tuple[Tuple, Tuple]: | ||
| key = (event["flagKey"], event["subject"]) | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Noticed that our event format is somewhat inconsistent between SDKs. In Python, Go, PHP, .NET, Rust, and Ruby:
Javascript and Java SDK use
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah, it's just a factor of evolution. We're trying to move (slowly) more towards explicit keys, such as |
||
| value = (event["banditKey"], event["action"]) | ||
| return key, value | ||
|
|
||
|
|
||
| def _cache_or_call(cache: Optional[MutableMapping], key, value, fn): | ||
| if cache is not None and (previous := cache.get(key)) and previous == value: | ||
| # ok, cached | ||
| return | ||
|
|
||
| fn() | ||
|
|
||
| if cache is not None: | ||
| cache[key] = value | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,4 @@ | ||
| # Note to developers: When ready to bump to 4.0, please change | ||
| # the `POLL_INTERVAL_SECONDS` constant in `eppo_client/constants.py` | ||
| # to 30 seconds to match the behavior of the other server SDKs. | ||
| __version__ = "3.5.4" | ||
| __version__ = "3.6.0" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,3 +3,5 @@ pytest | |
| pytest-mock | ||
| mypy | ||
| httpretty | ||
| cachetools | ||
| types-cachetools | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,7 +1,5 @@ | ||
| pydantic==2.4.* | ||
| pydantic-settings==2.0.* | ||
| requests==2.31.* | ||
| cachetools==5.3.* | ||
| types-cachetools==5.3.* | ||
| types-requests==2.31.* | ||
| semver==3.0.* |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -22,5 +22,4 @@ install_requires = | |
| pydantic | ||
| pydantic-settings | ||
| requests | ||
| cachetools | ||
| semver | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,112 @@ | ||
| from unittest.mock import Mock | ||
|
|
||
| from cachetools import LRUCache | ||
|
|
||
| from eppo_client.assignment_logger import AssignmentCacheLogger | ||
| from eppo_client.client import _utcnow | ||
| from eppo_client.version import __version__ | ||
|
|
||
|
|
||
| def test_non_caching(): | ||
| inner = Mock() | ||
| logger = AssignmentCacheLogger(inner) | ||
|
|
||
| logger.log_assignment(make_assignment_event()) | ||
| logger.log_assignment(make_assignment_event()) | ||
| logger.log_bandit_action(make_bandit_event()) | ||
| logger.log_bandit_action(make_bandit_event()) | ||
|
|
||
| assert inner.log_assignment.call_count == 2 | ||
| assert inner.log_bandit_action.call_count == 2 | ||
|
|
||
|
|
||
| def test_assignment_cache(): | ||
| inner = Mock() | ||
| logger = AssignmentCacheLogger(inner, assignment_cache=LRUCache(100)) | ||
|
|
||
| logger.log_assignment(make_assignment_event()) | ||
| logger.log_assignment(make_assignment_event()) | ||
|
|
||
| assert inner.log_assignment.call_count == 1 | ||
|
|
||
|
|
||
| def test_bandit_cache(): | ||
| inner = Mock() | ||
| logger = AssignmentCacheLogger(inner, bandit_cache=LRUCache(100)) | ||
|
|
||
| logger.log_bandit_action(make_bandit_event()) | ||
| logger.log_bandit_action(make_bandit_event()) | ||
|
|
||
| assert inner.log_bandit_action.call_count == 1 | ||
|
|
||
|
|
||
| def test_bandit_flip_flop(): | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ❤️ |
||
| inner = Mock() | ||
| logger = AssignmentCacheLogger(inner, bandit_cache=LRUCache(100)) | ||
|
|
||
| logger.log_bandit_action(make_bandit_event(action="action1")) | ||
| logger.log_bandit_action(make_bandit_event(action="action1")) | ||
| assert inner.log_bandit_action.call_count == 1 | ||
|
|
||
| logger.log_bandit_action(make_bandit_event(action="action2")) | ||
| assert inner.log_bandit_action.call_count == 2 | ||
|
|
||
| logger.log_bandit_action(make_bandit_event(action="action1")) | ||
| assert inner.log_bandit_action.call_count == 3 | ||
|
|
||
|
|
||
| def make_assignment_event( | ||
| *, | ||
| allocation="allocation", | ||
| experiment="experiment", | ||
| featureFlag="featureFlag", | ||
| variation="variation", | ||
| subject="subject", | ||
| timestamp=_utcnow().isoformat(), | ||
| subjectAttributes={}, | ||
| metaData={"sdkLanguage": "python", "sdkVersion": __version__}, | ||
| extra_logging={}, | ||
| ): | ||
| return { | ||
| **extra_logging, | ||
| "allocation": allocation, | ||
| "experiment": experiment, | ||
| "featureFlag": featureFlag, | ||
| "variation": variation, | ||
| "subject": subject, | ||
| "timestamp": timestamp, | ||
| "subjectAttributes": subjectAttributes, | ||
| "metaData": metaData, | ||
| } | ||
|
|
||
|
|
||
| def make_bandit_event( | ||
| *, | ||
| flag_key="flagKey", | ||
| bandit_key="banditKey", | ||
| subject_key="subjectKey", | ||
| action="action", | ||
| action_probability=1.0, | ||
| optimality_gap=None, | ||
| evaluation=None, | ||
| bandit_data=None, | ||
| subject_context_attributes=None, | ||
| timestamp=_utcnow().isoformat(), | ||
| model_version="model_version", | ||
| meta_data={"sdkLanguage": "python", "sdkVersion": __version__}, | ||
| ): | ||
| return { | ||
| "flagKey": flag_key, | ||
| "banditKey": bandit_key, | ||
| "subject": subject_key, | ||
| "action": action, | ||
| "actionProbability": action_probability, | ||
| "optimalityGap": optimality_gap, | ||
| "modelVersion": model_version, | ||
| "timestamp": timestamp, | ||
| "subjectNumericAttributes": {}, | ||
| "subjectCategoricalAttributes": {}, | ||
| "actionNumericAttributes": {}, | ||
| "actionCategoricalAttributes": {}, | ||
| "metaData": meta_data, | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
fantastic example code