Skip to content
This repository was archived by the owner on Nov 8, 2024. It is now read-only.

Commit 12583d6

Browse files
authored
FF-3143 feat: add caching logger for assignment and bandit events (#68)
* refactor: remove unnecessary evaluation checks BanditEvaluator.evaluate_bandit() always returns a result, so `is None` checks were unnecessary. Furthermore, `evaluation` fields are accessed without this guard a couple of lines below. * refactor: make AssignmentLogger to not be a pydantic model There is no reason for AssignmentLogger to be a pydantic model because it's not a model at all (it's behavior, not data). Remove inheritance from the base model for AssignmentLogger and allow arbitrary types on Config instead. Also exclude assignment_logger from pydantic serialization. This refactor makes it easier to work with AssignmentLogger. Especially when mocking it, as pydantic models are not easily mockable. * feat: add assignment cache logger * docs: add documentation for de-duplicating assignment and bandit actions * chore: move cachetools from hard to test requirements * chore: bump version * refactor: spell out mk as make
1 parent 1f6e9bd commit 12583d6

File tree

9 files changed

+211
-17
lines changed

9 files changed

+211
-17
lines changed

README.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,38 @@ class SegmentAssignmentLogger(AssignmentLogger):
104104
client_config = Config(api_key="<SDK-KEY-FROM-DASHBOARD>", assignment_logger=SegmentAssignmentLogger())
105105
```
106106

107+
### De-duplication of assignments
108+
109+
The SDK may see many duplicate assignments in a short period of time, and if you have configured a logging function, they will be transmitted to your downstream event store. This increases the cost of storage as well as warehouse costs during experiment analysis.
110+
111+
To mitigate this, a caching assignment logger is optionally available with configurable cache behavior.
112+
113+
The caching can be configured individually for assignment logs and bandit action logs using `AssignmentCacheLogger`.
114+
115+
`AssignmentCacheLogger` optionally accepts two caches. We recommend using [`cachetools`](https://pypi.org/project/cachetools/) but any subclass of `MutableMapping` works.
116+
117+
```python
118+
import cachetools
119+
from eppo_client.assignment_logger import AssignmentLogger, AssignmentCacheLogger
120+
121+
122+
class MyLogger(AssignmentLogger):
123+
# implement your logger
124+
pass
125+
126+
127+
client_config = Config(
128+
api_key="<SDK-KEY-FROM-DASHBOARD>",
129+
assignment_logger=AssignmentCacheLogger(
130+
MyLogger(),
131+
# cache 1024 least recently used assignments
132+
assignment_cache=cachetools.LRUCache(maxsize=1024),
133+
# cache bandit assignment for no longer than 10 minutes
134+
bandit_cache=cachetools.TTLCache(maxsize=2048, ttl=600),
135+
),
136+
)
137+
```
138+
107139
## Export configuration
108140

109141
To support the use-case of needing to bootstrap a front-end client, the Eppo SDK provides a function to export flag configurations to a JSON string.

eppo_client/assignment_logger.py

Lines changed: 52 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,59 @@
1-
from typing import Dict
2-
from eppo_client.base_model import BaseModel
3-
from pydantic import ConfigDict
1+
from typing import Dict, Optional, Tuple, MutableMapping
42

53

6-
class AssignmentLogger(BaseModel):
7-
model_config = ConfigDict(arbitrary_types_allowed=True)
8-
4+
class AssignmentLogger:
95
def log_assignment(self, assignment_event: Dict):
106
pass
117

128
def log_bandit_action(self, bandit_event: Dict):
139
pass
10+
11+
12+
class AssignmentCacheLogger(AssignmentLogger):
13+
def __init__(
14+
self,
15+
inner: AssignmentLogger,
16+
*,
17+
assignment_cache: Optional[MutableMapping] = None,
18+
bandit_cache: Optional[MutableMapping] = None,
19+
):
20+
self.__inner = inner
21+
self.__assignment_cache = assignment_cache
22+
self.__bandit_cache = bandit_cache
23+
24+
def log_assignment(self, event: Dict):
25+
_cache_or_call(
26+
self.__assignment_cache,
27+
*AssignmentCacheLogger.__assignment_cache_keyvalue(event),
28+
lambda: self.__inner.log_assignment(event),
29+
)
30+
31+
def log_bandit_action(self, event: Dict):
32+
_cache_or_call(
33+
self.__bandit_cache,
34+
*AssignmentCacheLogger.__bandit_cache_keyvalue(event),
35+
lambda: self.__inner.log_bandit_action(event),
36+
)
37+
38+
@staticmethod
39+
def __assignment_cache_keyvalue(event: Dict) -> Tuple[Tuple, Tuple]:
40+
key = (event["featureFlag"], event["subject"])
41+
value = (event["allocation"], event["variation"])
42+
return key, value
43+
44+
@staticmethod
45+
def __bandit_cache_keyvalue(event: Dict) -> Tuple[Tuple, Tuple]:
46+
key = (event["flagKey"], event["subject"])
47+
value = (event["banditKey"], event["action"])
48+
return key, value
49+
50+
51+
def _cache_or_call(cache: Optional[MutableMapping], key, value, fn):
52+
if cache is not None and (previous := cache.get(key)) and previous == value:
53+
# ok, cached
54+
return
55+
56+
fn()
57+
58+
if cache is not None:
59+
cache[key] = value

eppo_client/client.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -364,12 +364,10 @@ def evaluate_bandit_action(
364364
"flagKey": flag_key,
365365
"banditKey": bandit_data.bandit_key,
366366
"subject": subject_key,
367-
"action": evaluation.action_key if evaluation else None,
368-
"actionProbability": evaluation.action_weight if evaluation else None,
369-
"optimalityGap": evaluation.optimality_gap if evaluation else None,
370-
"modelVersion": (
371-
bandit_data.bandit_model_version if evaluation else None
372-
),
367+
"action": evaluation.action_key,
368+
"actionProbability": evaluation.action_weight,
369+
"optimalityGap": evaluation.optimality_gap,
370+
"modelVersion": (bandit_data.bandit_model_version),
373371
"timestamp": _utcnow().isoformat(),
374372
"subjectNumericAttributes": (
375373
subject_context_attributes.numeric_attributes

eppo_client/config.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from pydantic import Field, ConfigDict
2+
13
from eppo_client.assignment_logger import AssignmentLogger
24
from eppo_client.base_model import SdkBaseModel
35
from eppo_client.validation import validate_not_blank
@@ -8,9 +10,14 @@
810

911

1012
class Config(SdkBaseModel):
13+
model_config = ConfigDict(
14+
# AssignmentLogger is not a pydantic model
15+
arbitrary_types_allowed=True
16+
)
17+
1118
api_key: str
1219
base_url: str = "https://fscdn.eppo.cloud/api"
13-
assignment_logger: AssignmentLogger
20+
assignment_logger: AssignmentLogger = Field(exclude=True)
1421
is_graceful_mode: bool = True
1522
poll_interval_seconds: int = POLL_INTERVAL_SECONDS_DEFAULT
1623
poll_jitter_seconds: int = POLL_JITTER_SECONDS_DEFAULT

eppo_client/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
# Note to developers: When ready to bump to 4.0, please change
22
# the `POLL_INTERVAL_SECONDS` constant in `eppo_client/constants.py`
33
# to 30 seconds to match the behavior of the other server SDKs.
4-
__version__ = "3.5.4"
4+
__version__ = "3.6.0"

requirements-test.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,5 @@ pytest
33
pytest-mock
44
mypy
55
httpretty
6+
cachetools
7+
types-cachetools

requirements.txt

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
pydantic==2.4.*
22
pydantic-settings==2.0.*
33
requests==2.31.*
4-
cachetools==5.3.*
5-
types-cachetools==5.3.*
64
types-requests==2.31.*
75
semver==3.0.*

setup.cfg

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,5 +22,4 @@ install_requires =
2222
pydantic
2323
pydantic-settings
2424
requests
25-
cachetools
2625
semver
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
from unittest.mock import Mock
2+
3+
from cachetools import LRUCache
4+
5+
from eppo_client.assignment_logger import AssignmentCacheLogger
6+
from eppo_client.client import _utcnow
7+
from eppo_client.version import __version__
8+
9+
10+
def test_non_caching():
11+
inner = Mock()
12+
logger = AssignmentCacheLogger(inner)
13+
14+
logger.log_assignment(make_assignment_event())
15+
logger.log_assignment(make_assignment_event())
16+
logger.log_bandit_action(make_bandit_event())
17+
logger.log_bandit_action(make_bandit_event())
18+
19+
assert inner.log_assignment.call_count == 2
20+
assert inner.log_bandit_action.call_count == 2
21+
22+
23+
def test_assignment_cache():
24+
inner = Mock()
25+
logger = AssignmentCacheLogger(inner, assignment_cache=LRUCache(100))
26+
27+
logger.log_assignment(make_assignment_event())
28+
logger.log_assignment(make_assignment_event())
29+
30+
assert inner.log_assignment.call_count == 1
31+
32+
33+
def test_bandit_cache():
34+
inner = Mock()
35+
logger = AssignmentCacheLogger(inner, bandit_cache=LRUCache(100))
36+
37+
logger.log_bandit_action(make_bandit_event())
38+
logger.log_bandit_action(make_bandit_event())
39+
40+
assert inner.log_bandit_action.call_count == 1
41+
42+
43+
def test_bandit_flip_flop():
44+
inner = Mock()
45+
logger = AssignmentCacheLogger(inner, bandit_cache=LRUCache(100))
46+
47+
logger.log_bandit_action(make_bandit_event(action="action1"))
48+
logger.log_bandit_action(make_bandit_event(action="action1"))
49+
assert inner.log_bandit_action.call_count == 1
50+
51+
logger.log_bandit_action(make_bandit_event(action="action2"))
52+
assert inner.log_bandit_action.call_count == 2
53+
54+
logger.log_bandit_action(make_bandit_event(action="action1"))
55+
assert inner.log_bandit_action.call_count == 3
56+
57+
58+
def make_assignment_event(
59+
*,
60+
allocation="allocation",
61+
experiment="experiment",
62+
featureFlag="featureFlag",
63+
variation="variation",
64+
subject="subject",
65+
timestamp=_utcnow().isoformat(),
66+
subjectAttributes={},
67+
metaData={"sdkLanguage": "python", "sdkVersion": __version__},
68+
extra_logging={},
69+
):
70+
return {
71+
**extra_logging,
72+
"allocation": allocation,
73+
"experiment": experiment,
74+
"featureFlag": featureFlag,
75+
"variation": variation,
76+
"subject": subject,
77+
"timestamp": timestamp,
78+
"subjectAttributes": subjectAttributes,
79+
"metaData": metaData,
80+
}
81+
82+
83+
def make_bandit_event(
84+
*,
85+
flag_key="flagKey",
86+
bandit_key="banditKey",
87+
subject_key="subjectKey",
88+
action="action",
89+
action_probability=1.0,
90+
optimality_gap=None,
91+
evaluation=None,
92+
bandit_data=None,
93+
subject_context_attributes=None,
94+
timestamp=_utcnow().isoformat(),
95+
model_version="model_version",
96+
meta_data={"sdkLanguage": "python", "sdkVersion": __version__},
97+
):
98+
return {
99+
"flagKey": flag_key,
100+
"banditKey": bandit_key,
101+
"subject": subject_key,
102+
"action": action,
103+
"actionProbability": action_probability,
104+
"optimalityGap": optimality_gap,
105+
"modelVersion": model_version,
106+
"timestamp": timestamp,
107+
"subjectNumericAttributes": {},
108+
"subjectCategoricalAttributes": {},
109+
"actionNumericAttributes": {},
110+
"actionCategoricalAttributes": {},
111+
"metaData": meta_data,
112+
}

0 commit comments

Comments
 (0)