From faec4ce49194f619513227173f0d7ab070156d15 Mon Sep 17 00:00:00 2001 From: heitorlessa Date: Tue, 8 Jun 2021 09:46:06 +0200 Subject: [PATCH 01/36] feat(logger): add option to clear state per invocation --- aws_lambda_powertools/logging/logger.py | 19 +++++-- docs/core/logger.md | 72 ++++++++++++++++++++++++- tests/functional/test_logger.py | 20 +++++++ 3 files changed, 106 insertions(+), 5 deletions(-) diff --git a/aws_lambda_powertools/logging/logger.py b/aws_lambda_powertools/logging/logger.py index 3231f30eccd..ca01a5762a0 100644 --- a/aws_lambda_powertools/logging/logger.py +++ b/aws_lambda_powertools/logging/logger.py @@ -260,12 +260,18 @@ def _configure_sampling(self): ) def inject_lambda_context( - self, lambda_handler: Callable[[Dict, Any], Any] = None, log_event: bool = None, correlation_id_path: str = None + self, + lambda_handler: Callable[[Dict, Any], Any] = None, + log_event: bool = None, + correlation_id_path: str = None, + clear_custom_keys: bool = False, ): """Decorator to capture Lambda contextual info and inject into logger Parameters ---------- + clear_custom_keys : bool, optional + Instructs logger to remove any custom keys previously added lambda_handler : Callable Method to inject the lambda context log_event : bool, optional @@ -311,7 +317,10 @@ def handler(event, context): if lambda_handler is None: logger.debug("Decorator called with parameters") return functools.partial( - self.inject_lambda_context, log_event=log_event, correlation_id_path=correlation_id_path + self.inject_lambda_context, + log_event=log_event, + correlation_id_path=correlation_id_path, + clear_custom_keys=clear_custom_keys, ) log_event = resolve_truthy_env_var_choice( @@ -322,7 +331,11 @@ def handler(event, context): def decorate(event, context): lambda_context = build_lambda_context_model(context) cold_start = _is_cold_start() - self.append_keys(cold_start=cold_start, **lambda_context.__dict__) + + if clear_custom_keys: + self.structure_logs(cold_start=cold_start, **lambda_context.__dict__) + else: + self.append_keys(cold_start=cold_start, **lambda_context.__dict__) if correlation_id_path: self.set_correlation_id(jmespath.search(correlation_id_path, event)) diff --git a/docs/core/logger.md b/docs/core/logger.md index a544bf91e4b..d0e2f22a6d8 100644 --- a/docs/core/logger.md +++ b/docs/core/logger.md @@ -231,8 +231,9 @@ We provide [built-in JMESPath expressions](#built-in-correlation-id-expressions) ### Appending additional keys -!!! info "Keys might be persisted across invocations" - Always set additional keys as part of your handler to ensure they have the latest value. Additional keys are kept in memory as part of a Logger instance and might be reused in non-cold start scenarios. +!!! info "Custom keys are persisted across warm invocations" + Always set additional keys as part of your handler to ensure they have the latest value, or explicitly clear them with `clear_custom_keys=True`as explained above. + You can append additional keys using either mechanism: @@ -426,6 +427,73 @@ You can remove any additional key from Logger state using `remove_keys`. } ``` +#### Removing all custom keys + +Logger is commonly initialized in the global scope. Due to [Lambda Execution Context reuse](https://docs.aws.amazon.com/lambda/latest/dg/runtimes-context.html), this means that custom keys can be persisted across invocations. If you want all custom keys to be deleted, you can use `clear_custom_keys=True` param in `inject_lambda_context` decorator. + +!!! info + This is useful when you add multiple custom keys conditionally, instead of setting a default `None` value if not present. Any key with `None` value is automatically removed by Logger. + +!!! danger "This can have unintended side effects if you use Layers" + Lambda Layers code is imported before the Lambda handler. + + This means that `clear_custom_keys=True` will instruct Logger to remove any keys previously added before Lambda handler execution proceeds. + + You can either avoid running any code as part of Lambda Layers global scope, or override keys with their latest value as part of handler's execution. + +=== "collect.py" + + ```python hl_lines="5 8" + from aws_lambda_powertools import Logger + + logger = Logger(service="payment") + + @logger.inject_lambda_context(clear_custom_keys=True) + def handler(event, context): + if event.get("special_key"): + # Should only be available in the first request log + # as the second request doesn't contain `special_key` + logger.append_keys(debugging_key="value") + + logger.info("Collecting payment") + ``` + +=== "#1 request" + + ```json hl_lines="7" + { + "level": "INFO", + "location": "collect.handler:10", + "message": "Collecting payment", + "timestamp": "2021-05-03 11:47:12,494+0200", + "service": "payment", + "special_key": "debug_key", + "cold_start": true, + "lambda_function_name": "test", + "lambda_function_memory_size": 128, + "lambda_function_arn": "arn:aws:lambda:eu-west-1:12345678910:function:test", + "lambda_request_id": "52fdfc07-2182-154f-163f-5f0f9a621d72" + } + ``` + +=== "#2 request" + + ```json hl_lines="7" + { + "level": "INFO", + "location": "collect.handler:10", + "message": "Collecting payment", + "timestamp": "2021-05-03 11:47:12,494+0200", + "service": "payment", + "cold_start": false, + "lambda_function_name": "test", + "lambda_function_memory_size": 128, + "lambda_function_arn": "arn:aws:lambda:eu-west-1:12345678910:function:test", + "lambda_request_id": "52fdfc07-2182-154f-163f-5f0f9a621d72" + } + ``` + + ### Logging exceptions Use `logger.exception` method to log contextual information about exceptions. Logger will include `exception_name` and `exception` keys to aid troubleshooting and error enumeration. diff --git a/tests/functional/test_logger.py b/tests/functional/test_logger.py index ba6e82b72af..320c2ea1b7d 100644 --- a/tests/functional/test_logger.py +++ b/tests/functional/test_logger.py @@ -562,3 +562,23 @@ def handler(event, context): # THEN we should output to a file not stdout log = log_file.read_text() assert "custom handler" in log + + +def test_clear_keys_on_inject_lambda_context(lambda_context, stdout, service_name): + # GIVEN + logger = Logger(service=service_name, stream=stdout) + + # WHEN clear_custom_key is set and a key was conditionally added in the first invocation + @logger.inject_lambda_context(clear_custom_keys=True) + def handler(event, context): + if event.get("add_key"): + logger.append_keys(my_key="value") + logger.info("Foo") + + # THEN custom key should only exist in the first log + handler({"add_key": True}, lambda_context) + handler({}, lambda_context) + + first_log, second_log = capture_multiple_logging_statements_output(stdout) + assert "my_key" in first_log + assert "my_key" not in second_log From 9019f302cc19f7b4cb024f4640cf1ebff8add456 Mon Sep 17 00:00:00 2001 From: heitorlessa Date: Tue, 8 Jun 2021 09:56:18 +0200 Subject: [PATCH 02/36] refactor: rename to remove_custom_keys --- aws_lambda_powertools/logging/logger.py | 8 ++++---- docs/core/logger.md | 8 ++++---- tests/functional/test_logger.py | 4 ++-- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/aws_lambda_powertools/logging/logger.py b/aws_lambda_powertools/logging/logger.py index ca01a5762a0..ef64e0d502b 100644 --- a/aws_lambda_powertools/logging/logger.py +++ b/aws_lambda_powertools/logging/logger.py @@ -264,13 +264,13 @@ def inject_lambda_context( lambda_handler: Callable[[Dict, Any], Any] = None, log_event: bool = None, correlation_id_path: str = None, - clear_custom_keys: bool = False, + remove_custom_keys: bool = False, ): """Decorator to capture Lambda contextual info and inject into logger Parameters ---------- - clear_custom_keys : bool, optional + remove_custom_keys : bool, optional Instructs logger to remove any custom keys previously added lambda_handler : Callable Method to inject the lambda context @@ -320,7 +320,7 @@ def handler(event, context): self.inject_lambda_context, log_event=log_event, correlation_id_path=correlation_id_path, - clear_custom_keys=clear_custom_keys, + remove_custom_keys=remove_custom_keys, ) log_event = resolve_truthy_env_var_choice( @@ -332,7 +332,7 @@ def decorate(event, context): lambda_context = build_lambda_context_model(context) cold_start = _is_cold_start() - if clear_custom_keys: + if remove_custom_keys: self.structure_logs(cold_start=cold_start, **lambda_context.__dict__) else: self.append_keys(cold_start=cold_start, **lambda_context.__dict__) diff --git a/docs/core/logger.md b/docs/core/logger.md index d0e2f22a6d8..b2ac0bf568f 100644 --- a/docs/core/logger.md +++ b/docs/core/logger.md @@ -232,7 +232,7 @@ We provide [built-in JMESPath expressions](#built-in-correlation-id-expressions) ### Appending additional keys !!! info "Custom keys are persisted across warm invocations" - Always set additional keys as part of your handler to ensure they have the latest value, or explicitly clear them with `clear_custom_keys=True`as explained above. + Always set additional keys as part of your handler to ensure they have the latest value, or explicitly clear them with `remove_custom_keys=True`as explained above. You can append additional keys using either mechanism: @@ -429,7 +429,7 @@ You can remove any additional key from Logger state using `remove_keys`. #### Removing all custom keys -Logger is commonly initialized in the global scope. Due to [Lambda Execution Context reuse](https://docs.aws.amazon.com/lambda/latest/dg/runtimes-context.html), this means that custom keys can be persisted across invocations. If you want all custom keys to be deleted, you can use `clear_custom_keys=True` param in `inject_lambda_context` decorator. +Logger is commonly initialized in the global scope. Due to [Lambda Execution Context reuse](https://docs.aws.amazon.com/lambda/latest/dg/runtimes-context.html), this means that custom keys can be persisted across invocations. If you want all custom keys to be deleted, you can use `remove_custom_keys=True` param in `inject_lambda_context` decorator. !!! info This is useful when you add multiple custom keys conditionally, instead of setting a default `None` value if not present. Any key with `None` value is automatically removed by Logger. @@ -437,7 +437,7 @@ Logger is commonly initialized in the global scope. Due to [Lambda Execution Con !!! danger "This can have unintended side effects if you use Layers" Lambda Layers code is imported before the Lambda handler. - This means that `clear_custom_keys=True` will instruct Logger to remove any keys previously added before Lambda handler execution proceeds. + This means that `remove_custom_keys=True` will instruct Logger to remove any keys previously added before Lambda handler execution proceeds. You can either avoid running any code as part of Lambda Layers global scope, or override keys with their latest value as part of handler's execution. @@ -448,7 +448,7 @@ Logger is commonly initialized in the global scope. Due to [Lambda Execution Con logger = Logger(service="payment") - @logger.inject_lambda_context(clear_custom_keys=True) + @logger.inject_lambda_context(remove_custom_keys=True) def handler(event, context): if event.get("special_key"): # Should only be available in the first request log diff --git a/tests/functional/test_logger.py b/tests/functional/test_logger.py index 320c2ea1b7d..10297ba5e39 100644 --- a/tests/functional/test_logger.py +++ b/tests/functional/test_logger.py @@ -568,8 +568,8 @@ def test_clear_keys_on_inject_lambda_context(lambda_context, stdout, service_nam # GIVEN logger = Logger(service=service_name, stream=stdout) - # WHEN clear_custom_key is set and a key was conditionally added in the first invocation - @logger.inject_lambda_context(clear_custom_keys=True) + # WHEN remove_custom_keys is set and a key was conditionally added in the first invocation + @logger.inject_lambda_context(remove_custom_keys=True) def handler(event, context): if event.get("add_key"): logger.append_keys(my_key="value") From d1b7c2208269bf7857e8b8e38aaa2f70726ec84d Mon Sep 17 00:00:00 2001 From: heitorlessa Date: Tue, 8 Jun 2021 10:00:36 +0200 Subject: [PATCH 03/36] refactor: rename to clear_state --- aws_lambda_powertools/logging/logger.py | 8 ++++---- docs/core/logger.md | 10 +++++----- tests/functional/test_logger.py | 6 +++--- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/aws_lambda_powertools/logging/logger.py b/aws_lambda_powertools/logging/logger.py index ef64e0d502b..689409d9813 100644 --- a/aws_lambda_powertools/logging/logger.py +++ b/aws_lambda_powertools/logging/logger.py @@ -264,13 +264,13 @@ def inject_lambda_context( lambda_handler: Callable[[Dict, Any], Any] = None, log_event: bool = None, correlation_id_path: str = None, - remove_custom_keys: bool = False, + clear_state: bool = False, ): """Decorator to capture Lambda contextual info and inject into logger Parameters ---------- - remove_custom_keys : bool, optional + clear_state : bool, optional Instructs logger to remove any custom keys previously added lambda_handler : Callable Method to inject the lambda context @@ -320,7 +320,7 @@ def handler(event, context): self.inject_lambda_context, log_event=log_event, correlation_id_path=correlation_id_path, - remove_custom_keys=remove_custom_keys, + clear_state=clear_state, ) log_event = resolve_truthy_env_var_choice( @@ -332,7 +332,7 @@ def decorate(event, context): lambda_context = build_lambda_context_model(context) cold_start = _is_cold_start() - if remove_custom_keys: + if clear_state: self.structure_logs(cold_start=cold_start, **lambda_context.__dict__) else: self.append_keys(cold_start=cold_start, **lambda_context.__dict__) diff --git a/docs/core/logger.md b/docs/core/logger.md index b2ac0bf568f..675768b3de7 100644 --- a/docs/core/logger.md +++ b/docs/core/logger.md @@ -232,7 +232,7 @@ We provide [built-in JMESPath expressions](#built-in-correlation-id-expressions) ### Appending additional keys !!! info "Custom keys are persisted across warm invocations" - Always set additional keys as part of your handler to ensure they have the latest value, or explicitly clear them with `remove_custom_keys=True`as explained above. + Always set additional keys as part of your handler to ensure they have the latest value, or explicitly clear them with `clear_state=True`as explained above. You can append additional keys using either mechanism: @@ -427,9 +427,9 @@ You can remove any additional key from Logger state using `remove_keys`. } ``` -#### Removing all custom keys +#### Clearing all state -Logger is commonly initialized in the global scope. Due to [Lambda Execution Context reuse](https://docs.aws.amazon.com/lambda/latest/dg/runtimes-context.html), this means that custom keys can be persisted across invocations. If you want all custom keys to be deleted, you can use `remove_custom_keys=True` param in `inject_lambda_context` decorator. +Logger is commonly initialized in the global scope. Due to [Lambda Execution Context reuse](https://docs.aws.amazon.com/lambda/latest/dg/runtimes-context.html), this means that custom keys can be persisted across invocations. If you want all custom keys to be deleted, you can use `clear_state=True` param in `inject_lambda_context` decorator. !!! info This is useful when you add multiple custom keys conditionally, instead of setting a default `None` value if not present. Any key with `None` value is automatically removed by Logger. @@ -437,7 +437,7 @@ Logger is commonly initialized in the global scope. Due to [Lambda Execution Con !!! danger "This can have unintended side effects if you use Layers" Lambda Layers code is imported before the Lambda handler. - This means that `remove_custom_keys=True` will instruct Logger to remove any keys previously added before Lambda handler execution proceeds. + This means that `clear_state=True` will instruct Logger to remove any keys previously added before Lambda handler execution proceeds. You can either avoid running any code as part of Lambda Layers global scope, or override keys with their latest value as part of handler's execution. @@ -448,7 +448,7 @@ Logger is commonly initialized in the global scope. Due to [Lambda Execution Con logger = Logger(service="payment") - @logger.inject_lambda_context(remove_custom_keys=True) + @logger.inject_lambda_context(clear_state=True) def handler(event, context): if event.get("special_key"): # Should only be available in the first request log diff --git a/tests/functional/test_logger.py b/tests/functional/test_logger.py index 10297ba5e39..44249af6250 100644 --- a/tests/functional/test_logger.py +++ b/tests/functional/test_logger.py @@ -564,12 +564,12 @@ def handler(event, context): assert "custom handler" in log -def test_clear_keys_on_inject_lambda_context(lambda_context, stdout, service_name): +def test_clear_state_on_inject_lambda_context(lambda_context, stdout, service_name): # GIVEN logger = Logger(service=service_name, stream=stdout) - # WHEN remove_custom_keys is set and a key was conditionally added in the first invocation - @logger.inject_lambda_context(remove_custom_keys=True) + # WHEN clear_state is set and a key was conditionally added in the first invocation + @logger.inject_lambda_context(clear_state=True) def handler(event, context): if event.get("add_key"): logger.append_keys(my_key="value") From a6916762e40d12fafb23c9e23b048e56e3f30cdb Mon Sep 17 00:00:00 2001 From: heitorlessa Date: Tue, 8 Jun 2021 10:23:44 +0200 Subject: [PATCH 04/36] docs: fix anchor --- docs/core/logger.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/core/logger.md b/docs/core/logger.md index 675768b3de7..45119ca51d6 100644 --- a/docs/core/logger.md +++ b/docs/core/logger.md @@ -232,7 +232,7 @@ We provide [built-in JMESPath expressions](#built-in-correlation-id-expressions) ### Appending additional keys !!! info "Custom keys are persisted across warm invocations" - Always set additional keys as part of your handler to ensure they have the latest value, or explicitly clear them with `clear_state=True`as explained above. + Always set additional keys as part of your handler to ensure they have the latest value, or explicitly clear them with [`clear_state=True`](#clearing-all-state). You can append additional keys using either mechanism: From 7a66dab2c94afd1f57fe9358c56f4341ae7513c8 Mon Sep 17 00:00:00 2001 From: Alexander Melnyk Date: Fri, 6 Aug 2021 09:02:03 +0200 Subject: [PATCH 05/36] docs(feature-flags): add basic docs --- docs/utilities/feature_flags.md | 236 ++++++++++++++++++++++++++++++-- 1 file changed, 221 insertions(+), 15 deletions(-) diff --git a/docs/utilities/feature_flags.md b/docs/utilities/feature_flags.md index b450d45806c..f83bc5e2d48 100644 --- a/docs/utilities/feature_flags.md +++ b/docs/utilities/feature_flags.md @@ -1,57 +1,263 @@ --- -title: Feature flags -description: Utility +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. +The feature flags utility provides a simple rule engine to define when one or multiple features should be enabled +depending on the input. -!!! tip "For simpler use cases where a feature is simply on or off for all users, use [Parameters](parameters.md) utility instead." +!!! tip "For simpler use cases where a feature is simply on or off for all users, use [Parameters](parameters.md) +utility instead." ## 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 a system behaviour without having to change their 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 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). -**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 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`. -That being said, be mindful that feature flags can increase your application complexity over time if you're not careful; use them sparingly. +That being said, be mindful that feature flags can increase your application complexity over time if you're not careful; +use them sparingly. -!!! 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" +!!! 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" ## Key features -> TODO: Revisit once getting started and advanced sections are complete - * 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 ## Getting started + +### Create a feature flag store + +By default, this utility provides AWS AppConfig as a configuration store. To create a dedicate you can use this +cloudformation template: + +=== "template.yaml" + +```yaml +AWSTemplateFormatVersion: "2010-09-09" +Description: A 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" + } + ] + } + } + }, + "feature2": { + "default": true + } + } + ContentType: 'application/json' + + ConfigDeployment: + Type: AWS::AppConfig::Deployment + Properties: + ApplicationId: !Ref FeatureStoreApp + ConfigurationProfileId: !Ref FeatureStoreConfigProfile + ConfigurationVersion: !Ref HostedConfigVersion + DeploymentStrategyId: "AppConfig.AllAtOnce" + EnvironmentId: !Ref FeatureStoreDevEnv +``` + +The `Content` parameter is a json structure of the feature flags and rules. + ### 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. +Because powertools needs to fetch the configuration from the AppConfig, you need to add `appconfig:GetConfiguration` +action to your function. ### Creating feature flags -> NOTE: Explain schema, provide sample boto3 script and CFN to create one +When using the feature flags utility powertools expects specific schema stored in your AppConfig configuration which +incldues: + +* list of named features +* default value +* set of rules that powertools will evaluate + +Each rule should contain: + +* value if condition is met +* list of conditions with `action`, `key` and `value` attributes. + +Here is small example of a premium feature with a default value `false` and one rule with a condition: if the passed +context equals `{"tier": "premium"}` return true. + +```json +{ + "premium_feature": { + "default": false, + "rules": { + "customer tier equals premium": { + "when_match": true, + "conditions": [ + { + "action": "EQUALS", + "key": "tier", + "value": "premium" + } + ] + } + } + } +} +``` #### Rules +The rules are evaluated based on conditions which have the structure: + +```json +{ + "action": "EQUALS", + "key": "tier", + "value": "premium" +} +``` + +The `action` configuration can have 4 different values: `EQUALS`, `STARTSWITH`, `ENDSWITH` and `CONTAINS`. If you have +multiple rules powertools will evaluate every rule with a logical AND. +### Setup a feature flag store + +First setup is to setup the `AppConfigStore` based on the AppConfig parameters you have created with the previous +CloudFormation template. + +```python + +app_config = AppConfigStore( + environment="FeatureStore", + application="product-dev", + name="features", + cache_seconds=300 +) + +``` ### Fetching a single feature flag +To fetch a single feature, setup the `FeatureFlags` instance and call the `evaluate` method. + +```python +feature_flags = FeatureFlags(store=app_config) +ctx = {"username": "lessa", "tier": "premium", "location": "NL"} + +has_premium_features: bool = feature_flags.evaluate(name="premium_features", + context=ctx, + default=False) +``` + +The `context` parameter is optional and will be used for rule evaluation. In this case we have the `key` set +as `username` and `value` set to `lessa`. Feature flag schema to match this could look like this: + +```json +{ + "premium_features": { + "default": false, + "rules": { + "username is lessa and tier is premium": { + "when_match": true, + "conditions": [ + { + "action": "EQUALS", + "key": "username", + "value": "lessa" + }, + { + "action": "EQUALS", + "key": "tier", + "value": "premium" + } + ] + } + } + } +} +``` + + ### Fetching all feature flags +In cases where you need to get a list of all the features that are enabled you can use `get_enabled_features` method: + +```python +feature_flags = FeatureFlags(store=app_config) +ctx = {"username": "lessa", "tier": "premium", "location": "NL"} + +all_features: list[str] = feature_flags.get_enabled_features(context=ctx) +``` +As a result you will get a list of all the names of the features from your feaute flag configuration. +For example if you have two features with the following configuration and both are evaluated to `true`: + +```json hl_lines="2 6" +{ + "feature1": { + "default": false, + "rules": {...} + }, + "feature2": { + "default": false, + "rules": {...} + }, + ... + } +} +``` +The response of `get_enabled_features` will be `["feautre1", "feature2"]`. + ### Advanced #### Adjusting cache TTL + + ### Partially enabling features ### Bring your own store provider ## Testing your code - -> NOTE: Share example on how customers can unit test their feature flags From b34643d749401324fe17db36bb456b8f9980b930 Mon Sep 17 00:00:00 2001 From: heitorlessa Date: Fri, 6 Aug 2021 09:49:07 +0200 Subject: [PATCH 06/36] docs: fix tip rendering banner --- docs/utilities/feature_flags.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/utilities/feature_flags.md b/docs/utilities/feature_flags.md index f83bc5e2d48..c8f034bd182 100644 --- a/docs/utilities/feature_flags.md +++ b/docs/utilities/feature_flags.md @@ -5,8 +5,7 @@ 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. -!!! tip "For simpler use cases where a feature is simply on or off for all users, use [Parameters](parameters.md) -utility instead." +!!! tip "For simpler use cases where a feature is simply on or off for all users, use [Parameters](parameters.md) utility instead." ## Terminology From e5a24fd155fa895ac700ddf9c42c6d771afd3645 Mon Sep 17 00:00:00 2001 From: Alexander Melnyk Date: Fri, 6 Aug 2021 09:51:28 +0200 Subject: [PATCH 07/36] docs(feature-flags): refactored basic section structure --- docs/utilities/feature_flags.md | 153 ++++++++++++++++++-------------- 1 file changed, 88 insertions(+), 65 deletions(-) diff --git a/docs/utilities/feature_flags.md b/docs/utilities/feature_flags.md index f83bc5e2d48..d51b9d37483 100644 --- a/docs/utilities/feature_flags.md +++ b/docs/utilities/feature_flags.md @@ -23,6 +23,8 @@ multiple conditions on whether a feature flag should be `True` or `False`. That being said, be mindful that feature flags can increase your application complexity over time if you're not careful; use them sparingly. +TODO: fix tip + !!! 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" @@ -34,8 +36,14 @@ on different types of feature flags and trade-offs" ## Getting started -### Create a feature flag store +### IAM Permissions +Because powertools needs to fetch the configuration from the AppConfig, you need to add `appconfig:GetConfiguration` +action to your function. + +### Required resources + +TODO: link to appconfig By default, this utility provides AWS AppConfig as a configuration store. To create a dedicate you can use this cloudformation template: @@ -106,68 +114,18 @@ Resources: The `Content` parameter is a json structure of the feature flags and rules. -### IAM Permissions - -Because powertools needs to fetch the configuration from the AppConfig, you need to add `appconfig:GetConfiguration` -action to your function. - -### Creating feature flags - -When using the feature flags utility powertools expects specific schema stored in your AppConfig configuration which -incldues: - -* list of named features -* default value -* set of rules that powertools will evaluate - -Each rule should contain: - -* value if condition is met -* list of conditions with `action`, `key` and `value` attributes. - -Here is small example of a premium feature with a default value `false` and one rule with a condition: if the passed -context equals `{"tier": "premium"}` return true. - -```json -{ - "premium_feature": { - "default": false, - "rules": { - "customer tier equals premium": { - "when_match": true, - "conditions": [ - { - "action": "EQUALS", - "key": "tier", - "value": "premium" - } - ] - } - } - } -} -``` - -#### Rules - -The rules are evaluated based on conditions which have the structure: +TODO: add steps to create new version and new deployment for the config -```json -{ - "action": "EQUALS", - "key": "tier", - "value": "premium" -} -``` -The `action` configuration can have 4 different values: `EQUALS`, `STARTSWITH`, `ENDSWITH` and `CONTAINS`. If you have -multiple rules powertools will evaluate every rule with a logical AND. +### Use feature flag store -### Setup a feature flag store First setup is to setup the `AppConfigStore` based on the AppConfig parameters you have created with the previous CloudFormation template. +TOOD: provide full example + +=== app.py ```python app_config = AppConfigStore( @@ -176,10 +134,21 @@ app_config = AppConfigStore( name="features", cache_seconds=300 ) +feature_flags = FeatureFlags(store=app_config) +ctx = {"username": "lessa", "tier": "premium", "location": "NL"} +has_premium_features: bool = feature_flags.evaluate(name="premium_features", + context=ctx, + default=False) ``` -### Fetching a single feature flag +=== schema.json +```json + +``` + +### Evaluating a single feature flag + To fetch a single feature, setup the `FeatureFlags` instance and call the `evaluate` method. @@ -220,18 +189,19 @@ as `username` and `value` set to `lessa`. Feature flag schema to match this coul } ``` +### Get all enabled features -### Fetching all feature flags In cases where you need to get a list of all the features that are enabled you can use `get_enabled_features` method: ```python -feature_flags = FeatureFlags(store=app_config) +feature_flags = Feature****Flags(store=app_config) ctx = {"username": "lessa", "tier": "premium", "location": "NL"} all_features: list[str] = feature_flags.get_enabled_features(context=ctx) ``` -As a result you will get a list of all the names of the features from your feaute flag configuration. -For example if you have two features with the following configuration and both are evaluated to `true`: + +As a result you will get a list of all the names of the features from your feaute flag configuration. For example if you +have two features with the following configuration and both are evaluated to `true`: ```json hl_lines="2 6" { @@ -247,17 +217,70 @@ For example if you have two features with the following configuration and both a } } ``` + The response of `get_enabled_features` will be `["feautre1", "feature2"]`. -### Advanced +### Feature flags schema + +When using the feature flags utility powertools expects specific schema stored in your AppConfig configuration which +incldues: + +* list of named features +* default value +* set of rules that powertools will evaluate + +Each rule should contain: + +* value if condition is met +* list of conditions with `action`, `key` and `value` attributes. + +Here is small example of a premium feature with a default value `false` and one rule with a condition: if the passed +context equals `{"tier": "premium"}` return true. + +```json +{ + "premium_feature": { + "default": false + } +} +``` + +#### Rules + +TODO: explain rules here in detail. The rules are evaluated based on conditions which have the structure: + +```json +{ + "rules": { + "customer tier equals premium": { + "when_match": true, + "conditions": [ + { + "action": "EQUALS", + "key": "tier", + "value": "premium" + } + ] + } + } +} +``` + +#### Conditions + +The `action` configuration can have 4 different values: `EQUALS`, `STARTSWITH`, `ENDSWITH`, `IN`, `NOT_IN`. If you have +multiple rules powertools will evaluate every rule with a logical AND. + -#### Adjusting cache TTL +## Advanced +### Adjusting in-memory cache +### Envelope (any better name) -### Partially enabling features +### Built-in store provider -### Bring your own store provider +#### AppConfig ## Testing your code From 527921ee1081b108ae81975ffc8759c5e316d960 Mon Sep 17 00:00:00 2001 From: Alexander Melnyk Date: Fri, 6 Aug 2021 14:09:12 +0200 Subject: [PATCH 08/36] docs(feature-flags): refactored examples for getting started --- docs/utilities/feature_flags.md | 309 ++++++++++++++++++++------------ 1 file changed, 195 insertions(+), 114 deletions(-) diff --git a/docs/utilities/feature_flags.md b/docs/utilities/feature_flags.md index 7aa0787cfdd..1e790ad6fd3 100644 --- a/docs/utilities/feature_flags.md +++ b/docs/utilities/feature_flags.md @@ -5,7 +5,8 @@ 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. -!!! tip "For simpler use cases where a feature is simply on or off for all users, use [Parameters](parameters.md) utility instead." +!!! tip "For simpler use cases where a feature is simply on or off for all users, use [Parameters](parameters.md) +utility instead." ## Terminology @@ -22,8 +23,6 @@ multiple conditions on whether a feature flag should be `True` or `False`. That being said, be mindful that feature flags can increase your application complexity over time if you're not careful; use them sparingly. -TODO: fix tip - !!! 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" @@ -42,9 +41,9 @@ action to your function. ### Required resources -TODO: link to appconfig -By default, this utility provides AWS AppConfig as a configuration store. To create a dedicate you can use this -cloudformation template: +By default, this utility +provides [AWS AppConfig](https://docs.aws.amazon.com/appconfig/latest/userguide/what-is-appconfig.html) as a +configuration store. To create a dedicate you can use this cloudformation template: === "template.yaml" @@ -115,67 +114,179 @@ The `Content` parameter is a json structure of the feature flags and rules. TODO: add steps to create new version and new deployment for the config +TODO: add CDK example ### Use feature flag store +After you have created and configured `AppConfigStore` and added your feature configuraiton you can use the feature +flags in your code: + +=== "app.py" + + ```python + app_config = AppConfigStore( + environment="dev", + application="product-catalogue", + name="features" + ) + feature_flags = FeatureFlags(store=app_config) + ctx = {"username": "lessa", "tier": "premium", "location": "NL"} + + has_premium_features: bool = feature_flags.evaluate(name="premium_features", + context=ctx, + default=False) + ``` + +=== "features.json" + + ```json + { + "premium_features": { + "default": false, + "rules": { + "customer tier equals premium": { + "when_match": true, + "conditions": [ + { + "action": "EQUALS", + "key": "tier", + "value": "premium" + } + ] + } + } + } + } + ``` -First setup is to setup the `AppConfigStore` based on the AppConfig parameters you have created with the previous -CloudFormation template. +### Evaluating a single feature flag -TOOD: provide full example +To fetch a single feature, setup the `FeatureFlags` instance and call the `evaluate` method. -=== app.py -```python +=== "app.py" -app_config = AppConfigStore( - environment="FeatureStore", - application="product-dev", - name="features", - cache_seconds=300 -) -feature_flags = FeatureFlags(store=app_config) -ctx = {"username": "lessa", "tier": "premium", "location": "NL"} + ```python + feature_flags = FeatureFlags(store=app_config) -has_premium_features: bool = feature_flags.evaluate(name="premium_features", - context=ctx, - default=False) -``` + new_feature_active: bool = feature_flags.evaluate(name="new_feature", + default=False) + ``` -=== schema.json -```json +=== "features.json" -``` + ```json + { + "new_feature": { + "default": true + } + } + ``` + +In this example the feature flag is **static**, which mean it will be evaluated without any additional context such as +user or location. If you want to have **dynamic** feature flags that only works for specific user group or other contex +aware information you need to pass a context object and add rules to your feature configuration. + +=== "app.py" + + ```pyhthon + feature_flags = FeatureFlags(store=app_config) + ctx = {"username": "lessa", "tier": "premium", "location": "NL"} + + has_premium_features: bool = feature_flags.evaluate(name="premium_features", + context=ctx, + default=False + ``` + +=== "features.json" + + ```json + { + "premium_features": { + "default": false, + "rules": { + "customer tier equals premium": { + "when_match": true, + "conditions": [ + { + "action": "EQUALS", + "key": "tier", + "value": "premium" + } + ] + } + } + } + } + ``` -### Evaluating a single feature flag +### Get all enabled features +In cases where you need to get a list of all the features that are enabled you can use `get_enabled_features` method: -To fetch a single feature, setup the `FeatureFlags` instance and call the `evaluate` method. +=== "app.py" + + ```python + feature_flags = FeatureFlags(store=app_config) + ctx = {"username": "lessa", "tier": "premium", "location": "NL"} + + all_features: list[str] = feature_flags.get_enabled_features(context=ctx) + # all_features is evaluated to ["feautre1", "feature2"] + ``` + +=== "features.json" + + ```json hl_lines="2 6" + { + "feature1": { + "default": false, + "rules": {...} + }, + "feature2": { + "default": false, + "rules": {...} + }, + ... + } + } + ``` -```python -feature_flags = FeatureFlags(store=app_config) -ctx = {"username": "lessa", "tier": "premium", "location": "NL"} +As a result you will get a list of all the names of the features from your feature flags configuration. -has_premium_features: bool = feature_flags.evaluate(name="premium_features", - context=ctx, - default=False) -``` +### Feature flags schema -The `context` parameter is optional and will be used for rule evaluation. In this case we have the `key` set -as `username` and `value` set to `lessa`. Feature flag schema to match this could look like this: +When using the feature flags utility powertools expects specific schema stored in your AppConfig configuration. The +minimal requirement is the name of the feature and the default value, for example: ```json { - "premium_features": { + "global_feature": { + "default": true + } +} +``` + +This is a static flag that will be applied to every evaluation within your code. If you need more control and want to +provide context such as user group, permisisons, location or other information you need to add rules to your feature +flag configuration. + +#### Rules + +To use feature flags dynamically you can configure rules in your feature flags configuration and pass context +to `evaluate`. The rules block must have: + +* rule name as a key +* value when the condition is met +* list conditions for evaluation + +```json hl_lines="4-11" + +{ + "premium_feature": { "default": false, "rules": { - "username is lessa and tier is premium": { + "customer tier equals premium": { "when_match": true, "conditions": [ - { - "action": "EQUALS", - "key": "username", - "value": "lessa" - }, { "action": "EQUALS", "key": "tier", @@ -188,89 +299,59 @@ as `username` and `value` set to `lessa`. Feature flag schema to match this coul } ``` -### Get all enabled features - -In cases where you need to get a list of all the features that are enabled you can use `get_enabled_features` method: - -```python -feature_flags = Feature****Flags(store=app_config) -ctx = {"username": "lessa", "tier": "premium", "location": "NL"} - -all_features: list[str] = feature_flags.get_enabled_features(context=ctx) -``` - -As a result you will get a list of all the names of the features from your feaute flag configuration. For example if you -have two features with the following configuration and both are evaluated to `true`: - -```json hl_lines="2 6" -{ - "feature1": { - "default": false, - "rules": {...} - }, - "feature2": { - "default": false, - "rules": {...} - }, - ... - } -} -``` - -The response of `get_enabled_features` will be `["feautre1", "feature2"]`. - - -### Feature flags schema - -When using the feature flags utility powertools expects specific schema stored in your AppConfig configuration which -incldues: - -* list of named features -* default value -* set of rules that powertools will evaluate - -Each rule should contain: +You can have multiple rules with different names. The powertools will return the first result `when_match` of the +matching rule configuration or `default` value when none of the rules apply. -* value if condition is met -* list of conditions with `action`, `key` and `value` attributes. +#### Conditions -Here is small example of a premium feature with a default value `false` and one rule with a condition: if the passed -context equals `{"tier": "premium"}` return true. +The conditions block is a list of `action`, `key` `value`: ```json { - "premium_feature": { - "default": false - } + "action": "EQUALS", + "key": "tier", + "value": "premium" } ``` -#### Rules - -TODO: explain rules here in detail. The rules are evaluated based on conditions which have the structure: - -```json -{ - "rules": { - "customer tier equals premium": { - "when_match": true, - "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. + +If you have multiple conditions powertools will evaluate the list of conditions as a logical AND, so all conditions needs to be +matched to return `when_match` value. + +=== "features.json" + + ```json hl_lines="10-11" + { + "premium_feature": { + "default": false, + "rules": { + "customer tier equals premium": { + "when_match": true, + "conditions": [ + { + "action": "EQUALS", + "key": "tier", + "value": "premium" + } + ] + } } - ] + } } - } -} -``` + ``` -#### Conditions +=== "app.py" -The `action` configuration can have 4 different values: `EQUALS`, `STARTSWITH`, `ENDSWITH`, `IN`, `NOT_IN`. If you have -multiple rules powertools will evaluate every rule with a logical AND. + ```python hl_lines="2" + feature_flags = FeatureFlags(store=app_config) + ctx = {"username": "lessa", "tier": "premium", "location": "NL"} + has_premium_features: bool = feature_flags.evaluate(name="premium_features", + context=ctx, + default=False + ``` ## Advanced From 85d2fc554205d4a2348fe467f852baf1d3083c8b Mon Sep 17 00:00:00 2001 From: Alexander Melnyk Date: Tue, 10 Aug 2021 06:04:15 +0200 Subject: [PATCH 09/36] add external articles links --- docs/utilities/feature_flags.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/utilities/feature_flags.md b/docs/utilities/feature_flags.md index 1e790ad6fd3..aa8ae8472b2 100644 --- a/docs/utilities/feature_flags.md +++ b/docs/utilities/feature_flags.md @@ -5,8 +5,7 @@ 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. -!!! tip "For simpler use cases where a feature is simply on or off for all users, use [Parameters](parameters.md) -utility instead." +!!! tip "For simpler use cases where a feature is simply on or off for all users, use [Parameters](parameters.md) utility instead." ## Terminology @@ -23,8 +22,12 @@ multiple conditions on whether a feature flag should be `True` or `False`. That being said, be mindful that feature flags can increase your application complexity over time if you're not careful; use them sparingly. -!!! 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" +If you want to learn more about feature flags, differen types and trade-offs, check this 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) + ## Key features @@ -221,7 +224,7 @@ aware information you need to pass a context object and add rules to your featur ### Get all enabled features -In cases where you need to get a list of all the features that are enabled you can use `get_enabled_features` method: +In cases where you need to get a list of all the features that are enabled according to the input context you can use `get_enabled_features` method: === "app.py" From 84f8bfd9032f336c5fb1d83fc982d9a4564d8ac8 Mon Sep 17 00:00:00 2001 From: Alexander Melnyk Date: Tue, 10 Aug 2021 06:18:23 +0200 Subject: [PATCH 10/36] add CDK example for appconfig --- docs/utilities/feature_flags.md | 200 +++++++++++++++++++++----------- 1 file changed, 134 insertions(+), 66 deletions(-) diff --git a/docs/utilities/feature_flags.md b/docs/utilities/feature_flags.md index aa8ae8472b2..5fb99e83a3e 100644 --- a/docs/utilities/feature_flags.md +++ b/docs/utilities/feature_flags.md @@ -5,7 +5,8 @@ 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. -!!! tip "For simpler use cases where a feature is simply on or off for all users, use [Parameters](parameters.md) utility instead." +!!! tip "For simpler use cases where a feature is simply on or off for all users, use [Parameters](parameters.md) +utility instead." ## Terminology @@ -28,7 +29,6 @@ If you want to learn more about feature flags, differen types and trade-offs, ch * [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) - ## Key features * Define simple feature flags to dynamically decide when to enable a feature @@ -50,68 +50,135 @@ configuration store. To create a dedicate you can use this cloudformation templa === "template.yaml" -```yaml -AWSTemplateFormatVersion: "2010-09-09" -Description: A 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" + ```yaml + AWSTemplateFormatVersion: "2010-09-09" + Description: A 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" + } + ] } - ] + } + }, + "feature2": { + "default": true } - } - }, - "feature2": { - "default": true } - } - ContentType: 'application/json' - - ConfigDeployment: - Type: AWS::AppConfig::Deployment - Properties: - ApplicationId: !Ref FeatureStoreApp - ConfigurationProfileId: !Ref FeatureStoreConfigProfile - ConfigurationVersion: !Ref HostedConfigVersion - DeploymentStrategyId: "AppConfig.AllAtOnce" - EnvironmentId: !Ref FeatureStoreDevEnv -``` + 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 + 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" + } + ] + } + } + }, + "feature2": { + "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 + }); + } + } + ``` The `Content` parameter is a json structure of the feature flags and rules. @@ -224,7 +291,8 @@ aware information you need to pass a context object and add rules to your featur ### Get all enabled features -In cases where you need to get a list of all the features that are enabled according to the input context you can use `get_enabled_features` method: +In cases where you need to get a list of all the features that are enabled according to the input context you can +use `get_enabled_features` method: === "app.py" @@ -317,11 +385,11 @@ The conditions block is a list of `action`, `key` `value`: } ``` -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. +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. -If you have multiple conditions powertools will evaluate the list of conditions as a logical AND, so all conditions needs to be -matched to return `when_match` value. +If you have multiple conditions powertools will evaluate the list of conditions as a logical AND, so all conditions +needs to be matched to return `when_match` value. === "features.json" From 025051190b20d062fbfa6c2655aed99c67ec2764 Mon Sep 17 00:00:00 2001 From: Alexander Melnyk Date: Tue, 10 Aug 2021 06:46:03 +0200 Subject: [PATCH 11/36] add envelope --- docs/utilities/feature_flags.md | 47 ++++++++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/docs/utilities/feature_flags.md b/docs/utilities/feature_flags.md index 5fb99e83a3e..ee1b9dcf4bc 100644 --- a/docs/utilities/feature_flags.md +++ b/docs/utilities/feature_flags.md @@ -427,8 +427,53 @@ needs to be matched to return `when_match` value. ## Advanced ### Adjusting in-memory cache +Similar to other utilities you can set the number of seconds the `AppConfigProvider` should cache the configuration. +This will ensure that powertools will keep a configuration for up to `case_seconds` seconds between Lambda invocation and will not make an API call each time. + +```python hl_lines="5" +app_config = AppConfigStore( + environment="test", + application="powertools", + name="test_conf_name", + cache_seconds=300 +) +``` + +### Envelope +In previous examples the schema for feature flags was a top level element in the json document. +In some cases it can be embedded as a part of bigger configuration on a deeper nested level, for example: + +```json +{ + "logging" {...}, + "features": { + "feature_flags": { + "feature1": { + "default": false, + "rules": {...} + }, + "feature2": { + "default": false, + "rules": {...} + }, + ... + } + } + } +} +``` + +This schema would not work with the default `AppConfigProvider` because the feature configuration is not a top level element. +Therefore, you need to pass a correct JMESPath by using the `envelope` parameter. -### Envelope (any better name) +```python hl_lines="5" +app_config = AppConfigStore( + environment="test", + application="powertools", + name="test_conf_name", + envelope = "features.feature_flags" +) +``` ### Built-in store provider From 5385b4f59203377529a471359358285e6b93ab8c Mon Sep 17 00:00:00 2001 From: Alexander Melnyk Date: Tue, 10 Aug 2021 06:57:16 +0200 Subject: [PATCH 12/36] add appconfig store provider --- docs/utilities/feature_flags.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/utilities/feature_flags.md b/docs/utilities/feature_flags.md index ee1b9dcf4bc..092d84eefbd 100644 --- a/docs/utilities/feature_flags.md +++ b/docs/utilities/feature_flags.md @@ -476,7 +476,23 @@ app_config = AppConfigStore( ``` ### Built-in store provider +Powertools currently support only `AppConfig` store provider out of the box. +If you have your own store and want to use it with `FeatureFlags`, you need to extend from `StoreProvider` class and implement your `get_configuration` method. #### AppConfig +AppConfig store provider fetches any JSON document from your AppConfig definition with the `get_configuration` method. +You can use it to get more + +```python hl_lines="8" +app_config = AppConfigStore( + environment="test", + application="powertools", + name="test_conf_name", + envelope = "features.feature_flags" +) + +app_config.get_configuration() +``` + ## Testing your code From d246f673fa7c85326ad5d63d9a6094e741371d88 Mon Sep 17 00:00:00 2001 From: Alexander Melnyk Date: Tue, 10 Aug 2021 07:19:22 +0200 Subject: [PATCH 13/36] add testing section --- docs/utilities/feature_flags.md | 52 +++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/docs/utilities/feature_flags.md b/docs/utilities/feature_flags.md index 092d84eefbd..11b7cfcd72b 100644 --- a/docs/utilities/feature_flags.md +++ b/docs/utilities/feature_flags.md @@ -496,3 +496,55 @@ app_config.get_configuration() ## Testing your code +You can unit test your feature flags locally without any setup in AWS AppConfig. +Because `AppConfigStore` only fetches a JSON document with a specific schema you can mock the response and use it to verify the rule evaluation. +Here is an example how to test a single feature with one rule: + +=== "test_feature_flags" + ```python + def test_flags_condition_match(mocker): + expected_value = True + mocked_app_config_schema = { + "my_feature": { + "default": expected_value, + "rules": { + "tenant id equals 345345435": { + "when_match": True, + "conditions": [ + { + "action": RuleAction.EQUALS.value, + "key": "tenant_id", + "value": "345345435", + } + ], + } + }, + } + } + + feature_flags = init_feature_flags(mocker, mocked_app_config_schema, Config(region_name="us-east-1")) + toggle = feature_flags.evaluate(name="my_feature", context={"tenant_id": "345345435", "username": "a"}, default=False) + assert toggle == expected_value + ``` + +=== "init_feature_flags" + + ```python + def init_feature_flags( + mocker, mock_schema: Dict, config: Config, envelope: str = "", jmespath_options: Optional[Dict] = None + ) -> FeatureFlags: + mocked_get_conf = mocker.patch("aws_lambda_powertools.utilities.feature_flags.AppConfigStore.get_configuration") + mocked_get_conf.return_value = mock_schema + + app_conf_store = AppConfigStore( + environment="test_env", + application="test_app", + name="test_conf_name", + cache_seconds=600, + sdk_config=config, + envelope=envelope, + jmespath_options=jmespath_options, + ) + feature_flags: FeatureFlags = FeatureFlags(store=app_conf_fetcher) + return feature_flags + ``` From 7c5a881b2be66cf3c16f434e41929149b9876e05 Mon Sep 17 00:00:00 2001 From: heitorlessa Date: Tue, 10 Aug 2021 09:45:33 +0200 Subject: [PATCH 14/36] docs: add feat flags utility Signed-off-by: heitorlessa --- README.md | 1 + docs/index.md | 1 + 2 files changed, 2 insertions(+) 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/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 From 0f6a2301c46e2b4f6b8e81545e717a02bde43e3a Mon Sep 17 00:00:00 2001 From: heitorlessa Date: Tue, 10 Aug 2021 09:51:47 +0200 Subject: [PATCH 15/36] docs: fix typo --- docs/utilities/feature_flags.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/utilities/feature_flags.md b/docs/utilities/feature_flags.md index 11b7cfcd72b..fe29f43588f 100644 --- a/docs/utilities/feature_flags.md +++ b/docs/utilities/feature_flags.md @@ -23,7 +23,7 @@ multiple conditions on whether a feature flag should be `True` or `False`. That being said, be mindful that feature flags can increase your application complexity over time if you're not careful; use them sparingly. -If you want to learn more about feature flags, differen types and trade-offs, check this articles: +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) From 4a159ec5ff405919ae886e11df54711a20fcded6 Mon Sep 17 00:00:00 2001 From: heitorlessa Date: Tue, 10 Aug 2021 10:21:57 +0200 Subject: [PATCH 16/36] docs: complete adjusting in-memory cache section --- docs/utilities/feature_flags.md | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/docs/utilities/feature_flags.md b/docs/utilities/feature_flags.md index fe29f43588f..b973c449b73 100644 --- a/docs/utilities/feature_flags.md +++ b/docs/utilities/feature_flags.md @@ -427,14 +427,18 @@ needs to be matched to return `when_match` value. ## Advanced ### Adjusting in-memory cache -Similar to other utilities you can set the number of seconds the `AppConfigProvider` should cache the configuration. -This will ensure that powertools will keep a configuration for up to `case_seconds` seconds between Lambda invocation and will not make an API call each time. -```python hl_lines="5" +By default, we cache configuration retrieved from the store for 5 seconds for performance and reliability reasons. + +You can override `cache_seconds` parameter when instantiating the store. + +```python hl_lines="7" +from aws_lambda_powertools.utilities.feature_flags import FeatureFlags, AppConfigStore + app_config = AppConfigStore( - environment="test", - application="powertools", - name="test_conf_name", + environment="dev", + application="product-catalogue", + name="features", cache_seconds=300 ) ``` @@ -496,7 +500,9 @@ app_config.get_configuration() ## Testing your code + You can unit test your feature flags locally without any setup in AWS AppConfig. + Because `AppConfigStore` only fetches a JSON document with a specific schema you can mock the response and use it to verify the rule evaluation. Here is an example how to test a single feature with one rule: From 1c72a06a18dc5ff30cf7f690aec49953874a009d Mon Sep 17 00:00:00 2001 From: heitorlessa Date: Tue, 10 Aug 2021 10:50:25 +0200 Subject: [PATCH 17/36] docs: complete envelope section --- docs/utilities/feature_flags.md | 78 +++++++++++++++++++-------------- 1 file changed, 46 insertions(+), 32 deletions(-) diff --git a/docs/utilities/feature_flags.md b/docs/utilities/feature_flags.md index b973c449b73..eecb975bf5e 100644 --- a/docs/utilities/feature_flags.md +++ b/docs/utilities/feature_flags.md @@ -428,7 +428,7 @@ needs to be matched to return `when_match` value. ### Adjusting in-memory cache -By default, we cache configuration retrieved from the store for 5 seconds for performance and reliability reasons. +By default, we cache configuration retrieved from the Store for 5 seconds for performance and reliability reasons. You can override `cache_seconds` parameter when instantiating the store. @@ -444,40 +444,54 @@ app_config = AppConfigStore( ``` ### Envelope -In previous examples the schema for feature flags was a top level element in the json document. -In some cases it can be embedded as a part of bigger configuration on a deeper nested level, for example: -```json -{ - "logging" {...}, - "features": { - "feature_flags": { - "feature1": { - "default": false, - "rules": {...} - }, - "feature2": { - "default": false, - "rules": {...} - }, - ... - } - } - } -} -``` +There are scenarios where you might want to include feature flags as part of an existing application configuration. -This schema would not work with the default `AppConfigProvider` because the feature configuration is not a top level element. -Therefore, you need to pass a correct JMESPath by using the `envelope` parameter. +For this to work, you need to use a JMESPath expression via the `envelope` parameter to extract that key as the feature flags configuration. -```python hl_lines="5" -app_config = AppConfigStore( - environment="test", - application="powertools", - name="test_conf_name", - envelope = "features.feature_flags" -) -``` +=== "app.py" + + ```python hl_lines="7" + from aws_lambda_powertools.utilities.feature_flags import FeatureFlags, AppConfigStore + + app_config = AppConfigStore( + environment="dev", + application="product-catalogue", + name="configuration", + envelope = "features" + ) + ``` + +=== "configuration.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 + } + } + } + ``` ### Built-in store provider Powertools currently support only `AppConfig` store provider out of the box. From 186293f91e6684f4748819649f4818801e33e16f Mon Sep 17 00:00:00 2001 From: heitorlessa Date: Tue, 10 Aug 2021 11:14:55 +0200 Subject: [PATCH 18/36] fix(feature flags): set default cache to 5 as documented --- aws_lambda_powertools/utilities/feature_flags/appconfig.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aws_lambda_powertools/utilities/feature_flags/appconfig.py b/aws_lambda_powertools/utilities/feature_flags/appconfig.py index df506940ee1..7af7ac5e05b 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, + cache_seconds: int = 5, sdk_config: Optional[Config] = None, envelope: Optional[str] = "", jmespath_options: Optional[Dict] = None, From 8a3ee9d42cfed70b62667476db52a6aea4f34847 Mon Sep 17 00:00:00 2001 From: heitorlessa Date: Tue, 10 Aug 2021 11:20:04 +0200 Subject: [PATCH 19/36] docs: complete built-in stores section --- docs/utilities/feature_flags.md | 54 ++++++++++++++++++++++++++------- 1 file changed, 43 insertions(+), 11 deletions(-) diff --git a/docs/utilities/feature_flags.md b/docs/utilities/feature_flags.md index eecb975bf5e..6c7d70c4ccf 100644 --- a/docs/utilities/feature_flags.md +++ b/docs/utilities/feature_flags.md @@ -494,22 +494,54 @@ For this to work, you need to use a JMESPath expression via the `envelope` param ``` ### Built-in store provider -Powertools currently support only `AppConfig` store provider out of the box. -If you have your own store and want to use it with `FeatureFlags`, you need to extend from `StoreProvider` class and implement your `get_configuration` method. + +!!! info "For GA, you'll be able to bring your own store." #### AppConfig -AppConfig store provider fetches any JSON document from your AppConfig definition with the `get_configuration` method. -You can use it to get more -```python hl_lines="8" +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 +**cache_seconds** | `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="test", - application="powertools", - name="test_conf_name", - envelope = "features.feature_flags" + environment="dev", + application="product-catalogue", + name="configuration", + cache_seconds=120, + envelope = "features", + sdk_config=boto_config, + jmespath_options=custom_jmespath_options ) - -app_config.get_configuration() ``` From b8ee65f676776127e636c9760241aee02380a3c7 Mon Sep 17 00:00:00 2001 From: heitorlessa Date: Tue, 10 Aug 2021 11:33:23 +0200 Subject: [PATCH 20/36] docs: complete testing your code section --- docs/utilities/feature_flags.md | 93 +++++++++++++++++---------------- 1 file changed, 47 insertions(+), 46 deletions(-) diff --git a/docs/utilities/feature_flags.md b/docs/utilities/feature_flags.md index 6c7d70c4ccf..53cf5466bb4 100644 --- a/docs/utilities/feature_flags.md +++ b/docs/utilities/feature_flags.md @@ -547,56 +547,57 @@ app_config = AppConfigStore( ## Testing your code -You can unit test your feature flags locally without any setup in AWS AppConfig. +You can unit test your feature flags locally and independently without setting up AWS AppConfig. -Because `AppConfigStore` only fetches a JSON document with a specific schema you can mock the response and use it to verify the rule evaluation. -Here is an example how to test a single feature with one rule: +`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. -=== "test_feature_flags" - ```python - def test_flags_condition_match(mocker): - expected_value = True - mocked_app_config_schema = { - "my_feature": { - "default": expected_value, - "rules": { - "tenant id equals 345345435": { - "when_match": True, - "conditions": [ - { - "action": RuleAction.EQUALS.value, - "key": "tenant_id", - "value": "345345435", - } - ], - } - }, - } - } +!!! info "This example tests feature flags independently. If you're importing your code and testing, make sure to change the `patch` namespace accordingly" - feature_flags = init_feature_flags(mocker, mocked_app_config_schema, Config(region_name="us-east-1")) - toggle = feature_flags.evaluate(name="my_feature", context={"tenant_id": "345345435", "username": "a"}, default=False) - assert toggle == expected_value - ``` +=== "test_feature_flags.py" -=== "init_feature_flags" + !!! warning "This excerpt relies on `pytest` and `pytest-mock` dependencies" ```python - def init_feature_flags( - mocker, mock_schema: Dict, config: Config, envelope: str = "", jmespath_options: Optional[Dict] = None - ) -> FeatureFlags: - mocked_get_conf = mocker.patch("aws_lambda_powertools.utilities.feature_flags.AppConfigStore.get_configuration") - mocked_get_conf.return_value = mock_schema - - app_conf_store = AppConfigStore( - environment="test_env", - application="test_app", - name="test_conf_name", - cache_seconds=600, - sdk_config=config, - envelope=envelope, - jmespath_options=jmespath_options, - ) - feature_flags: FeatureFlags = FeatureFlags(store=app_conf_fetcher) - return feature_flags + from typing import Dict, List, Optional + + from aws_lambda_powertools.utilities.feature_flags import FeatureFlags, AppConfigStore + + + def init_feature_flags(mocker, mock_schema, envelope="") -> FeatureFlags: + mocked_get_conf = mocker.patch("aws_lambda_powertools.utilities.feature_flags.AppConfigStore.get_configuration") + 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): + 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", + } + ], + } + }, + } + } + + feature_flags = init_feature_flags(mocker=mocker, mock_schema=mocked_app_config_schema) + flag = feature_flags.evaluate(name="my_feature", context={"tenant_id": "12345", "username": "a"}, default=False) + assert flag == expected_value ``` From 4cb287b79e7ab7ecd2f340188bde62bd7fdf4c43 Mon Sep 17 00:00:00 2001 From: heitorlessa Date: Tue, 10 Aug 2021 12:33:50 +0200 Subject: [PATCH 21/36] fix: typo --- docs/utilities/feature_flags.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/utilities/feature_flags.md b/docs/utilities/feature_flags.md index 53cf5466bb4..113858f6bf2 100644 --- a/docs/utilities/feature_flags.md +++ b/docs/utilities/feature_flags.md @@ -458,7 +458,7 @@ For this to work, you need to use a JMESPath expression via the `envelope` param environment="dev", application="product-catalogue", name="configuration", - envelope = "features" + envelope = "feature_flags" ) ``` From 1195f7c2dfa51b0866e1aaae4610e68f71f2694a Mon Sep 17 00:00:00 2001 From: heitorlessa Date: Tue, 10 Aug 2021 12:53:43 +0200 Subject: [PATCH 22/36] fix: test docs identation --- docs/utilities/feature_flags.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/utilities/feature_flags.md b/docs/utilities/feature_flags.md index 113858f6bf2..51ead2c6cd8 100644 --- a/docs/utilities/feature_flags.md +++ b/docs/utilities/feature_flags.md @@ -597,7 +597,7 @@ You can unit test your feature flags locally and independently without setting u } } - feature_flags = init_feature_flags(mocker=mocker, mock_schema=mocked_app_config_schema) - flag = feature_flags.evaluate(name="my_feature", context={"tenant_id": "12345", "username": "a"}, default=False) - assert flag == expected_value + feature_flags = init_feature_flags(mocker=mocker, mock_schema=mocked_app_config_schema) + flag = feature_flags.evaluate(name="my_feature", context={"tenant_id": "12345", "username": "a"}, default=False) + assert flag == expected_value ``` From 48abcbaa1437f4723b71202d183e7fccd4c1c370 Mon Sep 17 00:00:00 2001 From: heitorlessa Date: Tue, 10 Aug 2021 12:57:35 +0200 Subject: [PATCH 23/36] fix: missing import on test --- docs/utilities/feature_flags.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/utilities/feature_flags.md b/docs/utilities/feature_flags.md index 51ead2c6cd8..266753848a3 100644 --- a/docs/utilities/feature_flags.md +++ b/docs/utilities/feature_flags.md @@ -560,7 +560,7 @@ You can unit test your feature flags locally and independently without setting u ```python from typing import Dict, List, Optional - from aws_lambda_powertools.utilities.feature_flags import FeatureFlags, AppConfigStore + from aws_lambda_powertools.utilities.feature_flags import FeatureFlags, AppConfigStore, RuleAction def init_feature_flags(mocker, mock_schema, envelope="") -> FeatureFlags: From 350abac2f54ade2cb241313420eda739cfa8a7b5 Mon Sep 17 00:00:00 2001 From: heitorlessa Date: Tue, 10 Aug 2021 13:05:02 +0200 Subject: [PATCH 24/36] fix: remove namespace warning after testing hypothesis --- docs/utilities/feature_flags.md | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/docs/utilities/feature_flags.md b/docs/utilities/feature_flags.md index 266753848a3..2ce1c44b014 100644 --- a/docs/utilities/feature_flags.md +++ b/docs/utilities/feature_flags.md @@ -551,20 +551,21 @@ You can unit test your feature flags locally and independently without setting u `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. -!!! info "This example tests feature flags independently. If you're importing your code and testing, make sure to change the `patch` namespace accordingly" +!!! warning "This excerpt relies on `pytest` and `pytest-mock` dependencies" -=== "test_feature_flags.py" +=== "test_feature_flags_independently.py" - !!! warning "This excerpt relies on `pytest` and `pytest-mock` dependencies" - - ```python + ```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: - mocked_get_conf = mocker.patch("aws_lambda_powertools.utilities.feature_flags.AppConfigStore.get_configuration") + """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( @@ -578,6 +579,7 @@ You can unit test your feature flags locally and independently without setting u def test_flags_condition_match(mocker): + # GIVEN expected_value = True mocked_app_config_schema = { "my_feature": { @@ -597,7 +599,11 @@ You can unit test your feature flags locally and independently without setting u } } + # 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={"tenant_id": "12345", "username": "a"}, default=False) + flag = feature_flags.evaluate(name="my_feature", context=ctx, default=False) + + # THEN assert flag == expected_value ``` From 8466cf7d3870c5170829a77981984d6d1b58d8a2 Mon Sep 17 00:00:00 2001 From: Alexander Melnyk Date: Tue, 10 Aug 2021 13:05:44 +0200 Subject: [PATCH 25/36] remove todo comments --- docs/utilities/feature_flags.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/docs/utilities/feature_flags.md b/docs/utilities/feature_flags.md index 51ead2c6cd8..2053225dcba 100644 --- a/docs/utilities/feature_flags.md +++ b/docs/utilities/feature_flags.md @@ -180,12 +180,6 @@ configuration store. To create a dedicate you can use this cloudformation templa } ``` -The `Content` parameter is a json structure of the feature flags and rules. - -TODO: add steps to create new version and new deployment for the config - -TODO: add CDK example - ### Use feature flag store After you have created and configured `AppConfigStore` and added your feature configuraiton you can use the feature From b5e837e10399596e6177a08f99c83756a2e3cabc Mon Sep 17 00:00:00 2001 From: heitorlessa Date: Tue, 10 Aug 2021 13:17:24 +0200 Subject: [PATCH 26/36] fix: tip banner rendering --- docs/utilities/feature_flags.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/docs/utilities/feature_flags.md b/docs/utilities/feature_flags.md index 2ce1c44b014..43b0a2658b7 100644 --- a/docs/utilities/feature_flags.md +++ b/docs/utilities/feature_flags.md @@ -2,11 +2,9 @@ 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. +The feature flags utility provides a simple rule engine to define when one or multiple features should be enabled depending on the input. -!!! tip "For simpler use cases where a feature is simply on or off for all users, use [Parameters](parameters.md) -utility instead." +!!! tip "For simpler use cases where a feature is simply on or off for all users, use [Parameters](parameters.md) utility instead." ## Terminology From 642d0ebc064ef07efeccf758f35ade23c3dabaf9 Mon Sep 17 00:00:00 2001 From: heitorlessa Date: Tue, 10 Aug 2021 13:48:08 +0200 Subject: [PATCH 27/36] docs: improve terminology section --- docs/utilities/feature_flags.md | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/docs/utilities/feature_flags.md b/docs/utilities/feature_flags.md index 1c67d32eb9a..77e2a88e688 100644 --- a/docs/utilities/feature_flags.md +++ b/docs/utilities/feature_flags.md @@ -8,18 +8,15 @@ The feature flags utility provides a simple rule engine to define when one or mu ## 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." + +!!! warning "Be mindful that feature flags can increase the complexity of your application over time; use them sparingly." If you want to learn more about feature flags, their variations and trade-offs, check these articles: From 1628b2a64c56b7da2848b07c3c09dc962df52d40 Mon Sep 17 00:00:00 2001 From: heitorlessa Date: Tue, 10 Aug 2021 13:48:41 +0200 Subject: [PATCH 28/36] fix: frontmatter --- docs/utilities/feature_flags.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/utilities/feature_flags.md b/docs/utilities/feature_flags.md index 77e2a88e688..06bedbde114 100644 --- a/docs/utilities/feature_flags.md +++ b/docs/utilities/feature_flags.md @@ -1,5 +1,6 @@ --- -title: Feature flags description: Utility +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. From f1e98cae9e75d2c909f57a11f3f2adb05abd031c Mon Sep 17 00:00:00 2001 From: heitorlessa Date: Tue, 10 Aug 2021 13:51:31 +0200 Subject: [PATCH 29/36] docs: add note about Beta --- docs/utilities/feature_flags.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/utilities/feature_flags.md b/docs/utilities/feature_flags.md index 06bedbde114..96b5e0ce99e 100644 --- a/docs/utilities/feature_flags.md +++ b/docs/utilities/feature_flags.md @@ -3,9 +3,9 @@ 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 @@ -29,7 +29,6 @@ If you want to learn more about feature flags, their variations and trade-offs, * 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 ## Getting started From 5567f6e0828143d1f14dce5d4927ab82090ffdc8 Mon Sep 17 00:00:00 2001 From: heitorlessa Date: Tue, 10 Aug 2021 13:52:58 +0200 Subject: [PATCH 30/36] refactor(store): use max_age over cache_seconds for consistency --- .../utilities/feature_flags/appconfig.py | 8 ++++---- .../utilities/feature_flags/feature_flags.py | 2 +- docs/utilities/feature_flags.md | 8 ++++---- tests/functional/feature_flags/test_feature_flags.py | 4 ++-- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/aws_lambda_powertools/utilities/feature_flags/appconfig.py b/aws_lambda_powertools/utilities/feature_flags/appconfig.py index 7af7ac5e05b..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 = 5, + 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 3d913de98d4..5bbf617c231 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/utilities/feature_flags.md b/docs/utilities/feature_flags.md index 96b5e0ce99e..122676f3d71 100644 --- a/docs/utilities/feature_flags.md +++ b/docs/utilities/feature_flags.md @@ -419,7 +419,7 @@ needs to be matched to return `when_match` value. By default, we cache configuration retrieved from the Store for 5 seconds for performance and reliability reasons. -You can override `cache_seconds` parameter when instantiating the store. +You can override `max_age` parameter when instantiating the store. ```python hl_lines="7" from aws_lambda_powertools.utilities.feature_flags import FeatureFlags, AppConfigStore @@ -428,7 +428,7 @@ app_config = AppConfigStore( environment="dev", application="product-catalogue", name="features", - cache_seconds=300 + max_age=300 ) ``` @@ -498,7 +498,7 @@ Parameter | Default | Description **application** | `""` | AWS AppConfig Application **name** | `""` | AWS AppConfig Configuration name **envelope** | `None` | JMESPath expression to use to extract feature flags configuration from AWS AppConfig configuration -**cache_seconds** | `5` | Number of seconds to cache feature flags configuration fetched from AWS AppConfig +**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"} @@ -526,7 +526,7 @@ app_config = AppConfigStore( environment="dev", application="product-catalogue", name="configuration", - cache_seconds=120, + max_age=120, envelope = "features", sdk_config=boto_config, jmespath_options=custom_jmespath_options diff --git a/tests/functional/feature_flags/test_feature_flags.py b/tests/functional/feature_flags/test_feature_flags.py index d2150268062..997a316e3a5 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, ) From 02c9169648101f8836ccd00d3f224b3a9e76cd96 Mon Sep 17 00:00:00 2001 From: heitorlessa Date: Tue, 10 Aug 2021 13:54:18 +0200 Subject: [PATCH 31/36] docs: complete key features section --- docs/utilities/feature_flags.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/utilities/feature_flags.md b/docs/utilities/feature_flags.md index 122676f3d71..3318f81e6a6 100644 --- a/docs/utilities/feature_flags.md +++ b/docs/utilities/feature_flags.md @@ -29,6 +29,7 @@ If you want to learn more about feature flags, their variations and trade-offs, * 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 ## Getting started From 58258cd6c078575e2732e1a944d11622e4383ede Mon Sep 17 00:00:00 2001 From: heitorlessa Date: Tue, 10 Aug 2021 14:18:12 +0200 Subject: [PATCH 32/36] docs: minor change on IAM permissions --- docs/utilities/feature_flags.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/utilities/feature_flags.md b/docs/utilities/feature_flags.md index 3318f81e6a6..1c418522fa4 100644 --- a/docs/utilities/feature_flags.md +++ b/docs/utilities/feature_flags.md @@ -35,8 +35,7 @@ If you want to learn more about feature flags, their variations and trade-offs, ### IAM Permissions -Because powertools needs to fetch the configuration from the AppConfig, you need to add `appconfig:GetConfiguration` -action to your function. +Your Lambda function must have `appconfig:GetConfiguration` IAM permission in order to fetch configuration from AWS AppConfig. ### Required resources From 1dabcd945dfd364b69919efd81439d5e1f9558ee Mon Sep 17 00:00:00 2001 From: heitorlessa Date: Tue, 10 Aug 2021 14:22:43 +0200 Subject: [PATCH 33/36] docs: complete required resources section --- docs/utilities/feature_flags.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/utilities/feature_flags.md b/docs/utilities/feature_flags.md index 1c418522fa4..576302d5b98 100644 --- a/docs/utilities/feature_flags.md +++ b/docs/utilities/feature_flags.md @@ -39,15 +39,15 @@ Your Lambda function must have `appconfig:GetConfiguration` IAM permission in or ### 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. To create a dedicate you can use this cloudformation template: +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 + ```yaml hl_lines="5 11 18 25 31-50 54" AWSTemplateFormatVersion: "2010-09-09" - Description: A sample template + Description: Lambda Powertools Feature flags sample template Resources: FeatureStoreApp: Type: AWS::AppConfig::Application @@ -110,7 +110,7 @@ configuration store. To create a dedicate you can use this cloudformation templa === "CDK" - ```typescript + ```typescript hl_lines="2-7 13-32 34-35 40 47 54" import * as cdk from '@aws-cdk/core'; import { CfnApplication, From fec2a60648154b61784932525f4ccee9ec7d7000 Mon Sep 17 00:00:00 2001 From: heitorlessa Date: Tue, 10 Aug 2021 19:33:04 +0200 Subject: [PATCH 34/36] docs: complete single and all features section --- docs/utilities/feature_flags.md | 254 ++++++++++++++++++++------------ 1 file changed, 163 insertions(+), 91 deletions(-) diff --git a/docs/utilities/feature_flags.md b/docs/utilities/feature_flags.md index 576302d5b98..8197399d4c4 100644 --- a/docs/utilities/feature_flags.md +++ b/docs/utilities/feature_flags.md @@ -92,8 +92,8 @@ The following sample infrastructure will be used throughout this documentation: } } }, - "feature2": { - "default": true + "ten_percent_off_campaign": { + "default": false } } ContentType: 'application/json' @@ -139,7 +139,7 @@ The following sample infrastructure will be used throughout this documentation: } } }, - "feature2": { + "ten_percent_off_campaign": { "default": true } } @@ -175,138 +175,210 @@ The following sample infrastructure will be used throughout this documentation: } ``` -### Use feature flag store +### 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. -After you have created and configured `AppConfigStore` and added your feature configuraiton you can use the feature -flags in your code: +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 + ```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) - ctx = {"username": "lessa", "tier": "premium", "location": "NL"} - has_premium_features: bool = feature_flags.evaluate(name="premium_features", - context=ctx, - default=False) + 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 - { - "premium_features": { - "default": false, - "rules": { - "customer tier equals premium": { - "when_match": true, - "conditions": [ - { - "action": "EQUALS", - "key": "tier", - "value": "premium" - } - ] - } - } - } + ```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 + } } ``` -### Evaluating a single feature flag +#### Static flags -To fetch a single feature, setup the `FeatureFlags` instance and call the `evaluate` method. +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 - feature_flags = FeatureFlags(store=app_config) + ```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) - new_feature_active: bool = feature_flags.evaluate(name="new_feature", - default=False) + if apply_discount: + # apply 10% discount to product + ... ``` === "features.json" - ```json - { - "new_feature": { - "default": true - } - } + ```json hl_lines="2-3" + { + "ten_percent_off_campaign": { + "default": false + } + } ``` -In this example the feature flag is **static**, which mean it will be evaluated without any additional context such as -user or location. If you want to have **dynamic** feature flags that only works for specific user group or other contex -aware information you need to pass a context object and add rules to your feature configuration. +### 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" - ```pyhthon - feature_flags = FeatureFlags(store=app_config) - ctx = {"username": "lessa", "tier": "premium", "location": "NL"} + ```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 - has_premium_features: bool = feature_flags.evaluate(name="premium_features", - context=ctx, - default=False - ``` + app = ApiGatewayResolver() -=== "features.json" + app_config = AppConfigStore( + environment="dev", + application="product-catalogue", + name="features" + ) - ```json - { - "premium_features": { - "default": false, - "rules": { - "customer tier equals premium": { - "when_match": true, - "conditions": [ - { - "action": "EQUALS", - "key": "tier", - "value": "premium" - } - ] - } - } - } - } - ``` + feature_flags = FeatureFlags(store=app_config) -### Get all enabled features -In cases where you need to get a list of all the features that are enabled according to the input context you can -use `get_enabled_features` method: + @app.get("/products") + def list_products(): + ctx = { + **app.current_event.headers, + **app.current_event.json_body + } -=== "app.py" + # all_features is evaluated to ["geo_customer_campaign", "ten_percent_off_campaign"] + all_features: list[str] = feature_flags.get_enabled_features(context=ctx) - ```python - feature_flags = FeatureFlags(store=app_config) - ctx = {"username": "lessa", "tier": "premium", "location": "NL"} + if "geo_customer_campaign" in all_features: + # apply discounts based on geo + ... - all_features: list[str] = feature_flags.get_enabled_features(context=ctx) - # all_features is evaluated to ["feautre1", "feature2"] + if "ten_percent_off_campaign" in all_features: + # apply additional 10% for all customers + ... + + def lambda_handler(event, context): + return app.resolve(event, context) ``` +=== "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="2 6" - { - "feature1": { - "default": false, - "rules": {...} - }, - "feature2": { - "default": false, - "rules": {...} - }, - ... - } + ```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"}, + } + ] + } + } + } } ``` From 802aab3e98f40926e82ea2eca715742387c179af Mon Sep 17 00:00:00 2001 From: heitorlessa Date: Tue, 10 Aug 2021 19:42:48 +0200 Subject: [PATCH 35/36] docs: add rule engine flowchart; move schema to advanced --- docs/media/feat_flags_evaluation_workflow.png | Bin 0 -> 70609 bytes docs/utilities/feature_flags.md | 12 ++++++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) create mode 100644 docs/media/feat_flags_evaluation_workflow.png diff --git a/docs/media/feat_flags_evaluation_workflow.png b/docs/media/feat_flags_evaluation_workflow.png new file mode 100644 index 0000000000000000000000000000000000000000..deca3dfc297c0c748a0c61f36db7fcaccef07bfb GIT binary patch literal 70609 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 Date: Tue, 10 Aug 2021 20:27:00 +0200 Subject: [PATCH 36/36] docs: complete schema section --- docs/utilities/feature_flags.md | 147 +++++++++++++------------------- 1 file changed, 59 insertions(+), 88 deletions(-) diff --git a/docs/utilities/feature_flags.md b/docs/utilities/feature_flags.md index 5839da5ff9d..806680e0794 100644 --- a/docs/utilities/feature_flags.md +++ b/docs/utilities/feature_flags.md @@ -382,118 +382,89 @@ You can use `get_enabled_features` method for scenarios where you need a list of } ``` -### Rule engine flowchart -When evaluating whether features should be enabled, there are a number of decisions this utility makes before returning with `True` or `False`. - -This is best described in the following flowchart. - -![Rule engine ](../media/feat_flags_evaluation_workflow.png) +## Advanced +### Schema -## Advanced +This utility expects a certain schema to be stored as JSON within AWS AppConfig. -### Feature flags schema +#### Features -When using the feature flags utility powertools expects specific schema stored in your AppConfig configuration. The -minimal requirement is the name of the feature and the default value, for example: +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 -{ - "global_feature": { - "default": true - } -} -``` +=== "minimal_schema.json" + ```json hl_lines="2-3" + { + "global_feature": { + "default": true + } + } + ``` -This is a static flag that will be applied to every evaluation within your code. If you need more control and want to -provide context such as user group, permisisons, location or other information you need to add rules to your feature -flag configuration. +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 -To use feature flags dynamically you can configure rules in your feature flags configuration and pass context -to `evaluate`. The rules block must have: - -* rule name as a key -* value when the condition is met -* list conditions for evaluation - -```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 powertools will return the first result `when_match` of the -matching rule configuration or `default` value when none of the rules apply. +When adding `rules` to a feature, they must contain: -#### Conditions +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 -The conditions block is a list of `action`, `key` `value`: +=== "feature_with_rules.json" -```json -{ - "action": "EQUALS", - "key": "tier", - "value": "premium" -} -``` + ```json hl_lines="4-11" + { + "premium_feature": { + "default": false, + "rules": { + "customer tier equals premium": { + "when_match": true, + "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. +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. -If you have multiple conditions powertools will evaluate the list of conditions as a logical AND, so all conditions -needs to be matched to return `when_match` value. +#### Conditions -=== "features.json" +The `conditions` block is a list of conditions that contain `action`, `key`, and `value` keys: - ```json hl_lines="10-11" +=== "conditions.json" + ```json hl_lines="8-11" { - "premium_feature": { - "default": false, - "rules": { - "customer tier equals premium": { - "when_match": true, - "conditions": [ - { - "action": "EQUALS", - "key": "tier", - "value": "premium" - } - ] - } - } - } + ... + "conditions": [ + { + "action": "EQUALS", + "key": "tier", + "value": "premium" + } + ] } ``` -=== "app.py" +The `action` configuration can have 5 different values: `EQUALS`, `STARTSWITH`, `ENDSWITH`, `IN`, `NOT_IN`. - ```python hl_lines="2" - feature_flags = FeatureFlags(store=app_config) - ctx = {"username": "lessa", "tier": "premium", "location": "NL"} +The `key` and `value` will be compared to the input from the context parameter. - has_premium_features: bool = feature_flags.evaluate(name="premium_features", - context=ctx, - default=False - ``` +**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