Skip to content

feat(feature_flags): add modulo range condition for segmented experimentation support #2331

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Jun 16, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
from datetime import datetime, tzinfo
from typing import Dict, Optional
from typing import Any, Dict, Optional

from dateutil.tz import gettz

from .schema import HOUR_MIN_SEPARATOR, TimeValues
from .schema import HOUR_MIN_SEPARATOR, ModuloRangeValues, TimeValues


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


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

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

days = values.get(TimeValues.DAYS.value, [])
days = condition_value.get(TimeValues.DAYS.value, [])
return current_day in days


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

start_date_str = values.get(TimeValues.START.value, "")
end_date_str = values.get(TimeValues.END.value, "")
start_date_str = condition_value.get(TimeValues.START.value, "")
end_date_str = condition_value.get(TimeValues.END.value, "")

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


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

start_hour, start_min = values.get(TimeValues.START.value, "").split(HOUR_MIN_SEPARATOR)
end_hour, end_min = values.get(TimeValues.END.value, "").split(HOUR_MIN_SEPARATOR)
start_hour, start_min = condition_value.get(TimeValues.START.value, "").split(HOUR_MIN_SEPARATOR)
end_hour, end_min = condition_value.get(TimeValues.END.value, "").split(HOUR_MIN_SEPARATOR)

start_time = current_time.replace(hour=int(start_hour), minute=int(start_min))
end_time = current_time.replace(hour=int(end_hour), minute=int(end_min))
Expand All @@ -71,3 +71,14 @@ def compare_time_range(action: str, values: Dict) -> bool:
else:
# In normal circumstances, we need to assert **both** conditions
return start_time <= current_time <= end_time


def compare_modulo_range(context_value: int, condition_value: Dict) -> bool:
"""
Returns for a given context 'a' and modulo condition 'b' -> b.start <= a % b.base <= b.end
"""
base = condition_value.get(ModuloRangeValues.BASE.value, 1)
start = condition_value.get(ModuloRangeValues.START.value, 1)
end = condition_value.get(ModuloRangeValues.END.value, 1)

return start <= context_value % base <= end
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@
from ...shared.types import JSONType
from . import schema
from .base import StoreProvider
from .exceptions import ConfigurationStoreError
from .time_conditions import (
from .comparators import (
compare_datetime_range,
compare_days_of_week,
compare_modulo_range,
compare_time_range,
)
from .exceptions import ConfigurationStoreError


class FeatureFlags:
Expand Down Expand Up @@ -65,6 +66,7 @@ def _match_by_action(self, action: str, condition_value: Any, context_value: Any
schema.RuleAction.SCHEDULE_BETWEEN_TIME_RANGE.value: lambda a, b: compare_time_range(a, b),
schema.RuleAction.SCHEDULE_BETWEEN_DATETIME_RANGE.value: lambda a, b: compare_datetime_range(a, b),
schema.RuleAction.SCHEDULE_BETWEEN_DAYS_OF_WEEK.value: lambda a, b: compare_days_of_week(a, b),
schema.RuleAction.MODULO_RANGE.value: lambda a, b: compare_modulo_range(a, b),
}

try:
Expand Down
33 changes: 33 additions & 0 deletions aws_lambda_powertools/utilities/feature_flags/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ class RuleAction(str, Enum):
SCHEDULE_BETWEEN_TIME_RANGE = "SCHEDULE_BETWEEN_TIME_RANGE" # hour:min 24 hours clock
SCHEDULE_BETWEEN_DATETIME_RANGE = "SCHEDULE_BETWEEN_DATETIME_RANGE" # full datetime format, excluding timezone
SCHEDULE_BETWEEN_DAYS_OF_WEEK = "SCHEDULE_BETWEEN_DAYS_OF_WEEK" # MONDAY, TUESDAY, .... see TimeValues enum
MODULO_RANGE = "MODULO_RANGE"


class TimeKeys(Enum):
Expand Down Expand Up @@ -71,6 +72,16 @@ class TimeValues(Enum):
SATURDAY = "SATURDAY"


class ModuloRangeValues(Enum):
"""
Possible values when using modulo range rule
"""

BASE = "BASE"
START = "START"
END = "END"


class SchemaValidator(BaseValidator):
"""Validates feature flag schema configuration

Expand Down Expand Up @@ -341,6 +352,9 @@ def validate_condition_value(condition: Dict[str, Any], rule_name: str):
)
elif action == RuleAction.SCHEDULE_BETWEEN_DAYS_OF_WEEK.value:
ConditionsValidator._validate_schedule_between_days_of_week(value, rule_name)
# modulo range condition needs validation on base, start, and end attributes
elif action == RuleAction.MODULO_RANGE.value:
ConditionsValidator._validate_modulo_range(value, rule_name)

@staticmethod
def _validate_datetime_value(datetime_str: str, rule_name: str):
Expand Down Expand Up @@ -434,3 +448,22 @@ def _validate_schedule_between_time_and_datetime_ranges(
# try to see if the timezone string corresponds to any known timezone
if not tz.gettz(timezone):
raise SchemaValidationError(f"'TIMEZONE' value must represent a valid IANA timezone, rule={rule_name}")

@staticmethod
def _validate_modulo_range(value: Any, rule_name: str):
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
if not isinstance(value, dict):
raise SchemaValidationError(error_str)

base = value.get(ModuloRangeValues.BASE.value)
start = value.get(ModuloRangeValues.START.value)
end = value.get(ModuloRangeValues.END.value)
if base is None or start is None or end is None:
raise SchemaValidationError(error_str)
if not isinstance(base, int) or not isinstance(start, int) or not isinstance(end, int):
raise SchemaValidationError(f"'BASE', 'START' and 'END' must be integers, rule={rule_name}")

if not 0 <= start <= end <= base - 1:
raise SchemaValidationError(
f"condition with 'MODULO_RANGE' action must satisfy 0 <= START <= END <= BASE-1, rule={rule_name}"
)
53 changes: 51 additions & 2 deletions docs/utilities/feature_flags.md
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,54 @@ You can also have features enabled only at specific days, for example: enable ch
When using `SCHEDULE_BETWEEN_DATETIME_RANGE`, use timestamps without timezone information, and
specify the timezone manually. This way, you'll avoid hitting problems with day light savings.

### Modulo Range Segmented Experimentation

Feature flags can also be used to run experiments on a segment of users based on modulo range conditions on context variables.
This allows you to have features that are only enabled for a certain segment of users, comparing across multiple variants
of the same experiment.

Use cases:

* Enable an experiment for a percentage of users
* Scale up an experiment incrementally in production - canary release
* Run multiple experiments or variants simultaneously by assigning a spectrum segment to each experiment variant.

The modulo range condition takes three values - `BASE`, `START` and `END`.

The condition evaluates `START <= CONTEXT_VALUE % BASE <= END`.

=== "modulo_range_feature.py"

```python hl_lines="1 6 38"
--8<-- "examples/feature_flags/src/modulo_range_feature.py"
```

=== "modulo_range_feature_event.json"

```json hl_lines="2"
--8<-- "examples/feature_flags/src/modulo_range_feature_event.json"
```

=== "modulo_range_features.json"

```json hl_lines="13-21"
--8<-- "examples/feature_flags/src/modulo_range_features.json"
```

You can run multiple experiments on your users with the spectrum of your choice.

=== "modulo_range_multiple_feature.py"

```python hl_lines="1 6 67"
--8<-- "examples/feature_flags/src/modulo_range_multiple_feature.py"
```

=== "modulo_range_multiple_features.json"

```json hl_lines="9-16 23-31 37-45"
--8<-- "examples/feature_flags/src/modulo_range_multiple_features.json"
```

### Beyond boolean feature flags

???+ info "When is this useful?"
Expand Down Expand Up @@ -385,9 +433,10 @@ The `action` configuration can have the following values, where the expressions
| **KEY_NOT_IN_VALUE** | `lambda a, b: a not in b` |
| **VALUE_IN_KEY** | `lambda a, b: b in a` |
| **VALUE_NOT_IN_KEY** | `lambda a, b: b not in a` |
| **SCHEDULE_BETWEEN_TIME_RANGE** | `lambda a, b: time(a).start <= b <= time(a).end` |
| **SCHEDULE_BETWEEN_DATETIME_RANGE** | `lambda a, b: datetime(a).start <= b <= datetime(b).end` |
| **SCHEDULE_BETWEEN_TIME_RANGE** | `lambda a, b: b.start <= time(a) <= b.end` |
| **SCHEDULE_BETWEEN_DATETIME_RANGE** | `lambda a, b: b.start <= datetime(a) <= b.end` |
| **SCHEDULE_BETWEEN_DAYS_OF_WEEK** | `lambda a, b: day_of_week(a) in b` |
| **MODULO_RANGE** | `lambda a, b: b.start <= a % b.base <= b.end` |

???+ info
The `key` and `value` will be compared to the input from the `context` parameter.
Expand Down
44 changes: 44 additions & 0 deletions examples/feature_flags/src/modulo_range_feature.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
from aws_lambda_powertools.utilities.feature_flags import AppConfigStore, FeatureFlags
from aws_lambda_powertools.utilities.typing import LambdaContext

app_config = AppConfigStore(environment="dev", application="product-catalogue", name="features")

feature_flags = FeatureFlags(store=app_config)


def lambda_handler(event: dict, context: LambdaContext):
"""
This feature flag is enabled under the following conditions:
- The request payload contains a field 'tier' with the value 'standard'.
- If the user_id belongs to the spectrum 0-19 modulo 100, (20% users) on whom we want to run the sale experiment.

Rule condition to be evaluated:
"conditions": [
{
"action": "EQUALS",
"key": "tier",
"value": "standard"
},
{
"action": "MODULO_RANGE",
"key": "user_id",
"value": {
"BASE": 100,
"START": 0,
"END": 19
}
}
]
"""

# Get customer's tier and identifier from incoming request
ctx = {"tier": event.get("tier", "standard"), "user_id": event.get("user_id", 0)}

# Checking if the sale_experiment is enable
sale_experiment = feature_flags.evaluate(name="sale_experiment", default=False, context=ctx)

if sale_experiment:
# Enable special discount for sale experiment segment users:
return {"message": "The sale experiment is enabled."}

return {"message": "The sale experiment is not enabled."}
5 changes: 5 additions & 0 deletions examples/feature_flags/src/modulo_range_feature_event.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"user_id": 134532511,
"tier": "standard",
"basked_id": "random_id"
}
26 changes: 26 additions & 0 deletions examples/feature_flags/src/modulo_range_features.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"sale_experiment": {
"default": false,
"rules": {
"experiment 1 segment - 20% users": {
"when_match": true,
"conditions": [
{
"action": "EQUALS",
"key": "tier",
"value": "standard"
},
{
"action": "MODULO_RANGE",
"key": "user_id",
"value": {
"BASE": 100,
"START": 0,
"END": 19
}
}
]
}
}
}
}
69 changes: 69 additions & 0 deletions examples/feature_flags/src/modulo_range_multiple_feature.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
from aws_lambda_powertools.utilities.feature_flags import AppConfigStore, FeatureFlags
from aws_lambda_powertools.utilities.typing import LambdaContext

app_config = AppConfigStore(environment="dev", application="product-catalogue", name="features")

feature_flags = FeatureFlags(store=app_config)


def lambda_handler(event: dict, context: LambdaContext):
"""
This non-boolean feature flag returns the percentage discount depending on the sale experiment segment:
- 10% standard discount if the user_id belongs to the spectrum 0-3 modulo 10, (40% users).
- 15% experiment discount if the user_id belongs to the spectrum 4-6 modulo 10, (30% users).
- 18% experiment discount if the user_id belongs to the spectrum 7-9 modulo 10, (30% users).

Rule conditions to be evaluated:
"rules": {
"control group - standard 10% discount segment": {
"when_match": 10,
"conditions": [
{
"action": "MODULO_RANGE",
"key": "user_id",
"value": {
"BASE": 10,
"START": 0,
"END": 3
}
}
]
},
"test experiment 1 - 15% discount segment": {
"when_match": 15,
"conditions": [
{
"action": "MODULO_RANGE",
"key": "user_id",
"value": {
"BASE": 10,
"START": 4,
"END": 6
}
}
]
},
"test experiment 2 - 18% discount segment": {
"when_match": 18,
"conditions": [
{
"action": "MODULO_RANGE",
"key": "user_id",
"value": {
"BASE": 10,
"START": 7,
"END": 9
}
}
]
}
}
"""

# Get customer's tier and identifier from incoming request
ctx = {"tier": event.get("tier", "standard"), "user_id": event.get("user_id", 0)}

# Get sale discount percentage from feature flag.
sale_experiment_discount = feature_flags.evaluate(name="sale_experiment_discount", default=0, context=ctx)

return {"message": f" {sale_experiment_discount}% discount applied."}
Loading