diff --git a/aws_lambda_powertools/shared/__init__.py b/aws_lambda_powertools/shared/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/aws_lambda_powertools/shared/constants.py b/aws_lambda_powertools/shared/constants.py new file mode 100644 index 00000000000..a3863c33286 --- /dev/null +++ b/aws_lambda_powertools/shared/constants.py @@ -0,0 +1,4 @@ +import os + +TRACER_CAPTURE_RESPONSE_ENV: str = os.getenv("POWERTOOLS_TRACER_CAPTURE_RESPONSE", "true") +TRACER_CAPTURE_ERROR_ENV: str = os.getenv("POWERTOOLS_TRACER_CAPTURE_ERROR", "true") diff --git a/aws_lambda_powertools/shared/functions.py b/aws_lambda_powertools/shared/functions.py new file mode 100644 index 00000000000..2a3af7db5f3 --- /dev/null +++ b/aws_lambda_powertools/shared/functions.py @@ -0,0 +1,5 @@ +from distutils.util import strtobool + + +def resolve_env_var_choice(env: str, choice: bool = None) -> bool: + return choice if choice is not None else strtobool(env) diff --git a/aws_lambda_powertools/tracing/tracer.py b/aws_lambda_powertools/tracing/tracer.py index 079f662a9ad..2e083e5ebab 100644 --- a/aws_lambda_powertools/tracing/tracer.py +++ b/aws_lambda_powertools/tracing/tracer.py @@ -5,11 +5,14 @@ import logging import os from distutils.util import strtobool -from typing import Any, Callable, Dict, List, Tuple +from typing import Any, Callable, Dict, List, Optional, Tuple import aws_xray_sdk import aws_xray_sdk.core +from aws_lambda_powertools.shared.constants import TRACER_CAPTURE_ERROR_ENV, TRACER_CAPTURE_RESPONSE_ENV +from aws_lambda_powertools.shared.functions import resolve_env_var_choice + is_cold_start = True logger = logging.getLogger(__name__) @@ -34,6 +37,10 @@ class Tracer: disable tracer (e.g. `"true", "True", "TRUE"`) POWERTOOLS_SERVICE_NAME : str service name + POWERTOOLS_TRACER_CAPTURE_RESPONSE : str + disable auto-capture response as metadata (e.g. `"true", "True", "TRUE"`) + POWERTOOLS_TRACER_CAPTURE_ERROR : str + disable auto-capture error as metadata (e.g. `"true", "True", "TRUE"`) Parameters ---------- @@ -226,7 +233,12 @@ def patch(self, modules: Tuple[str] = None): else: aws_xray_sdk.core.patch(modules) - def capture_lambda_handler(self, lambda_handler: Callable[[Dict, Any], Any] = None, capture_response: bool = True): + def capture_lambda_handler( + self, + lambda_handler: Callable[[Dict, Any], Any] = None, + capture_response: Optional[bool] = None, + capture_error: Optional[bool] = None, + ): """Decorator to create subsegment for lambda handlers As Lambda follows (event, context) signature we can remove some of the boilerplate @@ -237,7 +249,9 @@ def capture_lambda_handler(self, lambda_handler: Callable[[Dict, Any], Any] = No lambda_handler : Callable Method to annotate on capture_response : bool, optional - Instructs tracer to not include handler's response as metadata, by default True + Instructs tracer to not include handler's response as metadata + capture_error : bool, optional + Instructs tracer to not include handler's error as metadata, by default True Example ------- @@ -264,10 +278,15 @@ def handler(event, context): # Return a partial function with args filled if lambda_handler is None: logger.debug("Decorator called with parameters") - return functools.partial(self.capture_lambda_handler, capture_response=capture_response) + return functools.partial( + self.capture_lambda_handler, capture_response=capture_response, capture_error=capture_error + ) lambda_handler_name = lambda_handler.__name__ + capture_response = resolve_env_var_choice(env=TRACER_CAPTURE_RESPONSE_ENV, choice=capture_response) + capture_error = resolve_env_var_choice(env=TRACER_CAPTURE_ERROR_ENV, choice=capture_error) + @functools.wraps(lambda_handler) def decorate(event, context): with self.provider.in_subsegment(name=f"## {lambda_handler_name}") as subsegment: @@ -290,7 +309,7 @@ def decorate(event, context): except Exception as err: logger.exception(f"Exception received from {lambda_handler_name}") self._add_full_exception_as_metadata( - method_name=lambda_handler_name, error=err, subsegment=subsegment + method_name=lambda_handler_name, error=err, subsegment=subsegment, capture_error=capture_error ) raise @@ -298,7 +317,9 @@ def decorate(event, context): return decorate - def capture_method(self, method: Callable = None, capture_response: bool = True): + def capture_method( + self, method: Callable = None, capture_response: Optional[bool] = None, capture_error: Optional[bool] = None + ): """Decorator to create subsegment for arbitrary functions It also captures both response and exceptions as metadata @@ -317,7 +338,9 @@ def capture_method(self, method: Callable = None, capture_response: bool = True) method : Callable Method to annotate on capture_response : bool, optional - Instructs tracer to not include method's response as metadata, by default True + Instructs tracer to not include method's response as metadata + capture_error : bool, optional + Instructs tracer to not include handler's error as metadata, by default True Example ------- @@ -449,28 +472,39 @@ async def async_tasks(): # Return a partial function with args filled if method is None: logger.debug("Decorator called with parameters") - return functools.partial(self.capture_method, capture_response=capture_response) + return functools.partial( + self.capture_method, capture_response=capture_response, capture_error=capture_error + ) method_name = f"{method.__name__}" + capture_response = resolve_env_var_choice(env=TRACER_CAPTURE_RESPONSE_ENV, choice=capture_response) + capture_error = resolve_env_var_choice(env=TRACER_CAPTURE_ERROR_ENV, choice=capture_error) + if inspect.iscoroutinefunction(method): return self._decorate_async_function( - method=method, capture_response=capture_response, method_name=method_name + method=method, capture_response=capture_response, capture_error=capture_error, method_name=method_name ) elif inspect.isgeneratorfunction(method): return self._decorate_generator_function( - method=method, capture_response=capture_response, method_name=method_name + method=method, capture_response=capture_response, capture_error=capture_error, method_name=method_name ) elif hasattr(method, "__wrapped__") and inspect.isgeneratorfunction(method.__wrapped__): return self._decorate_generator_function_with_context_manager( - method=method, capture_response=capture_response, method_name=method_name + method=method, capture_response=capture_response, capture_error=capture_error, method_name=method_name ) else: return self._decorate_sync_function( - method=method, capture_response=capture_response, method_name=method_name + method=method, capture_response=capture_response, capture_error=capture_error, method_name=method_name ) - def _decorate_async_function(self, method: Callable = None, capture_response: bool = True, method_name: str = None): + def _decorate_async_function( + self, + method: Callable = None, + capture_response: Optional[bool] = None, + capture_error: Optional[bool] = None, + method_name: str = None, + ): @functools.wraps(method) async def decorate(*args, **kwargs): async with self.provider.in_subsegment_async(name=f"## {method_name}") as subsegment: @@ -478,14 +512,13 @@ async def decorate(*args, **kwargs): logger.debug(f"Calling method: {method_name}") response = await method(*args, **kwargs) self._add_response_as_metadata( - method_name=method_name, - data=response, - subsegment=subsegment, - capture_response=capture_response, + method_name=method_name, data=response, subsegment=subsegment, capture_response=capture_response ) except Exception as err: logger.exception(f"Exception received from '{method_name}' method") - self._add_full_exception_as_metadata(method_name=method_name, error=err, subsegment=subsegment) + self._add_full_exception_as_metadata( + method_name=method_name, error=err, subsegment=subsegment, capture_error=capture_error + ) raise return response @@ -493,7 +526,11 @@ async def decorate(*args, **kwargs): return decorate def _decorate_generator_function( - self, method: Callable = None, capture_response: bool = True, method_name: str = None + self, + method: Callable = None, + capture_response: Optional[bool] = None, + capture_error: Optional[bool] = None, + method_name: str = None, ): @functools.wraps(method) def decorate(*args, **kwargs): @@ -506,7 +543,9 @@ def decorate(*args, **kwargs): ) except Exception as err: logger.exception(f"Exception received from '{method_name}' method") - self._add_full_exception_as_metadata(method_name=method_name, error=err, subsegment=subsegment) + self._add_full_exception_as_metadata( + method_name=method_name, error=err, subsegment=subsegment, capture_error=capture_error + ) raise return result @@ -514,7 +553,11 @@ def decorate(*args, **kwargs): return decorate def _decorate_generator_function_with_context_manager( - self, method: Callable = None, capture_response: bool = True, method_name: str = None + self, + method: Callable = None, + capture_response: Optional[bool] = None, + capture_error: Optional[bool] = None, + method_name: str = None, ): @functools.wraps(method) @contextlib.contextmanager @@ -530,12 +573,20 @@ def decorate(*args, **kwargs): ) except Exception as err: logger.exception(f"Exception received from '{method_name}' method") - self._add_full_exception_as_metadata(method_name=method_name, error=err, subsegment=subsegment) + self._add_full_exception_as_metadata( + method_name=method_name, error=err, subsegment=subsegment, capture_error=capture_error + ) raise return decorate - def _decorate_sync_function(self, method: Callable = None, capture_response: bool = True, method_name: str = None): + def _decorate_sync_function( + self, + method: Callable = None, + capture_response: Optional[bool] = None, + capture_error: Optional[bool] = None, + method_name: str = None, + ): @functools.wraps(method) def decorate(*args, **kwargs): with self.provider.in_subsegment(name=f"## {method_name}") as subsegment: @@ -550,7 +601,9 @@ def decorate(*args, **kwargs): ) except Exception as err: logger.exception(f"Exception received from '{method_name}' method") - self._add_full_exception_as_metadata(method_name=method_name, error=err, subsegment=subsegment) + self._add_full_exception_as_metadata( + method_name=method_name, error=err, subsegment=subsegment, capture_error=capture_error + ) raise return response @@ -562,7 +615,7 @@ def _add_response_as_metadata( method_name: str = None, data: Any = None, subsegment: aws_xray_sdk.core.models.subsegment = None, - capture_response: bool = True, + capture_response: Optional[bool] = None, ): """Add response as metadata for given subsegment @@ -575,7 +628,7 @@ def _add_response_as_metadata( subsegment : aws_xray_sdk.core.models.subsegment, optional existing subsegment to add metadata on, by default None capture_response : bool, optional - Do not include response as metadata, by default True + Do not include response as metadata """ if data is None or not capture_response or subsegment is None: return @@ -583,7 +636,11 @@ def _add_response_as_metadata( subsegment.put_metadata(key=f"{method_name} response", value=data, namespace=self._config["service"]) def _add_full_exception_as_metadata( - self, method_name: str = None, error: Exception = None, subsegment: aws_xray_sdk.core.models.subsegment = None + self, + method_name: str = None, + error: Exception = None, + subsegment: aws_xray_sdk.core.models.subsegment = None, + capture_error: Optional[bool] = None, ): """Add full exception object as metadata for given subsegment @@ -595,7 +652,12 @@ def _add_full_exception_as_metadata( error to add as subsegment metadata, by default None subsegment : aws_xray_sdk.core.models.subsegment, optional existing subsegment to add metadata on, by default None + capture_error : bool, optional + Do not include error as metadata, by default True """ + if not capture_error: + return + subsegment.put_metadata(key=f"{method_name} error", value=error, namespace=self._config["service"]) @staticmethod diff --git a/docs/content/core/tracer.mdx b/docs/content/core/tracer.mdx index 62559ac9fe2..2e36f4b7d0b 100644 --- a/docs/content/core/tracer.mdx +++ b/docs/content/core/tracer.mdx @@ -65,12 +65,16 @@ def handler(event, context): ... ``` +### disabling response auto-capture + +> New in 1.9.0 + Returning sensitive information from your Lambda handler or functions, where Tracer is used?

- You can disable Tracer from capturing their responses as tracing metadata with capture_response=False parameter in both capture_lambda_handler and capture_method decorators.

+You can disable Tracer from capturing their responses as tracing metadata with capture_response=False parameter in both capture_lambda_handler and capture_method decorators. ```python:title=do_not_capture_response_as_metadata.py # Disables Tracer from capturing response and adding as metadata @@ -80,6 +84,25 @@ def handler(event, context): return "sensitive_information" ``` +### disabling exception auto-capture + +> New in 1.10.0 + + + Can exceptions contain sensitive information from your Lambda handler or functions, where Tracer is used? +

+

+ +You can disable Tracer from capturing their exceptions as tracing metadata with capture_error=False parameter in both capture_lambda_handler and capture_method decorators. + +```python:title=do_not_capture_exception_as_metadata.py +# Disables Tracer from capturing exception and adding as metadata +# Useful when dealing with sensitive data +@tracer.capture_lambda_handler(capture_error=False) # highlight-line +def handler(event, context): + raise ValueError("some sensitive info in the stack trace...") +``` + ### Annotations Annotations are key-values indexed by AWS X-Ray on a per trace basis. You can use them to filter traces as well as to create [Trace Groups](https://aws.amazon.com/about-aws/whats-new/2018/11/aws-xray-adds-the-ability-to-group-traces/). diff --git a/docs/content/index.mdx b/docs/content/index.mdx index 6af7d601862..6f1a0ea05bc 100644 --- a/docs/content/index.mdx +++ b/docs/content/index.mdx @@ -116,17 +116,23 @@ Utility | Description ## Environment variables + + Explicit parameters take precedence over environment variables.

+
+ **Environment variables** used across suite of utilities. -Environment variable | Description | Utility -------------------------------------------------- | --------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | ------------------------------------------------- -**POWERTOOLS_SERVICE_NAME** | Sets service name used for tracing namespace, metrics dimension and structured logging | All -**POWERTOOLS_METRICS_NAMESPACE** | Sets namespace used for metrics | [Metrics](./core/metrics) -**POWERTOOLS_TRACE_DISABLED** | Disables tracing | [Tracing](./core/tracer) -**POWERTOOLS_TRACE_MIDDLEWARES** | Creates sub-segment for each custom middleware | [Middleware factory](./utilities/middleware_factory) -**POWERTOOLS_LOGGER_LOG_EVENT** | Logs incoming event | [Logging](./core/logger) -**POWERTOOLS_LOGGER_SAMPLE_RATE** | Debug log sampling | [Logging](./core/logger) -**LOG_LEVEL** | Sets logging level | [Logging](./core/logger) +Environment variable | Description | Utility | Default +------------------------------------------------- | --------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | ------------------------------------------------- | ------------------------------------------------- +**POWERTOOLS_SERVICE_NAME** | Sets service name used for tracing namespace, metrics dimension and structured logging | All | `"service_undefined"` +**POWERTOOLS_METRICS_NAMESPACE** | Sets namespace used for metrics | [Metrics](./core/metrics) | `None` +**POWERTOOLS_TRACE_DISABLED** | Disables tracing | [Tracing](./core/tracer) | `false` +**POWERTOOLS_TRACER_CAPTURE_RESPONSE** | Captures Lambda or method return as metadata. | [Tracing](./core/tracer) | `true` +**POWERTOOLS_TRACER_CAPTURE_ERROR** | Captures Lambda or method exception as metadata. | [Tracing](./core/tracer) | `true` +**POWERTOOLS_TRACE_MIDDLEWARES** | Creates sub-segment for each custom middleware | [Middleware factory](./utilities/middleware_factory) | `false` +**POWERTOOLS_LOGGER_LOG_EVENT** | Logs incoming event | [Logging](./core/logger) | `false` +**POWERTOOLS_LOGGER_SAMPLE_RATE** | Debug log sampling | [Logging](./core/logger) | `0` +**LOG_LEVEL** | Sets logging level | [Logging](./core/logger) | `INFO` ## Debug mode diff --git a/tests/functional/test_shared_functions.py b/tests/functional/test_shared_functions.py new file mode 100644 index 00000000000..ac05babb753 --- /dev/null +++ b/tests/functional/test_shared_functions.py @@ -0,0 +1,9 @@ +from aws_lambda_powertools.shared.functions import resolve_env_var_choice + + +def test_resolve_env_var_choice_explicit_wins_over_env_var(): + assert resolve_env_var_choice(env="true", choice=False) is False + + +def test_resolve_env_var_choice_env_wins_over_absent_explicit(): + assert resolve_env_var_choice(env="true") == 1 diff --git a/tests/unit/test_tracing.py b/tests/unit/test_tracing.py index c8df34a76a6..71188e216ec 100644 --- a/tests/unit/test_tracing.py +++ b/tests/unit/test_tracing.py @@ -502,13 +502,15 @@ def generator_fn(): assert str(put_metadata_mock_args["value"]) == "test" -def test_tracer_lambda_handler_does_not_add_response_as_metadata(mocker, provider_stub, in_subsegment_mock): +def test_tracer_lambda_handler_override_response_as_metadata(mocker, provider_stub, in_subsegment_mock): # GIVEN tracer is initialized provider = provider_stub(in_subsegment=in_subsegment_mock.in_subsegment) + + mocker.patch("aws_lambda_powertools.tracing.tracer.TRACER_CAPTURE_RESPONSE_ENV", return_value=True) tracer = Tracer(provider=provider, auto_patch=False) # WHEN capture_lambda_handler decorator is used - # and the handler response is empty + # with capture_response set to False @tracer.capture_lambda_handler(capture_response=False) def handler(event, context): return "response" @@ -519,7 +521,7 @@ def handler(event, context): assert in_subsegment_mock.put_metadata.call_count == 0 -def test_tracer_method_does_not_add_response_as_metadata(mocker, provider_stub, in_subsegment_mock): +def test_tracer_method_override_response_as_metadata(provider_stub, in_subsegment_mock): # GIVEN tracer is initialized provider = provider_stub(in_subsegment=in_subsegment_mock.in_subsegment) tracer = Tracer(provider=provider, auto_patch=False)