Skip to content

Commit 635bc80

Browse files
authored
feat(metrics): add EphemeralMetrics as a non-singleton option (#1676)
1 parent 380a96d commit 635bc80

File tree

8 files changed

+421
-238
lines changed

8 files changed

+421
-238
lines changed

Diff for: aws_lambda_powertools/metrics/__init__.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@
33
from .base import MetricUnit
44
from .exceptions import MetricUnitError, MetricValueError, SchemaValidationError
55
from .metric import single_metric
6-
from .metrics import Metrics
6+
from .metrics import EphemeralMetrics, Metrics
77

88
__all__ = [
99
"Metrics",
10+
"EphemeralMetrics",
1011
"single_metric",
1112
"MetricUnit",
1213
"MetricUnitError",

Diff for: aws_lambda_powertools/metrics/base.py

+225-5
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import datetime
2+
import functools
23
import json
34
import logging
45
import numbers
56
import os
7+
import warnings
68
from collections import defaultdict
9+
from contextlib import contextmanager
710
from enum import Enum
8-
from typing import Any, Dict, List, Optional, Union
11+
from typing import Any, Callable, Dict, Generator, List, Optional, Union
912

1013
from ..shared import constants
1114
from ..shared.functions import resolve_env_var_choice
@@ -16,6 +19,8 @@
1619
MAX_METRICS = 100
1720
MAX_DIMENSIONS = 29
1821

22+
is_cold_start = True
23+
1924

2025
class MetricUnit(Enum):
2126
Seconds = "Seconds"
@@ -86,9 +91,9 @@ def __init__(
8691
self.dimension_set = dimension_set if dimension_set is not None else {}
8792
self.namespace = resolve_env_var_choice(choice=namespace, env=os.getenv(constants.METRICS_NAMESPACE_ENV))
8893
self.service = resolve_env_var_choice(choice=service, env=os.getenv(constants.SERVICE_NAME_ENV))
94+
self.metadata_set = metadata_set if metadata_set is not None else {}
8995
self._metric_units = [unit.value for unit in MetricUnit]
9096
self._metric_unit_options = list(MetricUnit.__members__)
91-
self.metadata_set = metadata_set if metadata_set is not None else {}
9297

9398
def add_metric(self, name: str, unit: Union[MetricUnit, str], value: float) -> None:
9499
"""Adds given metric
@@ -120,7 +125,7 @@ def add_metric(self, name: str, unit: Union[MetricUnit, str], value: float) -> N
120125
if not isinstance(value, numbers.Number):
121126
raise MetricValueError(f"{value} is not a valid number")
122127

123-
unit = self.__extract_metric_unit_value(unit=unit)
128+
unit = self._extract_metric_unit_value(unit=unit)
124129
metric: Dict = self.metric_set.get(name, defaultdict(list))
125130
metric["Unit"] = unit
126131
metric["Value"].append(float(value))
@@ -179,7 +184,7 @@ def serialize_metric_set(
179184

180185
if self.service and not self.dimension_set.get("service"):
181186
# self.service won't be a float
182-
self.add_dimension(name="service", value=self.service) # type: ignore[arg-type]
187+
self.add_dimension(name="service", value=self.service)
183188

184189
if len(metrics) == 0:
185190
raise SchemaValidationError("Must contain at least one metric.")
@@ -274,7 +279,86 @@ def add_metadata(self, key: str, value: Any) -> None:
274279
else:
275280
self.metadata_set[str(key)] = value
276281

277-
def __extract_metric_unit_value(self, unit: Union[str, MetricUnit]) -> str:
282+
def clear_metrics(self) -> None:
283+
logger.debug("Clearing out existing metric set from memory")
284+
self.metric_set.clear()
285+
self.dimension_set.clear()
286+
self.metadata_set.clear()
287+
288+
def log_metrics(
289+
self,
290+
lambda_handler: Union[Callable[[Dict, Any], Any], Optional[Callable[[Dict, Any, Optional[Dict]], Any]]] = None,
291+
capture_cold_start_metric: bool = False,
292+
raise_on_empty_metrics: bool = False,
293+
default_dimensions: Optional[Dict[str, str]] = None,
294+
):
295+
"""Decorator to serialize and publish metrics at the end of a function execution.
296+
297+
Be aware that the log_metrics **does call* the decorated function (e.g. lambda_handler).
298+
299+
Example
300+
-------
301+
**Lambda function using tracer and metrics decorators**
302+
303+
from aws_lambda_powertools import Metrics, Tracer
304+
305+
metrics = Metrics(service="payment")
306+
tracer = Tracer(service="payment")
307+
308+
@tracer.capture_lambda_handler
309+
@metrics.log_metrics
310+
def handler(event, context):
311+
...
312+
313+
Parameters
314+
----------
315+
lambda_handler : Callable[[Any, Any], Any], optional
316+
lambda function handler, by default None
317+
capture_cold_start_metric : bool, optional
318+
captures cold start metric, by default False
319+
raise_on_empty_metrics : bool, optional
320+
raise exception if no metrics are emitted, by default False
321+
default_dimensions: Dict[str, str], optional
322+
metric dimensions as key=value that will always be present
323+
324+
Raises
325+
------
326+
e
327+
Propagate error received
328+
"""
329+
330+
# If handler is None we've been called with parameters
331+
# Return a partial function with args filled
332+
if lambda_handler is None:
333+
logger.debug("Decorator called with parameters")
334+
return functools.partial(
335+
self.log_metrics,
336+
capture_cold_start_metric=capture_cold_start_metric,
337+
raise_on_empty_metrics=raise_on_empty_metrics,
338+
default_dimensions=default_dimensions,
339+
)
340+
341+
@functools.wraps(lambda_handler)
342+
def decorate(event, context):
343+
try:
344+
if default_dimensions:
345+
self.set_default_dimensions(**default_dimensions)
346+
response = lambda_handler(event, context)
347+
if capture_cold_start_metric:
348+
self._add_cold_start_metric(context=context)
349+
finally:
350+
if not raise_on_empty_metrics and not self.metric_set:
351+
warnings.warn("No metrics to publish, skipping")
352+
else:
353+
metrics = self.serialize_metric_set()
354+
self.clear_metrics()
355+
print(json.dumps(metrics, separators=(",", ":")))
356+
357+
return response
358+
359+
return decorate
360+
361+
def _extract_metric_unit_value(self, unit: Union[str, MetricUnit]) -> str:
278362
"""Return metric value from metric unit whether that's str or MetricUnit enum
279363
280364
Parameters
@@ -306,3 +390,139 @@ def __extract_metric_unit_value(self, unit: Union[str, MetricUnit]) -> str:
306390
unit = unit.value
307391

308392
return unit
393+
394+
def _add_cold_start_metric(self, context: Any) -> None:
395+
"""Add cold start metric and function_name dimension
396+
397+
Parameters
398+
----------
399+
context : Any
400+
Lambda context
401+
"""
402+
global is_cold_start
403+
if is_cold_start:
404+
logger.debug("Adding cold start metric and function_name dimension")
405+
with single_metric(name="ColdStart", unit=MetricUnit.Count, value=1, namespace=self.namespace) as metric:
406+
metric.add_dimension(name="function_name", value=context.function_name)
407+
if self.service:
408+
metric.add_dimension(name="service", value=str(self.service))
409+
is_cold_start = False
410+
411+
412+
class SingleMetric(MetricManager):
413+
"""SingleMetric creates an EMF object with a single metric.
414+
415+
EMF specification doesn't allow metrics with different dimensions.
416+
SingleMetric overrides MetricManager's add_metric method to do just that.
417+
418+
Use `single_metric` when you need to create metrics with different dimensions,
419+
otherwise `aws_lambda_powertools.metrics.metrics.Metrics` is
420+
a more cost effective option
421+
422+
Environment variables
423+
---------------------
424+
POWERTOOLS_METRICS_NAMESPACE : str
425+
metric namespace
426+
427+
Example
428+
-------
429+
**Creates cold start metric with function_version as dimension**
430+
431+
import json
432+
from aws_lambda_powertools.metrics import single_metric, MetricUnit
433+
metric = single_metric(namespace="ServerlessAirline")
434+
435+
metric.add_metric(name="ColdStart", unit=MetricUnit.Count, value=1)
436+
metric.add_dimension(name="function_version", value=47)
437+
438+
print(json.dumps(metric.serialize_metric_set(), indent=4))
439+
440+
Parameters
441+
----------
442+
MetricManager : MetricManager
443+
Inherits from `aws_lambda_powertools.metrics.base.MetricManager`
444+
"""
445+
446+
def add_metric(self, name: str, unit: Union[MetricUnit, str], value: float) -> None:
447+
"""Method to prevent more than one metric being created
448+
449+
Parameters
450+
----------
451+
name : str
452+
Metric name (e.g. BookingConfirmation)
453+
unit : MetricUnit
454+
Metric unit (e.g. "Seconds", MetricUnit.Seconds)
455+
value : float
456+
Metric value
457+
"""
458+
if len(self.metric_set) > 0:
459+
logger.debug(f"Metric {name} already set, skipping...")
460+
return
461+
return super().add_metric(name, unit, value)
462+
463+
464+
@contextmanager
465+
def single_metric(
466+
name: str, unit: MetricUnit, value: float, namespace: Optional[str] = None
467+
) -> Generator[SingleMetric, None, None]:
468+
"""Context manager to simplify creation of a single metric
469+
470+
Example
471+
-------
472+
**Creates cold start metric with function_version as dimension**
473+
474+
from aws_lambda_powertools import single_metric
475+
from aws_lambda_powertools.metrics import MetricUnit
476+
477+
with single_metric(name="ColdStart", unit=MetricUnit.Count, value=1, namespace="ServerlessAirline") as metric:
478+
metric.add_dimension(name="function_version", value="47")
479+
480+
**Same as above but set namespace using environment variable**
481+
482+
$ export POWERTOOLS_METRICS_NAMESPACE="ServerlessAirline"
483+
484+
from aws_lambda_powertools import single_metric
485+
from aws_lambda_powertools.metrics import MetricUnit
486+
487+
with single_metric(name="ColdStart", unit=MetricUnit.Count, value=1) as metric:
488+
metric.add_dimension(name="function_version", value="47")
489+
490+
Parameters
491+
----------
492+
name : str
493+
Metric name
494+
unit : MetricUnit
495+
`aws_lambda_powertools.helper.models.MetricUnit`
496+
value : float
497+
Metric value
498+
namespace: str
499+
Namespace for metrics
500+
501+
Yields
502+
-------
503+
SingleMetric
504+
SingleMetric class instance
505+
506+
Raises
507+
------
508+
MetricUnitError
509+
When metric metric isn't supported by CloudWatch
510+
MetricValueError
511+
When metric value isn't a number
512+
SchemaValidationError
513+
When metric object fails EMF schema validation
514+
"""
515+
metric_set: Optional[Dict] = None
516+
try:
517+
metric: SingleMetric = SingleMetric(namespace=namespace)
518+
metric.add_metric(name=name, unit=unit, value=value)
519+
yield metric
520+
metric_set = metric.serialize_metric_set()
521+
finally:
522+
print(json.dumps(metric_set, separators=(",", ":")))
523+
524+
525+
def reset_cold_start_flag():
526+
global is_cold_start
527+
if not is_cold_start:
528+
is_cold_start = True

0 commit comments

Comments
 (0)