diff --git a/localstack-core/localstack/utils/analytics/metrics.py b/localstack-core/localstack/utils/analytics/metrics.py index b6b2d7703594d..e0fa8d47048d9 100644 --- a/localstack-core/localstack/utils/analytics/metrics.py +++ b/localstack-core/localstack/utils/analytics/metrics.py @@ -5,7 +5,8 @@ import threading from abc import ABC, abstractmethod from collections import defaultdict -from typing import Dict, List, Optional, Tuple, Union, overload +from dataclasses import dataclass +from typing import Any, Optional, Union, overload from localstack import config from localstack.runtime import hooks @@ -16,6 +17,59 @@ LOG = logging.getLogger(__name__) +@dataclass(frozen=True) +class MetricRegistryKey: + namespace: str + name: str + + +@dataclass(frozen=True) +class CounterPayload: + """An immutable snapshot of a counter metric at the time of collection.""" + + namespace: str + name: str + value: int + type: str + labels: Optional[dict[str, Union[str, float]]] = None + + def as_dict(self) -> dict[str, Any]: + result = { + "namespace": self.namespace, + "name": self.name, + "value": self.value, + "type": self.type, + } + + if self.labels: + # Convert labels to the expected format (label_1, label_1_value, etc.) + for i, (label_name, label_value) in enumerate(self.labels.items(), 1): + result[f"label_{i}"] = label_name + result[f"label_{i}_value"] = label_value + + return result + + +@dataclass +class MetricPayload: + """ + Stores all metric payloads collected during the execution of the LocalStack emulator. + Currently, supports only counter-type metrics, but designed to accommodate other types in the future. + """ + + _payload: list[CounterPayload] # support for other metric types may be added in the future. + + @property + def payload(self) -> list[CounterPayload]: + return self._payload + + def __init__(self, payload: list[CounterPayload]): + self._payload = payload + + def as_dict(self) -> dict[str, list[dict[str, Any]]]: + return {"metrics": [payload.as_dict() for payload in self._payload]} + + class MetricRegistry: """ A Singleton class responsible for managing all registered metrics. @@ -39,7 +93,7 @@ def __init__(self): self._registry = dict() @property - def registry(self) -> Dict[str, "Metric"]: + def registry(self) -> dict[MetricRegistryKey, "Metric"]: return self._registry def register(self, metric: Metric) -> None: @@ -54,22 +108,28 @@ def register(self, metric: Metric) -> None: if not isinstance(metric, Metric): raise TypeError("Only subclasses of `Metric` can be registered.") - if metric.name in self._registry: - raise ValueError(f"Metric '{metric.name}' already exists.") + if not metric.namespace: + raise ValueError("Metric 'namespace' must be defined and non-empty.") - self._registry[metric.name] = metric + registry_unique_key = MetricRegistryKey(namespace=metric.namespace, name=metric.name) + if registry_unique_key in self._registry: + raise ValueError( + f"A metric named '{metric.name}' already exists in the '{metric.namespace}' namespace" + ) - def collect(self) -> Dict[str, List[Dict[str, Union[str, int]]]]: + self._registry[registry_unique_key] = metric + + def collect(self) -> MetricPayload: """ Collects all registered metrics. """ - return { - "metrics": [ - metric - for metric_instance in self._registry.values() - for metric in metric_instance.collect() - ] - } + payload = [ + metric + for metric_instance in self._registry.values() + for metric in metric_instance.collect() + ] + + return MetricPayload(payload=payload) class Metric(ABC): @@ -79,20 +139,30 @@ class Metric(ABC): Each subclass must implement the `collect()` method. """ + _namespace: str _name: str - def __init__(self, name: str): + def __init__(self, namespace: str, name: str): + if not namespace or namespace.strip() == "": + raise ValueError("Namespace must be non-empty string.") + self._namespace = namespace + if not name or name.strip() == "": raise ValueError("Metric name must be non-empty string.") - self._name = name + @property + def namespace(self) -> str: + return self._namespace + @property def name(self) -> str: return self._name @abstractmethod - def collect(self) -> List[Dict[str, Union[str, int]]]: + def collect( + self, + ) -> list[CounterPayload]: # support for other metric types may be added in the future. """ Collects and returns metric data. Subclasses must implement this to return collected metric data. """ @@ -143,18 +213,16 @@ class CounterMetric(Metric, BaseCounter): This class should not be instantiated directly, use the Counter class instead. """ - _namespace: Optional[str] _type: str - def __init__(self, name: str, namespace: Optional[str] = ""): - Metric.__init__(self, name=name) + def __init__(self, namespace: str, name: str): + Metric.__init__(self, namespace=namespace, name=name) BaseCounter.__init__(self) - self._namespace = namespace.strip() if namespace else "" self._type = "counter" MetricRegistry().register(self) - def collect(self) -> List[Dict[str, Union[str, int]]]: + def collect(self) -> list[CounterPayload]: """Collects the metric unless events are disabled.""" if config.DISABLE_EVENTS: return list() @@ -162,13 +230,11 @@ def collect(self) -> List[Dict[str, Union[str, int]]]: if self._count == 0: # Return an empty list if the count is 0, as there are no metrics to send to the analytics backend. return list() + return [ - { - "namespace": self._namespace, - "name": self.name, - "value": self._count, - "type": self._type, - } + CounterPayload( + namespace=self._namespace, name=self.name, value=self._count, type=self._type + ) ] @@ -178,15 +244,14 @@ class LabeledCounterMetric(Metric): This class should not be instantiated directly, use the Counter class instead. """ - _namespace: Optional[str] _type: str _unit: str _labels: list[str] - _label_values: Tuple[Optional[Union[str, float]], ...] - _counters_by_label_values: defaultdict[Tuple[Optional[Union[str, float]], ...], BaseCounter] + _label_values: tuple[Optional[Union[str, float]], ...] + _counters_by_label_values: defaultdict[tuple[Optional[Union[str, float]], ...], BaseCounter] - def __init__(self, name: str, labels: List[str], namespace: Optional[str] = ""): - super(LabeledCounterMetric, self).__init__(name=name) + def __init__(self, namespace: str, name: str, labels: list[str]): + super(LabeledCounterMetric, self).__init__(namespace=namespace, name=name) if not labels: raise ValueError("At least one label is required; the labels list cannot be empty.") @@ -197,7 +262,6 @@ def __init__(self, name: str, labels: List[str], namespace: Optional[str] = ""): if len(labels) > 8: raise ValueError("A maximum of 8 labels are allowed.") - self._namespace = namespace.strip() if namespace else "" self._type = "counter" self._labels = labels self._counters_by_label_values = defaultdict(BaseCounter) @@ -221,13 +285,12 @@ def labels(self, **kwargs: Union[str, float, None]) -> BaseCounter: return self._counters_by_label_values[_label_values] - def _as_list(self) -> List[Dict[str, Union[str, int]]]: - num_labels = len(self._labels) - - static_key_label_value = [f"label_{i + 1}_value" for i in range(num_labels)] - static_key_label = [f"label_{i + 1}" for i in range(num_labels)] + def collect(self) -> list[CounterPayload]: + if config.DISABLE_EVENTS: + return list() - collected_metrics = [] + payload = [] + num_labels = len(self._labels) for label_values, counter in self._counters_by_label_values.items(): if counter.count == 0: @@ -239,23 +302,23 @@ def _as_list(self) -> List[Dict[str, Union[str, int]]]: f"but got {len(label_values)} values {label_values}." ) - collected_metrics.append( - { - "namespace": self._namespace, - "name": self.name, - "value": counter.count, - "type": self._type, - **dict(zip(static_key_label_value, label_values)), - **dict(zip(static_key_label, self._labels)), - } - ) + # Create labels dictionary + labels_dict = { + label_name: label_value + for label_name, label_value in zip(self._labels, label_values) + } - return collected_metrics + payload.append( + CounterPayload( + namespace=self._namespace, + name=self.name, + value=counter.count, + type=self._type, + labels=labels_dict, + ) + ) - def collect(self) -> List[Dict[str, Union[str, int]]]: - if config.DISABLE_EVENTS: - return list() - return self._as_list() + return payload class Counter: @@ -268,17 +331,15 @@ class Counter: """ @overload - def __new__(cls, name: str, namespace: Optional[str] = "") -> CounterMetric: + def __new__(cls, namespace: str, name: str) -> CounterMetric: return CounterMetric(namespace=namespace, name=name) @overload - def __new__( - cls, name: str, labels: List[str], namespace: Optional[str] = "" - ) -> LabeledCounterMetric: + def __new__(cls, namespace: str, name: str, labels: list[str]) -> LabeledCounterMetric: return LabeledCounterMetric(namespace=namespace, name=name, labels=labels) def __new__( - cls, name: str, namespace: Optional[str] = "", labels: Optional[List[str]] = None + cls, namespace: str, name: str, labels: Optional[list[str]] = None ) -> Union[CounterMetric, LabeledCounterMetric]: if labels is not None: return LabeledCounterMetric(namespace=namespace, name=name, labels=labels) @@ -297,7 +358,7 @@ def publish_metrics() -> None: return collected_metrics = MetricRegistry().collect() - if not collected_metrics["metrics"]: # Skip publishing if no metrics remain after filtering + if not collected_metrics.payload: # Skip publishing if no metrics remain after filtering return metadata = EventMetadata( @@ -307,4 +368,6 @@ def publish_metrics() -> None: if collected_metrics: publisher = AnalyticsClientPublisher() - publisher.publish([Event(name="ls_metrics", metadata=metadata, payload=collected_metrics)]) + publisher.publish( + [Event(name="ls_metrics", metadata=metadata, payload=collected_metrics.as_dict())] + ) diff --git a/tests/unit/utils/analytics/test_metrics.py b/tests/unit/utils/analytics/test_metrics.py index e1bacfc5dd07d..bad18c47657a0 100644 --- a/tests/unit/utils/analytics/test_metrics.py +++ b/tests/unit/utils/analytics/test_metrics.py @@ -5,6 +5,7 @@ from localstack.utils.analytics.metrics import ( Counter, MetricRegistry, + MetricRegistryKey, ) @@ -15,17 +16,17 @@ def test_metric_registry_singleton(): def test_counter_increment(): - counter = Counter(name="test_counter") + counter = Counter(namespace="test_namespace", name="test_counter") counter.increment() counter.increment(value=3) collected = counter.collect() - assert collected[0]["value"] == 4, ( + assert collected[0].value == 4, ( f"Unexpected counter value: expected 4, got {collected[0]['value']}" ) def test_counter_reset(): - counter = Counter(name="test_counter") + counter = Counter(namespace="test_namespace", name="test_counter") counter.increment(value=5) counter.reset() collected = counter.collect() @@ -33,21 +34,28 @@ def test_counter_reset(): def test_labeled_counter_increment(): - labeled_counter = Counter(name="test_multilabel_counter", labels=["status"]) + labeled_counter = Counter( + namespace="test_namespace", name="test_multilabel_counter", labels=["status"] + ) labeled_counter.labels(status="success").increment(value=2) labeled_counter.labels(status="error").increment(value=3) collected_metrics = labeled_counter.collect() assert any( - metric["value"] == 2 for metric in collected_metrics if metric["label_1_value"] == "success" + metric.value == 2 and metric.labels and metric.labels.get("status") == "success" + for metric in collected_metrics ), "Unexpected counter value for label success" + assert any( - metric["value"] == 3 for metric in collected_metrics if metric["label_1_value"] == "error" + metric.value == 3 and metric.labels and metric.labels.get("status") == "error" + for metric in collected_metrics ), "Unexpected counter value for label error" def test_labeled_counter_reset(): - labeled_counter = Counter(name="test_multilabel_counter", labels=["status"]) + labeled_counter = Counter( + namespace="test_namespace", name="test_multilabel_counter", labels=["status"] + ) labeled_counter.labels(status="success").increment(value=5) labeled_counter.labels(status="error").increment(value=4) @@ -55,51 +63,62 @@ def test_labeled_counter_reset(): collected_metrics = labeled_counter.collect() - assert all(metric["label_1_value"] != "success" for metric in collected_metrics), ( - "Metric for label 'success' should not appear after reset." - ) + # Assert that no metric with label "success" is present anymore + assert all( + not metric.labels or metric.labels.get("status") != "success" + for metric in collected_metrics + ), "Metric for label 'success' should not appear after reset." + # Assert that metric with label "error" is still there with correct value assert any( - metric["value"] == 4 for metric in collected_metrics if metric["label_1_value"] == "error" + metric.value == 4 and metric.labels and metric.labels.get("status") == "error" + for metric in collected_metrics ), "Unexpected counter value for label error" def test_counter_when_events_disabled(disable_analytics): - counter = Counter(name="test_counter") + counter = Counter(namespace="test_namespace", name="test_counter") counter.increment(value=10) assert counter.collect() == [], "Counter should not collect any data" def test_labeled_counter_when_events_disabled_(disable_analytics): - labeled_counter = Counter(name="test_multilabel_counter", labels=["status"]) + labeled_counter = Counter( + namespace="test_namespace", name="test_multilabel_counter", labels=["status"] + ) labeled_counter.labels(status="status").increment(value=5) assert labeled_counter.collect() == [], "Counter should not collect any data" def test_metric_registry_register_and_collect(): - counter = Counter(name="test_counter") + counter = Counter(namespace="test_namespace", name="test_counter") registry = MetricRegistry() # Ensure the counter is already registered - assert counter.name in registry._registry, "Counter should automatically register itself" + assert MetricRegistryKey("test_namespace", "test_counter") in registry._registry, ( + "Counter should automatically register itself" + ) counter.increment(value=7) - collected = registry.collect() - assert any(metric["value"] == 7 for metric in collected["metrics"]), ( - f"Unexpected collected metrics: {collected}" + collected_metrics = registry.collect() + assert any(metric.value == 7 for metric in collected_metrics.payload), ( + f"Unexpected collected metrics: {collected_metrics}" ) def test_metric_registry_register_duplicate_counter(): - counter = Counter(name="test_counter") + counter = Counter(namespace="test_namespace", name="test_counter") registry = MetricRegistry() # Attempt to manually register the counter again, expecting a ValueError - with pytest.raises(ValueError, match=f"Metric '{counter.name}' already exists."): + with pytest.raises( + ValueError, + match=f"A metric named '{counter.name}' already exists in the '{counter.namespace}' namespace", + ): registry.register(counter) def test_thread_safety(): - counter = Counter(name="test_counter") + counter = Counter(namespace="test_namespace", name="test_counter") def increment(): for _ in range(1000): @@ -111,51 +130,71 @@ def increment(): for thread in threads: thread.join() - collected = counter.collect() - assert collected[0]["value"] == 5000, ( - f"Unexpected counter value: expected 5000, got {collected[0]['value']}" + collected_metrics = counter.collect() + assert collected_metrics[0].value == 5000, ( + f"Unexpected counter value: expected 5000, got {collected_metrics[0].value}" ) def test_max_labels_limit(): with pytest.raises(ValueError, match="A maximum of 8 labels are allowed."): - Counter(name="test_counter", labels=["l1", "l2", "l3", "l4", "l5", "l6", "l7", "l8", "l9"]) + Counter( + namespace="test_namespace", + name="test_counter", + labels=["l1", "l2", "l3", "l4", "l5", "l6", "l7", "l8", "l9"], + ) + + +def test_counter_raises_error_if_namespace_is_empty(): + with pytest.raises(ValueError, match="Namespace must be non-empty string."): + Counter(namespace="", name="") + + with pytest.raises(ValueError, match="Metric name must be non-empty string."): + Counter(namespace="test_namespace", name=" ") def test_counter_raises_error_if_name_is_empty(): with pytest.raises(ValueError, match="Metric name must be non-empty string."): - Counter(name="") + Counter(namespace="test_namespace", name="") with pytest.raises(ValueError, match="Metric name must be non-empty string."): - Counter(name=" ") + Counter(namespace="test_namespace", name=" ") def test_counter_raises_if_label_values_off(): with pytest.raises( ValueError, match="At least one label is required; the labels list cannot be empty." ): - Counter(name="test_counter", labels=[]).labels(l1="a") + Counter(namespace="test_namespace", name="test_counter", labels=[]).labels(l1="a") with pytest.raises(ValueError): - Counter(name="test_counter", labels=["l1", "l2"]).labels(l1="a", non_existing="asdf") + Counter(namespace="test_namespace", name="test_counter", labels=["l1", "l2"]).labels( + l1="a", non_existing="asdf" + ) with pytest.raises(ValueError): - Counter(name="test_counter", labels=["l1", "l2"]).labels(l1="a") + Counter(namespace="test_namespace", name="test_counter", labels=["l1", "l2"]).labels(l1="a") with pytest.raises(ValueError): - Counter(name="test_counter", labels=["l1", "l2"]).labels(l1="a", l2="b", l3="c") + Counter(namespace="test_namespace", name="test_counter", labels=["l1", "l2"]).labels( + l1="a", l2="b", l3="c" + ) def test_label_kwargs_order_independent(): - labeled_counter = Counter(name="test_multilabel_counter", labels=["status", "type"]) + labeled_counter = Counter( + namespace="test_namespace", name="test_multilabel_counter", labels=["status", "type"] + ) labeled_counter.labels(status="success", type="counter").increment(value=2) labeled_counter.labels(type="counter", status="success").increment(value=3) labeled_counter.labels(type="counter", status="error").increment(value=3) collected_metrics = labeled_counter.collect() assert any( - metric["value"] == 5 for metric in collected_metrics if metric["label_1_value"] == "success" + metric.value == 5 and metric.labels and metric.labels.get("status") == "success" + for metric in collected_metrics ), "Unexpected counter value for label success" assert any( - metric["value"] == 3 for metric in collected_metrics if metric["label_1_value"] == "error" + metric.value == 3 and metric.labels and metric.labels.get("status") == "error" + for metric in collected_metrics ), "Unexpected counter value for label error"