diff --git a/aws_lambda_powertools/metrics/base.py b/aws_lambda_powertools/metrics/base.py index b32421431cd..2c45aa1fb3e 100644 --- a/aws_lambda_powertools/metrics/base.py +++ b/aws_lambda_powertools/metrics/base.py @@ -17,12 +17,12 @@ MetricValueError, SchemaValidationError, ) -from aws_lambda_powertools.metrics.provider.cloudwatch_emf import cold_start -from aws_lambda_powertools.metrics.provider.cloudwatch_emf.cold_start import ( - reset_cold_start_flag, # noqa: F401 # backwards compatibility -) +from aws_lambda_powertools.metrics.provider import cold_start from aws_lambda_powertools.metrics.provider.cloudwatch_emf.constants import MAX_DIMENSIONS, MAX_METRICS from aws_lambda_powertools.metrics.provider.cloudwatch_emf.metric_properties import MetricResolution, MetricUnit +from aws_lambda_powertools.metrics.provider.cold_start import ( + reset_cold_start_flag, # noqa: F401 # backwards compatibility +) from aws_lambda_powertools.metrics.types import MetricNameUnitResolution from aws_lambda_powertools.shared import constants from aws_lambda_powertools.shared.functions import resolve_env_var_choice diff --git a/aws_lambda_powertools/metrics/functions.py b/aws_lambda_powertools/metrics/functions.py new file mode 100644 index 00000000000..d951c0749a3 --- /dev/null +++ b/aws_lambda_powertools/metrics/functions.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +from typing import List + +from aws_lambda_powertools.metrics.provider.cloudwatch_emf.exceptions import ( + MetricResolutionError, + MetricUnitError, +) +from aws_lambda_powertools.metrics.provider.cloudwatch_emf.metric_properties import MetricResolution, MetricUnit + + +def extract_cloudwatch_metric_resolution_value(metric_resolutions: List, resolution: int | MetricResolution) -> int: + """Return metric value from CloudWatch metric unit whether that's str or MetricResolution enum + + Parameters + ---------- + unit : Union[int, MetricResolution] + Metric resolution + + Returns + ------- + int + Metric resolution value must be 1 or 60 + + Raises + ------ + MetricResolutionError + When metric resolution is not supported by CloudWatch + """ + if isinstance(resolution, MetricResolution): + return resolution.value + + if isinstance(resolution, int) and resolution in metric_resolutions: + return resolution + + raise MetricResolutionError( + f"Invalid metric resolution '{resolution}', expected either option: {metric_resolutions}", # noqa: E501 + ) + + +def extract_cloudwatch_metric_unit_value(metric_units: List, metric_valid_options: List, unit: str | MetricUnit) -> str: + """Return metric value from CloudWatch metric unit whether that's str or MetricUnit enum + + Parameters + ---------- + unit : Union[str, MetricUnit] + Metric unit + + Returns + ------- + str + Metric unit value (e.g. "Seconds", "Count/Second") + + Raises + ------ + MetricUnitError + When metric unit is not supported by CloudWatch + """ + + if isinstance(unit, str): + if unit in metric_valid_options: + unit = MetricUnit[unit].value + + if unit not in metric_units: + raise MetricUnitError( + f"Invalid metric unit '{unit}', expected either option: {metric_valid_options}", + ) + + if isinstance(unit, MetricUnit): + unit = unit.value + + return unit diff --git a/aws_lambda_powertools/metrics/metrics.py b/aws_lambda_powertools/metrics/metrics.py index d65cb62720a..900e0da7dd7 100644 --- a/aws_lambda_powertools/metrics/metrics.py +++ b/aws_lambda_powertools/metrics/metrics.py @@ -1,14 +1,15 @@ # NOTE: keeps for compatibility from __future__ import annotations -from typing import Any, Callable, Dict, Optional, Union +from typing import Any, Callable, Dict, Optional from aws_lambda_powertools.metrics.base import MetricResolution, MetricUnit from aws_lambda_powertools.metrics.provider.cloudwatch_emf.cloudwatch import AmazonCloudWatchEMFProvider +from aws_lambda_powertools.metrics.provider.cloudwatch_emf.types import CloudWatchEMFOutput class Metrics: - """Metrics create an EMF object with up to 100 metrics + """Metrics create an CloudWatch EMF object with up to 100 metrics Use Metrics when you need to create multiple metrics that have dimensions in common (e.g. service_name="payment"). @@ -115,7 +116,7 @@ def serialize_metric_set( metrics: Dict | None = None, dimensions: Dict | None = None, metadata: Dict | None = None, - ) -> Dict: + ) -> CloudWatchEMFOutput: return self.provider.serialize_metric_set(metrics=metrics, dimensions=dimensions, metadata=metadata) def add_metadata(self, key: str, value: Any) -> None: @@ -138,15 +139,6 @@ def log_metrics( default_dimensions=default_dimensions, ) - def _extract_metric_resolution_value(self, resolution: Union[int, MetricResolution]) -> int: - return self.provider._extract_metric_resolution_value(resolution=resolution) - - def _extract_metric_unit_value(self, unit: Union[str, MetricUnit]) -> str: - return self.provider._extract_metric_unit_value(unit=unit) - - def _add_cold_start_metric(self, context: Any) -> None: - self.provider._add_cold_start_metric(context=context) - def set_default_dimensions(self, **dimensions) -> None: self.provider.set_default_dimensions(**dimensions) """Persist dimensions across Lambda invocations @@ -208,120 +200,3 @@ def service(self, service): # we can quickly revert and duplicate code while using self.provider EphemeralMetrics = AmazonCloudWatchEMFProvider - -# noqa: ERA001 -# class EphemeralMetrics(MetricManager): -# """Non-singleton version of Metrics to not persist metrics across instances -# -# NOTE: This is useful when you want to: -# -# - Create metrics for distinct namespaces -# - Create the same metrics with different dimensions more than once -# """ -# -# # _dimensions: Dict[str, str] = {} -# _default_dimensions: Dict[str, Any] = {} -# -# def __init__( -# self, -# service: str | None = None, -# namespace: str | None = None, -# provider: AmazonCloudWatchEMFProvider | None = None, -# ): -# super().__init__(namespace=namespace, service=service) -# -# self.default_dimensions = self._default_dimensions -# # # self.dimension_set = self._dimensions -# # self.dimension_set.update(**self._default_dimensions) -# -# self.provider = provider or AmazonCloudWatchEMFProvider( -# namespace=namespace, -# service=service, -# metric_set=self.metric_set, -# metadata_set=self.metadata_set, -# dimension_set=self.dimension_set, -# default_dimensions=self._default_dimensions, -# ) -# -# def add_metric( -# self, -# name: str, -# unit: MetricUnit | str, -# value: float, -# resolution: MetricResolution | int = 60, -# ) -> None: -# return self.provider.add_metric(name=name, unit=unit, value=value, resolution=resolution) -# -# def add_dimension(self, name: str, value: str) -> None: -# return self.provider.add_dimension(name=name, value=value) -# -# def serialize_metric_set( -# self, -# metrics: Dict | None = None, -# dimensions: Dict | None = None, -# metadata: Dict | None = None, -# ) -> Dict: -# return self.provider.serialize_metric_set(metrics=metrics, dimensions=dimensions, metadata=metadata) -# -# def add_metadata(self, key: str, value: Any) -> None: -# self.provider.add_metadata(key=key, value=value) -# -# def flush_metrics(self, raise_on_empty_metrics: bool = False) -> None: -# self.provider.flush_metrics(raise_on_empty_metrics=raise_on_empty_metrics) -# -# def log_metrics( -# self, -# lambda_handler: Callable[[Dict, Any], Any] | Optional[Callable[[Dict, Any, Optional[Dict]], Any]] = None, -# capture_cold_start_metric: bool = False, -# raise_on_empty_metrics: bool = False, -# default_dimensions: Dict[str, str] | None = None, -# ): -# return self.provider.log_metrics( -# lambda_handler=lambda_handler, -# capture_cold_start_metric=capture_cold_start_metric, -# raise_on_empty_metrics=raise_on_empty_metrics, -# default_dimensions=default_dimensions, -# ) -# -# def _extract_metric_resolution_value(self, resolution: Union[int, MetricResolution]) -> int: -# return self.provider._extract_metric_resolution_value(resolution=resolution) -# -# def _extract_metric_unit_value(self, unit: Union[str, MetricUnit]) -> str: -# return self.provider._extract_metric_unit_value(unit=unit) -# -# def _add_cold_start_metric(self, context: Any) -> None: -# return self.provider._add_cold_start_metric(context=context) -# -# def set_default_dimensions(self, **dimensions) -> None: -# """Persist dimensions across Lambda invocations -# -# Parameters -# ---------- -# dimensions : Dict[str, Any], optional -# metric dimensions as key=value -# -# Example -# ------- -# **Sets some default dimensions that will always be present across metrics and invocations** -# -# from aws_lambda_powertools import Metrics -# -# metrics = Metrics(namespace="ServerlessAirline", service="payment") -# metrics.set_default_dimensions(environment="demo", another="one") -# -# @metrics.log_metrics() -# def lambda_handler(): -# return True -# """ -# return self.provider.set_default_dimensions(**dimensions) -# -# def clear_default_dimensions(self) -> None: -# self.default_dimensions.clear() -# -# def clear_metrics(self) -> None: -# self.provider.clear_metrics() -# # re-add default dimensions -# self.set_default_dimensions(**self.default_dimensions) -# - -# __all__ = [] diff --git a/aws_lambda_powertools/metrics/provider/__init__.py b/aws_lambda_powertools/metrics/provider/__init__.py index 814812c135b..30019199c52 100644 --- a/aws_lambda_powertools/metrics/provider/__init__.py +++ b/aws_lambda_powertools/metrics/provider/__init__.py @@ -1,6 +1,5 @@ -from aws_lambda_powertools.metrics.provider.base import MetricsBase, MetricsProviderBase +from aws_lambda_powertools.metrics.provider.base import BaseProvider __all__ = [ - "MetricsBase", - "MetricsProviderBase", + "BaseProvider", ] diff --git a/aws_lambda_powertools/metrics/provider/base.py b/aws_lambda_powertools/metrics/provider/base.py index 7617193033e..8bd2440658a 100644 --- a/aws_lambda_powertools/metrics/provider/base.py +++ b/aws_lambda_powertools/metrics/provider/base.py @@ -2,21 +2,20 @@ import functools import logging +from abc import ABC, abstractmethod from typing import Any, Callable, Dict, Optional -from typing_extensions import Protocol +from aws_lambda_powertools.metrics.provider import cold_start +from aws_lambda_powertools.utilities.typing import LambdaContext logger = logging.getLogger(__name__) -is_cold_start = True - -class MetricsProviderBase(Protocol): +class BaseProvider(ABC): """ - Class for metric provider interface. + Interface to create a metrics provider. - This class serves as an interface for creating your own metric provider. Inherit from this class - and implement the required methods to define your specific metric provider. + BaseProvider implements `log_metrics` decorator for every provider as a value add feature. Usage: 1. Inherit from this class. @@ -24,6 +23,7 @@ class MetricsProviderBase(Protocol): 3. Customize the behavior and functionality of the metric provider in your subclass. """ + @abstractmethod def add_metric(self, *args: Any, **kwargs: Any) -> Any: """ Abstract method for adding a metric. @@ -49,6 +49,7 @@ def add_metric(self, *args: Any, **kwargs: Any) -> Any: """ raise NotImplementedError + @abstractmethod def serialize_metric_set(self, *args: Any, **kwargs: Any) -> Any: """ Abstract method for serialize a metric. @@ -74,7 +75,7 @@ def serialize_metric_set(self, *args: Any, **kwargs: Any) -> Any: """ raise NotImplementedError - # flush serialized data to output, or send to API directly + @abstractmethod def flush_metrics(self, *args: Any, **kwargs) -> Any: """ Abstract method for flushing a metric. @@ -95,22 +96,12 @@ def flush_metrics(self, *args: Any, **kwargs) -> Any: """ raise NotImplementedError - -class MetricsBase(Protocol): - """ - Class for metric template. - - This class serves as a template for creating your own metric class. Inherit from this class - and implement the necessary methods to define your specific metric. - - NOTE: need to improve this docstring - """ - - def add_metric(self, *args, **kwargs): + @abstractmethod + def clear_metrics(self, *args: Any, **kwargs) -> None: """ - Abstract method for adding a metric. + Abstract method for clear metric instance. - This method must be implemented in subclasses to add a metric and return a combined metrics dictionary. + This method must be implemented in subclasses to clear the metric instance Parameters ---------- @@ -119,11 +110,6 @@ def add_metric(self, *args, **kwargs): *kwargs: Keyword arguments. - Returns - ---------- - Dict - A combined metrics dictionary. - Raises ---------- NotImplementedError @@ -131,28 +117,24 @@ def add_metric(self, *args, **kwargs): """ raise NotImplementedError - def flush_metrics(self, raise_on_empty_metrics: bool = False) -> None: - """Manually flushes the metrics. This is normally not necessary, - unless you're running on other runtimes besides Lambda, where the @log_metrics - decorator already handles things for you. - - Parameters - ---------- - raise_on_empty_metrics : bool, optional - raise exception if no metrics are emitted, by default False + @abstractmethod + def add_cold_start_metric(self, context: LambdaContext) -> Any: """ - raise NotImplementedError + Abstract method for clear metric instance. - def add_cold_start_metric(self, metric_name: str, function_name: str) -> None: - """ - Add a cold start metric for a specific function. + This method must be implemented in subclasses to add a metric and return a combined metrics dictionary. Parameters ---------- - metric_name: str - The name of the cold start metric to add. - function_name: str - The name of the function associated with the cold start metric. + *args: + Positional arguments. + *kwargs: + Keyword arguments. + + Raises + ---------- + NotImplementedError + This method must be implemented in subclasses. """ raise NotImplementedError @@ -161,6 +143,7 @@ def log_metrics( lambda_handler: Callable[[Dict, Any], Any] | Optional[Callable[[Dict, Any, Optional[Dict]], Any]] = None, capture_cold_start_metric: bool = False, raise_on_empty_metrics: bool = False, + **kwargs, ): """Decorator to serialize and publish metrics at the end of a function execution. @@ -197,6 +180,8 @@ def handler(event, context): Propagate error received """ + default_dimensions = kwargs.get("default_dimensions") + # If handler is None we've been called with parameters # Return a partial function with args filled if lambda_handler is None: @@ -205,6 +190,7 @@ def handler(event, context): self.log_metrics, capture_cold_start_metric=capture_cold_start_metric, raise_on_empty_metrics=raise_on_empty_metrics, + default_dimensions=default_dimensions, ) @functools.wraps(lambda_handler) @@ -221,24 +207,23 @@ def decorate(event, context): return decorate def _add_cold_start_metric(self, context: Any) -> None: - """Add cold start metric and function_name dimension + """ + Add cold start metric Parameters ---------- context : Any Lambda context """ - global is_cold_start - if not is_cold_start: + if not cold_start.is_cold_start: return logger.debug("Adding cold start metric and function_name dimension") - self.add_cold_start_metric(metric_name="ColdStart", function_name=context.function_name) + self.add_cold_start_metric(context=context) - is_cold_start = False + cold_start.is_cold_start = False def reset_cold_start_flag_provider(): - global is_cold_start - if not is_cold_start: - is_cold_start = True + if not cold_start.is_cold_start: + cold_start.is_cold_start = True diff --git a/aws_lambda_powertools/metrics/provider/cloudwatch_emf/cloudwatch.py b/aws_lambda_powertools/metrics/provider/cloudwatch_emf/cloudwatch.py index 921fcee6045..16be60112c3 100644 --- a/aws_lambda_powertools/metrics/provider/cloudwatch_emf/cloudwatch.py +++ b/aws_lambda_powertools/metrics/provider/cloudwatch_emf/cloudwatch.py @@ -1,42 +1,42 @@ from __future__ import annotations import datetime -import functools import json import logging import numbers import os import warnings from collections import defaultdict -from typing import Any, Callable, Dict, List, Optional, Union +from typing import Any, Callable, Dict, List, Optional from aws_lambda_powertools.metrics.base import single_metric from aws_lambda_powertools.metrics.exceptions import MetricValueError, SchemaValidationError -from aws_lambda_powertools.metrics.provider import MetricsProviderBase -from aws_lambda_powertools.metrics.provider.cloudwatch_emf import cold_start -from aws_lambda_powertools.metrics.provider.cloudwatch_emf.constants import MAX_DIMENSIONS, MAX_METRICS -from aws_lambda_powertools.metrics.provider.cloudwatch_emf.exceptions import ( - MetricResolutionError, - MetricUnitError, +from aws_lambda_powertools.metrics.functions import ( + extract_cloudwatch_metric_resolution_value, + extract_cloudwatch_metric_unit_value, ) +from aws_lambda_powertools.metrics.provider.base import BaseProvider +from aws_lambda_powertools.metrics.provider.cloudwatch_emf.constants import MAX_DIMENSIONS, MAX_METRICS from aws_lambda_powertools.metrics.provider.cloudwatch_emf.metric_properties import MetricResolution, MetricUnit +from aws_lambda_powertools.metrics.provider.cloudwatch_emf.types import CloudWatchEMFOutput from aws_lambda_powertools.metrics.types import MetricNameUnitResolution from aws_lambda_powertools.shared import constants from aws_lambda_powertools.shared.functions import resolve_env_var_choice +from aws_lambda_powertools.utilities.typing import LambdaContext logger = logging.getLogger(__name__) -class AmazonCloudWatchEMFProvider(MetricsProviderBase): - """Base class for metric functionality (namespace, metric, dimension, serialization) +class AmazonCloudWatchEMFProvider(BaseProvider): + """ + AmazonCloudWatchEMFProvider creates metrics asynchronously via CloudWatch Embedded Metric Format (EMF). - MetricManager creates metrics asynchronously thanks to CloudWatch Embedded Metric Format (EMF). CloudWatch EMF can create up to 100 metrics per EMF object - and metrics, dimensions, and namespace created via MetricManager + and metrics, dimensions, and namespace created via AmazonCloudWatchEMFProvider will adhere to the schema, will be serialized and validated against EMF Schema. - **Use `aws_lambda_powertools.metrics.metrics.Metrics` or - `aws_lambda_powertools.metrics.metric.single_metric` to create EMF metrics.** + **Use `aws_lambda_powertools.Metrics` or + `aws_lambda_powertools.single_metric` to create EMF metrics.** Environment variables --------------------- @@ -123,8 +123,15 @@ def add_metric( if not isinstance(value, numbers.Number): raise MetricValueError(f"{value} is not a valid number") - unit = self._extract_metric_unit_value(unit=unit) - resolution = self._extract_metric_resolution_value(resolution=resolution) + unit = extract_cloudwatch_metric_unit_value( + metric_units=self._metric_units, + metric_valid_options=self._metric_unit_valid_options, + unit=unit, + ) + resolution = extract_cloudwatch_metric_resolution_value( + metric_resolutions=self._metric_resolutions, + resolution=resolution, + ) metric: Dict = self.metric_set.get(name, defaultdict(list)) metric["Unit"] = unit metric["StorageResolution"] = resolution @@ -146,7 +153,7 @@ def serialize_metric_set( metrics: Dict | None = None, dimensions: Dict | None = None, metadata: Dict | None = None, - ) -> Dict: + ) -> CloudWatchEMFOutput: """Serializes metric and dimensions set Parameters @@ -232,7 +239,8 @@ def serialize_metric_set( }, ], }, - **dimensions, # "service": "test_service" + # NOTE: Mypy doesn't recognize splats '** syntax' in TypedDict + **dimensions, # type: ignore[misc] # "service": "test_service" **metadata, # "username": "test" **metric_names_and_values, # "single_metric": 1.0 } @@ -329,7 +337,7 @@ def log_metrics( lambda_handler: Callable[[Dict, Any], Any] | Optional[Callable[[Dict, Any, Optional[Dict]], Any]] = None, capture_cold_start_metric: bool = False, raise_on_empty_metrics: bool = False, - default_dimensions: Dict[str, str] | None = None, + **kwargs, ): """Decorator to serialize and publish metrics at the end of a function execution. @@ -357,8 +365,7 @@ def handler(event, context): captures cold start metric, by default False raise_on_empty_metrics : bool, optional raise exception if no metrics are emitted, by default False - default_dimensions: Dict[str, str], optional - metric dimensions as key=value that will always be present + **kwargs Raises ------ @@ -366,94 +373,19 @@ def handler(event, context): Propagate error received """ - # If handler is None we've been called with parameters - # Return a partial function with args filled - if lambda_handler is None: - logger.debug("Decorator called with parameters") - return functools.partial( - self.log_metrics, - capture_cold_start_metric=capture_cold_start_metric, - raise_on_empty_metrics=raise_on_empty_metrics, - default_dimensions=default_dimensions, - ) - - @functools.wraps(lambda_handler) - def decorate(event, context): - try: - if default_dimensions: - self.set_default_dimensions(**default_dimensions) - response = lambda_handler(event, context) - if capture_cold_start_metric: - self._add_cold_start_metric(context=context) - finally: - self.flush_metrics(raise_on_empty_metrics=raise_on_empty_metrics) - - return response - - return decorate - - def _extract_metric_resolution_value(self, resolution: Union[int, MetricResolution]) -> int: - """Return metric value from metric unit whether that's str or MetricResolution enum + default_dimensions = kwargs.get("default_dimensions") - Parameters - ---------- - unit : Union[int, MetricResolution] - Metric resolution - - Returns - ------- - int - Metric resolution value must be 1 or 60 + if default_dimensions: + self.set_default_dimensions(**default_dimensions) - Raises - ------ - MetricResolutionError - When metric resolution is not supported by CloudWatch - """ - if isinstance(resolution, MetricResolution): - return resolution.value - - if isinstance(resolution, int) and resolution in self._metric_resolutions: - return resolution - - raise MetricResolutionError( - f"Invalid metric resolution '{resolution}', expected either option: {self._metric_resolutions}", # noqa: E501 + return super().log_metrics( + lambda_handler=lambda_handler, + capture_cold_start_metric=capture_cold_start_metric, + raise_on_empty_metrics=raise_on_empty_metrics, + **kwargs, ) - def _extract_metric_unit_value(self, unit: Union[str, MetricUnit]) -> str: - """Return metric value from metric unit whether that's str or MetricUnit enum - - Parameters - ---------- - unit : Union[str, MetricUnit] - Metric unit - - Returns - ------- - str - Metric unit value (e.g. "Seconds", "Count/Second") - - Raises - ------ - MetricUnitError - When metric unit is not supported by CloudWatch - """ - - if isinstance(unit, str): - if unit in self._metric_unit_valid_options: - unit = MetricUnit[unit].value - - if unit not in self._metric_units: - raise MetricUnitError( - f"Invalid metric unit '{unit}', expected either option: {self._metric_unit_valid_options}", - ) - - if isinstance(unit, MetricUnit): - unit = unit.value - - return unit - - def _add_cold_start_metric(self, context: Any) -> None: + def add_cold_start_metric(self, context: LambdaContext) -> None: """Add cold start metric and function_name dimension Parameters @@ -461,13 +393,11 @@ def _add_cold_start_metric(self, context: Any) -> None: context : Any Lambda context """ - if cold_start.is_cold_start: - logger.debug("Adding cold start metric and function_name dimension") - with single_metric(name="ColdStart", unit=MetricUnit.Count, value=1, namespace=self.namespace) as metric: - metric.add_dimension(name="function_name", value=context.function_name) - if self.service: - metric.add_dimension(name="service", value=str(self.service)) - cold_start.is_cold_start = False + logger.debug("Adding cold start metric and function_name dimension") + with single_metric(name="ColdStart", unit=MetricUnit.Count, value=1, namespace=self.namespace) as metric: + metric.add_dimension(name="function_name", value=context.function_name) + if self.service: + metric.add_dimension(name="service", value=str(self.service)) def set_default_dimensions(self, **dimensions) -> None: """Persist dimensions across Lambda invocations diff --git a/aws_lambda_powertools/metrics/provider/cloudwatch_emf/types.py b/aws_lambda_powertools/metrics/provider/cloudwatch_emf/types.py new file mode 100644 index 00000000000..bf3a48ea13f --- /dev/null +++ b/aws_lambda_powertools/metrics/provider/cloudwatch_emf/types.py @@ -0,0 +1,24 @@ +from typing import List + +from typing_extensions import NotRequired, TypedDict + + +class CloudWatchEMFMetric(TypedDict): + Name: str + Unit: str + StorageResolution: NotRequired[int] + + +class CloudWatchEMFMetrics(TypedDict): + Namespace: str + Dimensions: List[List[str]] # [ [ 'test_dimension' ] ] + Metrics: List[CloudWatchEMFMetric] + + +class CloudWatchEMFRoot(TypedDict): + Timestamp: int + CloudWatchMetrics: List[CloudWatchEMFMetrics] + + +class CloudWatchEMFOutput(TypedDict): + _aws: CloudWatchEMFRoot diff --git a/aws_lambda_powertools/metrics/provider/cloudwatch_emf/cold_start.py b/aws_lambda_powertools/metrics/provider/cold_start.py similarity index 100% rename from aws_lambda_powertools/metrics/provider/cloudwatch_emf/cold_start.py rename to aws_lambda_powertools/metrics/provider/cold_start.py diff --git a/tests/functional/metrics/conftest.py b/tests/functional/metrics/conftest.py new file mode 100644 index 00000000000..cb0e083ca1f --- /dev/null +++ b/tests/functional/metrics/conftest.py @@ -0,0 +1,91 @@ +from typing import Any, Dict, List, Union + +import pytest + +from aws_lambda_powertools.metrics import ( + MetricResolution, + Metrics, + MetricUnit, +) +from aws_lambda_powertools.metrics.provider.cold_start import reset_cold_start_flag + + +@pytest.fixture(scope="function", autouse=True) +def reset_metric_set(): + metrics = Metrics() + metrics.clear_metrics() + metrics.clear_default_dimensions() + reset_cold_start_flag() # ensure each test has cold start + yield + + +@pytest.fixture +def metric_with_resolution() -> Dict[str, Union[str, int]]: + return {"name": "single_metric", "unit": MetricUnit.Count, "value": 1, "resolution": MetricResolution.High} + + +@pytest.fixture +def metric() -> Dict[str, str]: + return {"name": "single_metric", "unit": MetricUnit.Count, "value": 1} + + +@pytest.fixture +def metrics() -> List[Dict[str, str]]: + return [ + {"name": "metric_one", "unit": MetricUnit.Count, "value": 1}, + {"name": "metric_two", "unit": MetricUnit.Count, "value": 1}, + ] + + +@pytest.fixture +def metrics_same_name() -> List[Dict[str, str]]: + return [ + {"name": "metric_one", "unit": MetricUnit.Count, "value": 1}, + {"name": "metric_one", "unit": MetricUnit.Count, "value": 5}, + ] + + +@pytest.fixture +def dimension() -> Dict[str, str]: + return {"name": "test_dimension", "value": "test"} + + +@pytest.fixture +def dimensions() -> List[Dict[str, str]]: + return [ + {"name": "test_dimension", "value": "test"}, + {"name": "test_dimension_2", "value": "test"}, + ] + + +@pytest.fixture +def non_str_dimensions() -> List[Dict[str, Any]]: + return [ + {"name": "test_dimension", "value": True}, + {"name": "test_dimension_2", "value": 3}, + ] + + +@pytest.fixture +def namespace() -> str: + return "test_namespace" + + +@pytest.fixture +def service() -> str: + return "test_service" + + +@pytest.fixture +def metadata() -> Dict[str, str]: + return {"key": "username", "value": "test"} + + +@pytest.fixture +def a_hundred_metrics() -> List[Dict[str, str]]: + return [{"name": f"metric_{i}", "unit": "Count", "value": 1} for i in range(100)] + + +@pytest.fixture +def a_hundred_metric_values() -> List[Dict[str, str]]: + return [{"name": "metric", "unit": "Count", "value": i} for i in range(100)] diff --git a/tests/functional/test_metrics.py b/tests/functional/metrics/test_metrics_cloudwatch_emf.py similarity index 84% rename from tests/functional/test_metrics.py rename to tests/functional/metrics/test_metrics_cloudwatch_emf.py index 329ff7064dd..5c4a1de1128 100644 --- a/tests/functional/test_metrics.py +++ b/tests/functional/metrics/test_metrics_cloudwatch_emf.py @@ -1,7 +1,9 @@ +from __future__ import annotations + import json import warnings from collections import namedtuple -from typing import Any, Dict, List, Union +from typing import Dict, List import pytest @@ -16,103 +18,23 @@ SchemaValidationError, single_metric, ) -from aws_lambda_powertools.metrics.provider import ( - MetricsBase, - MetricsProviderBase, +from aws_lambda_powertools.metrics.provider.cloudwatch_emf.cloudwatch import ( + AmazonCloudWatchEMFProvider, +) +from aws_lambda_powertools.metrics.provider.cloudwatch_emf.constants import ( + MAX_DIMENSIONS, +) +from aws_lambda_powertools.metrics.provider.cloudwatch_emf.types import ( + CloudWatchEMFOutput, ) -from aws_lambda_powertools.metrics.provider.base import reset_cold_start_flag_provider -from aws_lambda_powertools.metrics.provider.cloudwatch_emf.cloudwatch import AmazonCloudWatchEMFProvider -from aws_lambda_powertools.metrics.provider.cloudwatch_emf.cold_start import reset_cold_start_flag -from aws_lambda_powertools.metrics.provider.cloudwatch_emf.constants import MAX_DIMENSIONS - - -@pytest.fixture(scope="function", autouse=True) -def reset_metric_set(): - metrics = Metrics() - metrics.clear_metrics() - metrics.clear_default_dimensions() - reset_cold_start_flag() # ensure each test has cold start - yield - - -@pytest.fixture -def metric_with_resolution() -> Dict[str, Union[str, int]]: - return {"name": "single_metric", "unit": MetricUnit.Count, "value": 1, "resolution": MetricResolution.High} - - -@pytest.fixture -def metric() -> Dict[str, str]: - return {"name": "single_metric", "unit": MetricUnit.Count, "value": 1} - - -@pytest.fixture -def metrics() -> List[Dict[str, str]]: - return [ - {"name": "metric_one", "unit": MetricUnit.Count, "value": 1}, - {"name": "metric_two", "unit": MetricUnit.Count, "value": 1}, - ] - - -@pytest.fixture -def metrics_same_name() -> List[Dict[str, str]]: - return [ - {"name": "metric_one", "unit": MetricUnit.Count, "value": 1}, - {"name": "metric_one", "unit": MetricUnit.Count, "value": 5}, - ] - - -@pytest.fixture -def dimension() -> Dict[str, str]: - return {"name": "test_dimension", "value": "test"} - - -@pytest.fixture -def dimensions() -> List[Dict[str, str]]: - return [ - {"name": "test_dimension", "value": "test"}, - {"name": "test_dimension_2", "value": "test"}, - ] - - -@pytest.fixture -def non_str_dimensions() -> List[Dict[str, Any]]: - return [ - {"name": "test_dimension", "value": True}, - {"name": "test_dimension_2", "value": 3}, - ] - - -@pytest.fixture -def namespace() -> str: - return "test_namespace" - - -@pytest.fixture -def service() -> str: - return "test_service" - - -@pytest.fixture -def metadata() -> Dict[str, str]: - return {"key": "username", "value": "test"} - - -@pytest.fixture -def a_hundred_metrics() -> List[Dict[str, str]]: - return [{"name": f"metric_{i}", "unit": "Count", "value": 1} for i in range(100)] - - -@pytest.fixture -def a_hundred_metric_values() -> List[Dict[str, str]]: - return [{"name": "metric", "unit": "Count", "value": i} for i in range(100)] def serialize_metrics( metrics: List[Dict], dimensions: List[Dict], namespace: str, - metadatas: List[Dict] = None, -) -> Dict: + metadatas: List[Dict] | None = None, +) -> CloudWatchEMFOutput: """Helper function to build EMF object from a list of metrics, dimensions""" my_metrics = AmazonCloudWatchEMFProvider(namespace=namespace) for dimension in dimensions: @@ -129,7 +51,12 @@ def serialize_metrics( return my_metrics.serialize_metric_set() -def serialize_single_metric(metric: Dict, dimension: Dict, namespace: str, metadata: Dict = None) -> Dict: +def serialize_single_metric( + metric: Dict, + dimension: Dict, + namespace: str, + metadata: Dict | None = None, +) -> CloudWatchEMFOutput: """Helper function to build EMF object from a given metric, dimension and namespace""" my_metrics = AmazonCloudWatchEMFProvider(namespace=namespace) my_metrics.add_metric(**metric) @@ -151,7 +78,7 @@ def capture_metrics_output(capsys): return json.loads(capsys.readouterr().out.strip()) -def capture_metrics_output_multiple_emf_objects(capsys): +def capture_metrics_output_multiple_emf_objects(capsys) -> List[CloudWatchEMFOutput]: return [json.loads(line.strip()) for line in capsys.readouterr().out.split("\n") if line] @@ -1270,159 +1197,3 @@ def lambda_handler(evt, ctx): output = capture_metrics_output_multiple_emf_objects(capsys) assert len(output) == 2 - - -@pytest.fixture -def metrics_provider() -> MetricsProviderBase: - class MetricsProvider: - def __init__(self): - self.metric_store: List = [] - self.result: str - super().__init__() - - def add_metric(self, name: str, value: float, tag: List = None, *args, **kwargs): - self.metric_store.append({"name": name, "value": value, "tag": tag}) - - def serialize(self, raise_on_empty_metrics: bool = False, *args, **kwargs): - if raise_on_empty_metrics and len(self.metric_store) == 0: - raise SchemaValidationError("Must contain at least one metric.") - - self.result = json.dumps(self.metric_store) - - def flush(self, *args, **kwargs): - print(self.result) - - def clear(self): - self.result = "" - self.metric_store = [] - - return MetricsProvider - - -@pytest.fixture -def metrics_class() -> MetricsBase: - class MetricsClass(MetricsBase): - def __init__(self, provider): - self.provider = provider - super().__init__() - - def add_metric(self, name: str, value: float, tag: List = None, *args, **kwargs): - self.provider.add_metric(name=name, value=value, tag=tag) - - def flush_metrics(self, raise_on_empty_metrics: bool = False) -> None: - self.provider.serialize(raise_on_empty_metrics=raise_on_empty_metrics) - self.provider.flush() - self.provider.clear() - - def add_cold_start_metric(self, metric_name: str, function_name: str) -> None: - self.provider.add_metric(name=metric_name, value=1, function_name=function_name) - - return MetricsClass - - -def test_metrics_provider_basic(capsys, metrics_provider, metric): - provider = metrics_provider() - provider.add_metric(**metric) - provider.serialize() - provider.flush() - output = capture_metrics_output(capsys) - assert output[0]["name"] == metric["name"] - assert output[0]["value"] == metric["value"] - - -def test_metrics_provider_class_basic(capsys, metrics_provider, metrics_class, metric): - metrics = metrics_class(provider=metrics_provider()) - metrics.add_metric(**metric) - metrics.flush_metrics() - output = capture_metrics_output(capsys) - assert output[0]["name"] == metric["name"] - assert output[0]["value"] == metric["value"] - - -def test_metrics_provider_class_decorate(metrics_class, metrics_provider): - # GIVEN Metrics is initialized - my_metrics = metrics_class(provider=metrics_provider()) - - # WHEN log_metrics is used to serialize metrics - @my_metrics.log_metrics - def lambda_handler(evt, context): - return True - - # THEN log_metrics should invoke the function it decorates - # and return no error if we have a namespace and dimension - assert lambda_handler({}, {}) is True - - -def test_metrics_provider_class_coldstart(capsys, metrics_provider, metrics_class): - my_metrics = metrics_class(provider=metrics_provider()) - - # WHEN log_metrics is used with capture_cold_start_metric - @my_metrics.log_metrics(capture_cold_start_metric=True) - def lambda_handler(evt, context): - pass - - LambdaContext = namedtuple("LambdaContext", "function_name") - lambda_handler({}, LambdaContext("example_fn")) - - output = capture_metrics_output(capsys) - - # THEN ColdStart metric and function_name and service dimension should be logged - assert output[0]["name"] == "ColdStart" - - -def test_metrics_provider_class_no_coldstart(capsys, metrics_provider, metrics_class): - reset_cold_start_flag_provider() - my_metrics = metrics_class(provider=metrics_provider()) - - # WHEN log_metrics is used with capture_cold_start_metric - @my_metrics.log_metrics(capture_cold_start_metric=True) - def lambda_handler(evt, context): - pass - - LambdaContext = namedtuple("LambdaContext", "function_name") - lambda_handler({}, LambdaContext("example_fn")) - _ = capture_metrics_output(capsys) - # drop first one - - lambda_handler({}, LambdaContext("example_fn")) - output = capture_metrics_output(capsys) - - # no coldstart is here - assert "ColdStart" not in json.dumps(output) - - -def test_metric_provider_raise_on_empty_metrics(metrics_provider, metrics_class): - # GIVEN Metrics is initialized - my_metrics = metrics_class(provider=metrics_provider()) - - # WHEN log_metrics is used with raise_on_empty_metrics param and has no metrics - @my_metrics.log_metrics(raise_on_empty_metrics=True) - def lambda_handler(evt, context): - pass - - # THEN the raised exception should be SchemaValidationError - # and specifically about the lack of Metrics - with pytest.raises(SchemaValidationError, match="Must contain at least one metric."): - lambda_handler({}, {}) - - -def test_log_metrics_capture_cold_start_metric_once_with_provider_and_ephemeral(capsys, namespace, service): - # GIVEN Metrics is initialized - my_metrics = Metrics(service=service, namespace=namespace) - my_isolated_metrics = EphemeralMetrics(service=service, namespace=namespace) - - # WHEN log_metrics is used with capture_cold_start_metric - @my_metrics.log_metrics(capture_cold_start_metric=True) - @my_isolated_metrics.log_metrics(capture_cold_start_metric=True) - def lambda_handler(evt, context): - pass - - LambdaContext = namedtuple("LambdaContext", "function_name") - lambda_handler({}, LambdaContext("example_fn")) - - output = capture_metrics_output(capsys) - - # THEN ColdStart metric and function_name and service dimension should be logged - assert output["ColdStart"] == [1.0] - assert output["function_name"] == "example_fn" - assert output["service"] == service diff --git a/tests/functional/metrics/test_metrics_provider.py b/tests/functional/metrics/test_metrics_provider.py new file mode 100644 index 00000000000..2ed84a23a21 --- /dev/null +++ b/tests/functional/metrics/test_metrics_provider.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import json +from typing import Any, List + +from aws_lambda_powertools.metrics import ( + SchemaValidationError, +) +from aws_lambda_powertools.metrics.metrics import Metrics +from aws_lambda_powertools.metrics.provider import BaseProvider +from aws_lambda_powertools.utilities.typing import LambdaContext + + +def capture_metrics_output(capsys): + return json.loads(capsys.readouterr().out.strip()) + + +class FakeMetricsProvider(BaseProvider): + def __init__(self): + self.metric_store: List = [] + + def add_metric(self, name: str, value: float, tag: List = None, *args, **kwargs): + self.metric_store.append({"name": name, "value": value}) + + def serialize_metric_set(self, raise_on_empty_metrics: bool = False, *args, **kwargs): + if raise_on_empty_metrics and len(self.metric_store) == 0: + raise SchemaValidationError("Must contain at least one metric.") + + self.result = json.dumps(self.metric_store) + + def flush_metrics(self, *args, **kwargs): + print(json.dumps(self.metric_store)) + + def clear_metrics(self): + self.metric_store.clear() + + def add_cold_start_metric(self, context: LambdaContext) -> Any: + self.metric_store.append({"name": "ColdStart", "value": 1, "function_name": context.function_name}) + + +def test_metrics_class_with_custom_provider(capsys, metric): + provider = FakeMetricsProvider() + metrics = Metrics(provider=provider) + metrics.add_metric(**metric) + metrics.flush_metrics() + output = capture_metrics_output(capsys) + assert output[0]["name"] == metric["name"] + assert output[0]["value"] == metric["value"] + + +def test_metrics_provider_class_decorate(): + # GIVEN Metrics is initialized + my_metrics = Metrics() + + # WHEN log_metrics is used to serialize metrics + @my_metrics.log_metrics + def lambda_handler(evt, context): + return True + + # THEN log_metrics should invoke the function it decorates + # and return no error if we have a namespace and dimension + assert lambda_handler({}, {}) is True