Skip to content

Commit 363fffb

Browse files
ajwad-shaikhleandrodamascenarubenfonseca
authored andcommitted
feat(feature_flags): add modulo range condition for segmented experimentation support (#2331)
Co-authored-by: Leandro Damascena <[email protected]> Co-authored-by: Ruben Fonseca <[email protected]>
1 parent 4e65240 commit 363fffb

12 files changed

+545
-18
lines changed

aws_lambda_powertools/utilities/feature_flags/time_conditions.py renamed to aws_lambda_powertools/utilities/feature_flags/comparators.py

+24-13
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
from datetime import datetime, tzinfo
2-
from typing import Dict, Optional
2+
from typing import Any, Dict, Optional
33

44
from dateutil.tz import gettz
55

6-
from .schema import HOUR_MIN_SEPARATOR, TimeValues
6+
from .schema import HOUR_MIN_SEPARATOR, ModuloRangeValues, TimeValues
77

88

99
def _get_now_from_timezone(timezone: Optional[tzinfo]) -> datetime:
@@ -16,23 +16,23 @@ def _get_now_from_timezone(timezone: Optional[tzinfo]) -> datetime:
1616
return datetime.now(timezone)
1717

1818

19-
def compare_days_of_week(action: str, values: Dict) -> bool:
20-
timezone_name = values.get(TimeValues.TIMEZONE.value, "UTC")
19+
def compare_days_of_week(context_value: Any, condition_value: Dict) -> bool:
20+
timezone_name = condition_value.get(TimeValues.TIMEZONE.value, "UTC")
2121

2222
# %A = Weekday as locale’s full name.
2323
current_day = _get_now_from_timezone(gettz(timezone_name)).strftime("%A").upper()
2424

25-
days = values.get(TimeValues.DAYS.value, [])
25+
days = condition_value.get(TimeValues.DAYS.value, [])
2626
return current_day in days
2727

2828

29-
def compare_datetime_range(action: str, values: Dict) -> bool:
30-
timezone_name = values.get(TimeValues.TIMEZONE.value, "UTC")
29+
def compare_datetime_range(context_value: Any, condition_value: Dict) -> bool:
30+
timezone_name = condition_value.get(TimeValues.TIMEZONE.value, "UTC")
3131
timezone = gettz(timezone_name)
3232
current_time: datetime = _get_now_from_timezone(timezone)
3333

34-
start_date_str = values.get(TimeValues.START.value, "")
35-
end_date_str = values.get(TimeValues.END.value, "")
34+
start_date_str = condition_value.get(TimeValues.START.value, "")
35+
end_date_str = condition_value.get(TimeValues.END.value, "")
3636

3737
# Since start_date and end_date doesn't include timezone information, we mark the timestamp
3838
# with the same timezone as the current_time. This way all the 3 timestamps will be on
@@ -42,12 +42,12 @@ def compare_datetime_range(action: str, values: Dict) -> bool:
4242
return start_date <= current_time <= end_date
4343

4444

45-
def compare_time_range(action: str, values: Dict) -> bool:
46-
timezone_name = values.get(TimeValues.TIMEZONE.value, "UTC")
45+
def compare_time_range(context_value: Any, condition_value: Dict) -> bool:
46+
timezone_name = condition_value.get(TimeValues.TIMEZONE.value, "UTC")
4747
current_time: datetime = _get_now_from_timezone(gettz(timezone_name))
4848

49-
start_hour, start_min = values.get(TimeValues.START.value, "").split(HOUR_MIN_SEPARATOR)
50-
end_hour, end_min = values.get(TimeValues.END.value, "").split(HOUR_MIN_SEPARATOR)
49+
start_hour, start_min = condition_value.get(TimeValues.START.value, "").split(HOUR_MIN_SEPARATOR)
50+
end_hour, end_min = condition_value.get(TimeValues.END.value, "").split(HOUR_MIN_SEPARATOR)
5151

5252
start_time = current_time.replace(hour=int(start_hour), minute=int(start_min))
5353
end_time = current_time.replace(hour=int(end_hour), minute=int(end_min))
@@ -71,3 +71,14 @@ def compare_time_range(action: str, values: Dict) -> bool:
7171
else:
7272
# In normal circumstances, we need to assert **both** conditions
7373
return start_time <= current_time <= end_time
74+
75+
76+
def compare_modulo_range(context_value: int, condition_value: Dict) -> bool:
77+
"""
78+
Returns for a given context 'a' and modulo condition 'b' -> b.start <= a % b.base <= b.end
79+
"""
80+
base = condition_value.get(ModuloRangeValues.BASE.value, 1)
81+
start = condition_value.get(ModuloRangeValues.START.value, 1)
82+
end = condition_value.get(ModuloRangeValues.END.value, 1)
83+
84+
return start <= context_value % base <= end

aws_lambda_powertools/utilities/feature_flags/feature_flags.py

+4-2
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,13 @@
55
from ...shared.types import JSONType
66
from . import schema
77
from .base import StoreProvider
8-
from .exceptions import ConfigurationStoreError
9-
from .time_conditions import (
8+
from .comparators import (
109
compare_datetime_range,
1110
compare_days_of_week,
11+
compare_modulo_range,
1212
compare_time_range,
1313
)
14+
from .exceptions import ConfigurationStoreError
1415

1516

1617
class FeatureFlags:
@@ -65,6 +66,7 @@ def _match_by_action(self, action: str, condition_value: Any, context_value: Any
6566
schema.RuleAction.SCHEDULE_BETWEEN_TIME_RANGE.value: lambda a, b: compare_time_range(a, b),
6667
schema.RuleAction.SCHEDULE_BETWEEN_DATETIME_RANGE.value: lambda a, b: compare_datetime_range(a, b),
6768
schema.RuleAction.SCHEDULE_BETWEEN_DAYS_OF_WEEK.value: lambda a, b: compare_days_of_week(a, b),
69+
schema.RuleAction.MODULO_RANGE.value: lambda a, b: compare_modulo_range(a, b),
6870
}
6971

7072
try:

aws_lambda_powertools/utilities/feature_flags/schema.py

+33
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ class RuleAction(str, Enum):
4141
SCHEDULE_BETWEEN_TIME_RANGE = "SCHEDULE_BETWEEN_TIME_RANGE" # hour:min 24 hours clock
4242
SCHEDULE_BETWEEN_DATETIME_RANGE = "SCHEDULE_BETWEEN_DATETIME_RANGE" # full datetime format, excluding timezone
4343
SCHEDULE_BETWEEN_DAYS_OF_WEEK = "SCHEDULE_BETWEEN_DAYS_OF_WEEK" # MONDAY, TUESDAY, .... see TimeValues enum
44+
MODULO_RANGE = "MODULO_RANGE"
4445

4546

4647
class TimeKeys(Enum):
@@ -71,6 +72,16 @@ class TimeValues(Enum):
7172
SATURDAY = "SATURDAY"
7273

7374

75+
class ModuloRangeValues(Enum):
76+
"""
77+
Possible values when using modulo range rule
78+
"""
79+
80+
BASE = "BASE"
81+
START = "START"
82+
END = "END"
83+
84+
7485
class SchemaValidator(BaseValidator):
7586
"""Validates feature flag schema configuration
7687
@@ -341,6 +352,9 @@ def validate_condition_value(condition: Dict[str, Any], rule_name: str):
341352
)
342353
elif action == RuleAction.SCHEDULE_BETWEEN_DAYS_OF_WEEK.value:
343354
ConditionsValidator._validate_schedule_between_days_of_week(value, rule_name)
355+
# modulo range condition needs validation on base, start, and end attributes
356+
elif action == RuleAction.MODULO_RANGE.value:
357+
ConditionsValidator._validate_modulo_range(value, rule_name)
344358

345359
@staticmethod
346360
def _validate_datetime_value(datetime_str: str, rule_name: str):
@@ -434,3 +448,22 @@ def _validate_schedule_between_time_and_datetime_ranges(
434448
# try to see if the timezone string corresponds to any known timezone
435449
if not tz.gettz(timezone):
436450
raise SchemaValidationError(f"'TIMEZONE' value must represent a valid IANA timezone, rule={rule_name}")
451+
452+
@staticmethod
453+
def _validate_modulo_range(value: Any, rule_name: str):
454+
error_str = f"condition with a 'MODULO_RANGE' action must have a condition value type dictionary with 'BASE', 'START' and 'END' keys, rule={rule_name}" # noqa: E501
455+
if not isinstance(value, dict):
456+
raise SchemaValidationError(error_str)
457+
458+
base = value.get(ModuloRangeValues.BASE.value)
459+
start = value.get(ModuloRangeValues.START.value)
460+
end = value.get(ModuloRangeValues.END.value)
461+
if base is None or start is None or end is None:
462+
raise SchemaValidationError(error_str)
463+
if not isinstance(base, int) or not isinstance(start, int) or not isinstance(end, int):
464+
raise SchemaValidationError(f"'BASE', 'START' and 'END' must be integers, rule={rule_name}")
465+
466+
if not 0 <= start <= end <= base - 1:
467+
raise SchemaValidationError(
468+
f"condition with 'MODULO_RANGE' action must satisfy 0 <= START <= END <= BASE-1, rule={rule_name}"
469+
)

docs/utilities/feature_flags.md

+51-2
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,54 @@ You can also have features enabled only at specific days, for example: enable ch
262262
When using `SCHEDULE_BETWEEN_DATETIME_RANGE`, use timestamps without timezone information, and
263263
specify the timezone manually. This way, you'll avoid hitting problems with day light savings.
264264

265+
### Modulo Range Segmented Experimentation
266+
267+
Feature flags can also be used to run experiments on a segment of users based on modulo range conditions on context variables.
268+
This allows you to have features that are only enabled for a certain segment of users, comparing across multiple variants
269+
of the same experiment.
270+
271+
Use cases:
272+
273+
* Enable an experiment for a percentage of users
274+
* Scale up an experiment incrementally in production - canary release
275+
* Run multiple experiments or variants simultaneously by assigning a spectrum segment to each experiment variant.
276+
277+
The modulo range condition takes three values - `BASE`, `START` and `END`.
278+
279+
The condition evaluates `START <= CONTEXT_VALUE % BASE <= END`.
280+
281+
=== "modulo_range_feature.py"
282+
283+
```python hl_lines="1 6 38"
284+
--8<-- "examples/feature_flags/src/modulo_range_feature.py"
285+
```
286+
287+
=== "modulo_range_feature_event.json"
288+
289+
```json hl_lines="2"
290+
--8<-- "examples/feature_flags/src/modulo_range_feature_event.json"
291+
```
292+
293+
=== "modulo_range_features.json"
294+
295+
```json hl_lines="13-21"
296+
--8<-- "examples/feature_flags/src/modulo_range_features.json"
297+
```
298+
299+
You can run multiple experiments on your users with the spectrum of your choice.
300+
301+
=== "modulo_range_multiple_feature.py"
302+
303+
```python hl_lines="1 6 67"
304+
--8<-- "examples/feature_flags/src/modulo_range_multiple_feature.py"
305+
```
306+
307+
=== "modulo_range_multiple_features.json"
308+
309+
```json hl_lines="9-16 23-31 37-45"
310+
--8<-- "examples/feature_flags/src/modulo_range_multiple_features.json"
311+
```
312+
265313
### Beyond boolean feature flags
266314

267315
???+ info "When is this useful?"
@@ -385,9 +433,10 @@ The `action` configuration can have the following values, where the expressions
385433
| **KEY_NOT_IN_VALUE** | `lambda a, b: a not in b` |
386434
| **VALUE_IN_KEY** | `lambda a, b: b in a` |
387435
| **VALUE_NOT_IN_KEY** | `lambda a, b: b not in a` |
388-
| **SCHEDULE_BETWEEN_TIME_RANGE** | `lambda a, b: time(a).start <= b <= time(a).end` |
389-
| **SCHEDULE_BETWEEN_DATETIME_RANGE** | `lambda a, b: datetime(a).start <= b <= datetime(b).end` |
436+
| **SCHEDULE_BETWEEN_TIME_RANGE** | `lambda a, b: b.start <= time(a) <= b.end` |
437+
| **SCHEDULE_BETWEEN_DATETIME_RANGE** | `lambda a, b: b.start <= datetime(a) <= b.end` |
390438
| **SCHEDULE_BETWEEN_DAYS_OF_WEEK** | `lambda a, b: day_of_week(a) in b` |
439+
| **MODULO_RANGE** | `lambda a, b: b.start <= a % b.base <= b.end` |
391440

392441
???+ info
393442
The `key` and `value` will be compared to the input from the `context` parameter.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
from aws_lambda_powertools.utilities.feature_flags import AppConfigStore, FeatureFlags
2+
from aws_lambda_powertools.utilities.typing import LambdaContext
3+
4+
app_config = AppConfigStore(environment="dev", application="product-catalogue", name="features")
5+
6+
feature_flags = FeatureFlags(store=app_config)
7+
8+
9+
def lambda_handler(event: dict, context: LambdaContext):
10+
"""
11+
This feature flag is enabled under the following conditions:
12+
- The request payload contains a field 'tier' with the value 'standard'.
13+
- If the user_id belongs to the spectrum 0-19 modulo 100, (20% users) on whom we want to run the sale experiment.
14+
15+
Rule condition to be evaluated:
16+
"conditions": [
17+
{
18+
"action": "EQUALS",
19+
"key": "tier",
20+
"value": "standard"
21+
},
22+
{
23+
"action": "MODULO_RANGE",
24+
"key": "user_id",
25+
"value": {
26+
"BASE": 100,
27+
"START": 0,
28+
"END": 19
29+
}
30+
}
31+
]
32+
"""
33+
34+
# Get customer's tier and identifier from incoming request
35+
ctx = {"tier": event.get("tier", "standard"), "user_id": event.get("user_id", 0)}
36+
37+
# Checking if the sale_experiment is enable
38+
sale_experiment = feature_flags.evaluate(name="sale_experiment", default=False, context=ctx)
39+
40+
if sale_experiment:
41+
# Enable special discount for sale experiment segment users:
42+
return {"message": "The sale experiment is enabled."}
43+
44+
return {"message": "The sale experiment is not enabled."}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"user_id": 134532511,
3+
"tier": "standard",
4+
"basked_id": "random_id"
5+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
{
2+
"sale_experiment": {
3+
"default": false,
4+
"rules": {
5+
"experiment 1 segment - 20% users": {
6+
"when_match": true,
7+
"conditions": [
8+
{
9+
"action": "EQUALS",
10+
"key": "tier",
11+
"value": "standard"
12+
},
13+
{
14+
"action": "MODULO_RANGE",
15+
"key": "user_id",
16+
"value": {
17+
"BASE": 100,
18+
"START": 0,
19+
"END": 19
20+
}
21+
}
22+
]
23+
}
24+
}
25+
}
26+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
from aws_lambda_powertools.utilities.feature_flags import AppConfigStore, FeatureFlags
2+
from aws_lambda_powertools.utilities.typing import LambdaContext
3+
4+
app_config = AppConfigStore(environment="dev", application="product-catalogue", name="features")
5+
6+
feature_flags = FeatureFlags(store=app_config)
7+
8+
9+
def lambda_handler(event: dict, context: LambdaContext):
10+
"""
11+
This non-boolean feature flag returns the percentage discount depending on the sale experiment segment:
12+
- 10% standard discount if the user_id belongs to the spectrum 0-3 modulo 10, (40% users).
13+
- 15% experiment discount if the user_id belongs to the spectrum 4-6 modulo 10, (30% users).
14+
- 18% experiment discount if the user_id belongs to the spectrum 7-9 modulo 10, (30% users).
15+
16+
Rule conditions to be evaluated:
17+
"rules": {
18+
"control group - standard 10% discount segment": {
19+
"when_match": 10,
20+
"conditions": [
21+
{
22+
"action": "MODULO_RANGE",
23+
"key": "user_id",
24+
"value": {
25+
"BASE": 10,
26+
"START": 0,
27+
"END": 3
28+
}
29+
}
30+
]
31+
},
32+
"test experiment 1 - 15% discount segment": {
33+
"when_match": 15,
34+
"conditions": [
35+
{
36+
"action": "MODULO_RANGE",
37+
"key": "user_id",
38+
"value": {
39+
"BASE": 10,
40+
"START": 4,
41+
"END": 6
42+
}
43+
}
44+
]
45+
},
46+
"test experiment 2 - 18% discount segment": {
47+
"when_match": 18,
48+
"conditions": [
49+
{
50+
"action": "MODULO_RANGE",
51+
"key": "user_id",
52+
"value": {
53+
"BASE": 10,
54+
"START": 7,
55+
"END": 9
56+
}
57+
}
58+
]
59+
}
60+
}
61+
"""
62+
63+
# Get customer's tier and identifier from incoming request
64+
ctx = {"tier": event.get("tier", "standard"), "user_id": event.get("user_id", 0)}
65+
66+
# Get sale discount percentage from feature flag.
67+
sale_experiment_discount = feature_flags.evaluate(name="sale_experiment_discount", default=0, context=ctx)
68+
69+
return {"message": f" {sale_experiment_discount}% discount applied."}

0 commit comments

Comments
 (0)