Skip to content

Commit a8e8e50

Browse files
committed
* 'develop' of https://github.com/awslabs/aws-lambda-powertools-python: (104 commits) feat: add metrics metadata (#81) chore: cleanup tests (#79) chore: remove deprecated code before GA (#78) docs: customize contributing guide (#77) chore: move blockquotes as hidden comments chore: update CHANGELOG chore: bump version to 0.11.0 (#76) chore: version bump 0.10.1 fix: default dimension creation now happens when metrics are serialized instead of on metrics constructor (#74) fix: default dimension creation now happens when metrics are serialized instead of on metrics constructor (#74) docs: fix contrast on highlighted code text (#73) feat: improve error handling for log_metrics decorator (#71) chore(deps): bump graphql-playground-html from 1.6.19 to 1.6.25 in /docs feat: add high level imports (#70) fix: correct env var name for publish to pypi test (#69) chore: version bump (#68) feat: add capture_cold_start_metric for log_metrics (#67) chore(deps): bump websocket-extensions from 0.1.3 to 0.1.4 in /docs (#66) feat: automate publishing to pypi (#58) feat: add pre-commit hooks (#64) ...
2 parents efe5e28 + f5aab39 commit a8e8e50

File tree

4 files changed

+224
-22
lines changed

4 files changed

+224
-22
lines changed

Diff for: aws_lambda_powertools/metrics/base.py

+66-19
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import os
66
import pathlib
77
from enum import Enum
8-
from typing import Dict, List, Union
8+
from typing import Any, Dict, List, Union
99

1010
import fastjsonschema
1111

@@ -78,14 +78,20 @@ class MetricManager:
7878
"""
7979

8080
def __init__(
81-
self, metric_set: Dict[str, str] = None, dimension_set: Dict = None, namespace: str = None, service: str = None
81+
self,
82+
metric_set: Dict[str, str] = None,
83+
dimension_set: Dict = None,
84+
namespace: str = None,
85+
metadata_set: Dict[str, Any] = None,
86+
service: str = None,
8287
):
8388
self.metric_set = metric_set if metric_set is not None else {}
8489
self.dimension_set = dimension_set if dimension_set is not None else {}
8590
self.namespace = namespace or os.getenv("POWERTOOLS_METRICS_NAMESPACE")
8691
self.service = service or os.environ.get("POWERTOOLS_SERVICE_NAME")
8792
self._metric_units = [unit.value for unit in MetricUnit]
8893
self._metric_unit_options = list(MetricUnit.__members__)
94+
self.metadata_set = self.metadata_set if metadata_set is not None else {}
8995

9096
def add_metric(self, name: str, unit: MetricUnit, value: Union[float, int]):
9197
"""Adds given metric
@@ -131,7 +137,7 @@ def add_metric(self, name: str, unit: MetricUnit, value: Union[float, int]):
131137
# since we could have more than 100 metrics
132138
self.metric_set.clear()
133139

134-
def serialize_metric_set(self, metrics: Dict = None, dimensions: Dict = None) -> Dict:
140+
def serialize_metric_set(self, metrics: Dict = None, dimensions: Dict = None, metadata: Dict = None) -> Dict:
135141
"""Serializes metric and dimensions set
136142
137143
Parameters
@@ -165,39 +171,48 @@ def serialize_metric_set(self, metrics: Dict = None, dimensions: Dict = None) ->
165171
if dimensions is None: # pragma: no cover
166172
dimensions = self.dimension_set
167173

174+
if metadata is None: # pragma: no cover
175+
metadata = self.metadata_set
176+
168177
if self.service and not self.dimension_set.get("service"):
169178
self.dimension_set["service"] = self.service
170179

171180
logger.debug("Serializing...", {"metrics": metrics, "dimensions": dimensions})
172181

173-
dimension_keys: List[str] = list(dimensions.keys())
174-
metric_names_unit: List[Dict[str, str]] = []
175-
metric_set: Dict[str, str] = {}
182+
metric_names_and_units: List[Dict[str, str]] = [] # [ { "Name": "metric_name", "Unit": "Count" } ]
183+
metric_names_and_values: Dict[str, str] = {} # { "metric_name": 1.0 }
176184

177185
for metric_name in metrics:
178186
metric: str = metrics[metric_name]
179187
metric_value: int = metric.get("Value", 0)
180188
metric_unit: str = metric.get("Unit", "")
181189

182-
metric_names_unit.append({"Name": metric_name, "Unit": metric_unit})
183-
metric_set.update({metric_name: metric_value})
184-
185-
metrics_definition = {
186-
"CloudWatchMetrics": [
187-
{"Namespace": self.namespace, "Dimensions": [dimension_keys], "Metrics": metric_names_unit}
188-
]
190+
metric_names_and_units.append({"Name": metric_name, "Unit": metric_unit})
191+
metric_names_and_values.update({metric_name: metric_value})
192+
193+
embedded_metrics_object = {
194+
"_aws": {
195+
"Timestamp": int(datetime.datetime.now().timestamp() * 1000), # epoch
196+
"CloudWatchMetrics": [
197+
{
198+
"Namespace": self.namespace, # "test_namespace"
199+
"Dimensions": [list(dimensions.keys())], # [ "service" ]
200+
"Metrics": metric_names_and_units,
201+
}
202+
],
203+
},
204+
**dimensions, # "service": "test_service"
205+
**metadata, # "username": "test"
206+
**metric_names_and_values, # "single_metric": 1.0
189207
}
190-
metrics_timestamp = {"Timestamp": int(datetime.datetime.now().timestamp() * 1000)}
191-
metric_set["_aws"] = {**metrics_timestamp, **metrics_definition}
192-
metric_set.update(**dimensions)
193208

194209
try:
195-
logger.debug("Validating serialized metrics against CloudWatch EMF schema", metric_set)
196-
fastjsonschema.validate(definition=CLOUDWATCH_EMF_SCHEMA, data=metric_set)
210+
logger.debug("Validating serialized metrics against CloudWatch EMF schema", embedded_metrics_object)
211+
fastjsonschema.validate(definition=CLOUDWATCH_EMF_SCHEMA, data=embedded_metrics_object)
197212
except fastjsonschema.JsonSchemaException as e:
198213
message = f"Invalid format. Error: {e.message}, Invalid item: {e.name}" # noqa: B306, E501
199214
raise SchemaValidationError(message)
200-
return metric_set
215+
return embedded_metrics_object
201216

202217
def add_dimension(self, name: str, value: str):
203218
"""Adds given dimension to all metrics
@@ -225,6 +240,38 @@ def add_dimension(self, name: str, value: str):
225240
else:
226241
self.dimension_set[name] = str(value)
227242

243+
def add_metadata(self, key: str, value: Any):
244+
"""Adds high cardinal metadata for metrics object
245+
246+
This will not be available during metrics visualization.
247+
Instead, this will be searchable through logs.
248+
249+
If you're looking to add metadata to filter metrics, then
250+
use add_dimensions method.
251+
252+
Example
253+
-------
254+
**Add metrics metadata**
255+
256+
metric.add_metadata(key="booking_id", value="booking_id")
257+
258+
Parameters
259+
----------
260+
name : str
261+
Metadata key
262+
value : any
263+
Metadata value
264+
"""
265+
logger.debug(f"Adding metadata: {key}:{value}")
266+
267+
# Cast key to str according to EMF spec
268+
# Majority of keys are expected to be string already, so
269+
# checking before casting improves performance in most cases
270+
if isinstance(key, str):
271+
self.metadata_set[key] = value
272+
else:
273+
self.metadata_set[str(key)] = value
274+
228275
def __extract_metric_unit_value(self, unit: Union[str, MetricUnit]) -> str:
229276
"""Return metric value from metric unit whether that's str or MetricUnit enum
230277

Diff for: aws_lambda_powertools/metrics/metrics.py

+9-1
Original file line numberDiff line numberDiff line change
@@ -67,20 +67,28 @@ def do_something():
6767

6868
_metrics = {}
6969
_dimensions = {}
70+
_metadata = {}
7071

7172
def __init__(self, service: str = None, namespace: str = None):
7273
self.metric_set = self._metrics
7374
self.dimension_set = self._dimensions
7475
self.service = service
7576
self.namespace = namespace
77+
self.metadata_set = self._metadata
78+
7679
super().__init__(
77-
metric_set=self.metric_set, dimension_set=self.dimension_set, namespace=self.namespace, service=self.service
80+
metric_set=self.metric_set,
81+
dimension_set=self.dimension_set,
82+
namespace=self.namespace,
83+
metadata_set=self.metadata_set,
84+
service=self.service,
7885
)
7986

8087
def clear_metrics(self):
8188
logger.debug("Clearing out existing metric set from memory")
8289
self.metric_set.clear()
8390
self.dimension_set.clear()
91+
self.metadata_set.clear()
8492

8593
def log_metrics(
8694
self,

Diff for: docs/content/core/metrics.mdx

+50
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,56 @@ with single_metric(name="ColdStart", unit=MetricUnit.Count, value=1, namespace="
8989
...
9090
```
9191

92+
## Adding metadata
93+
94+
You can use `add_metadata` for advanced use cases, where you want to metadata as part of the serialized metrics object.
95+
96+
<Note type="info">
97+
<strong>This will not be available during metrics visualization</strong> - Use <strong>dimensions</strong> for this purpose
98+
</Note><br/>
99+
100+
```python:title=app.py
101+
from aws_lambda_powertools import Metrics
102+
from aws_lambda_powertools.metrics import MetricUnit
103+
104+
metrics = Metrics(namespace="ExampleApplication", service="booking")
105+
metrics.add_metric(name="SuccessfulBooking", unit=MetricUnit.Count, value=1)
106+
metrics.add_metadata(key="booking_id", value="booking_uuid") # highlight-line
107+
```
108+
109+
This will be available in CloudWatch Logs to ease operations on high cardinal data.
110+
111+
<details>
112+
<summary><strong>Exerpt output in CloudWatch Logs</strong></summary>
113+
114+
```json:title=cloudwatch_logs.json
115+
{
116+
"SuccessfulBooking": 1.0,
117+
"_aws": {
118+
"Timestamp": 1592234975665,
119+
"CloudWatchMetrics": [
120+
{
121+
"Namespace": "ExampleApplication",
122+
"Dimensions": [
123+
[
124+
"service"
125+
]
126+
],
127+
"Metrics": [
128+
{
129+
"Name": "SuccessfulBooking",
130+
"Unit": "Count"
131+
}
132+
]
133+
}
134+
]
135+
},
136+
"service": "booking",
137+
"booking_id": "booking_uuid" // highlight-line
138+
}
139+
```
140+
</details>
141+
92142
## Flushing metrics
93143

94144
As you finish adding all your metrics, you need to serialize and flush them to standard output. You can do that right before you return your response to the caller via `log_metrics`.

Diff for: tests/functional/test_metrics.py

+99-2
Original file line numberDiff line numberDiff line change
@@ -61,12 +61,19 @@ def service() -> str:
6161
return "test_service"
6262

6363

64+
@pytest.fixture
65+
def metadata() -> Dict[str, str]:
66+
return {"key": "username", "value": "test"}
67+
68+
6469
@pytest.fixture
6570
def a_hundred_metrics(namespace=namespace) -> List[Dict[str, str]]:
6671
return [{"name": f"metric_{i}", "unit": "Count", "value": 1} for i in range(100)]
6772

6873

69-
def serialize_metrics(metrics: List[Dict], dimensions: List[Dict], namespace: str) -> Dict:
74+
def serialize_metrics(
75+
metrics: List[Dict], dimensions: List[Dict], namespace: str, metadatas: List[Dict] = None
76+
) -> Dict:
7077
""" Helper function to build EMF object from a list of metrics, dimensions """
7178
my_metrics = MetricManager(namespace=namespace)
7279
for dimension in dimensions:
@@ -75,15 +82,23 @@ def serialize_metrics(metrics: List[Dict], dimensions: List[Dict], namespace: st
7582
for metric in metrics:
7683
my_metrics.add_metric(**metric)
7784

85+
if metadatas is not None:
86+
for metadata in metadatas:
87+
my_metrics.add_metadata(**metadata)
88+
7889
if len(metrics) != 100:
7990
return my_metrics.serialize_metric_set()
8091

8192

82-
def serialize_single_metric(metric: Dict, dimension: Dict, namespace: str) -> Dict:
93+
def serialize_single_metric(metric: Dict, dimension: Dict, namespace: str, metadata: Dict = None) -> Dict:
8394
""" Helper function to build EMF object from a given metric, dimension and namespace """
8495
my_metrics = MetricManager(namespace=namespace)
8596
my_metrics.add_metric(**metric)
8697
my_metrics.add_dimension(**dimension)
98+
99+
if metadata is not None:
100+
my_metrics.add_metadata(**metadata)
101+
87102
return my_metrics.serialize_metric_set()
88103

89104

@@ -533,3 +548,85 @@ def lambda_handler(evt, ctx):
533548

534549
for metric_record in second_output["_aws"]["CloudWatchMetrics"]:
535550
assert ["service"] in metric_record["Dimensions"]
551+
552+
553+
def test_add_metadata_non_string_dimension_keys(service, metric, namespace):
554+
# GIVEN Metrics is initialized
555+
my_metrics = Metrics(service=service, namespace=namespace)
556+
my_metrics.add_metric(**metric)
557+
558+
# WHEN we utilize add_metadata with non-string keys
559+
my_metrics.add_metadata(key=10, value="number_ten")
560+
561+
# THEN we should have no exceptions
562+
# and dimension values should be serialized as strings
563+
expected_metadata = {"10": "number_ten"}
564+
assert my_metrics.metadata_set == expected_metadata
565+
566+
567+
def test_add_metadata(service, metric, namespace, metadata):
568+
# GIVEN Metrics is initialized
569+
my_metrics = Metrics(service=service, namespace=namespace)
570+
my_metrics.add_metric(**metric)
571+
572+
# WHEN we utilize add_metadata with non-string keys
573+
my_metrics.add_metadata(**metadata)
574+
575+
# THEN we should have no exceptions
576+
# and dimension values should be serialized as strings
577+
assert my_metrics.metadata_set == {metadata["key"]: metadata["value"]}
578+
579+
580+
def test_log_metrics_with_metadata(capsys, metric, dimension, namespace, service, metadata):
581+
# GIVEN Metrics is initialized
582+
my_metrics = Metrics(namespace=namespace)
583+
my_metrics.add_metric(**metric)
584+
my_metrics.add_dimension(**dimension)
585+
586+
# WHEN we utilize log_metrics to serialize and add metadata
587+
@my_metrics.log_metrics
588+
def lambda_handler(evt, ctx):
589+
my_metrics.add_metadata(**metadata)
590+
pass
591+
592+
lambda_handler({}, {})
593+
594+
output = capture_metrics_output(capsys)
595+
expected = serialize_single_metric(metric=metric, dimension=dimension, namespace=namespace, metadata=metadata)
596+
597+
# THEN we should have no exceptions and metadata
598+
remove_timestamp(metrics=[output, expected])
599+
assert expected == output
600+
601+
602+
def test_serialize_metric_set_metric_definition(metric, dimension, namespace, service, metadata):
603+
expected_metric_definition = {
604+
"single_metric": 1.0,
605+
"_aws": {
606+
"Timestamp": 1592237875494,
607+
"CloudWatchMetrics": [
608+
{
609+
"Namespace": "test_namespace",
610+
"Dimensions": [["test_dimension", "service"]],
611+
"Metrics": [{"Name": "single_metric", "Unit": "Count"}],
612+
}
613+
],
614+
},
615+
"service": "test_service",
616+
"username": "test",
617+
"test_dimension": "test",
618+
}
619+
620+
# GIVEN Metrics is initialized
621+
my_metrics = Metrics(service=service, namespace=namespace)
622+
my_metrics.add_metric(**metric)
623+
my_metrics.add_dimension(**dimension)
624+
my_metrics.add_metadata(**metadata)
625+
626+
# WHEN metrics are serialized manually
627+
metric_definition_output = my_metrics.serialize_metric_set()
628+
629+
# THEN we should emit a valid embedded metric definition object
630+
assert "Timestamp" in metric_definition_output["_aws"]
631+
remove_timestamp(metrics=[metric_definition_output, expected_metric_definition])
632+
assert metric_definition_output == expected_metric_definition

0 commit comments

Comments
 (0)