Skip to content

Commit 8a57489

Browse files
authored
feat: evaluation v2 (#41)
1 parent a382ac6 commit 8a57489

32 files changed

+937
-286
lines changed

src/amplitude_experiment/__init__.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,4 @@
1212
from .cookie import AmplitudeCookie
1313
from .local.client import LocalEvaluationClient
1414
from .local.config import LocalEvaluationConfig
15-
from .flagresult import FlagResult
1615
from .assignment import AssignmentConfig
Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
import time
22
from typing import Dict
33

4-
from ..flagresult import FlagResult
4+
from .. import Variant
55
from ..user import User
66

77
DAY_MILLIS = 24 * 60 * 60 * 1000
88

99

1010
class Assignment:
1111

12-
def __init__(self, user: User, results: Dict[str, FlagResult]):
12+
def __init__(self, user: User, results: Dict[str, Variant]):
1313
self.user = user
1414
self.results = results
1515
self.timestamp = time.time() * 1000
@@ -18,8 +18,10 @@ def canonicalize(self) -> str:
1818
user = self.user.user_id.strip() if self.user.user_id else 'None'
1919
device = self.user.device_id.strip() if self.user.device_id else 'None'
2020
canonical = user + ' ' + device + ' '
21-
for key in sorted(self.results):
22-
value = self.results[key].variant['key'].strip() if self.results[key] and self.results[key].variant and \
23-
self.results[key].variant.get('key') else 'None'
24-
canonical += key.strip() + ' ' + value + ' '
21+
for flag_key in sorted(self.results):
22+
variant = self.results[flag_key]
23+
if variant.key is None:
24+
continue
25+
value = self.results[flag_key].key.strip()
26+
canonical += flag_key.strip() + ' ' + value + ' '
2527
return canonical

src/amplitude_experiment/assignment/assignment_service.py

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,23 +11,35 @@
1111
def to_event(assignment: Assignment) -> BaseEvent:
1212
event = BaseEvent(event_type='[Experiment] Assignment', user_id=assignment.user.user_id,
1313
device_id=assignment.user.device_id, event_properties={}, user_properties={})
14-
for key in sorted(assignment.results):
15-
event.event_properties[key + '.variant'] = assignment.results[key].variant['key']
16-
1714
set_props = {}
1815
unset_props = {}
1916

20-
for key in sorted(assignment.results):
21-
if assignment.results[key].type == FLAG_TYPE_MUTUAL_EXCLUSION_GROUP:
17+
for flag_key in sorted(assignment.results):
18+
variant = assignment.results[flag_key]
19+
if variant.key is None:
20+
continue
21+
# Get variant metadata
22+
version: int = variant.metadata.get('flagVersion') if variant.metadata is not None else None
23+
segment_name: str = variant.metadata.get('segmentName') if variant.metadata is not None else None
24+
flag_type: str = variant.metadata.get('flagType') if variant.metadata is not None else None
25+
default: bool = False
26+
if variant.metadata is not None and variant.metadata.get('default') is not None:
27+
default = variant.metadata.get('default')
28+
# Set event properties
29+
event.event_properties[flag_key + '.variant'] = variant.key
30+
if version is not None and segment_name is not None:
31+
event.event_properties[flag_key + '.details'] = f"v{version} rule:{segment_name}"
32+
# Build user properties
33+
if flag_type == FLAG_TYPE_MUTUAL_EXCLUSION_GROUP:
2234
continue
23-
elif assignment.results[key].is_default_variant:
24-
unset_props[f'[Experiment] {key}'] = '-'
35+
elif default:
36+
unset_props[f'[Experiment] {flag_key}'] = '-'
2537
else:
26-
set_props[f'[Experiment] {key}'] = assignment.results[key].variant['key']
38+
set_props[f'[Experiment] {flag_key}'] = variant.key
2739

40+
# Set user properties and insert id
2841
event.user_properties['$set'] = set_props
2942
event.user_properties['$unset'] = unset_props
30-
3143
event.insert_id = f'{event.user_id} {event.device_id} {hash_code(assignment.canonicalize())} {int(assignment.timestamp / DAY_MILLIS)}'
3244

3345
return event

src/amplitude_experiment/flagresult.py

Lines changed: 0 additions & 8 deletions
This file was deleted.

src/amplitude_experiment/local/client.py

Lines changed: 67 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,20 @@
11
import json
22
import logging
33
from threading import Lock
4-
from typing import Any, List, Dict
4+
from typing import Any, List, Dict, Set
55

66
from amplitude import Amplitude
77

88
from .config import LocalEvaluationConfig
9-
from ..flagresult import FlagResult
9+
from .topological_sort import topological_sort
1010
from ..assignment import Assignment, AssignmentFilter, AssignmentService
11-
from ..assignment.assignment_service import FLAG_TYPE_MUTUAL_EXCLUSION_GROUP, FLAG_TYPE_HOLDOUT_GROUP
1211
from ..user import User
1312
from ..connection_pool import HTTPConnectionPool
1413
from .poller import Poller
1514
from .evaluation.evaluation import evaluate
15+
from ..util import deprecated
16+
from ..util.user import user_to_evaluation_context
17+
from ..util.variant import evaluation_variants_json_to_variants
1618
from ..variant import Variant
1719
from ..version import __version__
1820

@@ -57,37 +59,62 @@ def start(self):
5759
self.__do_flags()
5860
self.poller.start()
5961

60-
def evaluate(self, user: User, flag_keys: List[str] = None) -> Dict[str, Variant]:
62+
def evaluate_v2(self, user: User, flag_keys: Set[str] = None) -> Dict[str, Variant]:
6163
"""
62-
Locally evaluates flag variants for a user.
63-
Parameters:
64+
Locally evaluates flag variants for a user.
65+
66+
This function will only evaluate flags for the keys specified in the flag_keys argument. If flag_keys is
67+
missing or None, all flags are evaluated. This function differs from evaluate as it will return a default
68+
variant object if the flag was evaluated but the user was not assigned (i.e. off).
69+
70+
Parameters:
6471
user (User): The user to evaluate
65-
flag_keys (List[str]): The flags to evaluate with the user. If empty, all flags from the flag cache are evaluated.
72+
flag_keys (List[str]): The flags to evaluate with the user. If empty, all flags are evaluated.
6673
6774
Returns:
6875
The evaluated variants.
6976
"""
70-
variants = {}
7177
if self.flags is None or len(self.flags) == 0:
72-
return variants
73-
user_json = str(user)
74-
self.logger.debug(f"[Experiment] Evaluate: User: {user_json} - Flags: {self.flags}")
75-
result_json = evaluate(self.flags, user_json)
78+
return {}
79+
self.logger.debug(f"[Experiment] Evaluate: user={user} - Flags: {self.flags}")
80+
context = user_to_evaluation_context(user)
81+
sorted_flags = topological_sort(self.flags, flag_keys)
82+
flags_json = json.dumps(sorted_flags)
83+
context_json = json.dumps(context)
84+
result_json = evaluate(flags_json, context_json)
7685
self.logger.debug(f"[Experiment] Evaluate Result: {result_json}")
7786
evaluation_result = json.loads(result_json)
78-
filter_result = flag_keys is not None
79-
assignment_result = {}
80-
for key, value in evaluation_result.items():
81-
included = not filter_result or key in flag_keys
82-
if not value.get('isDefaultVariant') and included:
83-
variants[key] = Variant(value['variant'].get('key'), value['variant'].get('payload'))
84-
if included or evaluation_result[key]['type'] == FLAG_TYPE_MUTUAL_EXCLUSION_GROUP or \
85-
evaluation_result[key]['type'] == FLAG_TYPE_HOLDOUT_GROUP:
86-
assignment_result[key] = FlagResult(value)
87-
if self.assignment_service:
88-
self.assignment_service.track(Assignment(user, assignment_result))
87+
error = evaluation_result.get('error')
88+
if error is not None:
89+
self.logger.error(f"[Experiment] Evaluation failed: {error}")
90+
return {}
91+
result = evaluation_result.get('result')
92+
if result is None:
93+
return {}
94+
variants = evaluation_variants_json_to_variants(result)
95+
if self.assignment_service is not None:
96+
self.assignment_service.track(Assignment(user, variants))
8997
return variants
9098

99+
@deprecated("Use evaluate_v2")
100+
def evaluate(self, user: User, flag_keys: List[str] = None) -> Dict[str, Variant]:
101+
"""
102+
Locally evaluates flag variants for a user.
103+
104+
This function will only evaluate flags for the keys specified in the flag_keys argument. If flag_keys is
105+
missing, all flags are evaluated.
106+
107+
Parameters:
108+
user (User): The user to evaluate
109+
flag_keys (List[str]): The flags to evaluate with the user. If empty, all flags are evaluated.
110+
111+
Returns:
112+
The evaluated variants.
113+
"""
114+
flag_keys = set(flag_keys) if flag_keys is not None else None
115+
variants = self.evaluate_v2(user, flag_keys)
116+
return self.__filter_default_variants(variants)
117+
91118
def __do_flags(self):
92119
conn = self._connection_pool.acquire()
93120
headers = {
@@ -98,14 +125,16 @@ def __do_flags(self):
98125
body = None
99126
self.logger.debug('[Experiment] Get flag configs')
100127
try:
101-
response = conn.request('GET', '/sdk/v1/flags', body, headers)
128+
response = conn.request('GET', '/sdk/v2/flags?v=0', body, headers)
102129
response_body = response.read().decode("utf8")
103130
if response.status != 200:
104131
raise Exception(
105132
f"[Experiment] Get flagConfigs - received error response: ${response.status}: ${response_body}")
106-
self.logger.debug(f"[Experiment] Got flag configs: {response_body}")
133+
flags = json.loads(response_body)
134+
flags_dict = {flag['key']: flag for flag in flags}
135+
self.logger.debug(f"[Experiment] Got flag configs: {flags}")
107136
self.lock.acquire()
108-
self.flags = response_body
137+
self.flags = flags_dict
109138
self.lock.release()
110139
finally:
111140
self._connection_pool.release(conn)
@@ -128,3 +157,15 @@ def __enter__(self) -> 'LocalEvaluationClient':
128157

129158
def __exit__(self, *exit_info: Any) -> None:
130159
self.stop()
160+
161+
@staticmethod
162+
def __filter_default_variants(variants: Dict[str, Variant]) -> Dict[str, Variant]:
163+
def is_default_variant(variant: Variant) -> bool:
164+
default = False if variant.metadata.get('default') is None else variant.metadata.get('default')
165+
deployed = True if variant.metadata.get('deployed') is None else variant.metadata.get('deployed')
166+
return default or not deployed
167+
168+
return {key: variant for key, variant in variants.items() if not is_default_variant(variant)}
169+
170+
171+
Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
11
from .libevaluation_interop import libevaluation_interop_symbols
22
from ctypes import cast, c_char_p
33

4-
def evaluate(rules: str, user: str) -> str:
4+
5+
def evaluate(rules: str, context: str) -> str:
56
"""
67
Local evaluation wrapper.
78
Parameters:
89
rules (str): rules JSON string
9-
user (str): user JSON string
10+
context (str): context JSON string
1011
1112
Returns:
1213
Evaluation results with variants in JSON
1314
"""
14-
result = libevaluation_interop_symbols().contents.kotlin.root.evaluate(rules, user)
15+
result = libevaluation_interop_symbols().contents.kotlin.root.evaluate(rules, context)
1516
py_result = cast(result, c_char_p).value
1617
libevaluation_interop_symbols().contents.DisposeString(result)
1718
return str(py_result, 'utf-8')
Binary file not shown.

src/amplitude_experiment/local/evaluation/lib/linuxArm64/libevaluation_interop_api.h

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ typedef struct {
9999
/* User functions. */
100100
struct {
101101
struct {
102-
const char* (*evaluate)(const char* rules, const char* user);
102+
const char* (*evaluate)(const char* flags, const char* context);
103103
} root;
104104
} kotlin;
105105
} libevaluation_interop_ExportedSymbols;
Binary file not shown.

src/amplitude_experiment/local/evaluation/lib/linuxX64/libevaluation_interop_api.h

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ typedef struct {
9999
/* User functions. */
100100
struct {
101101
struct {
102-
const char* (*evaluate)(const char* rules, const char* user);
102+
const char* (*evaluate)(const char* flags, const char* context);
103103
} root;
104104
} kotlin;
105105
} libevaluation_interop_ExportedSymbols;

0 commit comments

Comments
 (0)