|
1 | 1 | import datetime
|
| 2 | +import functools |
2 | 3 | import json
|
3 | 4 | import logging
|
4 | 5 | import numbers
|
5 | 6 | import os
|
| 7 | +import warnings |
6 | 8 | from collections import defaultdict
|
| 9 | +from contextlib import contextmanager |
7 | 10 | from enum import Enum
|
8 |
| -from typing import Any, Dict, List, Optional, Union |
| 11 | +from typing import Any, Callable, Dict, Generator, List, Optional, Union |
9 | 12 |
|
10 | 13 | from ..shared import constants
|
11 | 14 | from ..shared.functions import resolve_env_var_choice
|
|
16 | 19 | MAX_METRICS = 100
|
17 | 20 | MAX_DIMENSIONS = 29
|
18 | 21 |
|
| 22 | +is_cold_start = True |
| 23 | + |
19 | 24 |
|
20 | 25 | class MetricUnit(Enum):
|
21 | 26 | Seconds = "Seconds"
|
@@ -86,9 +91,9 @@ def __init__(
|
86 | 91 | self.dimension_set = dimension_set if dimension_set is not None else {}
|
87 | 92 | self.namespace = resolve_env_var_choice(choice=namespace, env=os.getenv(constants.METRICS_NAMESPACE_ENV))
|
88 | 93 | 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 {} |
89 | 95 | self._metric_units = [unit.value for unit in MetricUnit]
|
90 | 96 | self._metric_unit_options = list(MetricUnit.__members__)
|
91 |
| - self.metadata_set = metadata_set if metadata_set is not None else {} |
92 | 97 |
|
93 | 98 | def add_metric(self, name: str, unit: Union[MetricUnit, str], value: float) -> None:
|
94 | 99 | """Adds given metric
|
@@ -120,7 +125,7 @@ def add_metric(self, name: str, unit: Union[MetricUnit, str], value: float) -> N
|
120 | 125 | if not isinstance(value, numbers.Number):
|
121 | 126 | raise MetricValueError(f"{value} is not a valid number")
|
122 | 127 |
|
123 |
| - unit = self.__extract_metric_unit_value(unit=unit) |
| 128 | + unit = self._extract_metric_unit_value(unit=unit) |
124 | 129 | metric: Dict = self.metric_set.get(name, defaultdict(list))
|
125 | 130 | metric["Unit"] = unit
|
126 | 131 | metric["Value"].append(float(value))
|
@@ -179,7 +184,7 @@ def serialize_metric_set(
|
179 | 184 |
|
180 | 185 | if self.service and not self.dimension_set.get("service"):
|
181 | 186 | # 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) |
183 | 188 |
|
184 | 189 | if len(metrics) == 0:
|
185 | 190 | raise SchemaValidationError("Must contain at least one metric.")
|
@@ -274,7 +279,86 @@ def add_metadata(self, key: str, value: Any) -> None:
|
274 | 279 | else:
|
275 | 280 | self.metadata_set[str(key)] = value
|
276 | 281 |
|
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: |
278 | 362 | """Return metric value from metric unit whether that's str or MetricUnit enum
|
279 | 363 |
|
280 | 364 | Parameters
|
@@ -306,3 +390,139 @@ def __extract_metric_unit_value(self, unit: Union[str, MetricUnit]) -> str:
|
306 | 390 | unit = unit.value
|
307 | 391 |
|
308 | 392 | 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