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
47 changes: 37 additions & 10 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,11 +87,41 @@ 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
"""
self.default_dimensions.update(**dimensions)
for name, value in dimensions.items():
self.add_dimension(name, value)

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()
# re-add default dimensions
self.dimension_set.update(**self._default_dimensions)

def log_metrics(
self,
Expand Down
116 changes: 91 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,28 @@ 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 add default metric dimensions to ensure they are persisted across Lambda invocations using `set_default_dimenions`.

!!! info "If you'd like to remove them at some point, you can use `clear_default_dimensions` method"
Note that they continue to count against the maximum of 9 dimensions.

=== "Default dimensions"

```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)
```

### 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 +134,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 +143,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 +185,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 +204,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 +223,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 +251,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 +303,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 +323,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 +375,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)"
49 changes: 49 additions & 0 deletions tests/functional/test_metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
def reset_metric_set():
metrics = Metrics()
metrics.clear_metrics()
metrics.clear_default_dimensions()
metrics_global.is_cold_start = True # ensure each test has cold start
yield

Expand Down Expand Up @@ -749,3 +750,51 @@ def test_metric_manage_metadata_set():
assert metric.metadata_set == expected_dict
except AttributeError:
pytest.fail("AttributeError should not be raised")


def test_log_persist_default_dimensions(capsys, metrics, dimensions, namespace):
# GIVEN Metrics is initialized and we persist a set of default dimensions
my_metrics = Metrics(namespace=namespace)
my_metrics.set_default_dimensions(environment="test", log_group="/lambda/test")

# WHEN we utilize log_metrics to serialize
# and flush metrics and clear all metrics and dimensions from memory
# at the end of a function execution
@my_metrics.log_metrics
def lambda_handler(evt, ctx):
for metric in metrics:
my_metrics.add_metric(**metric)

lambda_handler({}, {})
first_invocation = capture_metrics_output(capsys)

lambda_handler({}, {})
second_invocation = capture_metrics_output(capsys)

# THEN we should have default dimensions in both outputs
assert "environment" in first_invocation
assert "environment" in second_invocation


def test_clear_default_dimensions(namespace):
# GIVEN Metrics is initialized and we persist a set of default dimensions
my_metrics = Metrics(namespace=namespace)
my_metrics.set_default_dimensions(environment="test", log_group="/lambda/test")

# WHEN they are removed via clear_default_dimensions method
my_metrics.clear_default_dimensions()

# THEN there should be no default dimensions
assert not my_metrics.default_dimensions


def test_default_dimensions_across_instances(namespace):
# GIVEN Metrics is initialized and we persist a set of default dimensions
my_metrics = Metrics(namespace=namespace)
my_metrics.set_default_dimensions(environment="test", log_group="/lambda/test")

# WHEN a new Metrics instance is created
same_metrics = Metrics()

# THEN default dimensions should also be present
assert "environment" in same_metrics.default_dimensions