Skip to content

feat(metrics): add support to persist default dimensions #410

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
59 changes: 46 additions & 13 deletions aws_lambda_powertools/metrics/metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,18 +34,13 @@ class Metrics(MetricManager):
from aws_lambda_powertools import Metrics

metrics = Metrics(namespace="ServerlessAirline", service="payment")
metrics.add_metric(name="ColdStart", unit=MetricUnit.Count, value=1)
metrics.add_metric(name="BookingConfirmation", unit="Count", value=1)
metrics.add_dimension(name="function_version", value="$LATEST")
...

@metrics.log_metrics()
@metrics.log_metrics(capture_cold_start_metric=True)
def lambda_handler():
do_something()
return True
metrics.add_metric(name="BookingConfirmation", unit="Count", value=1)
metrics.add_dimension(name="function_version", value="$LATEST")

def do_something():
metrics.add_metric(name="Something", unit="Count", value=1)
return True

Environment variables
---------------------
Expand Down Expand Up @@ -74,13 +69,15 @@ def do_something():
_metrics: Dict[str, Any] = {}
_dimensions: Dict[str, str] = {}
_metadata: Dict[str, Any] = {}
_default_dimensions: Dict[str, Any] = {}

def __init__(self, service: str = None, namespace: str = None):
self.metric_set = self._metrics
self.dimension_set = self._dimensions
self.service = service
self.namespace: Optional[str] = namespace
self.metadata_set = self._metadata
self.default_dimensions = self._default_dimensions
self.dimension_set = {**self._default_dimensions, **self._dimensions}

super().__init__(
metric_set=self.metric_set,
Expand All @@ -90,17 +87,48 @@ def __init__(self, service: str = None, namespace: str = None):
service=self.service,
)

def set_default_dimensions(self, **dimensions):
"""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
"""
for name, value in dimensions.items():
self.add_dimension(name, value)

self.default_dimensions.update(**dimensions)

def clear_default_dimensions(self):
self.default_dimensions.clear()

def clear_metrics(self):
logger.debug("Clearing out existing metric set from memory")
self.metric_set.clear()
self.dimension_set.clear()
self.metadata_set.clear()
self.set_default_dimensions(**self.default_dimensions) # re-add default dimensions

def log_metrics(
self,
lambda_handler: Callable[[Any, Any], Any] = None,
capture_cold_start_metric: bool = False,
raise_on_empty_metrics: bool = False,
default_dimensions: Dict[str, str] = None,
):
"""Decorator to serialize and publish metrics at the end of a function execution.

Expand All @@ -123,11 +151,13 @@ def handler(event, context):
Parameters
----------
lambda_handler : Callable[[Any, Any], Any], optional
Lambda function handler, by default None
lambda function handler, by default None
capture_cold_start_metric : bool, optional
Captures cold start metric, by default False
captures cold start metric, by default False
raise_on_empty_metrics : bool, optional
Raise exception if no metrics are emitted, by default False
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

Raises
------
Expand All @@ -143,11 +173,14 @@ 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)
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)
Expand Down
128 changes: 103 additions & 25 deletions docs/core/metrics.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,22 +74,28 @@ You can create metrics using `add_metric`, and you can create dimensions for all

=== "Metrics"

```python hl_lines="5"
```python hl_lines="8"
from aws_lambda_powertools import Metrics
from aws_lambda_powertools.metrics import MetricUnit

metrics = Metrics(namespace="ExampleApplication", service="booking")
metrics.add_metric(name="SuccessfulBooking", unit=MetricUnit.Count, value=1)

@metrics.log_metrics
def lambda_handler(evt, ctx):
metrics.add_metric(name="SuccessfulBooking", unit=MetricUnit.Count, value=1)
```
=== "Metrics with custom dimensions"

```python hl_lines="5 6"
```python hl_lines="8-9"
from aws_lambda_powertools import Metrics
from aws_lambda_powertools.metrics import MetricUnit

metrics = Metrics(namespace="ExampleApplication", service="booking")
metrics.add_dimension(name="environment", value="prod")
metrics.add_metric(name="SuccessfulBooking", unit=MetricUnit.Count, value=1)

@metrics.log_metrics
def lambda_handler(evt, ctx):
metrics.add_dimension(name="environment", value="prod")
metrics.add_metric(name="SuccessfulBooking", unit=MetricUnit.Count, value=1)
```

!!! tip "Autocomplete Metric Units"
Expand All @@ -98,6 +104,40 @@ You can create metrics using `add_metric`, and you can create dimensions for all
!!! note "Metrics overflow"
CloudWatch EMF supports a max of 100 metrics per batch. Metrics utility will flush all metrics when adding the 100th metric. Subsequent metrics, e.g. 101th, will be aggregated into a new EMF object, for your convenience.


### Adding default dimensions

You can use either `set_default_dimensions` method or `default_permissions` parameter in `log_metrics` decorator to persist dimensions across Lambda invocations.

If you'd like to remove them at some point, you can use `clear_default_dimensions` method.

=== "set_default_dimensions method"

```python hl_lines="5"
from aws_lambda_powertools import Metrics
from aws_lambda_powertools.metrics import MetricUnit

metrics = Metrics(namespace="ExampleApplication", service="booking")
metrics.set_default_dimensions(environment="prod", another="one")

@metrics.log_metrics
def lambda_handler(evt, ctx):
metrics.add_metric(name="SuccessfulBooking", unit=MetricUnit.Count, value=1)
```
=== "with log_metrics decorator"

```python hl_lines="5 7"
from aws_lambda_powertools import Metrics
from aws_lambda_powertools.metrics import MetricUnit

metrics = Metrics(namespace="ExampleApplication", service="booking")
DEFAULT_DIMENSIONS = {"environment": "prod", "another": "one"}

@metrics.log_metrics(default_dimensions=DEFAULT_DIMENSIONS)
def lambda_handler(evt, ctx):
metrics.add_metric(name="SuccessfulBooking", unit=MetricUnit.Count, value=1)
```

### 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.
Expand All @@ -106,7 +146,7 @@ This decorator also **validates**, **serializes**, and **flushes** all your metr

=== "app.py"

```python hl_lines="7"
```python hl_lines="6"
from aws_lambda_powertools import Metrics
from aws_lambda_powertools.metrics import MetricUnit

Expand All @@ -115,7 +155,6 @@ This decorator also **validates**, **serializes**, and **flushes** all your metr
@metrics.log_metrics
def lambda_handler(evt, ctx):
metrics.add_metric(name="BookingConfirmation", unit=MetricUnit.Count, value=1)
...
```
=== "Example CloudWatch Logs excerpt"

Expand Down Expand Up @@ -158,7 +197,7 @@ If you want to ensure that at least one metric is emitted, you can pass `raise_o

=== "app.py"

```python hl_lines="3"
```python hl_lines="5"
from aws_lambda_powertools.metrics import Metrics

metrics = Metrics()
Expand All @@ -177,20 +216,17 @@ When using multiple middlewares, use `log_metrics` as your **last decorator** wr

=== "nested_middlewares.py"

```python hl_lines="9-10"
```python hl_lines="7-8"
from aws_lambda_powertools import Metrics, Tracer
from aws_lambda_powertools.metrics import MetricUnit

tracer = Tracer(service="booking")
metrics = Metrics(namespace="ExampleApplication", service="booking")

metrics.add_metric(name="ColdStart", unit=MetricUnit.Count, value=1)

@metrics.log_metrics
@tracer.capture_lambda_handler
def lambda_handler(evt, ctx):
metrics.add_metric(name="BookingConfirmation", unit=MetricUnit.Count, value=1)
...
```

### Capturing cold start metric
Expand All @@ -199,7 +235,7 @@ You can optionally capture cold start metrics with `log_metrics` decorator via `

=== "app.py"

```python hl_lines="6"
```python hl_lines="5"
from aws_lambda_powertools import Metrics

metrics = Metrics(service="ExampleService")
Expand Down Expand Up @@ -227,13 +263,16 @@ You can add high-cardinality data as part of your Metrics log with `add_metadata

=== "app.py"

```python hl_lines="6"
```python hl_lines="9"
from aws_lambda_powertools import Metrics
from aws_lambda_powertools.metrics import MetricUnit

metrics = Metrics(namespace="ExampleApplication", service="booking")
metrics.add_metric(name="SuccessfulBooking", unit=MetricUnit.Count, value=1)
metrics.add_metadata(key="booking_id", value="booking_uuid")

@metrics.log_metrics
def lambda_handler(evt, ctx):
metrics.add_metric(name="SuccessfulBooking", unit=MetricUnit.Count, value=1)
metrics.add_metadata(key="booking_id", value="booking_uuid")
```

=== "Example CloudWatch Logs excerpt"
Expand Down Expand Up @@ -276,13 +315,15 @@ CloudWatch EMF uses the same dimensions across all your metrics. Use `single_met

=== "single_metric.py"

```python hl_lines="4"
```python hl_lines="6-7"
from aws_lambda_powertools import single_metric
from aws_lambda_powertools.metrics import MetricUnit

with single_metric(name="ColdStart", unit=MetricUnit.Count, value=1, namespace="ExampleApplication") as metric:
metric.add_dimension(name="function_context", value="$LATEST")
...

def lambda_handler(evt, ctx):
with single_metric(name="ColdStart", unit=MetricUnit.Count, value=1, namespace="ExampleApplication") as metric:
metric.add_dimension(name="function_context", value="$LATEST")
...
```

### Flushing metrics manually
Expand All @@ -294,17 +335,18 @@ If you prefer not to use `log_metrics` because you might want to encapsulate add

=== "manual_metric_serialization.py"

```python hl_lines="8-10"
```python hl_lines="9-11"
import json
from aws_lambda_powertools import Metrics
from aws_lambda_powertools.metrics import MetricUnit

metrics = Metrics(namespace="ExampleApplication", service="booking")
metrics.add_metric(name="ColdStart", unit=MetricUnit.Count, value=1)

your_metrics_object = metrics.serialize_metric_set()
metrics.clear_metrics()
print(json.dumps(your_metrics_object))
def lambda_handler(evt, ctx):
metrics.add_metric(name="ColdStart", unit=MetricUnit.Count, value=1)
your_metrics_object = metrics.serialize_metric_set()
metrics.clear_metrics()
print(json.dumps(your_metrics_object))
```

## Testing your code
Expand Down Expand Up @@ -345,5 +387,41 @@ If you prefer setting environment variable for specific tests, and are using Pyt
metrics = Metrics()
metrics.clear_metrics()
metrics_global.is_cold_start = True # ensure each test has cold start
metrics.clear_default_dimensions() # remove persisted default dimensions, if any
yield
```

### Inspecting metrics

As metrics are logged to standard output, you can read stdoutput and assert whether metrics are present. Here's an example using `pytest` with `capsys` built-in fixture:

=== "pytest_metrics_assertion.py"

```python hl_lines="6 9-10 23-34"
from aws_lambda_powertools import Metrics
from aws_lambda_powertools.metrics import MetricUnit

import json

def test_log_metrics(capsys):
# GIVEN Metrics is initialized
metrics = Metrics(namespace="ServerlessAirline")

# WHEN we utilize log_metrics to serialize
# and flush all metrics at the end of a function execution
@metrics.log_metrics
def lambda_handler(evt, ctx):
metrics.add_metric(name="SuccessfulBooking", unit=MetricUnit.Count, value=1)
metrics.add_dimension(name="environment", value="prod")

lambda_handler({}, {})
log = capsys.readouterr().out.strip() # remove any extra line
metrics_output = json.loads(log) # deserialize JSON str

# THEN we should have no exceptions
# and a valid EMF object should be flushed correctly
assert "SuccessfulBooking" in log # basic string assertion in JSON str
assert "SuccessfulBooking" in metrics_output["_aws"]["CloudWatchMetrics"][0]["Metrics"][0]["Name"]
```

!!! tip "For more elaborate assertions and comparisons, check out [our functional testing for Metrics utility](https://github.com/awslabs/aws-lambda-powertools-python/blob/develop/tests/functional/test_metrics.py)"
Loading