diff --git a/docs/utilities/feature_flags.md b/docs/utilities/feature_flags.md index 89393ddd54f..efe41c2f82f 100644 --- a/docs/utilities/feature_flags.md +++ b/docs/utilities/feature_flags.md @@ -6,7 +6,7 @@ description: Utility The feature flags utility provides a simple rule engine to define when one or multiple features should be enabled depending on the input. ???+ info - We currently only support AppConfig using [freeform configuration profile](https://docs.aws.amazon.com/appconfig/latest/userguide/appconfig-creating-configuration-and-profile.html#appconfig-creating-configuration-and-profile-free-form-configurations). + When using `AppConfigStore`, we currently only support AppConfig using [freeform configuration profile](https://docs.aws.amazon.com/appconfig/latest/userguide/appconfig-creating-configuration-and-profile.html#appconfig-creating-configuration-and-profile-free-form-configurations){target="_blank"} . ## Terminology @@ -24,94 +24,37 @@ Feature flags are used to modify behaviour without changing the application's co If you want to learn more about feature flags, their variations and trade-offs, check these articles: -* [Feature Toggles (aka Feature Flags) - Pete Hodgson](https://martinfowler.com/articles/feature-toggles.html) -* [AWS Lambda Feature Toggles Made Simple - Ran Isenberg](https://isenberg-ran.medium.com/aws-lambda-feature-toggles-made-simple-580b0c444233) -* [Feature Flags Getting Started - CloudBees](https://www.cloudbees.com/blog/ultimate-feature-flag-guide) +* [Feature Toggles (aka Feature Flags) - Pete Hodgson](https://martinfowler.com/articles/feature-toggles.html){target="_blank"} +* [AWS Lambda Feature Toggles Made Simple - Ran Isenberg](https://isenberg-ran.medium.com/aws-lambda-feature-toggles-made-simple-580b0c444233){target="_blank"} +* [Feature Flags Getting Started - CloudBees](https://www.cloudbees.com/blog/ultimate-feature-flag-guide){target="_blank"} ???+ note - AWS AppConfig requires two API calls to fetch configuration for the first time. You can improve latency by consolidating your feature settings in a single [Configuration](https://docs.aws.amazon.com/appconfig/latest/userguide/appconfig-creating-configuration-and-profile.html). + AWS AppConfig requires two API calls to fetch configuration for the first time. You can improve latency by consolidating your feature settings in a single [Configuration](https://docs.aws.amazon.com/appconfig/latest/userguide/appconfig-creating-configuration-and-profile.html){target="_blank"}. ## Key features * Define simple feature flags to dynamically decide when to enable a feature * Fetch one or all feature flags enabled for a given application context * Support for static feature flags to simply turn on/off a feature without rules +* Support for time based feature flags +* Bring your own Feature Flags Store Provider ## Getting started ### IAM Permissions -Your Lambda function IAM Role must have `appconfig:GetLatestConfiguration` and `appconfig:StartConfigurationSession` IAM permissions before using this feature. +When using the default store `AppConfigStore`, your Lambda function IAM Role must have `appconfig:GetLatestConfiguration` and `appconfig:StartConfigurationSession` IAM permissions before using this feature. ### Required resources -By default, this utility provides [AWS AppConfig](https://docs.aws.amazon.com/appconfig/latest/userguide/what-is-appconfig.html) as a configuration store. +By default, this utility provides [AWS AppConfig](https://docs.aws.amazon.com/appconfig/latest/userguide/what-is-appconfig.html){target="_blank"} as a configuration store. The following sample infrastructure will be used throughout this documentation: === "template.yaml" ```yaml hl_lines="5 11 18 25 31-50 54" - AWSTemplateFormatVersion: "2010-09-09" - Description: Lambda Powertools for Python Feature flags sample template - Resources: - FeatureStoreApp: - Type: AWS::AppConfig::Application - Properties: - Description: "AppConfig Application for feature toggles" - Name: product-catalogue - - FeatureStoreDevEnv: - Type: AWS::AppConfig::Environment - Properties: - ApplicationId: !Ref FeatureStoreApp - Description: "Development Environment for the App Config Store" - Name: dev - - FeatureStoreConfigProfile: - Type: AWS::AppConfig::ConfigurationProfile - Properties: - ApplicationId: !Ref FeatureStoreApp - Name: features - LocationUri: "hosted" - - HostedConfigVersion: - Type: AWS::AppConfig::HostedConfigurationVersion - Properties: - ApplicationId: !Ref FeatureStoreApp - ConfigurationProfileId: !Ref FeatureStoreConfigProfile - Description: 'A sample hosted configuration version' - Content: | - { - "premium_features": { - "default": false, - "rules": { - "customer tier equals premium": { - "when_match": true, - "conditions": [ - { - "action": "EQUALS", - "key": "tier", - "value": "premium" - } - ] - } - } - }, - "ten_percent_off_campaign": { - "default": false - } - } - ContentType: 'application/json' - - ConfigDeployment: - Type: AWS::AppConfig::Deployment - Properties: - ApplicationId: !Ref FeatureStoreApp - ConfigurationProfileId: !Ref FeatureStoreConfigProfile - ConfigurationVersion: !Ref HostedConfigVersion - DeploymentStrategyId: "AppConfig.AllAtOnce" - EnvironmentId: !Ref FeatureStoreDevEnv + --8<-- "examples/feature_flags/sam/template.yaml" ``` === "CDK" @@ -187,64 +130,21 @@ The `evaluate` method supports two optional parameters: * **context**: Value to be evaluated against each rule defined for the given feature * **default**: Sentinel value to use in case we experience any issues with our store, or feature doesn't exist -=== "app.py" - - ```python hl_lines="3 9 13 17-19" - from aws_lambda_powertools.utilities.feature_flags import FeatureFlags, AppConfigStore - - app_config = AppConfigStore( - environment="dev", - application="product-catalogue", - name="features" - ) - - feature_flags = FeatureFlags(store=app_config) +=== "getting_started_single_feature_flag.py" - def lambda_handler(event, context): - # Get customer's tier from incoming request - ctx = { "tier": event.get("tier", "standard") } - - # Evaluate whether customer's tier has access to premium features - # based on `has_premium_features` rules - has_premium_features: bool = feature_flags.evaluate(name="premium_features", - context=ctx, default=False) - if has_premium_features: - # enable premium features - ... + ```python hl_lines="3 6 8 27 31" + --8<-- "examples/feature_flags/src/getting_started_single_feature_flag.py" ``` -=== "event.json" +=== "getting_started_single_feature_flag_payload.json" ```json hl_lines="3" - { - "username": "lessa", - "tier": "premium", - "basked_id": "random_id" - } + --8<-- "examples/feature_flags/src/getting_started_single_feature_flag_payload.json" ``` -=== "features.json" +=== "getting_started_single_feature_flag_features.json" ```json hl_lines="2 6 9-11" - { - "premium_features": { - "default": false, - "rules": { - "customer tier equals premium": { - "when_match": true, - "conditions": [ - { - "action": "EQUALS", - "key": "tier", - "value": "premium" - } - ] - } - } - }, - "ten_percent_off_campaign": { - "default": false - } - } + --8<-- "examples/feature_flags/src/getting_started_single_feature_flag_features.json" ``` #### Static flags @@ -253,36 +153,21 @@ We have a static flag named `ten_percent_off_campaign`. Meaning, there are no co In this case, we could omit the `context` parameter and simply evaluate whether we should apply the 10% discount. -=== "app.py" +=== "getting_started_static_flag.py" - ```python hl_lines="12-13" - from aws_lambda_powertools.utilities.feature_flags import FeatureFlags, AppConfigStore - - app_config = AppConfigStore( - environment="dev", - application="product-catalogue", - name="features" - ) - - feature_flags = FeatureFlags(store=app_config) - - def lambda_handler(event, context): - apply_discount: bool = feature_flags.evaluate(name="ten_percent_off_campaign", - default=False) + ```python hl_lines="3 8 16" + --8<-- "examples/feature_flags/src/getting_started_static_flag.py" + ``` +=== "getting_started_static_flag_payload.json" - if apply_discount: - # apply 10% discount to product - ... + ```json hl_lines="2-3" + --8<-- "examples/feature_flags/src/getting_started_static_flag_payload.json" ``` -=== "features.json" +=== "getting_started_static_flag_features.json" - ```json hl_lines="2-3" - { - "ten_percent_off_campaign": { - "default": false - } - } + ```json hl_lines="2-4" + --8<-- "examples/feature_flags/src/getting_started_static_flag_features.json" ``` ### Getting all enabled features @@ -291,163 +176,25 @@ As you might have noticed, each `evaluate` call means an API call to the Store a You can use `get_enabled_features` method for scenarios where you need a list of all enabled features according to the input context. -=== "app.py" - - ```python hl_lines="17-20 23" - from aws_lambda_powertools.event_handler import APIGatewayRestResolver - from aws_lambda_powertools.utilities.feature_flags import FeatureFlags, AppConfigStore +=== "getting_all_enabled_features.py" - app = APIGatewayRestResolver() - - app_config = AppConfigStore( - environment="dev", - application="product-catalogue", - name="features" - ) - - feature_flags = FeatureFlags(store=app_config) - - @app.get("/products") - def list_products(): - ctx = { - **app.current_event.headers, - **app.current_event.json_body - } - - # all_features is evaluated to ["geo_customer_campaign", "ten_percent_off_campaign"] - all_features: list[str] = feature_flags.get_enabled_features(context=ctx) - - if "geo_customer_campaign" in all_features: - # apply discounts based on geo - ... - - if "ten_percent_off_campaign" in all_features: - # apply additional 10% for all customers - ... - - def lambda_handler(event, context): - return app.resolve(event, context) + ```python hl_lines="2 9 26" + --8<-- "examples/feature_flags/src/getting_all_enabled_features.py" ``` -=== "event.json" +=== "getting_all_enabled_features_payload.json" ```json hl_lines="2 8" - { - "body": "{\"username\": \"lessa\", \"tier\": \"premium\", \"basked_id\": \"random_id\"}", - "resource": "/products", - "path": "/products", - "httpMethod": "GET", - "isBase64Encoded": false, - "headers": { - "CloudFront-Viewer-Country": "NL" - } - } - ``` -=== "features.json" - - ```json hl_lines="17-18 20 27-29" - { - "premium_features": { - "default": false, - "rules": { - "customer tier equals premium": { - "when_match": true, - "conditions": [ - { - "action": "EQUALS", - "key": "tier", - "value": "premium" - } - ] - } - } - }, - "ten_percent_off_campaign": { - "default": true - }, - "geo_customer_campaign": { - "default": false, - "rules": { - "customer in temporary discount geo": { - "when_match": true, - "conditions": [ - { - "action": "KEY_IN_VALUE", - "key": "CloudFront-Viewer-Country", - "value": ["NL", "IE", "UK", "PL", "PT"] - } - ] - } - } - } - } + --8<-- "examples/feature_flags/src/getting_all_enabled_features_payload.json" ``` -### Beyond boolean feature flags +=== "getting_all_enabled_features_features.json" -???+ info "When is this useful?" - You might have a list of features to unlock for premium customers, unlock a specific set of features for admin users, etc. - -Feature flags can return any JSON values when `boolean_type` parameter is set to `false`. These can be dictionaries, list, string, integers, etc. - -=== "app.py" - - ```python hl_lines="3 9 13 16 18" - from aws_lambda_powertools.utilities.feature_flags import FeatureFlags, AppConfigStore - - app_config = AppConfigStore( - environment="dev", - application="product-catalogue", - name="features" - ) - - feature_flags = FeatureFlags(store=app_config) - - def lambda_handler(event, context): - # Get customer's tier from incoming request - ctx = { "tier": event.get("tier", "standard") } - - # Evaluate `has_premium_features` base don customer's tier - premium_features: list[str] = feature_flags.evaluate(name="premium_features", - context=ctx, default=False) - for feature in premium_features: - # enable premium features - ... - ``` - -=== "event.json" - - ```json hl_lines="3" - { - "username": "lessa", - "tier": "premium", - "basked_id": "random_id" - } - ``` -=== "features.json" - - ```json hl_lines="3-4 7" - { - "premium_features": { - "boolean_type": false, - "default": [], - "rules": { - "customer tier equals premium": { - "when_match": ["no_ads", "no_limits", "chat"], - "conditions": [ - { - "action": "EQUALS", - "key": "tier", - "value": "premium" - } - ] - } - } - } - } + ```json hl_lines="2 8-12 17-18 20 27-28 30" + --8<-- "examples/feature_flags/src/getting_all_enabled_features_features.json" ``` -#### Time based feature flags +### Time based feature flags Feature flags can also return enabled features based on time or datetime ranges. This allows you to have features that are only enabled on certain days of the week, certain time @@ -461,19 +208,19 @@ Use cases: You can also have features enabled only at certain times of the day for premium tier customers -=== "app.py" +=== "timebased_feature.py" - ```python hl_lines="12" + ```python hl_lines="1 6 40" --8<-- "examples/feature_flags/src/timebased_feature.py" ``` -=== "event.json" +=== "timebased_feature_event.json" ```json hl_lines="3" --8<-- "examples/feature_flags/src/timebased_feature_event.json" ``` -=== "features.json" +=== "timebased_features.json" ```json hl_lines="9-11 14-21" --8<-- "examples/feature_flags/src/timebased_features.json" @@ -481,40 +228,65 @@ You can also have features enabled only at certain times of the day for premium You can also have features enabled only at certain times of the day. -=== "app.py" +=== "timebased_happyhour_feature.py" - ```python hl_lines="9" + ```python hl_lines="1 6 29" --8<-- "examples/feature_flags/src/timebased_happyhour_feature.py" ``` -=== "features.json" +=== "timebased_happyhour_features.json" - ```json hl_lines="9-15" + ```json hl_lines="9-14" --8<-- "examples/feature_flags/src/timebased_happyhour_features.json" ``` You can also have features enabled only at specific days, for example: enable christmas sale discount during specific dates. -=== "app.py" +=== "datetime_feature.py" - ```python hl_lines="10" + ```python hl_lines="1 6 31" --8<-- "examples/feature_flags/src/datetime_feature.py" ``` -=== "features.json" +=== "datetime_features.json" ```json hl_lines="9-14" - --8<-- "examples/feature_flags/src/datetime_feature.json" + --8<-- "examples/feature_flags/src/datetime_features.json" ``` ???+ info "How should I use timezones?" - You can use any [IANA time zone](https://www.iana.org/time-zones) (as originally specified - in [PEP 615](https://peps.python.org/pep-0615/)) as part of your rules definition. + You can use any [IANA time zone](https://www.iana.org/time-zones){target="_blank"} (as originally specified + in [PEP 615](https://peps.python.org/pep-0615/){target="_blank"}) as part of your rules definition. Powertools takes care of converting and calculate the correct timestamps for you. 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. +### Beyond boolean feature flags + +???+ info "When is this useful?" + You might have a list of features to unlock for premium customers, unlock a specific set of features for admin users, etc. + +Feature flags can return any JSON values when `boolean_type` parameter is set to `false`. These can be dictionaries, list, string, integers, etc. + +=== "beyond_boolean.py" + + ```python hl_lines="3 8 16" + --8<-- "examples/feature_flags/src/beyond_boolean.py" + ``` + +=== "beyond_boolean_payload.json" + + ```json hl_lines="3" + --8<-- "examples/feature_flags/src/beyond_boolean_payload.json" + ``` + +=== "beyond_boolean_features.json" + + ```json hl_lines="7-11 14-16" + --8<-- "examples/feature_flags/src/beyond_boolean_features.json" + ``` + ## Advanced ### Adjusting in-memory cache @@ -523,17 +295,21 @@ By default, we cache configuration retrieved from the Store for 5 seconds for pe You can override `max_age` parameter when instantiating the store. -=== "app.py" +=== "getting_started_with_cache.py" - ```python hl_lines="7" - from aws_lambda_powertools.utilities.feature_flags import FeatureFlags, AppConfigStore + ```python hl_lines="6" + --8<-- "examples/feature_flags/src/getting_started_with_cache.py" + ``` +=== "getting_started_with_cache_payload.json" + + ```json hl_lines="2-3" + --8<-- "examples/feature_flags/src/getting_started_with_cache_payload.json" + ``` - app_config = AppConfigStore( - environment="dev", - application="product-catalogue", - name="features", - max_age=300 - ) +=== "getting_started_with_cache_features.json" + + ```json hl_lines="2-4" + --8<-- "examples/feature_flags/src/getting_started_with_cache_features.json" ``` ### Getting fetched configuration @@ -545,21 +321,10 @@ You can override `max_age` parameter when instantiating the store. You can access the configuration fetched from the store via `get_raw_configuration` property within the store instance. -=== "app.py" - - ```python hl_lines="12" - from aws_lambda_powertools.utilities.feature_flags import FeatureFlags, AppConfigStore - - app_config = AppConfigStore( - environment="dev", - application="product-catalogue", - name="configuration", - envelope = "feature_flags" - ) - - feature_flags = FeatureFlags(store=app_config) +=== "getting_stored_features.py" - config = app_config.get_raw_configuration + ```python hl_lines="9" + --8<-- "examples/feature_flags/src/getting_stored_features.py" ``` ### Schema @@ -570,17 +335,11 @@ This utility expects a certain schema to be stored as JSON within AWS AppConfig. A feature can simply have its name and a `default` value. This is either on or off, also known as a [static flag](#static-flags). -```json hl_lines="2-3 5-7" title="minimal_schema.json" -{ - "global_feature": { - "default": true - }, - "non_boolean_global_feature": { - "default": {"group": "read-only"}, - "boolean_type": false - }, -} -``` +=== "minimal_schema.json" + + ```json hl_lines="2-3 5-7" + --8<-- "examples/feature_flags/src/minimal_schema.json" + ``` If you need more control and want to provide context such as user group, permissions, location, etc., you need to add rules to your feature flag configuration. @@ -592,40 +351,11 @@ When adding `rules` to a feature, they must contain: 2. `when_match` boolean or JSON value that should be used when conditions match 3. A list of `conditions` for evaluation - ```json hl_lines="4-11 19-26" title="feature_with_rules.json" - { - "premium_feature": { - "default": false, - "rules": { - "customer tier equals premium": { - "when_match": true, - "conditions": [ - { - "action": "EQUALS", - "key": "tier", - "value": "premium" - } - ] - } - } - }, - "non_boolean_premium_feature": { - "default": [], - "rules": { - "customer tier equals premium": { - "when_match": ["remove_limits", "remove_ads"], - "conditions": [ - { - "action": "EQUALS", - "key": "tier", - "value": "premium" - } - ] - } - } - } - } - ``` +=== "feature_with_rules.json" + + ```json hl_lines="4-11 19-26" + --8<-- "examples/feature_flags/src/feature_with_rules.json" + ``` You can have multiple rules with different names. The rule engine will return the first result `when_match` of the matching rule configuration, or `default` value when none of the rules apply. @@ -633,18 +363,11 @@ You can have multiple rules with different names. The rule engine will return th The `conditions` block is a list of conditions that contain `action`, `key`, and `value` keys: -```json hl_lines="5-7" title="conditions.json" -{ - ... - "conditions": [ - { - "action": "EQUALS", - "key": "tier", - "value": "premium" - } - ] -} -``` +=== "conditions.json" + + ```json hl_lines="5-7" + --8<-- "examples/feature_flags/src/conditions.json" + ``` The `action` configuration can have the following values, where the expressions **`a`** is the `key` and **`b`** is the `value` above: @@ -667,17 +390,17 @@ The `action` configuration can have the following values, where the expressions | **SCHEDULE_BETWEEN_DAYS_OF_WEEK** | `lambda a, b: day_of_week(a) in b` | ???+ info - The `**key**` and `**value**` will be compared to the input from the `**context**` parameter. + The `key` and `value` will be compared to the input from the `context` parameter. ???+ "Time based keys" For time based keys, we provide a list of predefined keys. These will automatically get converted to the corresponding timestamp on each invocation of your Lambda function. - | Key | Meaning | - | ------------------- | ------------------------------------------------------------------------ | - | CURRENT_TIME | The current time, 24 hour format (HH:mm) | - | CURRENT_DATETIME | The current datetime ([ISO8601](https://en.wikipedia.org/wiki/ISO_8601)) | - | CURRENT_DAY_OF_WEEK | The current day of the week (Monday-Sunday) | + | Key | Meaning | + | ------------------- | ----------------------------------------------------------------------------------------- | + | CURRENT_TIME | The current time, 24 hour format (HH:mm) | + | CURRENT_DATETIME | The current datetime ([ISO8601](https://en.wikipedia.org/wiki/ISO_8601){target="_blank"}) | + | CURRENT_DAY_OF_WEEK | The current day of the week (Monday-Sunday) | If not specified, the timezone used for calculations will be UTC. @@ -695,55 +418,26 @@ There are scenarios where you might want to include feature flags as part of an For this to work, you need to use a JMESPath expression via the `envelope` parameter to extract that key as the feature flags configuration. -=== "app.py" +=== "extracting_envelope.py" ```python hl_lines="7" - from aws_lambda_powertools.utilities.feature_flags import FeatureFlags, AppConfigStore + --8<-- "examples/feature_flags/src/extracting_envelope.py" + ``` + +=== "extracting_envelope_payload.json" - app_config = AppConfigStore( - environment="dev", - application="product-catalogue", - name="configuration", - envelope = "feature_flags" - ) + ```json hl_lines="2-3" + --8<-- "examples/feature_flags/src/extracting_envelope_payload.json" ``` -=== "configuration.json" +=== "extracting_envelope_features.json" ```json hl_lines="6" - { - "logging": { - "level": "INFO", - "sampling_rate": 0.1 - }, - "feature_flags": { - "premium_feature": { - "default": false, - "rules": { - "customer tier equals premium": { - "when_match": true, - "conditions": [ - { - "action": "EQUALS", - "key": "tier", - "value": "premium" - } - ] - } - } - }, - "feature2": { - "default": false - } - } - } + --8<-- "examples/feature_flags/src/extracting_envelope_features.json" ``` ### Built-in store provider -???+ info - For GA, you'll be able to bring your own store. - #### AppConfig AppConfig store provider fetches any JSON document from AWS AppConfig. @@ -752,45 +446,68 @@ These are the available options for further customization. | Parameter | Default | Description | | -------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | -| **environment** | `""` | AWS AppConfig Environment, e.g. `test` | -| **application** | `""` | AWS AppConfig Application | -| **name** | `""` | AWS AppConfig Configuration name | +| **environment** | `""` | AWS AppConfig Environment, e.g. `dev` | +| **application** | `""` | AWS AppConfig Application, e.g. `product-catalogue` | +| **name** | `""` | AWS AppConfig Configuration name, e.g `features` | | **envelope** | `None` | JMESPath expression to use to extract feature flags configuration from AWS AppConfig configuration | | **max_age** | `5` | Number of seconds to cache feature flags configuration fetched from AWS AppConfig | | **sdk_config** | `None` | [Botocore Config object](https://botocore.amazonaws.com/v1/documentation/api/latest/reference/config.html){target="_blank"} | | **jmespath_options** | `None` | For advanced use cases when you want to bring your own [JMESPath functions](https://github.com/jmespath/jmespath.py#custom-functions){target="_blank"} | | **logger** | `logging.Logger` | Logger to use for debug. You can optionally supply an instance of Powertools Logger. | -```python hl_lines="21-27" title="AppConfigStore sample" -from botocore.config import Config +=== "appconfig_provider_options.py" + + ```python hl_lines="9 13-17 20 28-30" + --8<-- "examples/feature_flags/src/appconfig_provider_options.py" + ``` + +=== "appconfig_provider_options_payload.json" + + ```json hl_lines="2 3" + --8<-- "examples/feature_flags/src/appconfig_provider_options_payload.json" + ``` + +=== "appconfig_provider_options_features.json" + + ```json hl_lines="6-9" + --8<-- "examples/feature_flags/src/appconfig_provider_options_features.json" + ``` + +### Create your own store provider -import jmespath +You can create your own custom FeatureFlags store provider by inheriting the `StoreProvider` class, and implementing both `get_raw_configuration()` and `get_configuration()` methods to retrieve the configuration from your custom store. -from aws_lambda_powertools.utilities.feature_flags import AppConfigStore +* **`get_raw_configuration()`** – get the raw configuration from the store provider and return the parsed JSON dictionary +* **`get_configuration()`** – get the configuration from the store provider, parsing it as a JSON dictionary. If an envelope is set, extract the envelope data -boto_config = Config(read_timeout=10, retries={"total_max_attempts": 2}) +Here are an example of implementing a custom store provider using Amazon S3, a popular object storage. -# Custom JMESPath functions -class CustomFunctions(jmespath.functions.Functions): +???+ note + This is just one example of how you can create your own store provider. Before creating a custom store provider, carefully evaluate your requirements and consider factors such as performance, scalability, and ease of maintenance. - @jmespath.functions.signature({'types': ['string']}) - def _func_special_decoder(self, s): - return my_custom_decoder_logic(s) +=== "working_with_own_s3_store_provider.py" + ```python hl_lines="3 8 10" + --8<-- "examples/feature_flags/src/working_with_own_s3_store_provider.py" + ``` -custom_jmespath_options = {"custom_functions": CustomFunctions()} +=== "custom_s3_store_provider.py" + ```python hl_lines="33 37" + --8<-- "examples/feature_flags/src/custom_s3_store_provider.py" + ``` -app_config = AppConfigStore( - environment="dev", - application="product-catalogue", - name="configuration", - max_age=120, - envelope = "features", - sdk_config=boto_config, - jmespath_options=custom_jmespath_options -) -``` +=== "working_with_own_s3_store_provider_payload.json" + + ```json hl_lines="2 3" + --8<-- "examples/feature_flags/src/working_with_own_s3_store_provider_payload.json" + ``` + +=== "working_with_own_s3_store_provider_features.json" + + ```json hl_lines="2-4" + --8<-- "examples/feature_flags/src/working_with_own_s3_store_provider_features.json" + ``` ## Testing your code @@ -801,70 +518,16 @@ You can unit test your feature flags locally and independently without setting u ???+ warning This excerpt relies on `pytest` and `pytest-mock` dependencies. -```python hl_lines="7-9" title="Unit testing feature flags" -from aws_lambda_powertools.utilities.feature_flags import FeatureFlags, AppConfigStore, RuleAction - - -def init_feature_flags(mocker, mock_schema, envelope="") -> FeatureFlags: - """Mock AppConfig Store get_configuration method to use mock schema instead""" - - method_to_mock = "aws_lambda_powertools.utilities.feature_flags.AppConfigStore.get_configuration" - mocked_get_conf = mocker.patch(method_to_mock) - mocked_get_conf.return_value = mock_schema - - app_conf_store = AppConfigStore( - environment="test_env", - application="test_app", - name="test_conf_name", - envelope=envelope, - ) - - return FeatureFlags(store=app_conf_store) - - -def test_flags_condition_match(mocker): - # GIVEN - expected_value = True - mocked_app_config_schema = { - "my_feature": { - "default": False, - "rules": { - "tenant id equals 12345": { - "when_match": expected_value, - "conditions": [ - { - "action": RuleAction.EQUALS.value, - "key": "tenant_id", - "value": "12345", - } - ], - } - }, - } - } - - # WHEN - ctx = {"tenant_id": "12345", "username": "a"} - feature_flags = init_feature_flags(mocker=mocker, mock_schema=mocked_app_config_schema) - flag = feature_flags.evaluate(name="my_feature", context=ctx, default=False) - - # THEN - assert flag == expected_value -``` - -## Feature flags vs Parameters vs env vars +=== "Testing your code" + + ```python hl_lines="11-13" + --8<-- "examples/feature_flags/src/getting_started_with_tests.py" + ``` + +## Feature flags vs Parameters vs Env vars | Method | When to use | Requires new deployment on changes | Supported services | | --------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ---------------------------------- | ----------------------------------------------------- | | **[Environment variables](https://docs.aws.amazon.com/lambda/latest/dg/configuration-envvars.html){target="_blank"}** | Simple configuration that will rarely if ever change, because changing it requires a Lambda function deployment. | Yes | Lambda | | **[Parameters utility](parameters.md)** | Access to secrets, or fetch parameters in different formats from AWS System Manager Parameter Store or Amazon DynamoDB. | No | Parameter Store, DynamoDB, Secrets Manager, AppConfig | | **Feature flags utility** | Rule engine to define when one or multiple features should be enabled depending on the input. | No | AppConfig | - -## Deprecation list when GA - -| Breaking change | Recommendation | -| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `IN` RuleAction | Use `KEY_IN_VALUE` instead | -| `NOT_IN` RuleAction | Use `KEY_NOT_IN_VALUE` instead | -| `get_enabled_features` | Return type changes from `List[str]` to `Dict[str, Any]`. New return will contain a list of features enabled and their values. List of enabled features will be in `enabled_features` key to keep ease of assertion we have in Beta. | -| `boolean_type` Schema | This **might** not be necessary anymore before we go GA. We will return either the `default` value when there are no rules as well as `when_match` value. This will simplify on-boarding if we can keep the same set of validations already offered. | diff --git a/examples/feature_flags/sam/template.yaml b/examples/feature_flags/sam/template.yaml new file mode 100644 index 00000000000..944183975ec --- /dev/null +++ b/examples/feature_flags/sam/template.yaml @@ -0,0 +1,60 @@ +AWSTemplateFormatVersion: "2010-09-09" +Description: Lambda Powertools for Python Feature flags sample template +Resources: + FeatureStoreApp: + Type: AWS::AppConfig::Application + Properties: + Description: "AppConfig Application for feature toggles" + Name: product-catalogue + + FeatureStoreDevEnv: + Type: AWS::AppConfig::Environment + Properties: + ApplicationId: !Ref FeatureStoreApp + Description: "Development Environment for the App Config Store" + Name: dev + + FeatureStoreConfigProfile: + Type: AWS::AppConfig::ConfigurationProfile + Properties: + ApplicationId: !Ref FeatureStoreApp + Name: features + LocationUri: "hosted" + + HostedConfigVersion: + Type: AWS::AppConfig::HostedConfigurationVersion + Properties: + ApplicationId: !Ref FeatureStoreApp + ConfigurationProfileId: !Ref FeatureStoreConfigProfile + Description: 'A sample hosted configuration version' + Content: | + { + "premium_features": { + "default": false, + "rules": { + "customer tier equals premium": { + "when_match": true, + "conditions": [ + { + "action": "EQUALS", + "key": "tier", + "value": "premium" + } + ] + } + } + }, + "ten_percent_off_campaign": { + "default": false + } + } + ContentType: 'application/json' + + ConfigDeployment: + Type: AWS::AppConfig::Deployment + Properties: + ApplicationId: !Ref FeatureStoreApp + ConfigurationProfileId: !Ref FeatureStoreConfigProfile + ConfigurationVersion: !Ref HostedConfigVersion + DeploymentStrategyId: "AppConfig.AllAtOnce" + EnvironmentId: !Ref FeatureStoreDevEnv diff --git a/examples/feature_flags/src/appconfig_provider_options.py b/examples/feature_flags/src/appconfig_provider_options.py new file mode 100644 index 00000000000..8a41f651fc9 --- /dev/null +++ b/examples/feature_flags/src/appconfig_provider_options.py @@ -0,0 +1,45 @@ +from typing import Any + +from botocore.config import Config +from jmespath.functions import Functions, signature + +from aws_lambda_powertools.utilities.feature_flags import AppConfigStore, FeatureFlags +from aws_lambda_powertools.utilities.typing import LambdaContext + +boto_config = Config(read_timeout=10, retries={"total_max_attempts": 2}) + + +# Custom JMESPath functions +class CustomFunctions(Functions): + @signature({"types": ["object"]}) + def _func_special_decoder(self, features): + # You can add some logic here + return features + + +custom_jmespath_options = {"custom_functions": CustomFunctions()} + + +app_config = AppConfigStore( + environment="dev", + application="product-catalogue", + name="features", + max_age=120, + envelope="special_decoder(features)", # using a custom function defined in CustomFunctions Class + sdk_config=boto_config, + jmespath_options=custom_jmespath_options, +) + +feature_flags = FeatureFlags(store=app_config) + + +def lambda_handler(event: dict, context: LambdaContext): + apply_discount: Any = feature_flags.evaluate(name="ten_percent_off_campaign", default=False) + + price: Any = event.get("price") + + if apply_discount: + # apply 10% discount to product + price = price * 0.9 + + return {"price": price} diff --git a/examples/feature_flags/src/appconfig_provider_options_features.json b/examples/feature_flags/src/appconfig_provider_options_features.json new file mode 100644 index 00000000000..a26b0d34e53 --- /dev/null +++ b/examples/feature_flags/src/appconfig_provider_options_features.json @@ -0,0 +1,11 @@ +{ + "logging": { + "level": "INFO", + "sampling_rate": 0.1 + }, + "features": { + "ten_percent_off_campaign": { + "default": true + } + } + } diff --git a/examples/feature_flags/src/appconfig_provider_options_payload.json b/examples/feature_flags/src/appconfig_provider_options_payload.json new file mode 100644 index 00000000000..b2a71282f8e --- /dev/null +++ b/examples/feature_flags/src/appconfig_provider_options_payload.json @@ -0,0 +1,4 @@ +{ + "product": "laptop", + "price": 1000 +} diff --git a/examples/feature_flags/src/beyond_boolean.py b/examples/feature_flags/src/beyond_boolean.py new file mode 100644 index 00000000000..bd5ad021909 --- /dev/null +++ b/examples/feature_flags/src/beyond_boolean.py @@ -0,0 +1,18 @@ +from typing import Any + +from aws_lambda_powertools.utilities.feature_flags import AppConfigStore, FeatureFlags +from aws_lambda_powertools.utilities.typing import LambdaContext + +app_config = AppConfigStore(environment="dev", application="comments", name="config") + +feature_flags = FeatureFlags(store=app_config) + + +def lambda_handler(event: dict, context: LambdaContext): + # Get customer's tier from incoming request + ctx = {"tier": event.get("tier", "standard")} + + # Evaluate `has_premium_features` based on customer's tier + premium_features: Any = feature_flags.evaluate(name="premium_features", context=ctx, default=[]) + + return {"Premium features enabled": premium_features} diff --git a/examples/feature_flags/src/beyond_boolean_features.json b/examples/feature_flags/src/beyond_boolean_features.json new file mode 100644 index 00000000000..c48754a15f9 --- /dev/null +++ b/examples/feature_flags/src/beyond_boolean_features.json @@ -0,0 +1,22 @@ +{ + "premium_features": { + "boolean_type": false, + "default": [], + "rules": { + "customer tier equals premium": { + "when_match": [ + "no_ads", + "no_limits", + "chat" + ], + "conditions": [ + { + "action": "EQUALS", + "key": "tier", + "value": "premium" + } + ] + } + } + } + } diff --git a/examples/feature_flags/src/beyond_boolean_payload.json b/examples/feature_flags/src/beyond_boolean_payload.json new file mode 100644 index 00000000000..d63f3bff11a --- /dev/null +++ b/examples/feature_flags/src/beyond_boolean_payload.json @@ -0,0 +1,5 @@ +{ + "username": "lessa", + "tier": "premium", + "basked_id": "random_id" +} diff --git a/examples/feature_flags/src/conditions.json b/examples/feature_flags/src/conditions.json new file mode 100644 index 00000000000..30eda640e0f --- /dev/null +++ b/examples/feature_flags/src/conditions.json @@ -0,0 +1,9 @@ +{ + "conditions": [ + { + "action": "EQUALS", + "key": "tier", + "value": "premium" + } + ] +} diff --git a/examples/feature_flags/src/custom_s3_store_provider.py b/examples/feature_flags/src/custom_s3_store_provider.py new file mode 100644 index 00000000000..ea2c8a876be --- /dev/null +++ b/examples/feature_flags/src/custom_s3_store_provider.py @@ -0,0 +1,38 @@ +import json +from typing import Any, Dict + +import boto3 +from botocore.exceptions import ClientError + +from aws_lambda_powertools.utilities.feature_flags.base import StoreProvider +from aws_lambda_powertools.utilities.feature_flags.exceptions import ( + ConfigurationStoreError, +) + + +class S3StoreProvider(StoreProvider): + def __init__(self, bucket_name: str, object_key: str): + # Initialize the client to your custom store provider + + super().__init__() + + self.bucket_name = bucket_name + self.object_key = object_key + self.client = boto3.client("s3") + + def _get_s3_object(self) -> Dict[str, Any]: + # Retrieve the object content + parameters = {"Bucket": self.bucket_name, "Key": self.object_key} + + try: + response = self.client.get_object(**parameters) + return json.loads(response["Body"].read().decode()) + except ClientError as exc: + raise ConfigurationStoreError("Unable to get S3 Store Provider configuration file") from exc + + def get_configuration(self) -> Dict[str, Any]: + return self._get_s3_object() + + @property + def get_raw_configuration(self) -> Dict[str, Any]: + return self._get_s3_object() diff --git a/examples/feature_flags/src/datetime_feature.py b/examples/feature_flags/src/datetime_feature.py index 55c11ea6e7d..7dff14b8008 100644 --- a/examples/feature_flags/src/datetime_feature.py +++ b/examples/feature_flags/src/datetime_feature.py @@ -1,14 +1,37 @@ 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, context): - # Get customer's tier from incoming request +def lambda_handler(event: dict, context: LambdaContext): + """ + This feature flag is enabled under the following conditions: + - Start date: December 25th, 2022 at 12:00:00 PM EST + - End date: December 31st, 2022 at 11:59:59 PM EST + - Timezone: America/New_York + + Rule condition to be evaluated: + "conditions": [ + { + "action": "SCHEDULE_BETWEEN_DATETIME_RANGE", + "key": "CURRENT_DATETIME", + "value": { + "START": "2022-12-25T12:00:00", + "END": "2022-12-31T23:59:59", + "TIMEZONE": "America/New_York" + } + } + ] + """ + + # Checking if the Christmas discount is enable xmas_discount = feature_flags.evaluate(name="christmas_discount", default=False) if xmas_discount: # Enable special discount on christmas: - pass + return {"message": "The Christmas discount is enabled."} + + return {"message": "The Christmas discount is not enabled."} diff --git a/examples/feature_flags/src/datetime_feature.json b/examples/feature_flags/src/datetime_features.json similarity index 100% rename from examples/feature_flags/src/datetime_feature.json rename to examples/feature_flags/src/datetime_features.json diff --git a/examples/feature_flags/src/extracting_envelope.py b/examples/feature_flags/src/extracting_envelope.py new file mode 100644 index 00000000000..3c3194c0c1a --- /dev/null +++ b/examples/feature_flags/src/extracting_envelope.py @@ -0,0 +1,22 @@ +from typing import Any + +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", envelope="feature_flags" +) + +feature_flags = FeatureFlags(store=app_config) + + +def lambda_handler(event: dict, context: LambdaContext): + apply_discount: Any = feature_flags.evaluate(name="ten_percent_off_campaign", default=False) + + price: Any = event.get("price") + + if apply_discount: + # apply 10% discount to product + price = price * 0.9 + + return {"price": price} diff --git a/examples/feature_flags/src/extracting_envelope_features.json b/examples/feature_flags/src/extracting_envelope_features.json new file mode 100644 index 00000000000..a26b0d34e53 --- /dev/null +++ b/examples/feature_flags/src/extracting_envelope_features.json @@ -0,0 +1,11 @@ +{ + "logging": { + "level": "INFO", + "sampling_rate": 0.1 + }, + "features": { + "ten_percent_off_campaign": { + "default": true + } + } + } diff --git a/examples/feature_flags/src/extracting_envelope_payload.json b/examples/feature_flags/src/extracting_envelope_payload.json new file mode 100644 index 00000000000..b2a71282f8e --- /dev/null +++ b/examples/feature_flags/src/extracting_envelope_payload.json @@ -0,0 +1,4 @@ +{ + "product": "laptop", + "price": 1000 +} diff --git a/examples/feature_flags/src/feature_with_rules.json b/examples/feature_flags/src/feature_with_rules.json new file mode 100644 index 00000000000..60765ebd59b --- /dev/null +++ b/examples/feature_flags/src/feature_with_rules.json @@ -0,0 +1,32 @@ +{ + "premium_feature": { + "default": false, + "rules": { + "customer tier equals premium": { + "when_match": true, + "conditions": [ + { + "action": "EQUALS", + "key": "tier", + "value": "premium" + } + ] + } + } + }, + "non_boolean_premium_feature": { + "default": [], + "rules": { + "customer tier equals premium": { + "when_match": ["remove_limits", "remove_ads"], + "conditions": [ + { + "action": "EQUALS", + "key": "tier", + "value": "premium" + } + ] + } + } + } +} diff --git a/examples/feature_flags/src/getting_all_enabled_features.py b/examples/feature_flags/src/getting_all_enabled_features.py new file mode 100644 index 00000000000..6e3cab50b0d --- /dev/null +++ b/examples/feature_flags/src/getting_all_enabled_features.py @@ -0,0 +1,42 @@ +from aws_lambda_powertools.event_handler import APIGatewayRestResolver +from aws_lambda_powertools.utilities.feature_flags import AppConfigStore, FeatureFlags +from aws_lambda_powertools.utilities.typing import LambdaContext + +app = APIGatewayRestResolver() + +app_config = AppConfigStore(environment="dev", application="product-catalogue", name="features") + +feature_flags = FeatureFlags(store=app_config) + + +@app.get("/products") +def list_products(): + # getting fields from request + # https://awslabs.github.io/aws-lambda-powertools-python/latest/core/event_handler/api_gateway/#accessing-request-details + json_body = app.current_event.json_body + headers = app.current_event.headers + + ctx = {**headers, **json_body} + + # getting price from payload + price: float = float(json_body.get("price")) + percent_discount: int = 0 + + # all_features is evaluated to ["premium_features", "geo_customer_campaign", "ten_percent_off_campaign"] + all_features: list[str] = feature_flags.get_enabled_features(context=ctx) + + if "geo_customer_campaign" in all_features: + # apply 20% discounts for customers in NL + percent_discount += 20 + + if "ten_percent_off_campaign" in all_features: + # apply additional 10% for all customers + percent_discount += 10 + + price = price * (100 - percent_discount) / 100 + + return {"price": price} + + +def lambda_handler(event: dict, context: LambdaContext): + return app.resolve(event, context) diff --git a/examples/feature_flags/src/getting_all_enabled_features_features.json b/examples/feature_flags/src/getting_all_enabled_features_features.json new file mode 100644 index 00000000000..1017b872dfb --- /dev/null +++ b/examples/feature_flags/src/getting_all_enabled_features_features.json @@ -0,0 +1,41 @@ +{ + "premium_features": { + "default": false, + "rules": { + "customer tier equals premium": { + "when_match": true, + "conditions": [ + { + "action": "EQUALS", + "key": "tier", + "value": "premium" + } + ] + } + } + }, + "ten_percent_off_campaign": { + "default": true + }, + "geo_customer_campaign": { + "default": false, + "rules": { + "customer in temporary discount geo": { + "when_match": true, + "conditions": [ + { + "action": "KEY_IN_VALUE", + "key": "CloudFront-Viewer-Country", + "value": [ + "NL", + "IE", + "UK", + "PL", + "PT" + ] + } + ] + } + } + } + } diff --git a/examples/feature_flags/src/getting_all_enabled_features_payload.json b/examples/feature_flags/src/getting_all_enabled_features_payload.json new file mode 100644 index 00000000000..cb0a41847e3 --- /dev/null +++ b/examples/feature_flags/src/getting_all_enabled_features_payload.json @@ -0,0 +1,10 @@ +{ + "body": "{\"username\": \"lessa\", \"tier\": \"premium\", \"basked_id\": \"random_id\", \"price\": 1000}", + "resource": "/products", + "path": "/products", + "httpMethod": "GET", + "isBase64Encoded": false, + "headers": { + "CloudFront-Viewer-Country": "NL" + } +} diff --git a/examples/feature_flags/src/getting_started_single_feature_flag.py b/examples/feature_flags/src/getting_started_single_feature_flag.py new file mode 100644 index 00000000000..a3d54324766 --- /dev/null +++ b/examples/feature_flags/src/getting_started_single_feature_flag.py @@ -0,0 +1,34 @@ +from typing import Any + +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 'premium'. + + Rule condition to be evaluated: + "conditions": [ + { + "action": "EQUALS", + "key": "tier", + "value": "premium" + } + ] + """ + + # Get customer's tier from incoming request + ctx = {"tier": event.get("tier", "standard")} + + # Evaluate whether customer's tier has access to premium features + # based on `has_premium_features` rules + has_premium_features: Any = feature_flags.evaluate(name="premium_features", context=ctx, default=False) + if has_premium_features: + # enable premium features + ... diff --git a/examples/feature_flags/src/getting_started_single_feature_flag_features.json b/examples/feature_flags/src/getting_started_single_feature_flag_features.json new file mode 100644 index 00000000000..8f7a7615db3 --- /dev/null +++ b/examples/feature_flags/src/getting_started_single_feature_flag_features.json @@ -0,0 +1,20 @@ +{ + "premium_features": { + "default": false, + "rules": { + "customer tier equals premium": { + "when_match": true, + "conditions": [ + { + "action": "EQUALS", + "key": "tier", + "value": "premium" + } + ] + } + } + }, + "ten_percent_off_campaign": { + "default": false + } +} diff --git a/examples/feature_flags/src/getting_started_single_feature_flag_payload.json b/examples/feature_flags/src/getting_started_single_feature_flag_payload.json new file mode 100644 index 00000000000..d63f3bff11a --- /dev/null +++ b/examples/feature_flags/src/getting_started_single_feature_flag_payload.json @@ -0,0 +1,5 @@ +{ + "username": "lessa", + "tier": "premium", + "basked_id": "random_id" +} diff --git a/examples/feature_flags/src/getting_started_static_flag.py b/examples/feature_flags/src/getting_started_static_flag.py new file mode 100644 index 00000000000..5d8c185cf2d --- /dev/null +++ b/examples/feature_flags/src/getting_started_static_flag.py @@ -0,0 +1,24 @@ +from typing import Any + +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 by default for all requests. + """ + + apply_discount: Any = feature_flags.evaluate(name="ten_percent_off_campaign", default=False) + + price: Any = event.get("price") + + if apply_discount: + # apply 10% discount to product + price = price * 0.9 + + return {"price": price} diff --git a/examples/feature_flags/src/getting_started_static_flag_features.json b/examples/feature_flags/src/getting_started_static_flag_features.json new file mode 100644 index 00000000000..fe692cdf0c3 --- /dev/null +++ b/examples/feature_flags/src/getting_started_static_flag_features.json @@ -0,0 +1,5 @@ +{ + "ten_percent_off_campaign": { + "default": true + } +} diff --git a/examples/feature_flags/src/getting_started_static_flag_payload.json b/examples/feature_flags/src/getting_started_static_flag_payload.json new file mode 100644 index 00000000000..b2a71282f8e --- /dev/null +++ b/examples/feature_flags/src/getting_started_static_flag_payload.json @@ -0,0 +1,4 @@ +{ + "product": "laptop", + "price": 1000 +} diff --git a/examples/feature_flags/src/getting_started_with_cache.py b/examples/feature_flags/src/getting_started_with_cache.py new file mode 100644 index 00000000000..1437c7266be --- /dev/null +++ b/examples/feature_flags/src/getting_started_with_cache.py @@ -0,0 +1,24 @@ +from typing import Any + +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", max_age=300) + +feature_flags = FeatureFlags(store=app_config) + + +def lambda_handler(event: dict, context: LambdaContext): + """ + This feature flag is enabled by default for all requests. + """ + + apply_discount: Any = feature_flags.evaluate(name="ten_percent_off_campaign", default=False) + + price: Any = event.get("price") + + if apply_discount: + # apply 10% discount to product + price = price * 0.9 + + return {"price": price} diff --git a/examples/feature_flags/src/getting_started_with_cache_features.json b/examples/feature_flags/src/getting_started_with_cache_features.json new file mode 100644 index 00000000000..fe692cdf0c3 --- /dev/null +++ b/examples/feature_flags/src/getting_started_with_cache_features.json @@ -0,0 +1,5 @@ +{ + "ten_percent_off_campaign": { + "default": true + } +} diff --git a/examples/feature_flags/src/getting_started_with_cache_payload.json b/examples/feature_flags/src/getting_started_with_cache_payload.json new file mode 100644 index 00000000000..b2a71282f8e --- /dev/null +++ b/examples/feature_flags/src/getting_started_with_cache_payload.json @@ -0,0 +1,4 @@ +{ + "product": "laptop", + "price": 1000 +} diff --git a/examples/feature_flags/src/getting_started_with_tests.py b/examples/feature_flags/src/getting_started_with_tests.py new file mode 100644 index 00000000000..81152dca104 --- /dev/null +++ b/examples/feature_flags/src/getting_started_with_tests.py @@ -0,0 +1,52 @@ +from aws_lambda_powertools.utilities.feature_flags import ( + AppConfigStore, + FeatureFlags, + RuleAction, +) + + +def init_feature_flags(mocker, mock_schema, envelope="") -> FeatureFlags: + """Mock AppConfig Store get_configuration method to use mock schema instead""" + + method_to_mock = "aws_lambda_powertools.utilities.feature_flags.AppConfigStore.get_configuration" + mocked_get_conf = mocker.patch(method_to_mock) + mocked_get_conf.return_value = mock_schema + + app_conf_store = AppConfigStore( + environment="test_env", + application="test_app", + name="test_conf_name", + envelope=envelope, + ) + + return FeatureFlags(store=app_conf_store) + + +def test_flags_condition_match(mocker): + # GIVEN + expected_value = True + mocked_app_config_schema = { + "my_feature": { + "default": False, + "rules": { + "tenant id equals 12345": { + "when_match": expected_value, + "conditions": [ + { + "action": RuleAction.EQUALS.value, + "key": "tenant_id", + "value": "12345", + } + ], + } + }, + } + } + + # WHEN + ctx = {"tenant_id": "12345", "username": "a"} + feature_flags = init_feature_flags(mocker=mocker, mock_schema=mocked_app_config_schema) + flag = feature_flags.evaluate(name="my_feature", context=ctx, default=False) + + # THEN + assert flag == expected_value diff --git a/examples/feature_flags/src/getting_stored_features.py b/examples/feature_flags/src/getting_stored_features.py new file mode 100644 index 00000000000..07f115375a6 --- /dev/null +++ b/examples/feature_flags/src/getting_stored_features.py @@ -0,0 +1,10 @@ +from aws_lambda_powertools.utilities.feature_flags import AppConfigStore, FeatureFlags + +app_config = AppConfigStore( + environment="dev", application="product-catalogue", name="configuration", envelope="feature_flags" +) + +feature_flags = FeatureFlags(store=app_config) + +config = app_config.get_raw_configuration +... diff --git a/examples/feature_flags/src/minimal_schema.json b/examples/feature_flags/src/minimal_schema.json new file mode 100644 index 00000000000..7302ab2784a --- /dev/null +++ b/examples/feature_flags/src/minimal_schema.json @@ -0,0 +1,9 @@ +{ + "global_feature": { + "default": true + }, + "non_boolean_global_feature": { + "default": {"group": "read-only"}, + "boolean_type": false + } +} diff --git a/examples/feature_flags/src/timebased_feature.py b/examples/feature_flags/src/timebased_feature.py index 0b0963489f4..46fbbc1c3d5 100644 --- a/examples/feature_flags/src/timebased_feature.py +++ b/examples/feature_flags/src/timebased_feature.py @@ -1,16 +1,46 @@ 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, context): +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 'premium'. + - If the current day is either Saturday or Sunday in America/New_York timezone. + + Rule condition to be evaluated: + "conditions": [ + { + "action": "EQUALS", + "key": "tier", + "value": "premium" + }, + { + "action": "SCHEDULE_BETWEEN_DAYS_OF_WEEK", + "key": "CURRENT_DAY_OF_WEEK", + "value": { + "DAYS": [ + "SATURDAY", + "SUNDAY" + ], + "TIMEZONE": "America/New_York" + } + } + ] + """ + # Get customer's tier from incoming request ctx = {"tier": event.get("tier", "standard")} + # Checking if the weekend premum discount is enable weekend_premium_discount = feature_flags.evaluate(name="weekend_premium_discount", default=False, context=ctx) if weekend_premium_discount: - # Enable special discount for premium members on weekends - pass + # Enable special discount on weekend for premium users: + return {"message": "The weekend premium discount is enabled."} + + return {"message": "The weekend premium discount is not enabled."} diff --git a/examples/feature_flags/src/timebased_happyhour_feature.py b/examples/feature_flags/src/timebased_happyhour_feature.py index b008481c722..8b71062bdff 100644 --- a/examples/feature_flags/src/timebased_happyhour_feature.py +++ b/examples/feature_flags/src/timebased_happyhour_feature.py @@ -1,13 +1,35 @@ 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, context): +def lambda_handler(event: dict, context: LambdaContext): + """ + This feature flag is enabled under the following conditions: + - Every day between 17:00 to 19:00 in Europe/Copenhagen timezone + + Rule condition to be evaluated: + "conditions": [ + { + "action": "SCHEDULE_BETWEEN_TIME_RANGE", + "key": "CURRENT_TIME", + "value": { + "START": "17:00", + "END": "19:00", + "TIMEZONE": "Europe/Copenhagen" + } + } + ] + """ + + # Checking if the happy hour discount is enable is_happy_hour = feature_flags.evaluate(name="happy_hour", default=False) if is_happy_hour: - # Apply special discount - pass + # Enable special discount on happy hour: + return {"message": "The happy hour discount is enabled."} + + return {"message": "The happy hour discount is not enabled."} diff --git a/examples/feature_flags/src/working_with_own_s3_store_provider.py b/examples/feature_flags/src/working_with_own_s3_store_provider.py new file mode 100644 index 00000000000..ad7488388a4 --- /dev/null +++ b/examples/feature_flags/src/working_with_own_s3_store_provider.py @@ -0,0 +1,22 @@ +from typing import Any + +from custom_s3_store_provider import S3StoreProvider + +from aws_lambda_powertools.utilities.feature_flags import FeatureFlags +from aws_lambda_powertools.utilities.typing import LambdaContext + +s3_config_store = S3StoreProvider("your-bucket-name", "working_with_own_s3_store_provider_features.json") + +feature_flags = FeatureFlags(store=s3_config_store) + + +def lambda_handler(event: dict, context: LambdaContext): + apply_discount: Any = feature_flags.evaluate(name="ten_percent_off_campaign", default=False) + + price: Any = event.get("price") + + if apply_discount: + # apply 10% discount to product + price = price * 0.9 + + return {"price": price} diff --git a/examples/feature_flags/src/working_with_own_s3_store_provider_features.json b/examples/feature_flags/src/working_with_own_s3_store_provider_features.json new file mode 100644 index 00000000000..fe692cdf0c3 --- /dev/null +++ b/examples/feature_flags/src/working_with_own_s3_store_provider_features.json @@ -0,0 +1,5 @@ +{ + "ten_percent_off_campaign": { + "default": true + } +} diff --git a/examples/feature_flags/src/working_with_own_s3_store_provider_payload.json b/examples/feature_flags/src/working_with_own_s3_store_provider_payload.json new file mode 100644 index 00000000000..b2a71282f8e --- /dev/null +++ b/examples/feature_flags/src/working_with_own_s3_store_provider_payload.json @@ -0,0 +1,4 @@ +{ + "product": "laptop", + "price": 1000 +}