From 6569743732c734748eccc2b898027ddddbf3e063 Mon Sep 17 00:00:00 2001 From: Leandro Damascena Date: Fri, 22 Mar 2024 15:39:01 +0000 Subject: [PATCH 01/10] Initial commit --- aws_lambda_powertools/metrics/base.py | 14 +++++- aws_lambda_powertools/metrics/metrics.py | 3 ++ .../provider/cloudwatch_emf/cloudwatch.py | 13 +++++- .../metrics/test_metrics_cloudwatch_emf.py | 45 +++++++++++++++++++ 4 files changed, 73 insertions(+), 2 deletions(-) diff --git a/aws_lambda_powertools/metrics/base.py b/aws_lambda_powertools/metrics/base.py index 5e3b9c84733..8d68cb32170 100644 --- a/aws_lambda_powertools/metrics/base.py +++ b/aws_lambda_powertools/metrics/base.py @@ -76,6 +76,8 @@ def __init__( self.namespace = resolve_env_var_choice(choice=namespace, env=os.getenv(constants.METRICS_NAMESPACE_ENV)) self.service = resolve_env_var_choice(choice=service, env=os.getenv(constants.SERVICE_NAME_ENV)) self.metadata_set = metadata_set if metadata_set is not None else {} + self.timestamp: int | None = None + self._metric_units = [unit.value for unit in MetricUnit] self._metric_unit_valid_options = list(MetricUnit.__members__) self._metric_resolutions = [resolution.value for resolution in MetricResolution] @@ -224,7 +226,7 @@ def serialize_metric_set( return { "_aws": { - "Timestamp": int(datetime.datetime.now().timestamp() * 1000), # epoch + "Timestamp": self.timestamp or int(datetime.datetime.now().timestamp() * 1000), # epoch "CloudWatchMetrics": [ { "Namespace": self.namespace, # "test_namespace" @@ -296,6 +298,16 @@ def add_metadata(self, key: str, value: Any) -> None: else: self.metadata_set[str(key)] = value + def set_timestamp(self, timestamp: int): + if not isinstance(timestamp, int): + warnings.warn( + "The timestamp key must be an integer value representing an epoch time. ", + "The provided value is not valid. Using the current timestamp instead.", + stacklevel=2, + ) + else: + self.timestamp = timestamp + def clear_metrics(self) -> None: logger.debug("Clearing out existing metric set from memory") self.metric_set.clear() diff --git a/aws_lambda_powertools/metrics/metrics.py b/aws_lambda_powertools/metrics/metrics.py index 976380ab6a9..f4dbd595981 100644 --- a/aws_lambda_powertools/metrics/metrics.py +++ b/aws_lambda_powertools/metrics/metrics.py @@ -125,6 +125,9 @@ def serialize_metric_set( def add_metadata(self, key: str, value: Any) -> None: self.provider.add_metadata(key=key, value=value) + def set_timestamp(self, timestamp: int): + self.provider.set_timestamp(timestamp=timestamp) + def flush_metrics(self, raise_on_empty_metrics: bool = False) -> None: self.provider.flush_metrics(raise_on_empty_metrics=raise_on_empty_metrics) diff --git a/aws_lambda_powertools/metrics/provider/cloudwatch_emf/cloudwatch.py b/aws_lambda_powertools/metrics/provider/cloudwatch_emf/cloudwatch.py index f5859c5a48d..19046b36dc7 100644 --- a/aws_lambda_powertools/metrics/provider/cloudwatch_emf/cloudwatch.py +++ b/aws_lambda_powertools/metrics/provider/cloudwatch_emf/cloudwatch.py @@ -73,6 +73,7 @@ def __init__( self.namespace = resolve_env_var_choice(choice=namespace, env=os.getenv(constants.METRICS_NAMESPACE_ENV)) self.service = resolve_env_var_choice(choice=service, env=os.getenv(constants.SERVICE_NAME_ENV)) self.metadata_set = metadata_set if metadata_set is not None else {} + self.timestamp: int | None = None self._metric_units = [unit.value for unit in MetricUnit] self._metric_unit_valid_options = list(MetricUnit.__members__) @@ -231,7 +232,7 @@ def serialize_metric_set( return { "_aws": { - "Timestamp": int(datetime.datetime.now().timestamp() * 1000), # epoch + "Timestamp": self.timestamp or int(datetime.datetime.now().timestamp() * 1000), # epoch "CloudWatchMetrics": [ { "Namespace": self.namespace, # "test_namespace" @@ -304,6 +305,16 @@ def add_metadata(self, key: str, value: Any) -> None: else: self.metadata_set[str(key)] = value + def set_timestamp(self, timestamp: int): + if not isinstance(timestamp, int): + warnings.warn( + "The timestamp key must be an integer value representing an epoch time. " + "The provided value is not valid. Using the current timestamp instead.", + stacklevel=2, + ) + else: + self.timestamp = timestamp + def clear_metrics(self) -> None: logger.debug("Clearing out existing metric set from memory") self.metric_set.clear() diff --git a/tests/functional/metrics/test_metrics_cloudwatch_emf.py b/tests/functional/metrics/test_metrics_cloudwatch_emf.py index d3da81798b6..bba020d7f37 100644 --- a/tests/functional/metrics/test_metrics_cloudwatch_emf.py +++ b/tests/functional/metrics/test_metrics_cloudwatch_emf.py @@ -1,5 +1,6 @@ from __future__ import annotations +import datetime import json import warnings from collections import namedtuple @@ -1213,3 +1214,47 @@ def lambda_handler(evt, ctx): output = capture_metrics_output_multiple_emf_objects(capsys) assert len(output) == 2 + + +def test_metric_with_custom_timestamp(namespace, metric, capsys): + # GIVEN Metrics instance is initialized + my_metrics = Metrics(namespace=namespace) + + # Calculate the metric timestamp as 2 days before the current time + metric_timestamp = int((datetime.datetime.now() - datetime.timedelta(days=2)).timestamp() * 1000) + + # WHEN we set custom timestamp before to flush the metric + @my_metrics.log_metrics + def lambda_handler(evt, ctx): + my_metrics.add_metric(**metric) + my_metrics.set_timestamp(metric_timestamp) + + lambda_handler({}, {}) + invocation = capture_metrics_output(capsys) + + # THEN Timestamp must be the custom value + assert invocation["_aws"]["Timestamp"] == metric_timestamp + + +def test_metric_with_wrong_custom_timestamp(namespace, metric): + # GIVEN Metrics instance is initialized + my_metrics = Metrics(namespace=namespace) + + # Setting timestamp as datetime + metric_timestamp = datetime.datetime.now() + + # WHEN we set a wrong timestamp before to flush the metric + @my_metrics.log_metrics + def lambda_handler(evt, ctx): + my_metrics.add_metric(**metric) + my_metrics.set_timestamp(metric_timestamp) + + # THEN should raise a warning + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("default") + lambda_handler({}, {}) + assert len(w) == 1 + assert str(w[-1].message) == ( + "The timestamp key must be an integer value representing an epoch time. " + "The provided value is not valid. Using the current timestamp instead." + ) From dbc9f2e19843b0ae3c36a1ebe2838d89a8123799 Mon Sep 17 00:00:00 2001 From: Leandro Damascena Date: Tue, 26 Mar 2024 08:44:44 +0000 Subject: [PATCH 02/10] Refactoring logic + mypy still failling --- aws_lambda_powertools/metrics/functions.py | 57 +++++++++++++++++++ .../provider/cloudwatch_emf/cloudwatch.py | 15 +++-- aws_lambda_powertools/shared/constants.py | 5 ++ 3 files changed, 71 insertions(+), 6 deletions(-) diff --git a/aws_lambda_powertools/metrics/functions.py b/aws_lambda_powertools/metrics/functions.py index e259826f1a7..d0f16ff8a5c 100644 --- a/aws_lambda_powertools/metrics/functions.py +++ b/aws_lambda_powertools/metrics/functions.py @@ -1,10 +1,13 @@ from __future__ import annotations +from datetime import datetime, timezone + 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 +from aws_lambda_powertools.shared import constants from aws_lambda_powertools.shared.types import List @@ -69,3 +72,57 @@ def extract_cloudwatch_metric_unit_value(metric_units: List, metric_valid_option unit = unit.value return unit + + +def validate_emf_timestamp(timestamp: int | datetime) -> bool: + """ + Validates a given timestamp based on CloudWatch Timestamp guidelines. + + Timestamp must meet CloudWatch requirements, otherwise an InvalidTimestampError will be raised. + See [Timestamps](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/cloudwatch_concepts.html#about_timestamp) + for valid values. + + Parameters: + ---------- + timestamp: int | datetime + Datetime object or epoch time representing the timestamp to validate. + + Returns + ------- + bool + Valid or not timestamp values + """ + + if not isinstance(timestamp, (int, datetime)): + return False + + if isinstance(timestamp, datetime): + # Assuming the integer timestamp represents seconds since the epoch + timestamp = int(timestamp.timestamp() * 1000) + + current_time = int(datetime.now(timezone.utc).timestamp() * 1000) + min_valid_timestamp = current_time - constants.EMF_MAX_TIMESTAMP_PAST_AGE + max_valid_timestamp = current_time + constants.EMF_MAX_TIMESTAMP_FUTURE_AGE + + return min_valid_timestamp <= timestamp <= max_valid_timestamp + + +def convert_timestamp_to_emf_format(timestamp: int | datetime) -> int: + """ + Converts a timestamp to EMF compatible format. + + Parameters + ---------- + timestamp: int | datetime + The timestamp to convert. If already in milliseconds format, returns it as is. + If datetime object, converts it to milliseconds since Unix epoch. + + Returns: + -------- + int + The timestamp converted to EMF compatible format (milliseconds since Unix epoch). + """ + if isinstance(timestamp, int): + return timestamp + + return int(round(timestamp.timestamp() * 1000)) diff --git a/aws_lambda_powertools/metrics/provider/cloudwatch_emf/cloudwatch.py b/aws_lambda_powertools/metrics/provider/cloudwatch_emf/cloudwatch.py index 19046b36dc7..89c3b833e8d 100644 --- a/aws_lambda_powertools/metrics/provider/cloudwatch_emf/cloudwatch.py +++ b/aws_lambda_powertools/metrics/provider/cloudwatch_emf/cloudwatch.py @@ -14,6 +14,7 @@ from aws_lambda_powertools.metrics.functions import ( extract_cloudwatch_metric_resolution_value, extract_cloudwatch_metric_unit_value, + validate_emf_timestamp, ) from aws_lambda_powertools.metrics.provider.base import BaseProvider from aws_lambda_powertools.metrics.provider.cloudwatch_emf.constants import MAX_DIMENSIONS, MAX_METRICS @@ -305,15 +306,17 @@ def add_metadata(self, key: str, value: Any) -> None: else: self.metadata_set[str(key)] = value - def set_timestamp(self, timestamp: int): - if not isinstance(timestamp, int): + def set_timestamp(self, timestamp: int | datetime.datetime): + if not validate_emf_timestamp(timestamp): warnings.warn( - "The timestamp key must be an integer value representing an epoch time. " - "The provided value is not valid. Using the current timestamp instead.", + "The timestamp must be a Datetime object or an integer representing an epoch time. " + "This should not exceed 14 days in the past or be more than 2 hours in the future. " + "Any metrics failing to meet this criteria will be skipped by Amazon CloudWatch. " + "Please check the EMFValidationErrors metric in AWS/Logs namespace for more details.", stacklevel=2, ) - else: - self.timestamp = timestamp + + self.timestamp = timestamp def clear_metrics(self) -> None: logger.debug("Clearing out existing metric set from memory") diff --git a/aws_lambda_powertools/shared/constants.py b/aws_lambda_powertools/shared/constants.py index 82c131843fb..bb8164d1d37 100644 --- a/aws_lambda_powertools/shared/constants.py +++ b/aws_lambda_powertools/shared/constants.py @@ -39,6 +39,11 @@ METRICS_NAMESPACE_ENV: str = "POWERTOOLS_METRICS_NAMESPACE" DATADOG_FLUSH_TO_LOG: str = "DD_FLUSH_TO_LOG" SERVICE_NAME_ENV: str = "POWERTOOLS_SERVICE_NAME" +# If the timestamp of log event is more than 2 hours in future, the log event is skipped. +# If the timestamp of log event is more than 14 days in past, the log event is skipped. +# See https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/AgentReference.html +EMF_MAX_TIMESTAMP_PAST_AGE = 14 * 24 * 60 * 60 * 1000 # 14 days +EMF_MAX_TIMESTAMP_FUTURE_AGE = 2 * 60 * 60 * 1000 # 2 hours # Parameters constants PARAMETERS_SSM_DECRYPT_ENV: str = "POWERTOOLS_PARAMETERS_SSM_DECRYPT" From 1de4de96be509409d8e6df4c6b7048064c7c6991 Mon Sep 17 00:00:00 2001 From: Leandro Damascena Date: Tue, 26 Mar 2024 11:06:19 +0000 Subject: [PATCH 03/10] Metrics should raise a warning when outside of constraints --- aws_lambda_powertools/metrics/base.py | 18 ++++++++++++------ aws_lambda_powertools/metrics/functions.py | 2 +- .../provider/cloudwatch_emf/cloudwatch.py | 14 +++++++++----- .../metrics/test_metrics_cloudwatch_emf.py | 16 ++++++++++------ 4 files changed, 32 insertions(+), 18 deletions(-) diff --git a/aws_lambda_powertools/metrics/base.py b/aws_lambda_powertools/metrics/base.py index 8d68cb32170..cabaa946b59 100644 --- a/aws_lambda_powertools/metrics/base.py +++ b/aws_lambda_powertools/metrics/base.py @@ -17,6 +17,7 @@ MetricValueError, SchemaValidationError, ) +from aws_lambda_powertools.metrics.functions import convert_timestamp_to_emf_format, validate_emf_timestamp 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 @@ -298,15 +299,20 @@ def add_metadata(self, key: str, value: Any) -> None: else: self.metadata_set[str(key)] = value - def set_timestamp(self, timestamp: int): - if not isinstance(timestamp, int): + def set_timestamp(self, timestamp: int | datetime.datetime): + # The timestamp must be a Datetime object or an integer representing an epoch time. + # This should not exceed 14 days in the past or be more than 2 hours in the future. + # any metrics failing to meet this criteria will be skipped by Amazon CloudWatch. + # See: https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch_Embedded_Metric_Format_Specification.html + # See: https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/CloudWatch-Logs-Monitoring-CloudWatch-Metrics.html + if not validate_emf_timestamp(timestamp): warnings.warn( - "The timestamp key must be an integer value representing an epoch time. ", - "The provided value is not valid. Using the current timestamp instead.", + "This metric is outside of constraints and will be skipped By Amazon CloudWatch. " + "Ensure the timestamp is within 14 days past or 2 hours future.", stacklevel=2, ) - else: - self.timestamp = timestamp + + self.timestamp = convert_timestamp_to_emf_format(timestamp) def clear_metrics(self) -> None: logger.debug("Clearing out existing metric set from memory") diff --git a/aws_lambda_powertools/metrics/functions.py b/aws_lambda_powertools/metrics/functions.py index d0f16ff8a5c..f79a689c570 100644 --- a/aws_lambda_powertools/metrics/functions.py +++ b/aws_lambda_powertools/metrics/functions.py @@ -97,7 +97,7 @@ def validate_emf_timestamp(timestamp: int | datetime) -> bool: return False if isinstance(timestamp, datetime): - # Assuming the integer timestamp represents seconds since the epoch + # Converting timestamp to integer timestamp = int(timestamp.timestamp() * 1000) current_time = int(datetime.now(timezone.utc).timestamp() * 1000) diff --git a/aws_lambda_powertools/metrics/provider/cloudwatch_emf/cloudwatch.py b/aws_lambda_powertools/metrics/provider/cloudwatch_emf/cloudwatch.py index 89c3b833e8d..f61ec331d9b 100644 --- a/aws_lambda_powertools/metrics/provider/cloudwatch_emf/cloudwatch.py +++ b/aws_lambda_powertools/metrics/provider/cloudwatch_emf/cloudwatch.py @@ -12,6 +12,7 @@ from aws_lambda_powertools.metrics.base import single_metric from aws_lambda_powertools.metrics.exceptions import MetricValueError, SchemaValidationError from aws_lambda_powertools.metrics.functions import ( + convert_timestamp_to_emf_format, extract_cloudwatch_metric_resolution_value, extract_cloudwatch_metric_unit_value, validate_emf_timestamp, @@ -307,16 +308,19 @@ def add_metadata(self, key: str, value: Any) -> None: self.metadata_set[str(key)] = value def set_timestamp(self, timestamp: int | datetime.datetime): + # The timestamp must be a Datetime object or an integer representing an epoch time. + # This should not exceed 14 days in the past or be more than 2 hours in the future. + # any metrics failing to meet this criteria will be skipped by Amazon CloudWatch. + # See: https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch_Embedded_Metric_Format_Specification.html + # See: https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/CloudWatch-Logs-Monitoring-CloudWatch-Metrics.html if not validate_emf_timestamp(timestamp): warnings.warn( - "The timestamp must be a Datetime object or an integer representing an epoch time. " - "This should not exceed 14 days in the past or be more than 2 hours in the future. " - "Any metrics failing to meet this criteria will be skipped by Amazon CloudWatch. " - "Please check the EMFValidationErrors metric in AWS/Logs namespace for more details.", + "This metric is outside of constraints and will be skipped By Amazon CloudWatch. " + "Ensure the timestamp is within 14 days past or 2 hours future.", stacklevel=2, ) - self.timestamp = timestamp + self.timestamp = convert_timestamp_to_emf_format(timestamp) def clear_metrics(self) -> None: logger.debug("Clearing out existing metric set from memory") diff --git a/tests/functional/metrics/test_metrics_cloudwatch_emf.py b/tests/functional/metrics/test_metrics_cloudwatch_emf.py index bba020d7f37..6f95c4921d9 100644 --- a/tests/functional/metrics/test_metrics_cloudwatch_emf.py +++ b/tests/functional/metrics/test_metrics_cloudwatch_emf.py @@ -1216,12 +1216,16 @@ def lambda_handler(evt, ctx): assert len(output) == 2 -def test_metric_with_custom_timestamp(namespace, metric, capsys): +@pytest.mark.parametrize( + "timestamp", + [int((datetime.datetime.now() - datetime.timedelta(days=2)).timestamp() * 1000), 1711105187000], +) +def test_metric_with_custom_timestamp(namespace, metric, capsys, timestamp): # GIVEN Metrics instance is initialized my_metrics = Metrics(namespace=namespace) # Calculate the metric timestamp as 2 days before the current time - metric_timestamp = int((datetime.datetime.now() - datetime.timedelta(days=2)).timestamp() * 1000) + metric_timestamp = timestamp # WHEN we set custom timestamp before to flush the metric @my_metrics.log_metrics @@ -1240,8 +1244,8 @@ def test_metric_with_wrong_custom_timestamp(namespace, metric): # GIVEN Metrics instance is initialized my_metrics = Metrics(namespace=namespace) - # Setting timestamp as datetime - metric_timestamp = datetime.datetime.now() + # Setting timestamp outside of contraints with 20 days before + metric_timestamp = int((datetime.datetime.now() - datetime.timedelta(days=20)).timestamp() * 1000) # WHEN we set a wrong timestamp before to flush the metric @my_metrics.log_metrics @@ -1255,6 +1259,6 @@ def lambda_handler(evt, ctx): lambda_handler({}, {}) assert len(w) == 1 assert str(w[-1].message) == ( - "The timestamp key must be an integer value representing an epoch time. " - "The provided value is not valid. Using the current timestamp instead." + "This metric is outside of constraints and will be skipped By Amazon CloudWatch. " + "Ensure the timestamp is within 14 days past or 2 hours future." ) From dee6313e697513a6b8283e55f1f3f2d9094e11a7 Mon Sep 17 00:00:00 2001 From: Leandro Damascena Date: Tue, 26 Mar 2024 12:41:49 +0000 Subject: [PATCH 04/10] Adding timestamp to single_metric --- aws_lambda_powertools/metrics/base.py | 7 +++++ .../metrics/test_metrics_cloudwatch_emf.py | 26 +++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/aws_lambda_powertools/metrics/base.py b/aws_lambda_powertools/metrics/base.py index cabaa946b59..12dd2db0d39 100644 --- a/aws_lambda_powertools/metrics/base.py +++ b/aws_lambda_powertools/metrics/base.py @@ -557,6 +557,7 @@ def single_metric( resolution: MetricResolution | int = 60, namespace: str | None = None, default_dimensions: Dict[str, str] | None = None, + timestamp: int | datetime.datetime | None = None, ) -> Generator[SingleMetric, None, None]: """Context manager to simplify creation of a single metric @@ -594,6 +595,9 @@ def single_metric( Metric value namespace: str Namespace for metrics + default_dimensions: Dict[str, str], optional + Metric dimensions as key=value that will always be present + Yields ------- @@ -616,6 +620,9 @@ def single_metric( metric: SingleMetric = SingleMetric(namespace=namespace) metric.add_metric(name=name, unit=unit, value=value, resolution=resolution) + if timestamp: + metric.set_timestamp(timestamp) + if default_dimensions: for dim_name, dim_value in default_dimensions.items(): metric.add_dimension(name=dim_name, value=dim_value) diff --git a/tests/functional/metrics/test_metrics_cloudwatch_emf.py b/tests/functional/metrics/test_metrics_cloudwatch_emf.py index 6f95c4921d9..fd774bbc65e 100644 --- a/tests/functional/metrics/test_metrics_cloudwatch_emf.py +++ b/tests/functional/metrics/test_metrics_cloudwatch_emf.py @@ -57,6 +57,7 @@ def serialize_single_metric( dimension: Dict, namespace: str, metadata: Dict | None = None, + timestamp: int | datetime.datetime | None = None, ) -> CloudWatchEMFOutput: """Helper function to build EMF object from a given metric, dimension and namespace""" my_metrics = AmazonCloudWatchEMFProvider(namespace=namespace) @@ -66,6 +67,9 @@ def serialize_single_metric( if metadata is not None: my_metrics.add_metadata(**metadata) + if timestamp: + my_metrics.set_timestamp(timestamp) + return my_metrics.serialize_metric_set() @@ -143,6 +147,28 @@ def test_single_metric_default_dimensions(capsys, metric, dimension, namespace): assert expected == output +def test_single_metric_with_custom_timestamp(capsys, metric, dimension, namespace): + # GIVEN we provide a custom timestamp + # WHEN using single_metric context manager + + default_dimensions = {dimension["name"]: dimension["value"]} + + timestamp = int((datetime.datetime.now() - datetime.timedelta(days=2)).timestamp() * 1000) + with single_metric( + namespace=namespace, + default_dimensions=default_dimensions, + **metric, + timestamp=timestamp, + ) as my_metric: + my_metric.add_metric(name="second_metric", unit="Count", value=1) + + output = capture_metrics_output(capsys) + expected = serialize_single_metric(metric=metric, dimension=dimension, namespace=namespace, timestamp=timestamp) + + # THEN we should have custom timestamp added to the metric + assert expected == output + + def test_single_metric_default_dimensions_inherit(capsys, metric, dimension, namespace): # GIVEN we provide Metrics default dimensions # WHEN using single_metric context manager From c651b6f21e398f5b1cbed24adb39768fb336ec6f Mon Sep 17 00:00:00 2001 From: Leandro Damascena Date: Tue, 26 Mar 2024 12:43:39 +0000 Subject: [PATCH 05/10] Adding timestamp to single_metric --- aws_lambda_powertools/metrics/base.py | 2 +- .../metrics/provider/cloudwatch_emf/cloudwatch.py | 2 +- tests/functional/metrics/test_metrics_cloudwatch_emf.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/aws_lambda_powertools/metrics/base.py b/aws_lambda_powertools/metrics/base.py index 12dd2db0d39..005e2b5c8f5 100644 --- a/aws_lambda_powertools/metrics/base.py +++ b/aws_lambda_powertools/metrics/base.py @@ -307,7 +307,7 @@ def set_timestamp(self, timestamp: int | datetime.datetime): # See: https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/CloudWatch-Logs-Monitoring-CloudWatch-Metrics.html if not validate_emf_timestamp(timestamp): warnings.warn( - "This metric is outside of constraints and will be skipped By Amazon CloudWatch. " + "This metric doesn't meet the requirements and will be skipped by Amazon CloudWatch. " "Ensure the timestamp is within 14 days past or 2 hours future.", stacklevel=2, ) diff --git a/aws_lambda_powertools/metrics/provider/cloudwatch_emf/cloudwatch.py b/aws_lambda_powertools/metrics/provider/cloudwatch_emf/cloudwatch.py index f61ec331d9b..b2d851898f9 100644 --- a/aws_lambda_powertools/metrics/provider/cloudwatch_emf/cloudwatch.py +++ b/aws_lambda_powertools/metrics/provider/cloudwatch_emf/cloudwatch.py @@ -315,7 +315,7 @@ def set_timestamp(self, timestamp: int | datetime.datetime): # See: https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/CloudWatch-Logs-Monitoring-CloudWatch-Metrics.html if not validate_emf_timestamp(timestamp): warnings.warn( - "This metric is outside of constraints and will be skipped By Amazon CloudWatch. " + "This metric doesn't meet the requirements and will be skipped by Amazon CloudWatch. " "Ensure the timestamp is within 14 days past or 2 hours future.", stacklevel=2, ) diff --git a/tests/functional/metrics/test_metrics_cloudwatch_emf.py b/tests/functional/metrics/test_metrics_cloudwatch_emf.py index fd774bbc65e..bded64f6215 100644 --- a/tests/functional/metrics/test_metrics_cloudwatch_emf.py +++ b/tests/functional/metrics/test_metrics_cloudwatch_emf.py @@ -1285,6 +1285,6 @@ def lambda_handler(evt, ctx): lambda_handler({}, {}) assert len(w) == 1 assert str(w[-1].message) == ( - "This metric is outside of constraints and will be skipped By Amazon CloudWatch. " + "This metric doesn't meet the requirements and will be skipped by Amazon CloudWatch. " "Ensure the timestamp is within 14 days past or 2 hours future." ) From 9c15b5b4768d201ca274214437a135804647c847 Mon Sep 17 00:00:00 2001 From: Leandro Damascena Date: Tue, 26 Mar 2024 13:09:35 +0000 Subject: [PATCH 06/10] Adding test for wrong type --- aws_lambda_powertools/metrics/functions.py | 8 +++++- .../metrics/test_metrics_cloudwatch_emf.py | 26 ++++++++++++++++++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/aws_lambda_powertools/metrics/functions.py b/aws_lambda_powertools/metrics/functions.py index f79a689c570..5f51436bf14 100644 --- a/aws_lambda_powertools/metrics/functions.py +++ b/aws_lambda_powertools/metrics/functions.py @@ -125,4 +125,10 @@ def convert_timestamp_to_emf_format(timestamp: int | datetime) -> int: if isinstance(timestamp, int): return timestamp - return int(round(timestamp.timestamp() * 1000)) + try: + return int(round(timestamp.timestamp() * 1000)) + except AttributeError: + # If this point is reached, it indicates timestamp is not a datetime object + # Returning zero represents the initial date of epoch time, + # which will be skipped by Amazon CloudWatch. + return 0 diff --git a/tests/functional/metrics/test_metrics_cloudwatch_emf.py b/tests/functional/metrics/test_metrics_cloudwatch_emf.py index bded64f6215..1ce19ef6ff3 100644 --- a/tests/functional/metrics/test_metrics_cloudwatch_emf.py +++ b/tests/functional/metrics/test_metrics_cloudwatch_emf.py @@ -1266,7 +1266,7 @@ def lambda_handler(evt, ctx): assert invocation["_aws"]["Timestamp"] == metric_timestamp -def test_metric_with_wrong_custom_timestamp(namespace, metric): +def test_metric_custom_timestamp_more_than_14days_ago(namespace, metric): # GIVEN Metrics instance is initialized my_metrics = Metrics(namespace=namespace) @@ -1288,3 +1288,27 @@ def lambda_handler(evt, ctx): "This metric doesn't meet the requirements and will be skipped by Amazon CloudWatch. " "Ensure the timestamp is within 14 days past or 2 hours future." ) + + +def test_metric_custom_timestamp_with_wrong_type(namespace, metric): + # GIVEN Metrics instance is initialized + my_metrics = Metrics(namespace=namespace) + + # Setting timestamp outside of contraints with 20 days before + metric_timestamp = "timestamp_as_string" + + # WHEN we set a wrong timestamp before to flush the metric + @my_metrics.log_metrics + def lambda_handler(evt, ctx): + my_metrics.add_metric(**metric) + my_metrics.set_timestamp(metric_timestamp) + + # THEN should raise a warning + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("default") + lambda_handler({}, {}) + assert len(w) == 1 + assert str(w[-1].message) == ( + "This metric doesn't meet the requirements and will be skipped by Amazon CloudWatch. " + "Ensure the timestamp is within 14 days past or 2 hours future." + ) From a36df3756b59e172aab9397703a153677d52eb80 Mon Sep 17 00:00:00 2001 From: Leandro Damascena Date: Tue, 26 Mar 2024 14:02:52 +0000 Subject: [PATCH 07/10] Adding examples + doc --- aws_lambda_powertools/metrics/base.py | 4 --- docs/core/metrics.md | 27 +++++++++++++++---- .../single_metric_with_different_timestamp.py | 18 +++++++++++++ ...tric_with_different_timestamp_payload.json | 27 +++++++++++++++++++ .../metrics/test_metrics_cloudwatch_emf.py | 2 +- 5 files changed, 68 insertions(+), 10 deletions(-) create mode 100644 examples/metrics/src/single_metric_with_different_timestamp.py create mode 100644 examples/metrics/src/single_metric_with_different_timestamp_payload.json diff --git a/aws_lambda_powertools/metrics/base.py b/aws_lambda_powertools/metrics/base.py index 005e2b5c8f5..22222485453 100644 --- a/aws_lambda_powertools/metrics/base.py +++ b/aws_lambda_powertools/metrics/base.py @@ -557,7 +557,6 @@ def single_metric( resolution: MetricResolution | int = 60, namespace: str | None = None, default_dimensions: Dict[str, str] | None = None, - timestamp: int | datetime.datetime | None = None, ) -> Generator[SingleMetric, None, None]: """Context manager to simplify creation of a single metric @@ -620,9 +619,6 @@ def single_metric( metric: SingleMetric = SingleMetric(namespace=namespace) metric.add_metric(name=name, unit=unit, value=value, resolution=resolution) - if timestamp: - metric.set_timestamp(timestamp) - if default_dimensions: for dim_name, dim_value in default_dimensions.items(): metric.add_dimension(name=dim_name, value=dim_value) diff --git a/docs/core/metrics.md b/docs/core/metrics.md index 19a34cf21ad..b9a2f34c959 100644 --- a/docs/core/metrics.md +++ b/docs/core/metrics.md @@ -224,14 +224,15 @@ You can add high-cardinality data as part of your Metrics log with `add_metadata --8<-- "examples/metrics/src/add_metadata_output.json" ``` -### Single metric with a different dimension +### Single metric -CloudWatch EMF uses the same dimensions across all your metrics. Use `single_metric` if you have a metric that should have different dimensions. +CloudWatch EMF uses the same dimensions and timestamp across all your metrics. Use `single_metric` if you have a metric that should have different dimensions or timestamp. -???+ info - Generally, this would be an edge case since you [pay for unique metric](https://aws.amazon.com/cloudwatch/pricing){target="_blank"}. Keep the following formula in mind: +#### Working with different dimensions + +Generally, using different dimensions would be an edge case since you [pay for unique metric](https://aws.amazon.com/cloudwatch/pricing){target="_blank"}. - **unique metric = (metric_name + dimension_name + dimension_value)** +Keep the following formula in mind: **unique metric = (metric_name + dimension_name + dimension_value)** === "single_metric.py" @@ -259,6 +260,22 @@ By default it will skip all previously defined dimensions including default dime --8<-- "examples/metrics/src/single_metric_default_dimensions.py" ``` +#### Working with different timestamp + +When working with multiple metrics, customers may need different timestamps between them. In such cases, utilize `single_metric` to flush individual metrics with specific timestamps. + +=== "single_metric_with_different_timestamp.py" + + ```python hl_lines="15 17" + --8<-- "examples/metrics/src/single_metric_with_different_timestamp.py" + ``` + +=== "single_metric_with_different_timestamp_payload.json" + + ```json hl_lines="5 10 15 20 25" + --8<-- "examples/metrics/src/single_metric_with_different_timestamp_payload.json" + ``` + ### Flushing metrics manually If you are using the [AWS Lambda Web Adapter](https://github.com/awslabs/aws-lambda-web-adapter){target="_blank"} project, or a middleware with custom metric logic, you can use `flush_metrics()`. This method will serialize, print metrics available to standard output, and clear in-memory metrics data. diff --git a/examples/metrics/src/single_metric_with_different_timestamp.py b/examples/metrics/src/single_metric_with_different_timestamp.py new file mode 100644 index 00000000000..bd99041c007 --- /dev/null +++ b/examples/metrics/src/single_metric_with_different_timestamp.py @@ -0,0 +1,18 @@ +from aws_lambda_powertools import Logger, single_metric +from aws_lambda_powertools.metrics import MetricUnit +from aws_lambda_powertools.utilities.typing import LambdaContext + +logger = Logger() + + +def lambda_handler(event: dict, context: LambdaContext): + + for record in event: + + record_id: str = record.get("record_id") + amount: int = record.get("amount") + timestamp: int = record.get("timestamp") + + with single_metric(name="Orders", unit=MetricUnit.Count, value=amount, namespace="Powertools") as metric: + logger.info(f"Processing record id {record_id}") + metric.set_timestamp(timestamp) diff --git a/examples/metrics/src/single_metric_with_different_timestamp_payload.json b/examples/metrics/src/single_metric_with_different_timestamp_payload.json new file mode 100644 index 00000000000..4cd85c6a760 --- /dev/null +++ b/examples/metrics/src/single_metric_with_different_timestamp_payload.json @@ -0,0 +1,27 @@ +[ + { + "record_id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8", + "amount": 10, + "timestamp": 1648195200000 + }, + { + "record_id": "6ba7b811-9dad-11d1-80b4-00c04fd430c8", + "amount": 30, + "timestamp": 1648224000000 + }, + { + "record_id": "6ba7b812-9dad-11d1-80b4-00c04fd430c8", + "amount": 25, + "timestamp": 1648209600000 + }, + { + "record_id": "6ba7b813-9dad-11d1-80b4-00c04fd430c8", + "amount": 40, + "timestamp": 1648177200000 + }, + { + "record_id": "6ba7b814-9dad-11d1-80b4-00c04fd430c8", + "amount": 32, + "timestamp": 1648216800000 + } +] diff --git a/tests/functional/metrics/test_metrics_cloudwatch_emf.py b/tests/functional/metrics/test_metrics_cloudwatch_emf.py index 1ce19ef6ff3..a3dfa518400 100644 --- a/tests/functional/metrics/test_metrics_cloudwatch_emf.py +++ b/tests/functional/metrics/test_metrics_cloudwatch_emf.py @@ -158,8 +158,8 @@ def test_single_metric_with_custom_timestamp(capsys, metric, dimension, namespac namespace=namespace, default_dimensions=default_dimensions, **metric, - timestamp=timestamp, ) as my_metric: + my_metric.set_timestamp(timestamp) my_metric.add_metric(name="second_metric", unit="Count", value=1) output = capture_metrics_output(capsys) From cd9f06475ff6f5a08052f4db23b3798a94314d5c Mon Sep 17 00:00:00 2001 From: Leandro Damascena Date: Tue, 26 Mar 2024 14:16:25 +0000 Subject: [PATCH 08/10] Adding examples + doc --- docs/core/metrics.md | 13 +++++++++++++ .../src/set_custom_timestamp_log_metrics.py | 15 +++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 examples/metrics/src/set_custom_timestamp_log_metrics.py diff --git a/docs/core/metrics.md b/docs/core/metrics.md index b9a2f34c959..73e4150aad4 100644 --- a/docs/core/metrics.md +++ b/docs/core/metrics.md @@ -131,6 +131,19 @@ If you'd like to remove them at some point, you can use `clear_default_dimension --8<-- "examples/metrics/src/set_default_dimensions_log_metrics.py" ``` +### Changing default timestamp + +When creating metrics, we use the current timestamp. If you want to change the timestamp of all the metrics you create, utilize the `set_timestamp` function. + +???+ info + If you need to use different timestamps across multiple metrics, opt for [single_metric](#working-with-different-timestamp). + +=== "set_custom_timestamp_log_metrics.py" + + ```python hl_lines="15" + --8<-- "examples/metrics/src/set_custom_timestamp_log_metrics.py" + ``` + ### Flushing metrics As you finish adding all your metrics, you need to serialize and flush them to standard output. You can do that automatically with the `log_metrics` decorator. diff --git a/examples/metrics/src/set_custom_timestamp_log_metrics.py b/examples/metrics/src/set_custom_timestamp_log_metrics.py new file mode 100644 index 00000000000..4a6cda23ed3 --- /dev/null +++ b/examples/metrics/src/set_custom_timestamp_log_metrics.py @@ -0,0 +1,15 @@ +import datetime + +from aws_lambda_powertools import Metrics +from aws_lambda_powertools.metrics import MetricUnit +from aws_lambda_powertools.utilities.typing import LambdaContext + +metrics = Metrics() + + +@metrics.log_metrics # ensures metrics are flushed upon request completion/failure +def lambda_handler(event: dict, context: LambdaContext): + metrics.add_metric(name="SuccessfulBooking", unit=MetricUnit.Count, value=1) + + metric_timestamp = int((datetime.datetime.now() - datetime.timedelta(days=2)).timestamp() * 1000) + metrics.set_timestamp(metric_timestamp) From 05d1efc89300b04f8665e5a53b4a99a0542dafbc Mon Sep 17 00:00:00 2001 From: Leandro Damascena Date: Tue, 26 Mar 2024 20:47:27 +0000 Subject: [PATCH 09/10] Wording + TZ --- aws_lambda_powertools/metrics/base.py | 12 +++++++++++- aws_lambda_powertools/metrics/functions.py | 13 ++++++++----- aws_lambda_powertools/metrics/metrics.py | 10 ++++++++++ .../metrics/provider/cloudwatch_emf/cloudwatch.py | 12 +++++++++++- 4 files changed, 40 insertions(+), 7 deletions(-) diff --git a/aws_lambda_powertools/metrics/base.py b/aws_lambda_powertools/metrics/base.py index 22222485453..73b13e33e5c 100644 --- a/aws_lambda_powertools/metrics/base.py +++ b/aws_lambda_powertools/metrics/base.py @@ -300,9 +300,19 @@ def add_metadata(self, key: str, value: Any) -> None: self.metadata_set[str(key)] = value def set_timestamp(self, timestamp: int | datetime.datetime): + """ + Set the timestamp for the metric. + + Parameters: + ----------- + timestamp: int | datetime.datetime + The timestamp to create the metric. + If an integer is provided, it is assumed to be the epoch time in milliseconds. + If a datetime object is provided, it will be converted to epoch time in milliseconds. + """ # The timestamp must be a Datetime object or an integer representing an epoch time. # This should not exceed 14 days in the past or be more than 2 hours in the future. - # any metrics failing to meet this criteria will be skipped by Amazon CloudWatch. + # Any metrics failing to meet this criteria will be skipped by Amazon CloudWatch. # See: https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch_Embedded_Metric_Format_Specification.html # See: https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/CloudWatch-Logs-Monitoring-CloudWatch-Metrics.html if not validate_emf_timestamp(timestamp): diff --git a/aws_lambda_powertools/metrics/functions.py b/aws_lambda_powertools/metrics/functions.py index 5f51436bf14..ea8dc3603d1 100644 --- a/aws_lambda_powertools/metrics/functions.py +++ b/aws_lambda_powertools/metrics/functions.py @@ -1,6 +1,6 @@ from __future__ import annotations -from datetime import datetime, timezone +from datetime import datetime from aws_lambda_powertools.metrics.provider.cloudwatch_emf.exceptions import ( MetricResolutionError, @@ -85,7 +85,7 @@ def validate_emf_timestamp(timestamp: int | datetime) -> bool: Parameters: ---------- timestamp: int | datetime - Datetime object or epoch time representing the timestamp to validate. + Datetime object or epoch time in milliseconds representing the timestamp to validate. Returns ------- @@ -97,10 +97,13 @@ def validate_emf_timestamp(timestamp: int | datetime) -> bool: return False if isinstance(timestamp, datetime): - # Converting timestamp to integer + # Converting timestamp to epoch time in milliseconds timestamp = int(timestamp.timestamp() * 1000) - current_time = int(datetime.now(timezone.utc).timestamp() * 1000) + # Consider current timezone when working with date and time + current_timezone = datetime.now().astimezone().tzinfo + + current_time = int(datetime.now(current_timezone).timestamp() * 1000) min_valid_timestamp = current_time - constants.EMF_MAX_TIMESTAMP_PAST_AGE max_valid_timestamp = current_time + constants.EMF_MAX_TIMESTAMP_FUTURE_AGE @@ -114,7 +117,7 @@ def convert_timestamp_to_emf_format(timestamp: int | datetime) -> int: Parameters ---------- timestamp: int | datetime - The timestamp to convert. If already in milliseconds format, returns it as is. + The timestamp to convert. If already in epoch milliseconds format, returns it as is. If datetime object, converts it to milliseconds since Unix epoch. Returns: diff --git a/aws_lambda_powertools/metrics/metrics.py b/aws_lambda_powertools/metrics/metrics.py index f4dbd595981..05d9010684c 100644 --- a/aws_lambda_powertools/metrics/metrics.py +++ b/aws_lambda_powertools/metrics/metrics.py @@ -126,6 +126,16 @@ def add_metadata(self, key: str, value: Any) -> None: self.provider.add_metadata(key=key, value=value) def set_timestamp(self, timestamp: int): + """ + Set the timestamp for the metric. + + Parameters: + ----------- + timestamp: int | datetime.datetime + The timestamp to create the metric. + If an integer is provided, it is assumed to be the epoch time in milliseconds. + If a datetime object is provided, it will be converted to epoch time in milliseconds. + """ self.provider.set_timestamp(timestamp=timestamp) def flush_metrics(self, raise_on_empty_metrics: bool = False) -> None: diff --git a/aws_lambda_powertools/metrics/provider/cloudwatch_emf/cloudwatch.py b/aws_lambda_powertools/metrics/provider/cloudwatch_emf/cloudwatch.py index b2d851898f9..d59026ebf69 100644 --- a/aws_lambda_powertools/metrics/provider/cloudwatch_emf/cloudwatch.py +++ b/aws_lambda_powertools/metrics/provider/cloudwatch_emf/cloudwatch.py @@ -308,9 +308,19 @@ def add_metadata(self, key: str, value: Any) -> None: self.metadata_set[str(key)] = value def set_timestamp(self, timestamp: int | datetime.datetime): + """ + Set the timestamp for the metric. + + Parameters: + ----------- + timestamp: int | datetime.datetime + The timestamp to create the metric. + If an integer is provided, it is assumed to be the epoch time in milliseconds. + If a datetime object is provided, it will be converted to epoch time in milliseconds. + """ # The timestamp must be a Datetime object or an integer representing an epoch time. # This should not exceed 14 days in the past or be more than 2 hours in the future. - # any metrics failing to meet this criteria will be skipped by Amazon CloudWatch. + # Any metrics failing to meet this criteria will be skipped by Amazon CloudWatch. # See: https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch_Embedded_Metric_Format_Specification.html # See: https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/CloudWatch-Logs-Monitoring-CloudWatch-Metrics.html if not validate_emf_timestamp(timestamp): From cb8505987a99e45c4cfbd130ebe22d9722000151 Mon Sep 17 00:00:00 2001 From: Leandro Damascena Date: Tue, 26 Mar 2024 20:58:18 +0000 Subject: [PATCH 10/10] Improving doc --- docs/core/metrics.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/core/metrics.md b/docs/core/metrics.md index 73e4150aad4..7cb1f0b2527 100644 --- a/docs/core/metrics.md +++ b/docs/core/metrics.md @@ -133,7 +133,9 @@ If you'd like to remove them at some point, you can use `clear_default_dimension ### Changing default timestamp -When creating metrics, we use the current timestamp. If you want to change the timestamp of all the metrics you create, utilize the `set_timestamp` function. +When creating metrics, we use the current timestamp. If you want to change the timestamp of all the metrics you create, utilize the `set_timestamp` function. You can specify a datetime object or an integer representing an epoch timestamp in milliseconds. + +Note that when specifying the timestamp using an integer, it must adhere to the epoch timezone format in milliseconds. ???+ info If you need to use different timestamps across multiple metrics, opt for [single_metric](#working-with-different-timestamp).