diff --git a/README.md b/README.md index da2f9c5a964..89889bd3a92 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ A suite of Python utilities for AWS Lambda functions to ease adopting best pract * **[Event source data classes](https://awslabs.github.io/aws-lambda-powertools-python/latest/utilities/data_classes/)** - Data classes describing the schema of common Lambda event triggers * **[Parser](https://awslabs.github.io/aws-lambda-powertools-python/latest/utilities/parser/)** - Data parsing and deep validation using Pydantic * **[Idempotency](https://awslabs.github.io/aws-lambda-powertools-python/latest/utilities/idempotency/)** - Convert your Lambda functions into idempotent operations which are safe to retry +* **[Feature Flags](./utilities/feature_flags.md)** - A simple rule engine to evaluate when one or multiple features should be enabled depending on the input ### Installation diff --git a/aws_lambda_powertools/utilities/feature_flags/appconfig.py b/aws_lambda_powertools/utilities/feature_flags/appconfig.py index df506940ee1..30c70b6c590 100644 --- a/aws_lambda_powertools/utilities/feature_flags/appconfig.py +++ b/aws_lambda_powertools/utilities/feature_flags/appconfig.py @@ -21,7 +21,7 @@ def __init__( environment: str, application: str, name: str, - cache_seconds: int, + max_age: int = 5, sdk_config: Optional[Config] = None, envelope: Optional[str] = "", jmespath_options: Optional[Dict] = None, @@ -36,8 +36,8 @@ def __init__( AppConfig application name, e.g. 'powertools' name: str AppConfig configuration name e.g. `my_conf` - cache_seconds: int - cache expiration time, how often to call AppConfig to fetch latest configuration + max_age: int + cache expiration time in seconds, or how often to call AppConfig to fetch latest configuration sdk_config: Optional[Config] Botocore Config object to pass during client initialization envelope : Optional[str] @@ -49,7 +49,7 @@ def __init__( self.environment = environment self.application = application self.name = name - self.cache_seconds = cache_seconds + self.cache_seconds = max_age self.config = sdk_config self.envelope = envelope self.jmespath_options = jmespath_options diff --git a/aws_lambda_powertools/utilities/feature_flags/feature_flags.py b/aws_lambda_powertools/utilities/feature_flags/feature_flags.py index e7bde21c25b..50d65175e51 100644 --- a/aws_lambda_powertools/utilities/feature_flags/feature_flags.py +++ b/aws_lambda_powertools/utilities/feature_flags/feature_flags.py @@ -24,7 +24,7 @@ def __init__(self, store: StoreProvider): environment="test", application="powertools", name="test_conf_name", - cache_seconds=300, + max_age=300, envelope="features" ) diff --git a/docs/index.md b/docs/index.md index 104ed1d85d6..9220f2e1ca7 100644 --- a/docs/index.md +++ b/docs/index.md @@ -212,6 +212,7 @@ aws serverlessrepo list-application-versions \ [Event source data classes](./utilities/data_classes.md) | Data classes describing the schema of common Lambda event triggers [Parser](./utilities/parser.md) | Data parsing and deep validation using Pydantic [Idempotency](./utilities/idempotency.md) | Idempotent Lambda handler +[Feature Flags](./utilities/feature_flags.md) | A simple rule engine to evaluate when one or multiple features should be enabled depending on the input ## Environment variables diff --git a/docs/media/feat_flags_evaluation_workflow.png b/docs/media/feat_flags_evaluation_workflow.png new file mode 100644 index 00000000000..deca3dfc297 Binary files /dev/null and b/docs/media/feat_flags_evaluation_workflow.png differ diff --git a/docs/utilities/feature_flags.md b/docs/utilities/feature_flags.md index b450d45806c..806680e0794 100644 --- a/docs/utilities/feature_flags.md +++ b/docs/utilities/feature_flags.md @@ -3,55 +3,647 @@ title: Feature flags 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. +!!! note "This is currently in Beta, as we might change Store parameters in the next release." -!!! tip "For simpler use cases where a feature is simply on or off for all users, use [Parameters](parameters.md) utility instead." +The feature flags utility provides a simple rule engine to define when one or multiple features should be enabled depending on the input. ## Terminology -Feature flags are used to modify a system behaviour without having to change their code. These flags can be static or dynamic. +Feature flags are used to modify behaviour without changing the application's code. These flags can be **static** or **dynamic**. -**Static feature flags** are commonly used for long-lived behaviours that will rarely change, for example `TRACER_ENABLED=True`. These are better suited for [Parameters utility](parameters.md). +**Static flags**. Indicates something is simply `on` or `off`, for example `TRACER_ENABLED=True`. -**Dynamic feature flags** are typically used for experiments where you'd want to enable a feature for a limited set of customers, for example A/B testing and Canary releases. These are better suited for this utility, as you can create multiple conditions on whether a feature flag should be `True` or `False`. +**Dynamic flags**. Indicates something can have varying states, for example enable a premium feature for customer X not Y. -That being said, be mindful that feature flags can increase your application complexity over time if you're not careful; use them sparingly. +!!! tip "You can use [Parameters utility](parameters.md) for static flags while this utility can do both static and dynamic feature flags." -!!! tip "Read [this article](https://martinfowler.com/articles/feature-toggles.html){target="_blank"} for more details on different types of feature flags and trade-offs" +!!! warning "Be mindful that feature flags can increase the complexity of your application over time; use them sparingly." -## Key features +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) -> TODO: Revisit once getting started and advanced sections are complete +## 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 -* Bring your own configuration store +* Support for static feature flags to simply turn on/off a feature without rules ## Getting started + ### IAM Permissions -By default, this utility provides AWS AppConfig as a configuration store. As such, you IAM Role needs permission - `appconfig:GetConfiguration` - to fetch feature flags from AppConfig. +Your Lambda function must have `appconfig:GetConfiguration` IAM permission in order to fetch configuration from AWS AppConfig. + +### 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. + +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 Feature flags sample template + Resources: + FeatureStoreApp: + Type: AWS::AppConfig::Application + Properties: + Description: "AppConfig Appliction for feature toggles" + Name: my-app + + FeatureStoreDevEnv: + Type: AWS::AppConfig::Environment + Properties: + ApplicationId: !Ref FeatureStoreApp + Description: "Development Environment for the App Config Store" + Name: "development" + + FeatureStoreConfigProfile: + Type: AWS::AppConfig::ConfigurationProfile + Properties: + ApplicationId: !Ref FeatureStoreApp + Name: "MyTestProfile" + 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 + ``` + +=== "CDK" + + ```typescript hl_lines="2-7 13-32 34-35 40 47 54" + import * as cdk from '@aws-cdk/core'; + import { + CfnApplication, + CfnConfigurationProfile, CfnDeployment, + CfnEnvironment, + CfnHostedConfigurationVersion + } from "@aws-cdk/aws-appconfig"; + + export class CdkStack extends cdk.Stack { + constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) { + super(scope, id, props); + + const featureConfig = { + "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 + } + } + + const app = new CfnApplication(this, "app", {name: "productapp"}); + const env = new CfnEnvironment(this, "env", { + applicationId: app.ref, + name: "dev-env" + }); + + const configProfile = new CfnConfigurationProfile(this, "profile", { + applicationId: app.ref, + locationUri: "hosted", + name: "configProfile" + }); + + + const hostedConfigVersion = new CfnHostedConfigurationVersion(this, "version", { + applicationId: app.ref, + configurationProfileId: configProfile.ref, + content: JSON.stringify(featureConfig), + contentType: "application/json" + }); + + new CfnDeployment(this, "deploy", { + applicationId: app.ref, + configurationProfileId: configProfile.ref, + configurationVersion: hostedConfigVersion.ref, + deploymentStrategyId: "AppConfig.AllAtOnce", + environmentId: env.ref + }); + } + } + ``` + +### Evaluating a single feature flag + +To get started, you'd need to initialize `AppConfigStore` and `FeatureFlags`. Then call `FeatureFlags` `evaluate` method to fetch, validate, and evaluate your feature. + +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) + + 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="has_premium_features", + context=ctx, default=False) + if has_premium_features: + # enable premium features + ... + ``` + +=== "event.json" + + ```json hl_lines="3" + { + "username": "lessa", + "tier": "premium", + "basked_id": "random_id" + } + ``` +=== "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 + } + } + ``` + +#### Static flags + +We have a static flag named `ten_percent_off_campaign`. Meaning, there are no conditional rules, it's either ON or OFF for all customers. + +In this case, we could omit the `context` parameter and simply evaluate whether we should apply the 10% discount. + +=== "app.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) + + if apply_discount: + # apply 10% discount to product + ... + ``` + +=== "features.json" + + ```json hl_lines="2-3" + { + "ten_percent_off_campaign": { + "default": false + } + } + ``` + +### Getting all enabled features + +As you might have noticed, each `evaluate` call means an API call to the Store and the more features you have the more costly this becomes. + +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.api_gateway import ApiGatewayResolver + from aws_lambda_powertools.utilities.feature_flags import FeatureFlags, AppConfigStore + + app = ApiGatewayResolver() + + 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 + ... -### Creating feature flags + def lambda_handler(event, context): + return app.resolve(event, context) + ``` -> NOTE: Explain schema, provide sample boto3 script and CFN to create one +=== "event.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": "IN", + "key": "CloudFront-Viewer-Country", + "value": ["NL", "IE", "UK", "PL", "PT"}, + } + ] + } + } + } + } + ``` + + +## Advanced + +### Schema + +This utility expects a certain schema to be stored as JSON within AWS AppConfig. + +#### Features + +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). + +=== "minimal_schema.json" + ```json hl_lines="2-3" + { + "global_feature": { + "default": true + } + } + ``` + +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. #### Rules +When adding `rules` to a feature, they must contain: + +1. A rule name as a key +2. `when_match` boolean value that should be used when conditions match +3. A list of `conditions` for evaluation + +=== "feature_with_rules.json" + + ```json hl_lines="4-11" + { + "premium_feature": { + "default": false, + "rules": { + "customer tier equals premium": { + "when_match": true, + "conditions": [ + { + "action": "EQUALS", + "key": "tier", + "value": "premium" + } + ] + } + } + } + } + ``` + +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. + +#### Conditions + +The `conditions` block is a list of conditions that contain `action`, `key`, and `value` keys: + +=== "conditions.json" + ```json hl_lines="8-11" + { + ... + "conditions": [ + { + "action": "EQUALS", + "key": "tier", + "value": "premium" + } + ] + } + ``` + +The `action` configuration can have 5 different values: `EQUALS`, `STARTSWITH`, `ENDSWITH`, `IN`, `NOT_IN`. + +The `key` and `value` will be compared to the input from the context parameter. + +**For multiple conditions**, we will evaluate the list of conditions as a logical `AND`, so all conditions needs to match to return `when_match` value. + +#### Rule engine flowchart + +Now that you've seen all properties of a feature flag schema, this flowchart describes how the rule engines makes a decision on when to return `True` or `False`. + +![Rule engine ](../media/feat_flags_evaluation_workflow.png) + +### Adjusting in-memory cache + +By default, we cache configuration retrieved from the Store for 5 seconds for performance and reliability reasons. + +You can override `max_age` parameter when instantiating the store. + +```python hl_lines="7" +from aws_lambda_powertools.utilities.feature_flags import FeatureFlags, AppConfigStore + +app_config = AppConfigStore( + environment="dev", + application="product-catalogue", + name="features", + max_age=300 +) +``` + +### Envelope + +There are scenarios where you might want to include feature flags as part of an existing application configuration. +For this to work, you need to use a JMESPath expression via the `envelope` parameter to extract that key as the feature flags configuration. -### Fetching a single feature flag +=== "app.py" -### Fetching all feature flags + ```python hl_lines="7" + from aws_lambda_powertools.utilities.feature_flags import FeatureFlags, AppConfigStore -### Advanced + app_config = AppConfigStore( + environment="dev", + application="product-catalogue", + name="configuration", + envelope = "feature_flags" + ) + ``` -#### Adjusting cache TTL +=== "configuration.json" -### Partially enabling features + ```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 + } + } + } + ``` + +### 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. + +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 +**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"} + +=== "appconfig_store_example.py" + +```python hl_lines="19-25" +from botocore.config import Config + +import jmespath + +boto_config = Config(read_timeout=10, retries={"total_max_attempts": 2}) + +# Custom JMESPath functions +class CustomFunctions(jmespath.functions.Functions): + + @jmespath.functions.signature({'types': ['string']}) + def _func_special_decoder(self, s): + return my_custom_decoder_logic(s) + + +custom_jmespath_options = {"custom_functions": CustomFunctions()} + + +app_config = AppConfigStore( + environment="dev", + application="product-catalogue", + name="configuration", + max_age=120, + envelope = "features", + sdk_config=boto_config, + jmespath_options=custom_jmespath_options +) +``` -### Bring your own store provider ## Testing your code -> NOTE: Share example on how customers can unit test their feature flags +You can unit test your feature flags locally and independently without setting up AWS AppConfig. + +`AppConfigStore` only fetches a JSON document with a specific schema. This allows you to mock the response and use it to verify the rule evaluation. + +!!! warning "This excerpt relies on `pytest` and `pytest-mock` dependencies" + +=== "test_feature_flags_independently.py" + + ```python hl_lines="9-11" + from typing import Dict, List, Optional + + 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": expected_value, + "rules": { + "tenant id equals 12345": { + "when_match": True, + "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/tests/functional/feature_flags/test_feature_flags.py b/tests/functional/feature_flags/test_feature_flags.py index c0f463c78d0..9d647a81d2f 100644 --- a/tests/functional/feature_flags/test_feature_flags.py +++ b/tests/functional/feature_flags/test_feature_flags.py @@ -26,7 +26,7 @@ def init_feature_flags( environment="test_env", application="test_app", name="test_conf_name", - cache_seconds=600, + max_age=600, sdk_config=config, envelope=envelope, jmespath_options=jmespath_options, @@ -42,7 +42,7 @@ def init_fetcher_side_effect(mocker, config: Config, side_effect) -> AppConfigSt environment="env", application="application", name="conf", - cache_seconds=1, + max_age=1, sdk_config=config, )