From 18174f8c3bf4b2688bd727c84d9232f3c44064e9 Mon Sep 17 00:00:00 2001 From: Vittorio Polverino Date: Thu, 29 May 2025 12:23:00 +0200 Subject: [PATCH 1/7] refactor(metrics): enforce namespace+metric uniqueness in metric registry --- .../localstack/utils/analytics/metrics.py | 53 ++++++++++++------- 1 file changed, 34 insertions(+), 19 deletions(-) diff --git a/localstack-core/localstack/utils/analytics/metrics.py b/localstack-core/localstack/utils/analytics/metrics.py index b6b2d7703594d..7b506a1caa411 100644 --- a/localstack-core/localstack/utils/analytics/metrics.py +++ b/localstack-core/localstack/utils/analytics/metrics.py @@ -5,6 +5,7 @@ import threading from abc import ABC, abstractmethod from collections import defaultdict +from dataclasses import dataclass from typing import Dict, List, Optional, Tuple, Union, overload from localstack import config @@ -16,6 +17,12 @@ LOG = logging.getLogger(__name__) +@dataclass(frozen=True) +class MetricRegistryKey: + namespace: str + name: str + + class MetricRegistry: """ A Singleton class responsible for managing all registered metrics. @@ -39,7 +46,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,10 +61,16 @@ 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"Metric '{metric.name}' in namespace '{metric.namespace}' is already registered." + ) + + self._registry[registry_unique_key] = metric def collect(self) -> Dict[str, List[Dict[str, Union[str, int]]]]: """ @@ -79,14 +92,22 @@ 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 @@ -143,14 +164,12 @@ 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) @@ -178,15 +197,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] - 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 +215,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) @@ -268,17 +285,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) From e06182acb1fc484f7ce8643c8517603fb9d98b61 Mon Sep 17 00:00:00 2001 From: Vittorio Polverino Date: Thu, 29 May 2025 12:40:01 +0200 Subject: [PATCH 2/7] chore: update value errore message --- localstack-core/localstack/utils/analytics/metrics.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/localstack-core/localstack/utils/analytics/metrics.py b/localstack-core/localstack/utils/analytics/metrics.py index 7b506a1caa411..326074173faf1 100644 --- a/localstack-core/localstack/utils/analytics/metrics.py +++ b/localstack-core/localstack/utils/analytics/metrics.py @@ -67,7 +67,7 @@ def register(self, metric: Metric) -> None: registry_unique_key = MetricRegistryKey(namespace=metric.namespace, name=metric.name) if registry_unique_key in self._registry: raise ValueError( - f"Metric '{metric.name}' in namespace '{metric.namespace}' is already registered." + f"A metric named '{metric.name}' already exists in the '{metric.namespace}' namespace" ) self._registry[registry_unique_key] = metric From 910209b5c5dde9aa83e59b5c9a124539db6865b1 Mon Sep 17 00:00:00 2001 From: Vittorio Polverino Date: Thu, 29 May 2025 12:40:31 +0200 Subject: [PATCH 3/7] test: add unit tests to validate namespace+name uniqueness in the metric registry --- tests/unit/utils/analytics/test_metrics.py | 68 ++++++++++++++++------ 1 file changed, 49 insertions(+), 19 deletions(-) diff --git a/tests/unit/utils/analytics/test_metrics.py b/tests/unit/utils/analytics/test_metrics.py index e1bacfc5dd07d..94212005134f3 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,7 +16,7 @@ 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() @@ -25,7 +26,7 @@ def test_counter_increment(): 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,7 +34,9 @@ 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() @@ -47,7 +50,9 @@ def test_labeled_counter_increment(): 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) @@ -65,23 +70,27 @@ def test_labeled_counter_reset(): 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"]), ( @@ -90,16 +99,19 @@ def test_metric_registry_register_and_collect(): 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): @@ -119,35 +131,53 @@ def increment(): 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) From f1db57dbca4b9786e5b4f12d2254aa30e679f543 Mon Sep 17 00:00:00 2001 From: Vittorio Polverino Date: Thu, 29 May 2025 15:58:33 +0200 Subject: [PATCH 4/7] feat: add CounterSnapshot and MetricsCollection classes to avoid complex datatypes in collect() functions --- .../localstack/utils/analytics/metrics.py | 133 ++++++++++++++---- 1 file changed, 105 insertions(+), 28 deletions(-) diff --git a/localstack-core/localstack/utils/analytics/metrics.py b/localstack-core/localstack/utils/analytics/metrics.py index 326074173faf1..8e6c8eb0b787f 100644 --- a/localstack-core/localstack/utils/analytics/metrics.py +++ b/localstack-core/localstack/utils/analytics/metrics.py @@ -6,7 +6,7 @@ from abc import ABC, abstractmethod from collections import defaultdict from dataclasses import dataclass -from typing import Dict, List, Optional, Tuple, Union, overload +from typing import Any, Optional, Union, overload from localstack import config from localstack.runtime import hooks @@ -23,6 +23,51 @@ class MetricRegistryKey: name: str +@dataclass(frozen=True) +class CounterSnapshot: + """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]: + """Convert to dictionary format expected by analytics backend.""" + 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 MetricsCollection: + """Container for collected metric snapshots.""" + + _snapshots: list[CounterSnapshot] # support for other metric types may be added in the future. + + @property + def snapshots(self) -> list[CounterSnapshot]: + return self._snapshots + + def __init__(self, snapshots: list[CounterSnapshot]): + self._snapshots = snapshots + + def as_dict(self) -> dict[str, list[dict[str, Any]]]: + return {"metrics": [snapshot.as_dict() for snapshot in self._snapshots]} + + class MetricRegistry: """ A Singleton class responsible for managing all registered metrics. @@ -46,7 +91,7 @@ def __init__(self): self._registry = dict() @property - def registry(self) -> Dict[MetricRegistryKey, "Metric"]: + def registry(self) -> dict[MetricRegistryKey, "Metric"]: return self._registry def register(self, metric: Metric) -> None: @@ -72,17 +117,17 @@ def register(self, metric: Metric) -> None: self._registry[registry_unique_key] = metric - def collect(self) -> Dict[str, List[Dict[str, Union[str, int]]]]: + def collect(self) -> MetricsCollection: """ Collects all registered metrics. """ - return { - "metrics": [ - metric - for metric_instance in self._registry.values() - for metric in metric_instance.collect() - ] - } + metric_snapshots = [ + metric + for metric_instance in self._registry.values() + for metric in metric_instance.collect() + ] + + return MetricsCollection(snapshots=metric_snapshots) class Metric(ABC): @@ -113,7 +158,9 @@ def name(self) -> str: return self._name @abstractmethod - def collect(self) -> List[Dict[str, Union[str, int]]]: + def collect( + self, + ) -> list[CounterSnapshot]: # 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. """ @@ -173,7 +220,7 @@ def __init__(self, namespace: str, name: str): self._type = "counter" MetricRegistry().register(self) - def collect(self) -> List[Dict[str, Union[str, int]]]: + def collect(self) -> list[CounterSnapshot]: """Collects the metric unless events are disabled.""" if config.DISABLE_EVENTS: return list() @@ -181,13 +228,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, - } + CounterSnapshot( + namespace=self._namespace, name=self.name, value=self._count, type=self._type + ) ] @@ -200,10 +245,10 @@ class LabeledCounterMetric(Metric): _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, namespace: str, name: str, labels: List[str]): + def __init__(self, namespace: str, name: str, labels: list[str]): super(LabeledCounterMetric, self).__init__(namespace=namespace, name=name) if not labels: @@ -238,7 +283,7 @@ 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]]]: + 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)] @@ -269,10 +314,40 @@ def _as_list(self) -> List[Dict[str, Union[str, int]]]: return collected_metrics - def collect(self) -> List[Dict[str, Union[str, int]]]: + def collect(self) -> list[CounterSnapshot]: if config.DISABLE_EVENTS: return list() - return self._as_list() + + metric_snapshots = [] + num_labels = len(self._labels) + + for label_values, counter in self._counters_by_label_values.items(): + if counter.count == 0: + continue # Skip items with a count of 0, as they should not be sent to the analytics backend. + + if len(label_values) != num_labels: + raise ValueError( + f"Label count mismatch: expected {num_labels} labels {self._labels}, " + f"but got {len(label_values)} values {label_values}." + ) + + # Create labels dictionary + labels_dict = { + label_name: label_value + for label_name, label_value in zip(self._labels, label_values) + } + + snapshot = CounterSnapshot( + namespace=self._namespace, + name=self.name, + value=counter.count, + type=self._type, + labels=labels_dict, + ) + metric_snapshots.append(snapshot) + + return metric_snapshots + # return self._as_list() class Counter: @@ -289,11 +364,11 @@ def __new__(cls, namespace: str, name: str) -> CounterMetric: return CounterMetric(namespace=namespace, name=name) @overload - def __new__(cls, namespace: str, name: str, labels: List[str]) -> LabeledCounterMetric: + def __new__(cls, namespace: str, name: str, labels: list[str]) -> LabeledCounterMetric: return LabeledCounterMetric(namespace=namespace, name=name, labels=labels) def __new__( - cls, namespace: str, name: 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) @@ -312,7 +387,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.snapshots: # Skip publishing if no metrics remain after filtering return metadata = EventMetadata( @@ -322,4 +397,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())] + ) From e5d9019cb36125a5ec01db295b07d485a39983a4 Mon Sep 17 00:00:00 2001 From: Vittorio Polverino Date: Thu, 29 May 2025 16:06:20 +0200 Subject: [PATCH 5/7] test: update tests following refactor with CounterSnapshot and MetricCollection in metrics module --- tests/unit/utils/analytics/test_metrics.py | 39 +++++++++++++--------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/tests/unit/utils/analytics/test_metrics.py b/tests/unit/utils/analytics/test_metrics.py index 94212005134f3..62deb9a422e25 100644 --- a/tests/unit/utils/analytics/test_metrics.py +++ b/tests/unit/utils/analytics/test_metrics.py @@ -20,7 +20,7 @@ def test_counter_increment(): 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']}" ) @@ -42,10 +42,13 @@ def test_labeled_counter_increment(): 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" @@ -60,12 +63,16 @@ 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" @@ -92,9 +99,9 @@ def test_metric_registry_register_and_collect(): "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.snapshots), ( + f"Unexpected collected metrics: {collected_metrics}" ) @@ -123,9 +130,9 @@ 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}" ) @@ -184,8 +191,10 @@ def test_label_kwargs_order_independent(): 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" From eb0a5d72201a90324afaaa96314cfc5eda520e60 Mon Sep 17 00:00:00 2001 From: Vittorio Polverino Date: Fri, 30 May 2025 15:33:08 +0200 Subject: [PATCH 6/7] refactor: rename dataclass --- .../localstack/utils/analytics/metrics.py | 89 +++++++------------ 1 file changed, 30 insertions(+), 59 deletions(-) diff --git a/localstack-core/localstack/utils/analytics/metrics.py b/localstack-core/localstack/utils/analytics/metrics.py index 8e6c8eb0b787f..e0fa8d47048d9 100644 --- a/localstack-core/localstack/utils/analytics/metrics.py +++ b/localstack-core/localstack/utils/analytics/metrics.py @@ -24,7 +24,7 @@ class MetricRegistryKey: @dataclass(frozen=True) -class CounterSnapshot: +class CounterPayload: """An immutable snapshot of a counter metric at the time of collection.""" namespace: str @@ -34,7 +34,6 @@ class CounterSnapshot: labels: Optional[dict[str, Union[str, float]]] = None def as_dict(self) -> dict[str, Any]: - """Convert to dictionary format expected by analytics backend.""" result = { "namespace": self.namespace, "name": self.name, @@ -52,20 +51,23 @@ def as_dict(self) -> dict[str, Any]: @dataclass -class MetricsCollection: - """Container for collected metric snapshots.""" +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. + """ - _snapshots: list[CounterSnapshot] # support for other metric types may be added in the future. + _payload: list[CounterPayload] # support for other metric types may be added in the future. @property - def snapshots(self) -> list[CounterSnapshot]: - return self._snapshots + def payload(self) -> list[CounterPayload]: + return self._payload - def __init__(self, snapshots: list[CounterSnapshot]): - self._snapshots = snapshots + def __init__(self, payload: list[CounterPayload]): + self._payload = payload def as_dict(self) -> dict[str, list[dict[str, Any]]]: - return {"metrics": [snapshot.as_dict() for snapshot in self._snapshots]} + return {"metrics": [payload.as_dict() for payload in self._payload]} class MetricRegistry: @@ -117,17 +119,17 @@ def register(self, metric: Metric) -> None: self._registry[registry_unique_key] = metric - def collect(self) -> MetricsCollection: + def collect(self) -> MetricPayload: """ Collects all registered metrics. """ - metric_snapshots = [ + payload = [ metric for metric_instance in self._registry.values() for metric in metric_instance.collect() ] - return MetricsCollection(snapshots=metric_snapshots) + return MetricPayload(payload=payload) class Metric(ABC): @@ -160,7 +162,7 @@ def name(self) -> str: @abstractmethod def collect( self, - ) -> list[CounterSnapshot]: # support for other metric types may be added in the future. + ) -> 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. """ @@ -220,7 +222,7 @@ def __init__(self, namespace: str, name: str): self._type = "counter" MetricRegistry().register(self) - def collect(self) -> list[CounterSnapshot]: + def collect(self) -> list[CounterPayload]: """Collects the metric unless events are disabled.""" if config.DISABLE_EVENTS: return list() @@ -230,7 +232,7 @@ def collect(self) -> list[CounterSnapshot]: return list() return [ - CounterSnapshot( + CounterPayload( namespace=self._namespace, name=self.name, value=self._count, type=self._type ) ] @@ -283,42 +285,11 @@ 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)] - - collected_metrics = [] - - for label_values, counter in self._counters_by_label_values.items(): - if counter.count == 0: - continue # Skip items with a count of 0, as they should not be sent to the analytics backend. - - if len(label_values) != num_labels: - raise ValueError( - f"Label count mismatch: expected {num_labels} labels {self._labels}, " - 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)), - } - ) - - return collected_metrics - - def collect(self) -> list[CounterSnapshot]: + def collect(self) -> list[CounterPayload]: if config.DISABLE_EVENTS: return list() - metric_snapshots = [] + payload = [] num_labels = len(self._labels) for label_values, counter in self._counters_by_label_values.items(): @@ -337,17 +308,17 @@ def collect(self) -> list[CounterSnapshot]: for label_name, label_value in zip(self._labels, label_values) } - snapshot = CounterSnapshot( - namespace=self._namespace, - name=self.name, - value=counter.count, - type=self._type, - labels=labels_dict, + payload.append( + CounterPayload( + namespace=self._namespace, + name=self.name, + value=counter.count, + type=self._type, + labels=labels_dict, + ) ) - metric_snapshots.append(snapshot) - return metric_snapshots - # return self._as_list() + return payload class Counter: @@ -387,7 +358,7 @@ def publish_metrics() -> None: return collected_metrics = MetricRegistry().collect() - if not collected_metrics.snapshots: # Skip publishing if no metrics remain after filtering + if not collected_metrics.payload: # Skip publishing if no metrics remain after filtering return metadata = EventMetadata( From e8ebc9db28894af0128db58c7e1913e7573d73d0 Mon Sep 17 00:00:00 2001 From: Vittorio Polverino Date: Fri, 30 May 2025 15:34:35 +0200 Subject: [PATCH 7/7] refactor: rename class attribute --- tests/unit/utils/analytics/test_metrics.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/utils/analytics/test_metrics.py b/tests/unit/utils/analytics/test_metrics.py index 62deb9a422e25..bad18c47657a0 100644 --- a/tests/unit/utils/analytics/test_metrics.py +++ b/tests/unit/utils/analytics/test_metrics.py @@ -100,7 +100,7 @@ def test_metric_registry_register_and_collect(): ) counter.increment(value=7) collected_metrics = registry.collect() - assert any(metric.value == 7 for metric in collected_metrics.snapshots), ( + assert any(metric.value == 7 for metric in collected_metrics.payload), ( f"Unexpected collected metrics: {collected_metrics}" )