Skip to content

Commit 17a7ac5

Browse files
feat(metrics): allow custom timestamps for metrics (#4006)
* Initial commit * Refactoring logic + mypy still failling * Metrics should raise a warning when outside of constraints * Adding timestamp to single_metric * Adding timestamp to single_metric * Adding test for wrong type * Adding examples + doc * Adding examples + doc * Wording + TZ * Improving doc
1 parent fd9f882 commit 17a7ac5

File tree

10 files changed

+341
-7
lines changed

10 files changed

+341
-7
lines changed

aws_lambda_powertools/metrics/base.py

+32-1
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
MetricValueError,
1818
SchemaValidationError,
1919
)
20+
from aws_lambda_powertools.metrics.functions import convert_timestamp_to_emf_format, validate_emf_timestamp
2021
from aws_lambda_powertools.metrics.provider import cold_start
2122
from aws_lambda_powertools.metrics.provider.cloudwatch_emf.constants import MAX_DIMENSIONS, MAX_METRICS
2223
from aws_lambda_powertools.metrics.provider.cloudwatch_emf.metric_properties import MetricResolution, MetricUnit
@@ -76,6 +77,8 @@ def __init__(
7677
self.namespace = resolve_env_var_choice(choice=namespace, env=os.getenv(constants.METRICS_NAMESPACE_ENV))
7778
self.service = resolve_env_var_choice(choice=service, env=os.getenv(constants.SERVICE_NAME_ENV))
7879
self.metadata_set = metadata_set if metadata_set is not None else {}
80+
self.timestamp: int | None = None
81+
7982
self._metric_units = [unit.value for unit in MetricUnit]
8083
self._metric_unit_valid_options = list(MetricUnit.__members__)
8184
self._metric_resolutions = [resolution.value for resolution in MetricResolution]
@@ -224,7 +227,7 @@ def serialize_metric_set(
224227

225228
return {
226229
"_aws": {
227-
"Timestamp": int(datetime.datetime.now().timestamp() * 1000), # epoch
230+
"Timestamp": self.timestamp or int(datetime.datetime.now().timestamp() * 1000), # epoch
228231
"CloudWatchMetrics": [
229232
{
230233
"Namespace": self.namespace, # "test_namespace"
@@ -296,6 +299,31 @@ def add_metadata(self, key: str, value: Any) -> None:
296299
else:
297300
self.metadata_set[str(key)] = value
298301

302+
def set_timestamp(self, timestamp: int | datetime.datetime):
303+
"""
304+
Set the timestamp for the metric.
305+
306+
Parameters:
307+
-----------
308+
timestamp: int | datetime.datetime
309+
The timestamp to create the metric.
310+
If an integer is provided, it is assumed to be the epoch time in milliseconds.
311+
If a datetime object is provided, it will be converted to epoch time in milliseconds.
312+
"""
313+
# The timestamp must be a Datetime object or an integer representing an epoch time.
314+
# This should not exceed 14 days in the past or be more than 2 hours in the future.
315+
# Any metrics failing to meet this criteria will be skipped by Amazon CloudWatch.
316+
# See: https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch_Embedded_Metric_Format_Specification.html
317+
# See: https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/CloudWatch-Logs-Monitoring-CloudWatch-Metrics.html
318+
if not validate_emf_timestamp(timestamp):
319+
warnings.warn(
320+
"This metric doesn't meet the requirements and will be skipped by Amazon CloudWatch. "
321+
"Ensure the timestamp is within 14 days past or 2 hours future.",
322+
stacklevel=2,
323+
)
324+
325+
self.timestamp = convert_timestamp_to_emf_format(timestamp)
326+
299327
def clear_metrics(self) -> None:
300328
logger.debug("Clearing out existing metric set from memory")
301329
self.metric_set.clear()
@@ -576,6 +604,9 @@ def single_metric(
576604
Metric value
577605
namespace: str
578606
Namespace for metrics
607+
default_dimensions: Dict[str, str], optional
608+
Metric dimensions as key=value that will always be present
609+
579610
580611
Yields
581612
-------

aws_lambda_powertools/metrics/functions.py

+66
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
from __future__ import annotations
22

3+
from datetime import datetime
4+
35
from aws_lambda_powertools.metrics.provider.cloudwatch_emf.exceptions import (
46
MetricResolutionError,
57
MetricUnitError,
68
)
79
from aws_lambda_powertools.metrics.provider.cloudwatch_emf.metric_properties import MetricResolution, MetricUnit
10+
from aws_lambda_powertools.shared import constants
811
from aws_lambda_powertools.shared.types import List
912

1013

@@ -69,3 +72,66 @@ def extract_cloudwatch_metric_unit_value(metric_units: List, metric_valid_option
6972
unit = unit.value
7073

7174
return unit
75+
76+
77+
def validate_emf_timestamp(timestamp: int | datetime) -> bool:
78+
"""
79+
Validates a given timestamp based on CloudWatch Timestamp guidelines.
80+
81+
Timestamp must meet CloudWatch requirements, otherwise an InvalidTimestampError will be raised.
82+
See [Timestamps](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/cloudwatch_concepts.html#about_timestamp)
83+
for valid values.
84+
85+
Parameters:
86+
----------
87+
timestamp: int | datetime
88+
Datetime object or epoch time in milliseconds representing the timestamp to validate.
89+
90+
Returns
91+
-------
92+
bool
93+
Valid or not timestamp values
94+
"""
95+
96+
if not isinstance(timestamp, (int, datetime)):
97+
return False
98+
99+
if isinstance(timestamp, datetime):
100+
# Converting timestamp to epoch time in milliseconds
101+
timestamp = int(timestamp.timestamp() * 1000)
102+
103+
# Consider current timezone when working with date and time
104+
current_timezone = datetime.now().astimezone().tzinfo
105+
106+
current_time = int(datetime.now(current_timezone).timestamp() * 1000)
107+
min_valid_timestamp = current_time - constants.EMF_MAX_TIMESTAMP_PAST_AGE
108+
max_valid_timestamp = current_time + constants.EMF_MAX_TIMESTAMP_FUTURE_AGE
109+
110+
return min_valid_timestamp <= timestamp <= max_valid_timestamp
111+
112+
113+
def convert_timestamp_to_emf_format(timestamp: int | datetime) -> int:
114+
"""
115+
Converts a timestamp to EMF compatible format.
116+
117+
Parameters
118+
----------
119+
timestamp: int | datetime
120+
The timestamp to convert. If already in epoch milliseconds format, returns it as is.
121+
If datetime object, converts it to milliseconds since Unix epoch.
122+
123+
Returns:
124+
--------
125+
int
126+
The timestamp converted to EMF compatible format (milliseconds since Unix epoch).
127+
"""
128+
if isinstance(timestamp, int):
129+
return timestamp
130+
131+
try:
132+
return int(round(timestamp.timestamp() * 1000))
133+
except AttributeError:
134+
# If this point is reached, it indicates timestamp is not a datetime object
135+
# Returning zero represents the initial date of epoch time,
136+
# which will be skipped by Amazon CloudWatch.
137+
return 0

aws_lambda_powertools/metrics/metrics.py

+13
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,19 @@ def serialize_metric_set(
125125
def add_metadata(self, key: str, value: Any) -> None:
126126
self.provider.add_metadata(key=key, value=value)
127127

128+
def set_timestamp(self, timestamp: int):
129+
"""
130+
Set the timestamp for the metric.
131+
132+
Parameters:
133+
-----------
134+
timestamp: int | datetime.datetime
135+
The timestamp to create the metric.
136+
If an integer is provided, it is assumed to be the epoch time in milliseconds.
137+
If a datetime object is provided, it will be converted to epoch time in milliseconds.
138+
"""
139+
self.provider.set_timestamp(timestamp=timestamp)
140+
128141
def flush_metrics(self, raise_on_empty_metrics: bool = False) -> None:
129142
self.provider.flush_metrics(raise_on_empty_metrics=raise_on_empty_metrics)
130143

aws_lambda_powertools/metrics/provider/cloudwatch_emf/cloudwatch.py

+29-1
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,10 @@
1212
from aws_lambda_powertools.metrics.base import single_metric
1313
from aws_lambda_powertools.metrics.exceptions import MetricValueError, SchemaValidationError
1414
from aws_lambda_powertools.metrics.functions import (
15+
convert_timestamp_to_emf_format,
1516
extract_cloudwatch_metric_resolution_value,
1617
extract_cloudwatch_metric_unit_value,
18+
validate_emf_timestamp,
1719
)
1820
from aws_lambda_powertools.metrics.provider.base import BaseProvider
1921
from aws_lambda_powertools.metrics.provider.cloudwatch_emf.constants import MAX_DIMENSIONS, MAX_METRICS
@@ -73,6 +75,7 @@ def __init__(
7375
self.namespace = resolve_env_var_choice(choice=namespace, env=os.getenv(constants.METRICS_NAMESPACE_ENV))
7476
self.service = resolve_env_var_choice(choice=service, env=os.getenv(constants.SERVICE_NAME_ENV))
7577
self.metadata_set = metadata_set if metadata_set is not None else {}
78+
self.timestamp: int | None = None
7679

7780
self._metric_units = [unit.value for unit in MetricUnit]
7881
self._metric_unit_valid_options = list(MetricUnit.__members__)
@@ -231,7 +234,7 @@ def serialize_metric_set(
231234

232235
return {
233236
"_aws": {
234-
"Timestamp": int(datetime.datetime.now().timestamp() * 1000), # epoch
237+
"Timestamp": self.timestamp or int(datetime.datetime.now().timestamp() * 1000), # epoch
235238
"CloudWatchMetrics": [
236239
{
237240
"Namespace": self.namespace, # "test_namespace"
@@ -304,6 +307,31 @@ def add_metadata(self, key: str, value: Any) -> None:
304307
else:
305308
self.metadata_set[str(key)] = value
306309

310+
def set_timestamp(self, timestamp: int | datetime.datetime):
311+
"""
312+
Set the timestamp for the metric.
313+
314+
Parameters:
315+
-----------
316+
timestamp: int | datetime.datetime
317+
The timestamp to create the metric.
318+
If an integer is provided, it is assumed to be the epoch time in milliseconds.
319+
If a datetime object is provided, it will be converted to epoch time in milliseconds.
320+
"""
321+
# The timestamp must be a Datetime object or an integer representing an epoch time.
322+
# This should not exceed 14 days in the past or be more than 2 hours in the future.
323+
# Any metrics failing to meet this criteria will be skipped by Amazon CloudWatch.
324+
# See: https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch_Embedded_Metric_Format_Specification.html
325+
# See: https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/CloudWatch-Logs-Monitoring-CloudWatch-Metrics.html
326+
if not validate_emf_timestamp(timestamp):
327+
warnings.warn(
328+
"This metric doesn't meet the requirements and will be skipped by Amazon CloudWatch. "
329+
"Ensure the timestamp is within 14 days past or 2 hours future.",
330+
stacklevel=2,
331+
)
332+
333+
self.timestamp = convert_timestamp_to_emf_format(timestamp)
334+
307335
def clear_metrics(self) -> None:
308336
logger.debug("Clearing out existing metric set from memory")
309337
self.metric_set.clear()

aws_lambda_powertools/shared/constants.py

+5
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@
3939
METRICS_NAMESPACE_ENV: str = "POWERTOOLS_METRICS_NAMESPACE"
4040
DATADOG_FLUSH_TO_LOG: str = "DD_FLUSH_TO_LOG"
4141
SERVICE_NAME_ENV: str = "POWERTOOLS_SERVICE_NAME"
42+
# If the timestamp of log event is more than 2 hours in future, the log event is skipped.
43+
# If the timestamp of log event is more than 14 days in past, the log event is skipped.
44+
# See https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/AgentReference.html
45+
EMF_MAX_TIMESTAMP_PAST_AGE = 14 * 24 * 60 * 60 * 1000 # 14 days
46+
EMF_MAX_TIMESTAMP_FUTURE_AGE = 2 * 60 * 60 * 1000 # 2 hours
4247

4348
# Parameters constants
4449
PARAMETERS_SSM_DECRYPT_ENV: str = "POWERTOOLS_PARAMETERS_SSM_DECRYPT"

docs/core/metrics.md

+37-5
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,21 @@ If you'd like to remove them at some point, you can use `clear_default_dimension
131131
--8<-- "examples/metrics/src/set_default_dimensions_log_metrics.py"
132132
```
133133

134+
### Changing default timestamp
135+
136+
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.
137+
138+
Note that when specifying the timestamp using an integer, it must adhere to the epoch timezone format in milliseconds.
139+
140+
???+ info
141+
If you need to use different timestamps across multiple metrics, opt for [single_metric](#working-with-different-timestamp).
142+
143+
=== "set_custom_timestamp_log_metrics.py"
144+
145+
```python hl_lines="15"
146+
--8<-- "examples/metrics/src/set_custom_timestamp_log_metrics.py"
147+
```
148+
134149
### Flushing metrics
135150

136151
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.
@@ -224,14 +239,15 @@ You can add high-cardinality data as part of your Metrics log with `add_metadata
224239
--8<-- "examples/metrics/src/add_metadata_output.json"
225240
```
226241

227-
### Single metric with a different dimension
242+
### Single metric
228243

229-
CloudWatch EMF uses the same dimensions across all your metrics. Use `single_metric` if you have a metric that should have different dimensions.
244+
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.
230245

231-
???+ info
232-
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:
246+
#### Working with different dimensions
233247

234-
**unique metric = (metric_name + dimension_name + dimension_value)**
248+
Generally, using different dimensions would be an edge case since you [pay for unique metric](https://aws.amazon.com/cloudwatch/pricing){target="_blank"}.
249+
250+
Keep the following formula in mind: **unique metric = (metric_name + dimension_name + dimension_value)**
235251

236252
=== "single_metric.py"
237253

@@ -259,6 +275,22 @@ By default it will skip all previously defined dimensions including default dime
259275
--8<-- "examples/metrics/src/single_metric_default_dimensions.py"
260276
```
261277

278+
#### Working with different timestamp
279+
280+
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.
281+
282+
=== "single_metric_with_different_timestamp.py"
283+
284+
```python hl_lines="15 17"
285+
--8<-- "examples/metrics/src/single_metric_with_different_timestamp.py"
286+
```
287+
288+
=== "single_metric_with_different_timestamp_payload.json"
289+
290+
```json hl_lines="5 10 15 20 25"
291+
--8<-- "examples/metrics/src/single_metric_with_different_timestamp_payload.json"
292+
```
293+
262294
### Flushing metrics manually
263295

264296
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.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import datetime
2+
3+
from aws_lambda_powertools import Metrics
4+
from aws_lambda_powertools.metrics import MetricUnit
5+
from aws_lambda_powertools.utilities.typing import LambdaContext
6+
7+
metrics = Metrics()
8+
9+
10+
@metrics.log_metrics # ensures metrics are flushed upon request completion/failure
11+
def lambda_handler(event: dict, context: LambdaContext):
12+
metrics.add_metric(name="SuccessfulBooking", unit=MetricUnit.Count, value=1)
13+
14+
metric_timestamp = int((datetime.datetime.now() - datetime.timedelta(days=2)).timestamp() * 1000)
15+
metrics.set_timestamp(metric_timestamp)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
from aws_lambda_powertools import Logger, single_metric
2+
from aws_lambda_powertools.metrics import MetricUnit
3+
from aws_lambda_powertools.utilities.typing import LambdaContext
4+
5+
logger = Logger()
6+
7+
8+
def lambda_handler(event: dict, context: LambdaContext):
9+
10+
for record in event:
11+
12+
record_id: str = record.get("record_id")
13+
amount: int = record.get("amount")
14+
timestamp: int = record.get("timestamp")
15+
16+
with single_metric(name="Orders", unit=MetricUnit.Count, value=amount, namespace="Powertools") as metric:
17+
logger.info(f"Processing record id {record_id}")
18+
metric.set_timestamp(timestamp)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
[
2+
{
3+
"record_id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
4+
"amount": 10,
5+
"timestamp": 1648195200000
6+
},
7+
{
8+
"record_id": "6ba7b811-9dad-11d1-80b4-00c04fd430c8",
9+
"amount": 30,
10+
"timestamp": 1648224000000
11+
},
12+
{
13+
"record_id": "6ba7b812-9dad-11d1-80b4-00c04fd430c8",
14+
"amount": 25,
15+
"timestamp": 1648209600000
16+
},
17+
{
18+
"record_id": "6ba7b813-9dad-11d1-80b4-00c04fd430c8",
19+
"amount": 40,
20+
"timestamp": 1648177200000
21+
},
22+
{
23+
"record_id": "6ba7b814-9dad-11d1-80b4-00c04fd430c8",
24+
"amount": 32,
25+
"timestamp": 1648216800000
26+
}
27+
]

0 commit comments

Comments
 (0)