diff --git a/aws_lambda_powertools/helper/__init__.py b/aws_lambda_powertools/helper/__init__.py deleted file mode 100644 index eb6356d8ec9..00000000000 --- a/aws_lambda_powertools/helper/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -"""Collection of reusable code shared across powertools utilities -""" diff --git a/aws_lambda_powertools/helper/models.py b/aws_lambda_powertools/helper/models.py deleted file mode 100644 index bc6ea23eaa6..00000000000 --- a/aws_lambda_powertools/helper/models.py +++ /dev/null @@ -1,132 +0,0 @@ -"""Collection of classes as models and builder functions -that provide classes as data representation for -key data used in more than one place. -""" - -from enum import Enum -from typing import Union - - -class LambdaContextModel: - """A handful of Lambda Runtime Context fields - - Full Lambda Context object: https://docs.aws.amazon.com/lambda/latest/dg/python-context-object.html - - NOTE - ---- - - Originally, memory_size is `int` but we cast to `str` in this model - due to aws_lambda_logging library use of `%` during formatting - Ref: https://gitlab.com/hadrien/aws_lambda_logging/blob/master/aws_lambda_logging.py#L47 - - Parameters - ---------- - function_name: str - Lambda function name, by default "UNDEFINED" - e.g. "test" - function_memory_size: str - Lambda function memory in MB, by default "UNDEFINED" - e.g. "128" - casting from int to str due to aws_lambda_logging using `%` when enumerating fields - function_arn: str - Lambda function ARN, by default "UNDEFINED" - e.g. "arn:aws:lambda:eu-west-1:809313241:function:test" - function_request_id: str - Lambda function unique request id, by default "UNDEFINED" - e.g. "52fdfc07-2182-154f-163f-5f0f9a621d72" - """ - - def __init__( - self, - function_name: str = "UNDEFINED", - function_memory_size: str = "UNDEFINED", - function_arn: str = "UNDEFINED", - function_request_id: str = "UNDEFINED", - ): - self.function_name = function_name - self.function_memory_size = function_memory_size - self.function_arn = function_arn - self.function_request_id = function_request_id - - -def build_lambda_context_model(context: object) -> LambdaContextModel: - """Captures Lambda function runtime info to be used across all log statements - - Parameters - ---------- - context : object - Lambda context object - - Returns - ------- - LambdaContextModel - Lambda context only with select fields - """ - - context = { - "function_name": context.function_name, - "function_memory_size": context.memory_limit_in_mb, - "function_arn": context.invoked_function_arn, - "function_request_id": context.aws_request_id, - } - - return LambdaContextModel(**context) - - -class MetricUnit(Enum): - Seconds = "Seconds" - Microseconds = "Microseconds" - Milliseconds = "Milliseconds" - Bytes = "Bytes" - Kilobytes = "Kilobytes" - Megabytes = "Megabytes" - Gigabytes = "Gigabytes" - Terabytes = "Terabytes" - Bits = "Bits" - Kilobits = "Kilobits" - Megabits = "Megabits" - Gigabits = "Gigabits" - Terabits = "Terabits" - Percent = "Percent" - Count = "Count" - BytesPerSecond = "Bytes/Second" - KilobytesPerSecond = "Kilobytes/Second" - MegabytesPerSecond = "Megabytes/Second" - GigabytesPerSecond = "Gigabytes/Second" - TerabytesPerSecond = "Terabytes/Second" - BitsPerSecond = "Bits/Second" - KilobitsPerSecond = "Kilobits/Second" - MegabitsPerSecond = "Megabits/Second" - GigabitsPerSecond = "Gigabits/Second" - TerabitsPerSecond = "Terabits/Second" - CountPerSecond = "Count/Second" - - -def build_metric_unit_from_str(unit: Union[str, MetricUnit]) -> MetricUnit: - """Builds correct metric unit value from string or return Count as default - - Parameters - ---------- - unit : str, MetricUnit - metric unit - - Returns - ------- - MetricUnit - Metric Unit enum from string value or MetricUnit.Count as a default - """ - if isinstance(unit, MetricUnit): - return unit - - if isinstance(unit, str): - unit = unit.lower().capitalize() - - metric_unit = None - - try: - metric_unit = MetricUnit[unit] - except (TypeError, KeyError): - metric_units = [units for units, _ in MetricUnit.__members__.items()] - raise ValueError(f"Invalid Metric Unit - Received {unit}. Value Metric Units are {metric_units}") - - return metric_unit diff --git a/aws_lambda_powertools/logging/__init__.py b/aws_lambda_powertools/logging/__init__.py index 4c1bb2ec5c6..0456b202ffa 100644 --- a/aws_lambda_powertools/logging/__init__.py +++ b/aws_lambda_powertools/logging/__init__.py @@ -1,6 +1,5 @@ """Logging utility """ -from ..helper.models import MetricUnit -from .logger import Logger, log_metric, logger_inject_lambda_context, logger_setup +from .logger import Logger -__all__ = ["logger_setup", "logger_inject_lambda_context", "log_metric", "MetricUnit", "Logger"] +__all__ = ["Logger"] diff --git a/aws_lambda_powertools/logging/formatter.py b/aws_lambda_powertools/logging/formatter.py new file mode 100644 index 00000000000..8aa07069f97 --- /dev/null +++ b/aws_lambda_powertools/logging/formatter.py @@ -0,0 +1,99 @@ +import json +import logging +from typing import Any + + +def json_formatter(unserialized_value: Any): + """JSON custom serializer to cast unserialisable values to strings. + + Example + ------- + + **Serialize unserialisable value to string** + + class X: pass + value = {"x": X()} + + json.dumps(value, default=json_formatter) + + Parameters + ---------- + unserialized_value: Any + Python object unserializable by JSON + """ + return str(unserialized_value) + + +class JsonFormatter(logging.Formatter): + """AWS Lambda Logging formatter. + + Formats the log message as a JSON encoded string. If the message is a + dict it will be used directly. If the message can be parsed as JSON, then + the parse d value is used in the output record. + + Originally taken from https://gitlab.com/hadrien/aws_lambda_logging/ + + """ + + def __init__(self, **kwargs): + """Return a JsonFormatter instance. + + The `json_default` kwarg is used to specify a formatter for otherwise + unserialisable values. It must not throw. Defaults to a function that + coerces the value to a string. + + Other kwargs are used to specify log field format strings. + """ + datefmt = kwargs.pop("datefmt", None) + + super(JsonFormatter, self).__init__(datefmt=datefmt) + self.reserved_keys = ["timestamp", "level", "location"] + self.format_dict = { + "timestamp": "%(asctime)s", + "level": "%(levelname)s", + "location": "%(funcName)s:%(lineno)d", + } + self.format_dict.update(kwargs) + self.default_json_formatter = kwargs.pop("json_default", json_formatter) + + def format(self, record): # noqa: A003 + record_dict = record.__dict__.copy() + record_dict["asctime"] = self.formatTime(record, self.datefmt) + + log_dict = {} + for key, value in self.format_dict.items(): + if value and key in self.reserved_keys: + # converts default logging expr to its record value + # e.g. '%(asctime)s' to '2020-04-24 09:35:40,698' + log_dict[key] = value % record_dict + else: + log_dict[key] = value + + if isinstance(record_dict["msg"], dict): + log_dict["message"] = record_dict["msg"] + else: + log_dict["message"] = record.getMessage() + + # Attempt to decode the message as JSON, if so, merge it with the + # overall message for clarity. + try: + log_dict["message"] = json.loads(log_dict["message"]) + except (json.decoder.JSONDecodeError, TypeError, ValueError): + pass + + if record.exc_info: + # Cache the traceback text to avoid converting it multiple times + # (it's constant anyway) + # from logging.Formatter:format + if not record.exc_text: + record.exc_text = self.formatException(record.exc_info) + + if record.exc_text: + log_dict["exception"] = record.exc_text + + json_record = json.dumps(log_dict, default=self.default_json_formatter) + + if hasattr(json_record, "decode"): # pragma: no cover + json_record = json_record.decode("utf-8") + + return json_record diff --git a/aws_lambda_powertools/logging/lambda_context.py b/aws_lambda_powertools/logging/lambda_context.py new file mode 100644 index 00000000000..75da8711f03 --- /dev/null +++ b/aws_lambda_powertools/logging/lambda_context.py @@ -0,0 +1,55 @@ +class LambdaContextModel: + """A handful of Lambda Runtime Context fields + + Full Lambda Context object: https://docs.aws.amazon.com/lambda/latest/dg/python-context-object.html + + Parameters + ---------- + function_name: str + Lambda function name, by default "UNDEFINED" + e.g. "test" + function_memory_size: int + Lambda function memory in MB, by default 128 + function_arn: str + Lambda function ARN, by default "UNDEFINED" + e.g. "arn:aws:lambda:eu-west-1:809313241:function:test" + function_request_id: str + Lambda function unique request id, by default "UNDEFINED" + e.g. "52fdfc07-2182-154f-163f-5f0f9a621d72" + """ + + def __init__( + self, + function_name: str = "UNDEFINED", + function_memory_size: int = 128, + function_arn: str = "UNDEFINED", + function_request_id: str = "UNDEFINED", + ): + self.function_name = function_name + self.function_memory_size = function_memory_size + self.function_arn = function_arn + self.function_request_id = function_request_id + + +def build_lambda_context_model(context: object) -> LambdaContextModel: + """Captures Lambda function runtime info to be used across all log statements + + Parameters + ---------- + context : object + Lambda context object + + Returns + ------- + LambdaContextModel + Lambda context only with select fields + """ + + context = { + "function_name": context.function_name, + "function_memory_size": context.memory_limit_in_mb, + "function_arn": context.invoked_function_arn, + "function_request_id": context.aws_request_id, + } + + return LambdaContextModel(**context) diff --git a/aws_lambda_powertools/logging/logger.py b/aws_lambda_powertools/logging/logger.py index 63fa6ed8b28..9a943536b4e 100644 --- a/aws_lambda_powertools/logging/logger.py +++ b/aws_lambda_powertools/logging/logger.py @@ -1,157 +1,21 @@ import copy import functools -import itertools -import json import logging import os import random import sys -import warnings from distutils.util import strtobool from typing import Any, Callable, Dict, Union -from ..helper.models import MetricUnit, build_lambda_context_model, build_metric_unit_from_str from .exceptions import InvalidLoggerSamplingRateError +from .formatter import JsonFormatter +from .lambda_context import build_lambda_context_model logger = logging.getLogger(__name__) is_cold_start = True -def json_formatter(unserialized_value: Any): - """JSON custom serializer to cast unserialisable values to strings. - - Example - ------- - - **Serialize unserialisable value to string** - - class X: pass - value = {"x": X()} - - json.dumps(value, default=json_formatter) - - Parameters - ---------- - unserialized_value: Any - Python object unserializable by JSON - """ - return str(unserialized_value) - - -class JsonFormatter(logging.Formatter): - """AWS Lambda Logging formatter. - - Formats the log message as a JSON encoded string. If the message is a - dict it will be used directly. If the message can be parsed as JSON, then - the parse d value is used in the output record. - - Originally taken from https://gitlab.com/hadrien/aws_lambda_logging/ - - """ - - def __init__(self, **kwargs): - """Return a JsonFormatter instance. - - The `json_default` kwarg is used to specify a formatter for otherwise - unserialisable values. It must not throw. Defaults to a function that - coerces the value to a string. - - Other kwargs are used to specify log field format strings. - """ - datefmt = kwargs.pop("datefmt", None) - - super(JsonFormatter, self).__init__(datefmt=datefmt) - self.reserved_keys = ["timestamp", "level", "location"] - self.format_dict = { - "timestamp": "%(asctime)s", - "level": "%(levelname)s", - "location": "%(funcName)s:%(lineno)d", - } - self.format_dict.update(kwargs) - self.default_json_formatter = kwargs.pop("json_default", json_formatter) - - def format(self, record): # noqa: A003 - record_dict = record.__dict__.copy() - record_dict["asctime"] = self.formatTime(record, self.datefmt) - - log_dict = {} - for key, value in self.format_dict.items(): - if value and key in self.reserved_keys: - # converts default logging expr to its record value - # e.g. '%(asctime)s' to '2020-04-24 09:35:40,698' - log_dict[key] = value % record_dict - else: - log_dict[key] = value - - if isinstance(record_dict["msg"], dict): - log_dict["message"] = record_dict["msg"] - else: - log_dict["message"] = record.getMessage() - - # Attempt to decode the message as JSON, if so, merge it with the - # overall message for clarity. - try: - log_dict["message"] = json.loads(log_dict["message"]) - except (json.decoder.JSONDecodeError, TypeError, ValueError): - pass - - if record.exc_info: - # Cache the traceback text to avoid converting it multiple times - # (it's constant anyway) - # from logging.Formatter:format - if not record.exc_text: - record.exc_text = self.formatException(record.exc_info) - - if record.exc_text: - log_dict["exception"] = record.exc_text - - json_record = json.dumps(log_dict, default=self.default_json_formatter) - - if hasattr(json_record, "decode"): # pragma: no cover - json_record = json_record.decode("utf-8") - - return json_record - - -def logger_setup( - service: str = None, level: str = None, sampling_rate: float = 0.0, legacy: bool = False, **kwargs -) -> DeprecationWarning: - """DEPRECATED - - This will be removed when GA - Use `aws_lambda_powertools.logging.logger.Logger` instead - - Example - ------- - **Logger class - Same UX** - - from aws_lambda_powertools import Logger - logger = Logger(service="payment") # same env var still applies - - """ - raise DeprecationWarning("Use Logger instead - This method will be removed when GA") - - -def logger_inject_lambda_context( - lambda_handler: Callable[[Dict, Any], Any] = None, log_event: bool = False -) -> DeprecationWarning: - """DEPRECATED - - This will be removed when GA - Use `aws_lambda_powertools.logging.logger.Logger` instead - - Example - ------- - **Logger class - Same UX** - - from aws_lambda_powertools import Logger - logger = Logger(service="payment") # same env var still applies - @logger.inject_lambda_context - def handler(evt, ctx): - pass - """ - raise DeprecationWarning("Use Logger instead - This method will be removed when GA") - - def _is_cold_start() -> bool: """Verifies whether is cold start @@ -170,113 +34,6 @@ def _is_cold_start() -> bool: return cold_start -def log_metric( - name: str, namespace: str, unit: MetricUnit, value: float = 0, service: str = "service_undefined", **dimensions, -): - """Logs a custom metric in a statsD-esque format to stdout. - - **This will be removed when GA - Use `aws_lambda_powertools.metrics.metrics.Metrics` instead** - - Creating Custom Metrics synchronously impact on performance/execution time. - Instead, log_metric prints a metric to CloudWatch Logs. - That allows us to pick them up asynchronously via another Lambda function and create them as a metric. - - NOTE: It takes up to 9 dimensions by default, and Metric units are conveniently available via MetricUnit Enum. - If service is not passed as arg or via env var, "service_undefined" will be used as dimension instead. - - **Output in CloudWatch Logs**: `MONITORING|||||` - - Serverless Application Repository App that creates custom metric from this log output: - https://serverlessrepo.aws.amazon.com/applications/arn:aws:serverlessrepo:us-east-1:374852340823:applications~async-custom-metrics - - Environment variables - --------------------- - POWERTOOLS_SERVICE_NAME: str - service name - - Parameters - ---------- - name : str - metric name, by default None - namespace : str - metric namespace (e.g. application name), by default None - unit : MetricUnit, by default MetricUnit.Count - metric unit enum value (e.g. MetricUnit.Seconds), by default None\n - API Info: https://docs.aws.amazon.com/AmazonCloudWatch/latest/APIReference/API_MetricDatum.html - value : float, optional - metric value, by default 0 - service : str, optional - service name used as dimension, by default "service_undefined" - dimensions: dict, optional - keyword arguments as additional dimensions (e.g. `customer=customerId`) - - Example - ------- - **Log metric to count number of successful payments; define service via env var** - - $ export POWERTOOLS_SERVICE_NAME="payment" - from aws_lambda_powertools.logging import MetricUnit, log_metric - log_metric( - name="SuccessfulPayments", - unit=MetricUnit.Count, - value=1, - namespace="DemoApp" - ) - - **Log metric to count number of successful payments per campaign & customer** - - from aws_lambda_powertools.logging import MetricUnit, log_metric - log_metric( - name="SuccessfulPayments", - service="payment", - unit=MetricUnit.Count, - value=1, - namespace="DemoApp", - campaign=campaign_id, - customer=customer_id - ) - """ - - warnings.warn(message="This method will be removed in GA; use Metrics instead", category=DeprecationWarning) - logger.debug(f"Building new custom metric. Name: {name}, Unit: {unit}, Value: {value}, Dimensions: {dimensions}") - service = os.getenv("POWERTOOLS_SERVICE_NAME") or service - dimensions = __build_dimensions(**dimensions) - unit = build_metric_unit_from_str(unit) - - metric = f"MONITORING|{value}|{unit.name}|{name}|{namespace}|service={service}" - if dimensions: - metric = f"MONITORING|{value}|{unit.name}|{name}|{namespace}|service={service},{dimensions}" - - print(metric) - - -def __build_dimensions(**dimensions) -> str: - """Builds correct format for custom metric dimensions from kwargs - - Parameters - ---------- - dimensions: dict, optional - additional dimensions - - Returns - ------- - str - Dimensions in the form of "key=value,key2=value2" - """ - MAX_DIMENSIONS = 10 - dimension = "" - - # CloudWatch accepts a max of 10 dimensions per metric - # We include service name as a dimension - # so we take up to 9 values as additional dimensions - # before we convert everything to a string of key=value - dimensions_partition = dict(itertools.islice(dimensions.items(), MAX_DIMENSIONS)) - dimensions_list = [dimension + "=" + value for dimension, value in dimensions_partition.items() if value] - dimension = ",".join(dimensions_list) - - return dimension - - class Logger(logging.Logger): """Creates and setups a logger to format statements in JSON. diff --git a/aws_lambda_powertools/metrics/__init__.py b/aws_lambda_powertools/metrics/__init__.py index 2f71957437d..7379dad8b88 100644 --- a/aws_lambda_powertools/metrics/__init__.py +++ b/aws_lambda_powertools/metrics/__init__.py @@ -1,7 +1,7 @@ """CloudWatch Embedded Metric Format utility """ -from ..helper.models import MetricUnit -from .exceptions import MetricUnitError, MetricValueError, SchemaValidationError, UniqueNamespaceError +from .base import MetricUnit +from .exceptions import MetricUnitError, MetricValueError, SchemaValidationError from .metric import single_metric from .metrics import Metrics @@ -12,5 +12,4 @@ "MetricUnitError", "SchemaValidationError", "MetricValueError", - "UniqueNamespaceError", ] diff --git a/aws_lambda_powertools/metrics/base.py b/aws_lambda_powertools/metrics/base.py index d6529cf71e2..1eece781bbf 100644 --- a/aws_lambda_powertools/metrics/base.py +++ b/aws_lambda_powertools/metrics/base.py @@ -4,13 +4,12 @@ import numbers import os import pathlib -import warnings +from enum import Enum from typing import Dict, List, Union import fastjsonschema -from ..helper.models import MetricUnit -from .exceptions import MetricUnitError, MetricValueError, SchemaValidationError, UniqueNamespaceError +from .exceptions import MetricUnitError, MetricValueError, SchemaValidationError logger = logging.getLogger(__name__) @@ -21,6 +20,35 @@ MAX_METRICS = 100 +class MetricUnit(Enum): + Seconds = "Seconds" + Microseconds = "Microseconds" + Milliseconds = "Milliseconds" + Bytes = "Bytes" + Kilobytes = "Kilobytes" + Megabytes = "Megabytes" + Gigabytes = "Gigabytes" + Terabytes = "Terabytes" + Bits = "Bits" + Kilobits = "Kilobits" + Megabits = "Megabits" + Gigabits = "Gigabits" + Terabits = "Terabits" + Percent = "Percent" + Count = "Count" + BytesPerSecond = "Bytes/Second" + KilobytesPerSecond = "Kilobytes/Second" + MegabytesPerSecond = "Megabytes/Second" + GigabytesPerSecond = "Gigabytes/Second" + TerabytesPerSecond = "Terabytes/Second" + BitsPerSecond = "Bits/Second" + KilobitsPerSecond = "Kilobits/Second" + MegabitsPerSecond = "Megabits/Second" + GigabitsPerSecond = "Gigabits/Second" + TerabitsPerSecond = "Terabits/Second" + CountPerSecond = "Count/Second" + + class MetricManager: """Base class for metric functionality (namespace, metric, dimension, serialization) @@ -45,8 +73,6 @@ class MetricManager: When metric metric isn't supported by CloudWatch MetricValueError When metric value isn't a number - UniqueNamespaceError - When an additional namespace is set SchemaValidationError When metric object fails EMF schema validation """ @@ -61,30 +87,6 @@ def __init__( self._metric_units = [unit.value for unit in MetricUnit] self._metric_unit_options = list(MetricUnit.__members__) - def add_namespace(self, name: str): - """Adds given metric namespace - - Example - ------- - **Add metric namespace** - - metric.add_namespace(name="ServerlessAirline") - - Parameters - ---------- - name : str - Metric namespace - """ - warnings.warn( - "add_namespace method is deprecated. Pass namespace to Metrics constructor instead", DeprecationWarning - ) - if self.namespace is not None: - raise UniqueNamespaceError( - f"Namespace '{self.namespace}' already set - Only one namespace is allowed across metrics" - ) - logger.debug(f"Adding metrics namespace: {name}") - self.namespace = name - def add_metric(self, name: str, unit: MetricUnit, value: Union[float, int]): """Adds given metric diff --git a/aws_lambda_powertools/metrics/exceptions.py b/aws_lambda_powertools/metrics/exceptions.py index 88a38c24229..0376c55a40e 100644 --- a/aws_lambda_powertools/metrics/exceptions.py +++ b/aws_lambda_powertools/metrics/exceptions.py @@ -14,9 +14,3 @@ class MetricValueError(Exception): """When metric value isn't a valid number""" pass - - -class UniqueNamespaceError(Exception): - """When an additional namespace is set""" - - pass diff --git a/aws_lambda_powertools/metrics/metric.py b/aws_lambda_powertools/metrics/metric.py index 53434b4a9d4..1293139afbe 100644 --- a/aws_lambda_powertools/metrics/metric.py +++ b/aws_lambda_powertools/metrics/metric.py @@ -3,8 +3,7 @@ from contextlib import contextmanager from typing import Dict -from ..helper.models import MetricUnit -from .base import MetricManager +from .base import MetricManager, MetricUnit logger = logging.getLogger(__name__) diff --git a/tests/functional/test_logger.py b/tests/functional/test_logger.py index 19c45a8587e..6b6a4bb6dde 100644 --- a/tests/functional/test_logger.py +++ b/tests/functional/test_logger.py @@ -6,7 +6,6 @@ import pytest from aws_lambda_powertools import Logger, Tracer -from aws_lambda_powertools.logging import MetricUnit, log_metric, logger_inject_lambda_context, logger_setup from aws_lambda_powertools.logging.exceptions import InvalidLoggerSamplingRateError from aws_lambda_powertools.logging.logger import JsonFormatter, set_package_logger @@ -236,68 +235,6 @@ def handler(event, context): assert fourth_log["cold_start"] is False -def test_log_metric(capsys): - # GIVEN a service, unit and value have been provided - # WHEN log_metric is called - # THEN custom metric line should be match given values - log_metric( - service="payment", name="test_metric", unit=MetricUnit.Seconds, value=60, namespace="DemoApp", - ) - expected = "MONITORING|60|Seconds|test_metric|DemoApp|service=payment\n" - captured = capsys.readouterr() - - assert captured.out == expected - - -def test_log_metric_env_var(monkeypatch, capsys): - # GIVEN a service, unit and value have been provided - # WHEN log_metric is called - # THEN custom metric line should be match given values - service_name = "payment" - monkeypatch.setenv("POWERTOOLS_SERVICE_NAME", service_name) - - log_metric(name="test_metric", unit=MetricUnit.Seconds, value=60, namespace="DemoApp") - expected = "MONITORING|60|Seconds|test_metric|DemoApp|service=payment\n" - captured = capsys.readouterr() - - assert captured.out == expected - - -def test_log_metric_multiple_dimensions(capsys): - # GIVEN multiple optional dimensions are provided - # WHEN log_metric is called - # THEN dimensions should appear as dimenion=value - log_metric( - name="test_metric", unit=MetricUnit.Seconds, value=60, customer="abc", charge_id="123", namespace="DemoApp", - ) - expected = "MONITORING|60|Seconds|test_metric|DemoApp|service=service_undefined,customer=abc,charge_id=123\n" - captured = capsys.readouterr() - - assert captured.out == expected - - -@pytest.mark.parametrize( - "invalid_input,expected", - [ - ({"unit": "seconds"}, "MONITORING|0|Seconds|test_metric|DemoApp|service=service_undefined\n",), - ( - {"unit": "Seconds", "customer": None, "charge_id": "123", "payment_status": ""}, - "MONITORING|0|Seconds|test_metric|DemoApp|service=service_undefined,charge_id=123\n", - ), - ], - ids=["metric unit as string lower case", "empty dimension value"], -) -def test_log_metric_partially_correct_args(capsys, invalid_input, expected): - # GIVEN invalid arguments are provided such as empty dimension values and metric units in strings - # WHEN log_metric is called - # THEN default values should be used such as "Count" as a unit, invalid dimensions not included - # and no exception raised - log_metric(name="test_metric", namespace="DemoApp", **invalid_input) - captured = capsys.readouterr() - - assert captured.out == expected - - def test_package_logger(capsys): set_package_logger() @@ -315,32 +252,6 @@ def test_package_logger_format(stdout, capsys): assert "test" in output["formatter"] -@pytest.mark.parametrize( - "invalid_input,expected", - [({"unit": "Blah"}, ValueError), ({"unit": None}, ValueError), ({}, TypeError)], - ids=["invalid metric unit as str", "unit as None", "missing required unit"], -) -def test_log_metric_invalid_unit(capsys, invalid_input, expected): - # GIVEN invalid units are provided - # WHEN log_metric is called - # THEN ValueError exception should be raised - - with pytest.raises(expected): - log_metric(name="test_metric", namespace="DemoApp", **invalid_input) - - -def test_logger_setup_deprecated(): - # Should be removed when GA - with pytest.raises(DeprecationWarning): - logger_setup() - - -def test_logger_inject_lambda_context_deprecated(): - # Should be removed when GA - with pytest.raises(DeprecationWarning): - logger_inject_lambda_context() - - def test_logger_append_duplicated(stdout): logger = Logger(stream=stdout, request_id="value") logger.structure_logs(append=True, request_id="new_value") diff --git a/tests/functional/test_metrics.py b/tests/functional/test_metrics.py index 3e2ebe34fe1..244a56119cd 100644 --- a/tests/functional/test_metrics.py +++ b/tests/functional/test_metrics.py @@ -6,13 +6,7 @@ import pytest from aws_lambda_powertools import Metrics, single_metric -from aws_lambda_powertools.metrics import ( - MetricUnit, - MetricUnitError, - MetricValueError, - SchemaValidationError, - UniqueNamespaceError, -) +from aws_lambda_powertools.metrics import MetricUnit, MetricUnitError, MetricValueError, SchemaValidationError from aws_lambda_powertools.metrics.base import MetricManager @@ -59,11 +53,11 @@ def non_str_dimensions() -> List[Dict[str, Any]]: @pytest.fixture def namespace() -> Dict[str, str]: - return {"name": "test_namespace"} + return "test_namespace" @pytest.fixture -def a_hundred_metrics() -> List[Dict[str, str]]: +def a_hundred_metrics(namespace=namespace) -> List[Dict[str, str]]: metrics = [] for i in range(100): metrics.append({"name": f"metric_{i}", "unit": "Count", "value": 1}) @@ -71,13 +65,12 @@ def a_hundred_metrics() -> List[Dict[str, str]]: return metrics -def serialize_metrics(metrics: List[Dict], dimensions: List[Dict], namespace: Dict) -> Dict: +def serialize_metrics(metrics: List[Dict], dimensions: List[Dict], namespace: str) -> Dict: """ Helper function to build EMF object from a list of metrics, dimensions """ - my_metrics = MetricManager() + my_metrics = MetricManager(namespace=namespace) for dimension in dimensions: my_metrics.add_dimension(**dimension) - my_metrics.add_namespace(**namespace) for metric in metrics: my_metrics.add_metric(**metric) @@ -85,12 +78,11 @@ def serialize_metrics(metrics: List[Dict], dimensions: List[Dict], namespace: Di return my_metrics.serialize_metric_set() -def serialize_single_metric(metric: Dict, dimension: Dict, namespace: Dict) -> Dict: +def serialize_single_metric(metric: Dict, dimension: Dict, namespace: str) -> Dict: """ Helper function to build EMF object from a given metric, dimension and namespace """ - my_metrics = MetricManager() + my_metrics = MetricManager(namespace=namespace) my_metrics.add_metric(**metric) my_metrics.add_dimension(**dimension) - my_metrics.add_namespace(**namespace) return my_metrics.serialize_metric_set() @@ -103,11 +95,10 @@ def remove_timestamp(metrics: List): def test_single_metric_one_metric_only(capsys, metric, dimension, namespace): # GIVEN we attempt to add more than one metric # WHEN using single_metric context manager - with single_metric(**metric) as my_metric: + with single_metric(namespace=namespace, **metric) as my_metric: my_metric.add_metric(name="second_metric", unit="Count", value=1) my_metric.add_metric(name="third_metric", unit="Seconds", value=1) my_metric.add_dimension(**dimension) - my_metric.add_namespace(**namespace) output = json.loads(capsys.readouterr().out.strip()) expected = serialize_single_metric(metric=metric, dimension=dimension, namespace=namespace) @@ -118,25 +109,9 @@ def test_single_metric_one_metric_only(capsys, metric, dimension, namespace): assert expected["_aws"] == output["_aws"] -def test_multiple_namespaces_exception(metric, dimension, namespace): - # GIVEN we attempt to add multiple namespaces - namespace_a = {"name": "OtherNamespace"} - namespace_b = {"name": "AnotherNamespace"} - - # WHEN an EMF object can only have one - # THEN we should raise UniqueNamespaceError exception - with pytest.raises(UniqueNamespaceError): - with single_metric(**metric) as my_metric: - my_metric.add_dimension(**dimension) - my_metric.add_namespace(**namespace) - my_metric.add_namespace(**namespace_a) - my_metric.add_namespace(**namespace_b) - - def test_log_metrics(capsys, metrics, dimensions, namespace): # GIVEN Metrics is initialized - my_metrics = Metrics() - my_metrics.add_namespace(**namespace) + my_metrics = Metrics(namespace=namespace) for metric in metrics: my_metrics.add_metric(**metric) for dimension in dimensions: @@ -164,7 +139,7 @@ def lambda_handler(evt, ctx): def test_namespace_env_var(monkeypatch, capsys, metric, dimension, namespace): # GIVEN we use POWERTOOLS_METRICS_NAMESPACE - monkeypatch.setenv("POWERTOOLS_METRICS_NAMESPACE", namespace["name"]) + monkeypatch.setenv("POWERTOOLS_METRICS_NAMESPACE", namespace) # WHEN creating a metric but don't explicitly # add a namespace @@ -185,7 +160,7 @@ def test_namespace_env_var(monkeypatch, capsys, metric, dimension, namespace): def test_service_env_var(monkeypatch, capsys, metric, namespace): # GIVEN we use POWERTOOLS_SERVICE_NAME monkeypatch.setenv("POWERTOOLS_SERVICE_NAME", "test_service") - my_metrics = Metrics(namespace=namespace["name"]) + my_metrics = Metrics(namespace=namespace) # WHEN creating a metric but don't explicitly # add a dimension @@ -210,9 +185,8 @@ def lambda_handler(evt, context): def test_metrics_spillover(monkeypatch, capsys, metric, dimension, namespace, a_hundred_metrics): # GIVEN Metrics is initialized and we have over a hundred metrics to add - my_metrics = Metrics() + my_metrics = Metrics(namespace=namespace) my_metrics.add_dimension(**dimension) - my_metrics.add_namespace(**namespace) # WHEN we add more than 100 metrics for _metric in a_hundred_metrics: @@ -242,12 +216,11 @@ def test_metrics_spillover(monkeypatch, capsys, metric, dimension, namespace, a_ def test_log_metrics_should_invoke_function(metric, dimension, namespace): # GIVEN Metrics is initialized - my_metrics = Metrics() + my_metrics = Metrics(namespace=namespace) # WHEN log_metrics is used to serialize metrics @my_metrics.log_metrics def lambda_handler(evt, context): - my_metrics.add_namespace(**namespace) my_metrics.add_metric(**metric) my_metrics.add_dimension(**dimension) return True @@ -266,7 +239,6 @@ def test_incorrect_metric_unit(metric, dimension, namespace): with pytest.raises(MetricUnitError): with single_metric(**metric) as my_metric: my_metric.add_dimension(**dimension) - my_metric.add_namespace(**namespace) def test_schema_no_namespace(metric, dimension): @@ -289,13 +261,11 @@ def test_schema_incorrect_value(metric, dimension, namespace): with pytest.raises(MetricValueError): with single_metric(**metric) as my_metric: my_metric.add_dimension(**dimension) - my_metric.add_namespace(**namespace) def test_schema_no_metrics(dimensions, namespace): # GIVEN Metrics is initialized - my_metrics = Metrics() - my_metrics.add_namespace(**namespace) + my_metrics = Metrics(namespace=namespace) # WHEN no metrics have been added # but a namespace and dimensions only @@ -317,18 +287,16 @@ def test_exceed_number_of_dimensions(metric, namespace): # THEN it should fail validation and raise SchemaValidationError with pytest.raises(SchemaValidationError): with single_metric(**metric) as my_metric: - my_metric.add_namespace(**namespace) for dimension in dimensions: my_metric.add_dimension(**dimension) def test_log_metrics_during_exception(capsys, metric, dimension, namespace): # GIVEN Metrics is initialized - my_metrics = Metrics() + my_metrics = Metrics(namespace=namespace) my_metrics.add_metric(**metric) my_metrics.add_dimension(**dimension) - my_metrics.add_namespace(**namespace) # WHEN log_metrics is used to serialize metrics # but an error has been raised during handler execution @@ -347,18 +315,18 @@ def lambda_handler(evt, context): assert expected["_aws"] == output["_aws"] -def test_log_no_metrics_error_propagation(capsys, metric, dimension, namespace): +def test_log_metrics_raise_on_empty_metrics(capsys, metric, dimension, namespace): # GIVEN Metrics is initialized - my_metrics = Metrics() + my_metrics = Metrics(service="test_service", namespace=namespace) @my_metrics.log_metrics(raise_on_empty_metrics=True) def lambda_handler(evt, context): # WHEN log_metrics is used with raise_on_empty_metrics param and has no metrics - # and the function decorated also raised an exception - raise ValueError("Bubble up") + return True - # THEN the raised exception should be - with pytest.raises(SchemaValidationError): + # THEN the raised exception should be SchemaValidationError + # and specifically about the lack of Metrics + with pytest.raises(SchemaValidationError, match="_aws\.CloudWatchMetrics\[0\]\.Metrics"): # noqa: W605 lambda_handler({}, {}) @@ -370,9 +338,8 @@ def test_all_possible_metric_units(metric, dimension, namespace): metric["unit"] = unit.name # WHEN we iterate over all available metric unit keys from MetricUnit enum # THEN we raise no MetricUnitError nor SchemaValidationError - with single_metric(**metric) as my_metric: + with single_metric(namespace=namespace, **metric) as my_metric: my_metric.add_dimension(**dimension) - my_metric.add_namespace(**namespace) # WHEN we iterate over all available metric unit keys from MetricUnit enum all_metric_units = [unit.value for unit in MetricUnit] @@ -381,18 +348,17 @@ def test_all_possible_metric_units(metric, dimension, namespace): for unit in all_metric_units: metric["unit"] = unit # THEN we raise no MetricUnitError nor SchemaValidationError - with single_metric(**metric) as my_metric: + with single_metric(namespace=namespace, **metric) as my_metric: my_metric.add_dimension(**dimension) - my_metric.add_namespace(**namespace) def test_metrics_reuse_metric_set(metric, dimension, namespace): # GIVEN Metrics is initialized - my_metrics = Metrics() + my_metrics = Metrics(namespace=namespace) my_metrics.add_metric(**metric) # WHEN Metrics is initialized one more time - my_metrics_2 = Metrics() + my_metrics_2 = Metrics(namespace=namespace) # THEN Both class instances should have the same metric set assert my_metrics_2.metric_set == my_metrics.metric_set @@ -400,11 +366,10 @@ def test_metrics_reuse_metric_set(metric, dimension, namespace): def test_log_metrics_clear_metrics_after_invocation(metric, dimension, namespace): # GIVEN Metrics is initialized - my_metrics = Metrics() + my_metrics = Metrics(namespace=namespace) my_metrics.add_metric(**metric) my_metrics.add_dimension(**dimension) - my_metrics.add_namespace(**namespace) # WHEN log_metrics is used to flush metrics from memory @my_metrics.log_metrics @@ -419,8 +384,7 @@ def lambda_handler(evt, context): def test_log_metrics_non_string_dimension_values(capsys, metrics, non_str_dimensions, namespace): # GIVEN Metrics is initialized and dimensions with non-string values are added - my_metrics = Metrics() - my_metrics.add_namespace(**namespace) + my_metrics = Metrics(namespace=namespace) for metric in metrics: my_metrics.add_metric(**metric) for dimension in non_str_dimensions: @@ -441,16 +405,9 @@ def lambda_handler(evt, ctx): assert isinstance(output[dimension["name"]], str) -def test_add_namespace_warns_for_deprecation(capsys, metrics, dimensions, namespace): - # GIVEN Metrics is initialized - my_metrics = Metrics() - with pytest.deprecated_call(): - my_metrics.add_namespace(**namespace) - - def test_log_metrics_with_explicit_namespace(capsys, metrics, dimensions, namespace): # GIVEN Metrics is initialized with service specified - my_metrics = Metrics(service="test_service", namespace=namespace["name"]) + my_metrics = Metrics(service="test_service", namespace=namespace) for metric in metrics: my_metrics.add_metric(**metric) for dimension in dimensions: @@ -476,9 +433,9 @@ def lambda_handler(evt, ctx): assert expected == output -def test_log_metrics_with_implicit_dimensions(capsys, metrics): +def test_log_metrics_with_implicit_dimensions(capsys, metrics, namespace): # GIVEN Metrics is initialized with service specified - my_metrics = Metrics(service="test_service", namespace="test_application") + my_metrics = Metrics(service="test_service", namespace=namespace) for metric in metrics: my_metrics.add_metric(**metric) @@ -492,9 +449,7 @@ def lambda_handler(evt, ctx): output = json.loads(capsys.readouterr().out.strip()) expected_dimensions = [{"name": "service", "value": "test_service"}] - expected = serialize_metrics( - metrics=metrics, dimensions=expected_dimensions, namespace={"name": "test_application"} - ) + expected = serialize_metrics(metrics=metrics, dimensions=expected_dimensions, namespace=namespace) remove_timestamp(metrics=[output, expected]) # Timestamp will always be different @@ -530,37 +485,15 @@ def lambda_handler(evt, ctx): assert second_output["service"] == "another_test_service" -def test_log_metrics_with_namespace_overridden(capsys, metrics, dimensions): - # GIVEN Metrics is initialized with namespace specified - my_metrics = Metrics(namespace="test_service") - for metric in metrics: - my_metrics.add_metric(**metric) - for dimension in dimensions: - my_metrics.add_dimension(**dimension) - - # WHEN we try to call add_namespace - # THEN we should raise UniqueNamespaceError exception - @my_metrics.log_metrics - def lambda_handler(evt, ctx): - my_metrics.add_namespace(name="new_namespace") - return True - - with pytest.raises(UniqueNamespaceError): - lambda_handler({}, {}) - - with pytest.raises(UniqueNamespaceError): - my_metrics.add_namespace(name="another_new_namespace") - - -def test_single_metric_with_service(capsys, metric, dimension): +def test_single_metric_with_service(capsys, metric, dimension, namespace): # GIVEN we pass namespace parameter to single_metric # WHEN creating a metric - with single_metric(**metric, namespace="test_service") as my_metrics: + with single_metric(**metric, namespace=namespace) as my_metrics: my_metrics.add_dimension(**dimension) output = json.loads(capsys.readouterr().out.strip()) - expected = serialize_single_metric(metric=metric, dimension=dimension, namespace={"name": "test_service"}) + expected = serialize_single_metric(metric=metric, dimension=dimension, namespace=namespace) remove_timestamp(metrics=[output, expected]) # Timestamp will always be different @@ -570,10 +503,10 @@ def test_single_metric_with_service(capsys, metric, dimension): def test_namespace_var_precedence(monkeypatch, capsys, metric, dimension, namespace): # GIVEN we use POWERTOOLS_METRICS_NAMESPACE - monkeypatch.setenv("POWERTOOLS_METRICS_NAMESPACE", namespace["name"]) + monkeypatch.setenv("POWERTOOLS_METRICS_NAMESPACE", namespace) # WHEN creating a metric and explicitly set a namespace - with single_metric(**metric, namespace=namespace["name"]) as my_metrics: + with single_metric(namespace=namespace, **metric) as my_metrics: my_metrics.add_dimension(**dimension) monkeypatch.delenv("POWERTOOLS_METRICS_NAMESPACE") @@ -588,8 +521,7 @@ def test_namespace_var_precedence(monkeypatch, capsys, metric, dimension, namesp def test_emit_cold_start_metric(capsys, namespace): # GIVEN Metrics is initialized - my_metrics = Metrics() - my_metrics.add_namespace(**namespace) + my_metrics = Metrics(service="test_service", namespace=namespace) # WHEN log_metrics is used with capture_cold_start_metric @my_metrics.log_metrics(capture_cold_start_metric=True) @@ -608,8 +540,7 @@ def lambda_handler(evt, context): def test_emit_cold_start_metric_only_once(capsys, namespace, dimension, metric): # GIVEN Metrics is initialized - my_metrics = Metrics() - my_metrics.add_namespace(**namespace) + my_metrics = Metrics(namespace=namespace) # WHEN log_metrics is used with capture_cold_start_metric # and handler is called more than once @@ -635,7 +566,7 @@ def lambda_handler(evt, context): def test_log_metrics_decorator_no_metrics(dimensions, namespace): # GIVEN Metrics is initialized - my_metrics = Metrics(namespace=namespace["name"], service="test_service") + my_metrics = Metrics(namespace=namespace, service="test_service") # WHEN using the log_metrics decorator and no metrics have been added @my_metrics.log_metrics @@ -649,9 +580,9 @@ def lambda_handler(evt, context): assert str(w[-1].message) == "No metrics to publish, skipping" -def test_log_metrics_with_implicit_dimensions_called_twice(capsys, metrics): +def test_log_metrics_with_implicit_dimensions_called_twice(capsys, metrics, namespace): # GIVEN Metrics is initialized with service specified - my_metrics = Metrics(service="test_service", namespace="test_application") + my_metrics = Metrics(service="test_service", namespace=namespace) # WHEN we utilize log_metrics to serialize and don't explicitly add any dimensions, # and the lambda function is called more than once @@ -668,9 +599,7 @@ def lambda_handler(evt, ctx): second_output = json.loads(capsys.readouterr().out.strip()) expected_dimensions = [{"name": "service", "value": "test_service"}] - expected = serialize_metrics( - metrics=metrics, dimensions=expected_dimensions, namespace={"name": "test_application"} - ) + expected = serialize_metrics(metrics=metrics, dimensions=expected_dimensions, namespace=namespace) remove_timestamp(metrics=[output, expected, second_output]) # Timestamp will always be different