From 6fddafa40505933a525ef9bd95d0812f1c6695b7 Mon Sep 17 00:00:00 2001 From: Vittorio Polverino Date: Thu, 5 Jun 2025 00:51:47 +0200 Subject: [PATCH 01/18] refactor: move and rename the base counter class in counter.py module --- .../localstack/utils/analytics/metrics.py | 373 ------------------ .../utils/analytics/metrics/counter.py | 41 ++ 2 files changed, 41 insertions(+), 373 deletions(-) delete mode 100644 localstack-core/localstack/utils/analytics/metrics.py create mode 100644 localstack-core/localstack/utils/analytics/metrics/counter.py diff --git a/localstack-core/localstack/utils/analytics/metrics.py b/localstack-core/localstack/utils/analytics/metrics.py deleted file mode 100644 index 87a52e593547e..0000000000000 --- a/localstack-core/localstack/utils/analytics/metrics.py +++ /dev/null @@ -1,373 +0,0 @@ -from __future__ import annotations - -import datetime -import logging -import threading -from abc import ABC, abstractmethod -from collections import defaultdict -from dataclasses import dataclass -from typing import Any, Optional, Union, overload - -from localstack import config -from localstack.runtime import hooks -from localstack.utils.analytics import get_session_id -from localstack.utils.analytics.events import Event, EventMetadata -from localstack.utils.analytics.publisher import AnalyticsClientPublisher - -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. - Provides methods for retrieving and collecting metrics. - """ - - _instance: "MetricRegistry" = None - _mutex: threading.Lock = threading.Lock() - - def __new__(cls): - # avoid locking if the instance already exist - if cls._instance is None: - with cls._mutex: - # Prevents race conditions when multiple threads enter the first check simultaneously - if cls._instance is None: - cls._instance = super().__new__(cls) - return cls._instance - - def __init__(self): - if not hasattr(self, "_registry"): - self._registry = dict() - - @property - def registry(self) -> dict[MetricRegistryKey, "Metric"]: - return self._registry - - def register(self, metric: Metric) -> None: - """ - Registers a new metric. - - :param metric: The metric instance to register. - :type metric: Metric - :raises TypeError: If the provided metric is not an instance of `Metric`. - :raises ValueError: If a metric with the same name already exists. - """ - if not isinstance(metric, Metric): - raise TypeError("Only subclasses of `Metric` can be registered.") - - if not metric.namespace: - raise ValueError("Metric 'namespace' must be defined and non-empty.") - - 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" - ) - - self._registry[registry_unique_key] = metric - - def collect(self) -> MetricPayload: - """ - Collects all registered metrics. - """ - payload = [ - metric - for metric_instance in self._registry.values() - for metric in metric_instance.collect() - ] - - return MetricPayload(payload=payload) - - -class Metric(ABC): - """ - Base class for all metrics (e.g., Counter, Gauge). - - Each subclass must implement the `collect()` method. - """ - - _namespace: str - _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[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. - """ - pass - - -class BaseCounter: - """ - A thread-safe counter for any kind of tracking. - This class should not be instantiated directly, use the Counter class instead. - """ - - _mutex: threading.Lock - _count: int - - def __init__(self): - super(BaseCounter, self).__init__() - self._mutex = threading.Lock() - self._count = 0 - - @property - def count(self) -> int: - return self._count - - def increment(self, value: int = 1) -> None: - """Increments the counter unless events are disabled.""" - if config.DISABLE_EVENTS: - return - - if value <= 0: - raise ValueError("Increment value must be positive.") - - with self._mutex: - self._count += value - - def reset(self) -> None: - """Resets the counter to zero unless events are disabled.""" - if config.DISABLE_EVENTS: - return - - with self._mutex: - self._count = 0 - - -class CounterMetric(Metric, BaseCounter): - """ - A thread-safe counter for tracking occurrences of an event without labels. - This class should not be instantiated directly, use the Counter class instead. - """ - - _type: str - - def __init__(self, namespace: str, name: str): - Metric.__init__(self, namespace=namespace, name=name) - BaseCounter.__init__(self) - - self._type = "counter" - MetricRegistry().register(self) - - def collect(self) -> list[CounterPayload]: - """Collects the metric unless events are disabled.""" - if config.DISABLE_EVENTS: - return list() - - 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 [ - CounterPayload( - namespace=self._namespace, name=self.name, value=self._count, type=self._type - ) - ] - - -class LabeledCounterMetric(Metric): - """ - A labeled counter that tracks occurrences of an event across different label combinations. - This class should not be instantiated directly, use the Counter class instead. - """ - - _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, 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.") - - if any(not label for label in labels): - raise ValueError("Labels must be non-empty strings.") - - if len(labels) > 6: - raise ValueError("Too many labels: counters allow a maximum of 6.") - - self._type = "counter" - self._labels = labels - self._counters_by_label_values = defaultdict(BaseCounter) - MetricRegistry().register(self) - - def labels(self, **kwargs: Union[str, float, None]) -> BaseCounter: - """ - Create a scoped counter instance with specific label values. - - This method assigns values to the predefined labels of a labeled counter and returns - a BaseCounter object that allows tracking metrics for that specific - combination of label values. - - :raises ValueError: - - If the set of keys provided labels does not match the expected set of labels. - """ - if set(self._labels) != set(kwargs.keys()): - raise ValueError(f"Expected labels {self._labels}, got {list(kwargs.keys())}") - - _label_values = tuple(kwargs[label] for label in self._labels) - - return self._counters_by_label_values[_label_values] - - def collect(self) -> list[CounterPayload]: - if config.DISABLE_EVENTS: - return list() - - payload = [] - 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) - } - - payload.append( - CounterPayload( - namespace=self._namespace, - name=self.name, - value=counter.count, - type=self._type, - labels=labels_dict, - ) - ) - - return payload - - -class Counter: - """ - A factory class for creating counter instances. - - This class provides a flexible way to create either a simple counter - (`CounterMetric`) or a labeled counter (`LabeledCounterMetric`) based on - whether labels are provided. - """ - - @overload - 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: - return LabeledCounterMetric(namespace=namespace, name=name, labels=labels) - - def __new__( - 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) - return CounterMetric(namespace=namespace, name=name) - - -@hooks.on_infra_shutdown() -def publish_metrics() -> None: - """ - Collects all the registered metrics and immediately sends them to the analytics service. - Skips execution if event tracking is disabled (`config.DISABLE_EVENTS`). - - This function is automatically triggered on infrastructure shutdown. - """ - if config.DISABLE_EVENTS: - return - - collected_metrics = MetricRegistry().collect() - if not collected_metrics.payload: # Skip publishing if no metrics remain after filtering - return - - metadata = EventMetadata( - session_id=get_session_id(), - client_time=str(datetime.datetime.now()), - ) - - if collected_metrics: - publisher = AnalyticsClientPublisher() - publisher.publish( - [Event(name="ls_metrics", metadata=metadata, payload=collected_metrics.as_dict())] - ) diff --git a/localstack-core/localstack/utils/analytics/metrics/counter.py b/localstack-core/localstack/utils/analytics/metrics/counter.py new file mode 100644 index 0000000000000..91e4a5fad3efa --- /dev/null +++ b/localstack-core/localstack/utils/analytics/metrics/counter.py @@ -0,0 +1,41 @@ +import threading + +from localstack import config + + +class ThreadSafeCounter: + """ + A thread-safe counter for any kind of tracking. + This class should not be instantiated directly, use the 'Counter; factory instead. + """ + + _mutex: threading.Lock + _count: int + + def __init__(self): + super(ThreadSafeCounter, self).__init__() + self._mutex = threading.Lock() + self._count = 0 + + @property + def count(self) -> int: + return self._count + + def increment(self, value: int = 1) -> None: + """Increments the counter unless events are disabled.""" + if config.DISABLE_EVENTS: + return + + if value <= 0: + raise ValueError("Increment value must be positive.") + + with self._mutex: + self._count += value + + def reset(self) -> None: + """Resets the counter to zero unless events are disabled.""" + if config.DISABLE_EVENTS: + return + + with self._mutex: + self._count = 0 From 06ea75e7ba25576d5c44d90845dead083599f075 Mon Sep 17 00:00:00 2001 From: Vittorio Polverino Date: Thu, 5 Jun 2025 00:52:45 +0200 Subject: [PATCH 02/18] refactor: move and the Counter factory class in factory.py module --- .../utils/analytics/metrics/factory.py | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 localstack-core/localstack/utils/analytics/metrics/factory.py diff --git a/localstack-core/localstack/utils/analytics/metrics/factory.py b/localstack-core/localstack/utils/analytics/metrics/factory.py new file mode 100644 index 0000000000000..b20b71e2ca359 --- /dev/null +++ b/localstack-core/localstack/utils/analytics/metrics/factory.py @@ -0,0 +1,21 @@ +from localstack.utils.analytics.metrics.counter_metric import _CounterMetric, _LabeledCounterMetric +from localstack.utils.analytics.metrics.interfaces import CounterMetric, LabeledCounterMetric + + +class Counter: + """ + A factory class for counter metrics. + Use `Counter.base(...)` for base counters, or `Counter.with_labels(...)` for labeled counters. + """ + + @staticmethod + def base(namespace: str, name: str) -> CounterMetric: + return _CounterMetric(namespace=namespace, name=name) + + @staticmethod + def with_labels( + namespace: str, name: str, schema_version: int, labels: list[str] + ) -> LabeledCounterMetric: + return _LabeledCounterMetric( + namespace=namespace, name=name, schema_version=schema_version, labels=labels + ) From 2deda13f01d2a3a1304192164ba573a3803729e5 Mon Sep 17 00:00:00 2001 From: Vittorio Polverino Date: Thu, 5 Jun 2025 00:53:36 +0200 Subject: [PATCH 03/18] refactor: move publish_metrics in hooks.py module --- .../utils/analytics/metrics/hooks.py | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 localstack-core/localstack/utils/analytics/metrics/hooks.py diff --git a/localstack-core/localstack/utils/analytics/metrics/hooks.py b/localstack-core/localstack/utils/analytics/metrics/hooks.py new file mode 100644 index 0000000000000..62bac517b5b27 --- /dev/null +++ b/localstack-core/localstack/utils/analytics/metrics/hooks.py @@ -0,0 +1,35 @@ +from datetime import datetime + +from localstack import config +from localstack.runtime import hooks +from localstack.utils.analytics import get_session_id +from localstack.utils.analytics.events import Event, EventMetadata +from localstack.utils.analytics.metrics import MetricRegistry +from localstack.utils.analytics.publisher import AnalyticsClientPublisher + + +@hooks.on_infra_shutdown() +def publish_metrics() -> None: + """ + Collects all the registered metrics and immediately sends them to the analytics service. + Skips execution if event tracking is disabled (`config.DISABLE_EVENTS`). + + This function is automatically triggered on infrastructure shutdown. + """ + if config.DISABLE_EVENTS: + return + + collected_metrics = MetricRegistry().collect() + if not collected_metrics.payload: # Skip publishing if no metrics remain after filtering + return + + metadata = EventMetadata( + session_id=get_session_id(), + client_time=str(datetime.now()), + ) + + if collected_metrics: + publisher = AnalyticsClientPublisher() + publisher.publish( + [Event(name="ls_metrics", metadata=metadata, payload=collected_metrics.as_dict())] + ) From 859c27919e431a968f7ce30f06060d013e404050 Mon Sep 17 00:00:00 2001 From: Vittorio Polverino Date: Thu, 5 Jun 2025 00:54:16 +0200 Subject: [PATCH 04/18] refactor: move abstract classes in interfaces.py module --- .../utils/analytics/metrics/interfaces.py | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 localstack-core/localstack/utils/analytics/metrics/interfaces.py diff --git a/localstack-core/localstack/utils/analytics/metrics/interfaces.py b/localstack-core/localstack/utils/analytics/metrics/interfaces.py new file mode 100644 index 0000000000000..4a172cba28bc4 --- /dev/null +++ b/localstack-core/localstack/utils/analytics/metrics/interfaces.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Union + +from localstack.utils.analytics.metrics.counter import ThreadSafeCounter +from localstack.utils.analytics.metrics.types import CounterPayload + + +class Metric(ABC): + """ + Base class for all metrics (e.g., Counter, Gauge). + + Each subclass must implement the `collect()` method. + """ + + _namespace: str + _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[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. + """ + pass + + +class CounterMetric(ABC): + """ + Abstract base class for counter metrics. + Defines the interface that all counter implementations must follow. + """ + + @abstractmethod + def increment(self, value: int = 1) -> None: + """Increment the counter by the specified value.""" + pass + + @abstractmethod + def reset(self) -> None: + """Reset the counter to zero.""" + pass + + @abstractmethod + def collect(self) -> list[CounterPayload]: + """Collect and return the current metric data.""" + pass + + @property + @abstractmethod + def count(self) -> int: + """Get the current count value.""" + pass + + +class LabeledCounterMetric(ABC): + """ + Abstract base class for labeled counter metrics. + Defines the interface for counters that support labels. + """ + + @abstractmethod + def collect(self) -> list[CounterPayload]: + """Collect and return the current metric data.""" + pass + + @abstractmethod + def labels(self, **kwargs: Union[str, float, None]) -> ThreadSafeCounter: + """Get a counter instance for specific label values.""" + pass From 5064c946ff46ffb4448841abe81608e0cc5710f3 Mon Sep 17 00:00:00 2001 From: Vittorio Polverino Date: Thu, 5 Jun 2025 00:55:09 +0200 Subject: [PATCH 05/18] refactor: move MetricRegistry in registry.py module --- .../utils/analytics/metrics/registry.py | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 localstack-core/localstack/utils/analytics/metrics/registry.py diff --git a/localstack-core/localstack/utils/analytics/metrics/registry.py b/localstack-core/localstack/utils/analytics/metrics/registry.py new file mode 100644 index 0000000000000..f86d34617973d --- /dev/null +++ b/localstack-core/localstack/utils/analytics/metrics/registry.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import logging +import threading + +from .interfaces import Metric +from .types import MetricPayload, MetricRegistryKey + +LOG = logging.getLogger(__name__) + + +class MetricRegistry: + """ + A Singleton class responsible for managing all registered metrics. + Provides methods for retrieving and collecting metrics. + """ + + _instance: "MetricRegistry" = None + _mutex: threading.Lock = threading.Lock() + + def __new__(cls): + # avoid locking if the instance already exist + if cls._instance is None: + with cls._mutex: + # Prevents race conditions when multiple threads enter the first check simultaneously + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __init__(self): + if not hasattr(self, "_registry"): + self._registry = dict() + + @property + def registry(self) -> dict[MetricRegistryKey, Metric]: + return self._registry + + def register(self, metric: Metric) -> None: + """ + Registers a new metric. + + :param metric: The metric instance to register. + :type metric: Metric + :raises TypeError: If the provided metric is not an instance of `Metric`. + :raises ValueError: If a metric with the same name already exists. + """ + if not isinstance(metric, Metric): + raise TypeError("Only subclasses of `Metric` can be registered.") + + if not metric.namespace: + raise ValueError("Metric 'namespace' must be defined and non-empty.") + + 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" + ) + + self._registry[registry_unique_key] = metric + + def collect(self) -> MetricPayload: + """ + Collects all registered metrics. + """ + payload = [ + metric + for metric_instance in self._registry.values() + for metric in metric_instance.collect() + ] + + return MetricPayload(payload=payload) From b241a637c6e72a1ab427e4240de57caa1168284a Mon Sep 17 00:00:00 2001 From: Vittorio Polverino Date: Thu, 5 Jun 2025 00:55:52 +0200 Subject: [PATCH 06/18] refactor: move dataclasses/payloads in types.py module --- .../utils/analytics/metrics/types.py | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 localstack-core/localstack/utils/analytics/metrics/types.py diff --git a/localstack-core/localstack/utils/analytics/metrics/types.py b/localstack-core/localstack/utils/analytics/metrics/types.py new file mode 100644 index 0000000000000..fffc54a1a7f03 --- /dev/null +++ b/localstack-core/localstack/utils/analytics/metrics/types.py @@ -0,0 +1,57 @@ +from dataclasses import dataclass +from typing import Any, Optional, Union + + +@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 + schema_version: int + 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, + "schema_version": self.schema_version, + } + + 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]} From 75de035e49291fe17f28919ea3854573ee8683c4 Mon Sep 17 00:00:00 2001 From: Vittorio Polverino Date: Thu, 5 Jun 2025 00:57:59 +0200 Subject: [PATCH 07/18] refactor: organize public export in __init__.py --- .../utils/analytics/metrics/__init__.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 localstack-core/localstack/utils/analytics/metrics/__init__.py diff --git a/localstack-core/localstack/utils/analytics/metrics/__init__.py b/localstack-core/localstack/utils/analytics/metrics/__init__.py new file mode 100644 index 0000000000000..848d9d27ac996 --- /dev/null +++ b/localstack-core/localstack/utils/analytics/metrics/__init__.py @@ -0,0 +1,15 @@ +"""LocalStack metrics instrumentation framework""" + +from .factory import Counter +from .interfaces import CounterMetric, LabeledCounterMetric +from .registry import MetricRegistry +from .types import CounterPayload, MetricPayload + +__all__ = [ + "Counter", + "CounterMetric", + "LabeledCounterMetric", + "MetricRegistry", + "CounterPayload", + "MetricPayload", +] From 1267c95b3986399972c3fb405f62b531ede2d3d9 Mon Sep 17 00:00:00 2001 From: Vittorio Polverino Date: Thu, 5 Jun 2025 11:57:40 +0200 Subject: [PATCH 08/18] chore: rename modules --- .../stepfunctions/{usage.py => analytics.py} | 4 ++-- .../utils/analytics/metrics/factory.py | 21 ------------------- .../analytics/metrics/{hooks.py => hook.py} | 0 .../metrics/{interfaces.py => interface.py} | 0 .../analytics/metrics/{types.py => type.py} | 0 5 files changed, 2 insertions(+), 23 deletions(-) rename localstack-core/localstack/services/stepfunctions/{usage.py => analytics.py} (70%) delete mode 100644 localstack-core/localstack/utils/analytics/metrics/factory.py rename localstack-core/localstack/utils/analytics/metrics/{hooks.py => hook.py} (100%) rename localstack-core/localstack/utils/analytics/metrics/{interfaces.py => interface.py} (100%) rename localstack-core/localstack/utils/analytics/metrics/{types.py => type.py} (100%) diff --git a/localstack-core/localstack/services/stepfunctions/usage.py b/localstack-core/localstack/services/stepfunctions/analytics.py similarity index 70% rename from localstack-core/localstack/services/stepfunctions/usage.py rename to localstack-core/localstack/services/stepfunctions/analytics.py index 63c5c90411b40..c96b2c140af13 100644 --- a/localstack-core/localstack/services/stepfunctions/usage.py +++ b/localstack-core/localstack/services/stepfunctions/analytics.py @@ -2,10 +2,10 @@ Usage reporting for StepFunctions service """ -from localstack.utils.analytics.metrics import Counter +from localstack.utils.analytics.metrics import LabeledCounter # Initialize a counter to record the usage of language features for each state machine. -language_features_counter = Counter( +language_features_counter = LabeledCounter( namespace="stepfunctions", name="language_features_used", labels=["query_language", "uses_variables"], diff --git a/localstack-core/localstack/utils/analytics/metrics/factory.py b/localstack-core/localstack/utils/analytics/metrics/factory.py deleted file mode 100644 index b20b71e2ca359..0000000000000 --- a/localstack-core/localstack/utils/analytics/metrics/factory.py +++ /dev/null @@ -1,21 +0,0 @@ -from localstack.utils.analytics.metrics.counter_metric import _CounterMetric, _LabeledCounterMetric -from localstack.utils.analytics.metrics.interfaces import CounterMetric, LabeledCounterMetric - - -class Counter: - """ - A factory class for counter metrics. - Use `Counter.base(...)` for base counters, or `Counter.with_labels(...)` for labeled counters. - """ - - @staticmethod - def base(namespace: str, name: str) -> CounterMetric: - return _CounterMetric(namespace=namespace, name=name) - - @staticmethod - def with_labels( - namespace: str, name: str, schema_version: int, labels: list[str] - ) -> LabeledCounterMetric: - return _LabeledCounterMetric( - namespace=namespace, name=name, schema_version=schema_version, labels=labels - ) diff --git a/localstack-core/localstack/utils/analytics/metrics/hooks.py b/localstack-core/localstack/utils/analytics/metrics/hook.py similarity index 100% rename from localstack-core/localstack/utils/analytics/metrics/hooks.py rename to localstack-core/localstack/utils/analytics/metrics/hook.py diff --git a/localstack-core/localstack/utils/analytics/metrics/interfaces.py b/localstack-core/localstack/utils/analytics/metrics/interface.py similarity index 100% rename from localstack-core/localstack/utils/analytics/metrics/interfaces.py rename to localstack-core/localstack/utils/analytics/metrics/interface.py diff --git a/localstack-core/localstack/utils/analytics/metrics/types.py b/localstack-core/localstack/utils/analytics/metrics/type.py similarity index 100% rename from localstack-core/localstack/utils/analytics/metrics/types.py rename to localstack-core/localstack/utils/analytics/metrics/type.py From 3f2c2d680181170287c8085a259a8c04d2b383a5 Mon Sep 17 00:00:00 2001 From: Vittorio Polverino Date: Thu, 5 Jun 2025 11:58:54 +0200 Subject: [PATCH 09/18] refactor: remove factory class and interfaces for counter and labeled counter --- .../utils/analytics/metrics/__init__.py | 9 +- .../utils/analytics/metrics/counter.py | 127 +++++++++++++++++- .../utils/analytics/metrics/hook.py | 3 +- .../utils/analytics/metrics/interface.py | 53 +------- .../utils/analytics/metrics/registry.py | 12 +- .../utils/analytics/metrics/type.py | 49 ++++--- 6 files changed, 171 insertions(+), 82 deletions(-) diff --git a/localstack-core/localstack/utils/analytics/metrics/__init__.py b/localstack-core/localstack/utils/analytics/metrics/__init__.py index 848d9d27ac996..442e4deee2d70 100644 --- a/localstack-core/localstack/utils/analytics/metrics/__init__.py +++ b/localstack-core/localstack/utils/analytics/metrics/__init__.py @@ -1,15 +1,14 @@ """LocalStack metrics instrumentation framework""" -from .factory import Counter -from .interfaces import CounterMetric, LabeledCounterMetric +from .counter import Counter, LabeledCounter from .registry import MetricRegistry -from .types import CounterPayload, MetricPayload +from .type import CounterPayload, MetricPayload, MetricRegistryKey __all__ = [ "Counter", - "CounterMetric", - "LabeledCounterMetric", + "LabeledCounter", "MetricRegistry", "CounterPayload", "MetricPayload", + "MetricRegistryKey", ] diff --git a/localstack-core/localstack/utils/analytics/metrics/counter.py b/localstack-core/localstack/utils/analytics/metrics/counter.py index 91e4a5fad3efa..6341761232951 100644 --- a/localstack-core/localstack/utils/analytics/metrics/counter.py +++ b/localstack-core/localstack/utils/analytics/metrics/counter.py @@ -1,12 +1,18 @@ import threading +from collections import defaultdict +from typing import Optional, Union from localstack import config +from .interface import Metric +from .registry import MetricRegistry +from .type import CounterPayload, LabeledCounterPayload + class ThreadSafeCounter: """ A thread-safe counter for any kind of tracking. - This class should not be instantiated directly, use the 'Counter; factory instead. + This class should not be instantiated directly, use Counter or LabeledCounter instead. """ _mutex: threading.Lock @@ -39,3 +45,122 @@ def reset(self) -> None: with self._mutex: self._count = 0 + + +class Counter(Metric, ThreadSafeCounter): + """ + A thread-safe, unlabeled counter for tracking the total number of occurrences of a specific event. + This class is intended for metrics that do not require differentiation across dimensions. + For use cases where metrics need to be grouped or segmented by labels, use `LabeledCounter` instead. + """ + + _type: str + + def __init__(self, namespace: str, name: str): + Metric.__init__(self, namespace=namespace, name=name) + ThreadSafeCounter.__init__(self) + + self._type = "counter" + + MetricRegistry().register(self) + + def collect(self) -> list[CounterPayload]: + """Collects the metric unless events are disabled.""" + if config.DISABLE_EVENTS: + return list() + + 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 [ + CounterPayload( + namespace=self._namespace, name=self.name, value=self._count, type=self._type + ) + ] + + +class LabeledCounter(Metric): + """ + A thread-safe counter for tracking occurrences of an event across multiple combinations of label values. + It enables fine-grained metric collection and analysis, with each unique label set stored and counted independently. + Use this class when you need dimensional insights into event occurrences. + For simpler, unlabeled use cases, see the `Counter` class. + """ + + _type: str + _labels: list[str] + _label_values: tuple[Optional[Union[str, float]], ...] + _counters_by_label_values: defaultdict[ + tuple[Optional[Union[str, float]], ...], ThreadSafeCounter + ] + + def __init__(self, namespace: str, name: str, labels: list[str]): + super(LabeledCounter, self).__init__(namespace=namespace, name=name) + + if not labels: + raise ValueError("At least one label is required; the labels list cannot be empty.") + + if any(not label for label in labels): + raise ValueError("Labels must be non-empty strings.") + + if len(labels) > 6: + raise ValueError("Too many labels: counters allow a maximum of 6.") + + self._type = "counter" + self._labels = labels + self._counters_by_label_values = defaultdict(ThreadSafeCounter) + MetricRegistry().register(self) + + def labels(self, **kwargs: Union[str, float, None]) -> ThreadSafeCounter: + """ + Create a scoped counter instance with specific label values. + + This method assigns values to the predefined labels of a labeled counter and returns + a ThreadSafeCounter object that allows tracking metrics for that specific + combination of label values. + + :raises ValueError: + - If the set of keys provided labels does not match the expected set of labels. + """ + if set(self._labels) != set(kwargs.keys()): + raise ValueError(f"Expected labels {self._labels}, got {list(kwargs.keys())}") + + _label_values = tuple(kwargs[label] for label in self._labels) + + return self._counters_by_label_values[_label_values] + + def collect(self) -> list[LabeledCounterPayload]: + if config.DISABLE_EVENTS: + return list() + + payload = [] + 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) + } + + payload.append( + LabeledCounterPayload( + namespace=self._namespace, + name=self.name, + value=counter.count, + type=self._type, + labels=labels_dict, + ) + ) + + return payload diff --git a/localstack-core/localstack/utils/analytics/metrics/hook.py b/localstack-core/localstack/utils/analytics/metrics/hook.py index 62bac517b5b27..52639fbc80e93 100644 --- a/localstack-core/localstack/utils/analytics/metrics/hook.py +++ b/localstack-core/localstack/utils/analytics/metrics/hook.py @@ -4,9 +4,10 @@ from localstack.runtime import hooks from localstack.utils.analytics import get_session_id from localstack.utils.analytics.events import Event, EventMetadata -from localstack.utils.analytics.metrics import MetricRegistry from localstack.utils.analytics.publisher import AnalyticsClientPublisher +from .registry import MetricRegistry + @hooks.on_infra_shutdown() def publish_metrics() -> None: diff --git a/localstack-core/localstack/utils/analytics/metrics/interface.py b/localstack-core/localstack/utils/analytics/metrics/interface.py index 4a172cba28bc4..fa7a182e9d4f2 100644 --- a/localstack-core/localstack/utils/analytics/metrics/interface.py +++ b/localstack-core/localstack/utils/analytics/metrics/interface.py @@ -3,14 +3,12 @@ from abc import ABC, abstractmethod from typing import Union -from localstack.utils.analytics.metrics.counter import ThreadSafeCounter -from localstack.utils.analytics.metrics.types import CounterPayload +from .type import CounterPayload, LabeledCounterPayload class Metric(ABC): """ Base class for all metrics (e.g., Counter, Gauge). - Each subclass must implement the `collect()` method. """ @@ -35,55 +33,8 @@ def name(self) -> str: return self._name @abstractmethod - def collect( - self, - ) -> list[CounterPayload]: # support for other metric types may be added in the future. + def collect(self) -> list[Union[CounterPayload, LabeledCounterPayload]]: """ Collects and returns metric data. Subclasses must implement this to return collected metric data. """ pass - - -class CounterMetric(ABC): - """ - Abstract base class for counter metrics. - Defines the interface that all counter implementations must follow. - """ - - @abstractmethod - def increment(self, value: int = 1) -> None: - """Increment the counter by the specified value.""" - pass - - @abstractmethod - def reset(self) -> None: - """Reset the counter to zero.""" - pass - - @abstractmethod - def collect(self) -> list[CounterPayload]: - """Collect and return the current metric data.""" - pass - - @property - @abstractmethod - def count(self) -> int: - """Get the current count value.""" - pass - - -class LabeledCounterMetric(ABC): - """ - Abstract base class for labeled counter metrics. - Defines the interface for counters that support labels. - """ - - @abstractmethod - def collect(self) -> list[CounterPayload]: - """Collect and return the current metric data.""" - pass - - @abstractmethod - def labels(self, **kwargs: Union[str, float, None]) -> ThreadSafeCounter: - """Get a counter instance for specific label values.""" - pass diff --git a/localstack-core/localstack/utils/analytics/metrics/registry.py b/localstack-core/localstack/utils/analytics/metrics/registry.py index f86d34617973d..b3e32e2d1bd8b 100644 --- a/localstack-core/localstack/utils/analytics/metrics/registry.py +++ b/localstack-core/localstack/utils/analytics/metrics/registry.py @@ -3,8 +3,8 @@ import logging import threading -from .interfaces import Metric -from .types import MetricPayload, MetricRegistryKey +from .interface import Metric +from .type import MetricPayload, MetricRegistryKey LOG = logging.getLogger(__name__) @@ -37,12 +37,10 @@ def registry(self) -> dict[MetricRegistryKey, Metric]: def register(self, metric: Metric) -> None: """ - Registers a new metric. + Registers a metric instance. - :param metric: The metric instance to register. - :type metric: Metric - :raises TypeError: If the provided metric is not an instance of `Metric`. - :raises ValueError: If a metric with the same name already exists. + Raises a TypeError if the object is not a Metric, + or a ValueError if a metric with the same namespace and name is already registered """ if not isinstance(metric, Metric): raise TypeError("Only subclasses of `Metric` can be registered.") diff --git a/localstack-core/localstack/utils/analytics/metrics/type.py b/localstack-core/localstack/utils/analytics/metrics/type.py index fffc54a1a7f03..612aad3318e9d 100644 --- a/localstack-core/localstack/utils/analytics/metrics/type.py +++ b/localstack-core/localstack/utils/analytics/metrics/type.py @@ -1,56 +1,71 @@ from dataclasses import dataclass -from typing import Any, Optional, Union +from typing import Any, Union @dataclass(frozen=True) class MetricRegistryKey: + """A unique identifier for a metric, composed of namespace and name.""" + namespace: str name: str @dataclass(frozen=True) class CounterPayload: - """An immutable snapshot of a counter metric at the time of collection.""" + """A data object storing the value of a Counter metric.""" + + namespace: str + name: str + value: int + type: str + + def as_dict(self) -> dict[str, Any]: + return { + "namespace": self.namespace, + "name": self.name, + "value": self.value, + "type": self.type, + } + + +@dataclass(frozen=True) +class LabeledCounterPayload: + """A data object storing the value of a LabeledCounter metric.""" namespace: str name: str value: int type: str - schema_version: int - labels: Optional[dict[str, Union[str, float]]] = None + labels: dict[str, Union[str, float]] def as_dict(self) -> dict[str, Any]: - result = { + dict = { "namespace": self.namespace, "name": self.name, "value": self.value, "type": self.type, - "schema_version": self.schema_version, } - 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 + for i, (label_name, label_value) in enumerate(self.labels.items(), 1): + dict[f"label_{i}"] = label_name + dict[f"label_{i}_value"] = label_value - return result + return dict @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. + A data object storing the value of all metrics collected during the execution of the application. """ - _payload: list[CounterPayload] # support for other metric types may be added in the future. + _payload: list[Union[CounterPayload, LabeledCounterPayload]] @property - def payload(self) -> list[CounterPayload]: + def payload(self) -> list[Union[CounterPayload, LabeledCounterPayload]]: return self._payload - def __init__(self, payload: list[CounterPayload]): + def __init__(self, payload: list[Union[CounterPayload, LabeledCounterPayload]]): self._payload = payload def as_dict(self) -> dict[str, list[dict[str, Any]]]: From a63594e3f365fbcc3e5d174b6b6051f4ea5444b5 Mon Sep 17 00:00:00 2001 From: Vittorio Polverino Date: Thu, 5 Jun 2025 11:59:31 +0200 Subject: [PATCH 10/18] refactor: adapt test to the changes in the analytics.metrics module --- tests/unit/utils/analytics/test_metrics.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/tests/unit/utils/analytics/test_metrics.py b/tests/unit/utils/analytics/test_metrics.py index cc15499768381..8bdec6df31ca9 100644 --- a/tests/unit/utils/analytics/test_metrics.py +++ b/tests/unit/utils/analytics/test_metrics.py @@ -4,6 +4,7 @@ from localstack.utils.analytics.metrics import ( Counter, + LabeledCounter, MetricRegistry, MetricRegistryKey, ) @@ -34,7 +35,7 @@ def test_counter_reset(): def test_labeled_counter_increment(): - labeled_counter = Counter( + labeled_counter = LabeledCounter( namespace="test_namespace", name="test_multilabel_counter", labels=["status"] ) labeled_counter.labels(status="success").increment(value=2) @@ -53,7 +54,7 @@ def test_labeled_counter_increment(): def test_labeled_counter_reset(): - labeled_counter = Counter( + labeled_counter = LabeledCounter( namespace="test_namespace", name="test_multilabel_counter", labels=["status"] ) labeled_counter.labels(status="success").increment(value=5) @@ -83,7 +84,7 @@ def test_counter_when_events_disabled(disable_analytics): def test_labeled_counter_when_events_disabled_(disable_analytics): - labeled_counter = Counter( + labeled_counter = LabeledCounter( namespace="test_namespace", name="test_multilabel_counter", labels=["status"] ) labeled_counter.labels(status="status").increment(value=5) @@ -138,7 +139,7 @@ def increment(): def test_max_labels_limit(): with pytest.raises(ValueError, match="Too many labels: counters allow a maximum of 6."): - Counter( + LabeledCounter( namespace="test_namespace", name="test_counter", labels=["l1", "l2", "l3", "l4", "l5", "l6", "l7"], @@ -165,24 +166,26 @@ 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(namespace="test_namespace", name="test_counter", labels=[]).labels(l1="a") + LabeledCounter(namespace="test_namespace", name="test_counter", labels=[]).labels(l1="a") with pytest.raises(ValueError): - Counter(namespace="test_namespace", name="test_counter", labels=["l1", "l2"]).labels( + LabeledCounter(namespace="test_namespace", name="test_counter", labels=["l1", "l2"]).labels( l1="a", non_existing="asdf" ) with pytest.raises(ValueError): - Counter(namespace="test_namespace", name="test_counter", labels=["l1", "l2"]).labels(l1="a") + LabeledCounter(namespace="test_namespace", name="test_counter", labels=["l1", "l2"]).labels( + l1="a" + ) with pytest.raises(ValueError): - Counter(namespace="test_namespace", name="test_counter", labels=["l1", "l2"]).labels( + LabeledCounter(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( + labeled_counter = LabeledCounter( namespace="test_namespace", name="test_multilabel_counter", labels=["status", "type"] ) labeled_counter.labels(status="success", type="counter").increment(value=2) From 7060400d0f4d3c11c60fbbc908e2991469ee0d5f Mon Sep 17 00:00:00 2001 From: Vittorio Polverino Date: Thu, 5 Jun 2025 12:01:14 +0200 Subject: [PATCH 11/18] refactor: update counter definitions --- .../localstack/services/apigateway/analytics.py | 4 ++-- .../apigateway/next_gen/execute_api/handlers/analytics.py | 6 +++--- .../services/cloudformation/{usage.py => analytics.py} | 4 ++-- .../services/cloudformation/resource_provider.py | 2 +- localstack-core/localstack/services/events/analytics.py | 6 ++++-- localstack-core/localstack/services/lambda_/analytics.py | 8 ++++---- localstack-core/localstack/services/sns/analytics.py | 6 ++++-- .../asl/static_analyser/usage_metrics_static_analyser.py | 2 +- 8 files changed, 21 insertions(+), 17 deletions(-) rename localstack-core/localstack/services/cloudformation/{usage.py => analytics.py} (58%) diff --git a/localstack-core/localstack/services/apigateway/analytics.py b/localstack-core/localstack/services/apigateway/analytics.py index 13bd7109358ce..d01d93a943f65 100644 --- a/localstack-core/localstack/services/apigateway/analytics.py +++ b/localstack-core/localstack/services/apigateway/analytics.py @@ -1,5 +1,5 @@ -from localstack.utils.analytics.metrics import Counter +from localstack.utils.analytics.metrics import LabeledCounter -invocation_counter = Counter( +invocation_counter = LabeledCounter( namespace="apigateway", name="rest_api_execute", labels=["invocation_type"] ) diff --git a/localstack-core/localstack/services/apigateway/next_gen/execute_api/handlers/analytics.py b/localstack-core/localstack/services/apigateway/next_gen/execute_api/handlers/analytics.py index 7c6525eb0e7e1..46fe8d06a9e9e 100644 --- a/localstack-core/localstack/services/apigateway/next_gen/execute_api/handlers/analytics.py +++ b/localstack-core/localstack/services/apigateway/next_gen/execute_api/handlers/analytics.py @@ -1,7 +1,7 @@ import logging from localstack.http import Response -from localstack.utils.analytics.metrics import LabeledCounterMetric +from localstack.utils.analytics.metrics import LabeledCounter from ..api import RestApiGatewayHandler, RestApiGatewayHandlerChain from ..context import RestApiInvocationContext @@ -10,9 +10,9 @@ class IntegrationUsageCounter(RestApiGatewayHandler): - counter: LabeledCounterMetric + counter: LabeledCounter - def __init__(self, counter: LabeledCounterMetric): + def __init__(self, counter: LabeledCounter): self.counter = counter def __call__( diff --git a/localstack-core/localstack/services/cloudformation/usage.py b/localstack-core/localstack/services/cloudformation/analytics.py similarity index 58% rename from localstack-core/localstack/services/cloudformation/usage.py rename to localstack-core/localstack/services/cloudformation/analytics.py index 66d99b2e4cab0..f5530e262f92e 100644 --- a/localstack-core/localstack/services/cloudformation/usage.py +++ b/localstack-core/localstack/services/cloudformation/analytics.py @@ -1,7 +1,7 @@ -from localstack.utils.analytics.metrics import Counter +from localstack.utils.analytics.metrics import LabeledCounter COUNTER_NAMESPACE = "cloudformation" -resources = Counter( +resources = LabeledCounter( namespace=COUNTER_NAMESPACE, name="resources", labels=["resource_type", "missing"] ) diff --git a/localstack-core/localstack/services/cloudformation/resource_provider.py b/localstack-core/localstack/services/cloudformation/resource_provider.py index 7e48ed8ca5703..04a624ac55683 100644 --- a/localstack-core/localstack/services/cloudformation/resource_provider.py +++ b/localstack-core/localstack/services/cloudformation/resource_provider.py @@ -19,7 +19,7 @@ from localstack import config from localstack.aws.connect import InternalClientFactory, ServiceLevelClientFactory -from localstack.services.cloudformation import usage +from localstack.services.cloudformation import analytics as usage from localstack.services.cloudformation.deployment_utils import ( check_not_found_exception, convert_data_types, diff --git a/localstack-core/localstack/services/events/analytics.py b/localstack-core/localstack/services/events/analytics.py index f47924d04fdb4..8ebe75d8dd5fd 100644 --- a/localstack-core/localstack/services/events/analytics.py +++ b/localstack-core/localstack/services/events/analytics.py @@ -1,6 +1,6 @@ from enum import StrEnum -from localstack.utils.analytics.metrics import Counter +from localstack.utils.analytics.metrics import LabeledCounter class InvocationStatus(StrEnum): @@ -11,4 +11,6 @@ class InvocationStatus(StrEnum): # number of EventBridge rule invocations per target (e.g., aws:lambda) # - status label can be `success` or `error`, see InvocationStatus # - service label is the target service name -rule_invocation = Counter(namespace="events", name="rule_invocations", labels=["status", "service"]) +rule_invocation = LabeledCounter( + namespace="events", name="rule_invocations", labels=["status", "service"] +) diff --git a/localstack-core/localstack/services/lambda_/analytics.py b/localstack-core/localstack/services/lambda_/analytics.py index 4545f23a7139e..ff4a1ae6f516c 100644 --- a/localstack-core/localstack/services/lambda_/analytics.py +++ b/localstack-core/localstack/services/lambda_/analytics.py @@ -1,12 +1,12 @@ from enum import StrEnum -from localstack.utils.analytics.metrics import Counter +from localstack.utils.analytics.metrics import LabeledCounter NAMESPACE = "lambda" -hotreload_counter = Counter(namespace=NAMESPACE, name="hotreload", labels=["operation"]) +hotreload_counter = LabeledCounter(namespace=NAMESPACE, name="hotreload", labels=["operation"]) -function_counter = Counter( +function_counter = LabeledCounter( namespace=NAMESPACE, name="function", labels=[ @@ -38,7 +38,7 @@ class FunctionStatus(StrEnum): invocation_error = "invocation_error" -esm_counter = Counter(namespace=NAMESPACE, name="esm", labels=["source", "status"]) +esm_counter = LabeledCounter(namespace=NAMESPACE, name="esm", labels=["source", "status"]) class EsmExecutionStatus(StrEnum): diff --git a/localstack-core/localstack/services/sns/analytics.py b/localstack-core/localstack/services/sns/analytics.py index c74ed6ad2b141..426c5403bae6b 100644 --- a/localstack-core/localstack/services/sns/analytics.py +++ b/localstack-core/localstack/services/sns/analytics.py @@ -2,8 +2,10 @@ Usage analytics for SNS internal endpoints """ -from localstack.utils.analytics.metrics import Counter +from localstack.utils.analytics.metrics import LabeledCounter # number of times SNS internal endpoint per resource types # (e.g. PlatformMessage invoked 10x times, SMSMessage invoked 3x times, SubscriptionToken...) -internal_api_calls = Counter(namespace="sns", name="internal_api_call", labels=["resource_type"]) +internal_api_calls = LabeledCounter( + namespace="sns", name="internal_api_call", labels=["resource_type"] +) diff --git a/localstack-core/localstack/services/stepfunctions/asl/static_analyser/usage_metrics_static_analyser.py b/localstack-core/localstack/services/stepfunctions/asl/static_analyser/usage_metrics_static_analyser.py index b19fd0d4bf420..8b13143021a40 100644 --- a/localstack-core/localstack/services/stepfunctions/asl/static_analyser/usage_metrics_static_analyser.py +++ b/localstack-core/localstack/services/stepfunctions/asl/static_analyser/usage_metrics_static_analyser.py @@ -3,7 +3,7 @@ import logging from typing import Final -import localstack.services.stepfunctions.usage as UsageMetrics +import localstack.services.stepfunctions.analytics as UsageMetrics from localstack.services.stepfunctions.asl.antlr.runtime.ASLParser import ASLParser from localstack.services.stepfunctions.asl.component.common.query_language import ( QueryLanguageMode, From 7410828fad58b511241c4927568b17b78b989516 Mon Sep 17 00:00:00 2001 From: "localstack[bot]" Date: Thu, 5 Jun 2025 07:26:49 +0000 Subject: [PATCH 12/18] release version 4.5.0 From cb3585a133d5dbc6ec75f74f26d7e55af85a8f04 Mon Sep 17 00:00:00 2001 From: "localstack[bot]" Date: Thu, 5 Jun 2025 07:28:31 +0000 Subject: [PATCH 13/18] prepare next development iteration From 2244fc68275856d8af6f49381b0faf63c42202a6 Mon Sep 17 00:00:00 2001 From: Vittorio Polverino Date: Tue, 10 Jun 2025 12:49:29 +0200 Subject: [PATCH 14/18] refactor: rename module from hook to publisher --- .../localstack/utils/analytics/metrics/{hook.py => publisher.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename localstack-core/localstack/utils/analytics/metrics/{hook.py => publisher.py} (100%) diff --git a/localstack-core/localstack/utils/analytics/metrics/hook.py b/localstack-core/localstack/utils/analytics/metrics/publisher.py similarity index 100% rename from localstack-core/localstack/utils/analytics/metrics/hook.py rename to localstack-core/localstack/utils/analytics/metrics/publisher.py From 418a15b8bef84bfa15c3a6d8343dc5530930b03d Mon Sep 17 00:00:00 2001 From: Vittorio Polverino Date: Tue, 10 Jun 2025 22:04:26 +0200 Subject: [PATCH 15/18] refactor: rename module in api and add a Payload protocol --- .../utils/analytics/metrics/__init__.py | 10 +-- .../metrics/{interface.py => api.py} | 8 ++- .../utils/analytics/metrics/counter.py | 49 ++++++++++++- .../utils/analytics/metrics/registry.py | 12 +++- .../utils/analytics/metrics/type.py | 72 ------------------- 5 files changed, 62 insertions(+), 89 deletions(-) rename localstack-core/localstack/utils/analytics/metrics/{interface.py => api.py} (85%) delete mode 100644 localstack-core/localstack/utils/analytics/metrics/type.py diff --git a/localstack-core/localstack/utils/analytics/metrics/__init__.py b/localstack-core/localstack/utils/analytics/metrics/__init__.py index 442e4deee2d70..0dd4d6e1c9773 100644 --- a/localstack-core/localstack/utils/analytics/metrics/__init__.py +++ b/localstack-core/localstack/utils/analytics/metrics/__init__.py @@ -2,13 +2,5 @@ from .counter import Counter, LabeledCounter from .registry import MetricRegistry -from .type import CounterPayload, MetricPayload, MetricRegistryKey -__all__ = [ - "Counter", - "LabeledCounter", - "MetricRegistry", - "CounterPayload", - "MetricPayload", - "MetricRegistryKey", -] +__all__ = ["Counter", "LabeledCounter", "MetricRegistry"] diff --git a/localstack-core/localstack/utils/analytics/metrics/interface.py b/localstack-core/localstack/utils/analytics/metrics/api.py similarity index 85% rename from localstack-core/localstack/utils/analytics/metrics/interface.py rename to localstack-core/localstack/utils/analytics/metrics/api.py index fa7a182e9d4f2..56125a9ddc472 100644 --- a/localstack-core/localstack/utils/analytics/metrics/interface.py +++ b/localstack-core/localstack/utils/analytics/metrics/api.py @@ -1,9 +1,11 @@ from __future__ import annotations from abc import ABC, abstractmethod -from typing import Union +from typing import Any, Protocol -from .type import CounterPayload, LabeledCounterPayload + +class Payload(Protocol): + def as_dict(self) -> dict[str, Any]: ... class Metric(ABC): @@ -33,7 +35,7 @@ def name(self) -> str: return self._name @abstractmethod - def collect(self) -> list[Union[CounterPayload, LabeledCounterPayload]]: + def collect(self) -> list[Payload]: """ Collects and returns metric data. Subclasses must implement this to return collected metric data. """ diff --git a/localstack-core/localstack/utils/analytics/metrics/counter.py b/localstack-core/localstack/utils/analytics/metrics/counter.py index 6341761232951..31b8a6a9de008 100644 --- a/localstack-core/localstack/utils/analytics/metrics/counter.py +++ b/localstack-core/localstack/utils/analytics/metrics/counter.py @@ -1,12 +1,55 @@ import threading from collections import defaultdict -from typing import Optional, Union +from dataclasses import dataclass +from typing import Any, Optional, Union from localstack import config -from .interface import Metric +from .api import Metric from .registry import MetricRegistry -from .type import CounterPayload, LabeledCounterPayload + + +@dataclass(frozen=True) +class CounterPayload: + """A data object storing the value of a Counter metric.""" + + namespace: str + name: str + value: int + type: str + + def as_dict(self) -> dict[str, Any]: + return { + "namespace": self.namespace, + "name": self.name, + "value": self.value, + "type": self.type, + } + + +@dataclass(frozen=True) +class LabeledCounterPayload: + """A data object storing the value of a LabeledCounter metric.""" + + namespace: str + name: str + value: int + type: str + labels: dict[str, Union[str, float]] + + def as_dict(self) -> dict[str, Any]: + payload_dict = { + "namespace": self.namespace, + "name": self.name, + "value": self.value, + "type": self.type, + } + + for i, (label_name, label_value) in enumerate(self.labels.items(), 1): + payload_dict[f"label_{i}"] = label_name + payload_dict[f"label_{i}_value"] = label_value + + return payload_dict class ThreadSafeCounter: diff --git a/localstack-core/localstack/utils/analytics/metrics/registry.py b/localstack-core/localstack/utils/analytics/metrics/registry.py index b3e32e2d1bd8b..8a0cd7b19a5a4 100644 --- a/localstack-core/localstack/utils/analytics/metrics/registry.py +++ b/localstack-core/localstack/utils/analytics/metrics/registry.py @@ -2,13 +2,21 @@ import logging import threading +from dataclasses import dataclass -from .interface import Metric -from .type import MetricPayload, MetricRegistryKey +from .api import Metric, MetricPayload LOG = logging.getLogger(__name__) +@dataclass(frozen=True) +class MetricRegistryKey: + """A unique identifier for a metric, composed of namespace and name.""" + + namespace: str + name: str + + class MetricRegistry: """ A Singleton class responsible for managing all registered metrics. diff --git a/localstack-core/localstack/utils/analytics/metrics/type.py b/localstack-core/localstack/utils/analytics/metrics/type.py deleted file mode 100644 index 612aad3318e9d..0000000000000 --- a/localstack-core/localstack/utils/analytics/metrics/type.py +++ /dev/null @@ -1,72 +0,0 @@ -from dataclasses import dataclass -from typing import Any, Union - - -@dataclass(frozen=True) -class MetricRegistryKey: - """A unique identifier for a metric, composed of namespace and name.""" - - namespace: str - name: str - - -@dataclass(frozen=True) -class CounterPayload: - """A data object storing the value of a Counter metric.""" - - namespace: str - name: str - value: int - type: str - - def as_dict(self) -> dict[str, Any]: - return { - "namespace": self.namespace, - "name": self.name, - "value": self.value, - "type": self.type, - } - - -@dataclass(frozen=True) -class LabeledCounterPayload: - """A data object storing the value of a LabeledCounter metric.""" - - namespace: str - name: str - value: int - type: str - labels: dict[str, Union[str, float]] - - def as_dict(self) -> dict[str, Any]: - dict = { - "namespace": self.namespace, - "name": self.name, - "value": self.value, - "type": self.type, - } - - for i, (label_name, label_value) in enumerate(self.labels.items(), 1): - dict[f"label_{i}"] = label_name - dict[f"label_{i}_value"] = label_value - - return dict - - -@dataclass -class MetricPayload: - """ - A data object storing the value of all metrics collected during the execution of the application. - """ - - _payload: list[Union[CounterPayload, LabeledCounterPayload]] - - @property - def payload(self) -> list[Union[CounterPayload, LabeledCounterPayload]]: - return self._payload - - def __init__(self, payload: list[Union[CounterPayload, LabeledCounterPayload]]): - self._payload = payload - - def as_dict(self) -> dict[str, list[dict[str, Any]]]: - return {"metrics": [payload.as_dict() for payload in self._payload]} From fa780f36f285450727fccee0b78d33c3a3dd87c2 Mon Sep 17 00:00:00 2001 From: Vittorio Polverino Date: Tue, 10 Jun 2025 22:05:40 +0200 Subject: [PATCH 16/18] refactor: move MetricPayload in registry.py --- .../utils/analytics/metrics/registry.py | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/localstack-core/localstack/utils/analytics/metrics/registry.py b/localstack-core/localstack/utils/analytics/metrics/registry.py index 8a0cd7b19a5a4..50f23c345ad67 100644 --- a/localstack-core/localstack/utils/analytics/metrics/registry.py +++ b/localstack-core/localstack/utils/analytics/metrics/registry.py @@ -3,12 +3,32 @@ import logging import threading from dataclasses import dataclass +from typing import Any -from .api import Metric, MetricPayload +from .api import Metric, Payload LOG = logging.getLogger(__name__) +@dataclass +class MetricPayload: + """ + A data object storing the value of all metrics collected during the execution of the application. + """ + + _payload: list[Payload] + + @property + def payload(self) -> list[Payload]: + return self._payload + + def __init__(self, payload: list[Payload]): + self._payload = payload + + def as_dict(self) -> dict[str, list[dict[str, Any]]]: + return {"metrics": [payload.as_dict() for payload in self._payload]} + + @dataclass(frozen=True) class MetricRegistryKey: """A unique identifier for a metric, composed of namespace and name.""" From d64049e8615ed82becf87c1739ab31debac9dfaf Mon Sep 17 00:00:00 2001 From: Vittorio Polverino Date: Tue, 10 Jun 2025 22:07:12 +0200 Subject: [PATCH 17/18] refactor: remove import alias --- .../localstack/services/cloudformation/resource_provider.py | 6 +++--- .../asl/static_analyser/usage_metrics_static_analyser.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/localstack-core/localstack/services/cloudformation/resource_provider.py b/localstack-core/localstack/services/cloudformation/resource_provider.py index 04a624ac55683..31ac0938712bb 100644 --- a/localstack-core/localstack/services/cloudformation/resource_provider.py +++ b/localstack-core/localstack/services/cloudformation/resource_provider.py @@ -19,7 +19,7 @@ from localstack import config from localstack.aws.connect import InternalClientFactory, ServiceLevelClientFactory -from localstack.services.cloudformation import analytics as usage +from localstack.services.cloudformation import analytics from localstack.services.cloudformation.deployment_utils import ( check_not_found_exception, convert_data_types, @@ -581,7 +581,7 @@ def try_load_resource_provider(resource_type: str) -> ResourceProvider | None: # 2. try to load community resource provider try: plugin = plugin_manager.load(resource_type) - usage.resources.labels(resource_type=resource_type, missing=False).increment() + analytics.resources.labels(resource_type=resource_type, missing=False).increment() return plugin.factory() except ValueError: # could not find a plugin for that name @@ -600,7 +600,7 @@ def try_load_resource_provider(resource_type: str) -> ResourceProvider | None: f'No resource provider found for "{resource_type}"', ) - usage.resources.labels(resource_type=resource_type, missing=True).increment() + analytics.resources.labels(resource_type=resource_type, missing=True).increment() if config.CFN_IGNORE_UNSUPPORTED_RESOURCE_TYPES: # TODO: figure out a better way to handle non-implemented here? diff --git a/localstack-core/localstack/services/stepfunctions/asl/static_analyser/usage_metrics_static_analyser.py b/localstack-core/localstack/services/stepfunctions/asl/static_analyser/usage_metrics_static_analyser.py index 8b13143021a40..65d5029e137c7 100644 --- a/localstack-core/localstack/services/stepfunctions/asl/static_analyser/usage_metrics_static_analyser.py +++ b/localstack-core/localstack/services/stepfunctions/asl/static_analyser/usage_metrics_static_analyser.py @@ -3,7 +3,7 @@ import logging from typing import Final -import localstack.services.stepfunctions.analytics as UsageMetrics +from localstack.services.stepfunctions import analytics from localstack.services.stepfunctions.asl.antlr.runtime.ASLParser import ASLParser from localstack.services.stepfunctions.asl.component.common.query_language import ( QueryLanguageMode, @@ -40,7 +40,7 @@ def process(definition: str) -> UsageMetricsStaticAnalyser: uses_variables = analyser.uses_variables # Count. - UsageMetrics.language_features_counter.labels( + analytics.language_features_counter.labels( query_language=language_used, uses_variables=uses_variables ).increment() except Exception as e: From d2d3cff33b7471e20187206fe2bf7f9bc4c2064d Mon Sep 17 00:00:00 2001 From: Vittorio Polverino Date: Tue, 10 Jun 2025 22:13:23 +0200 Subject: [PATCH 18/18] fix: add MetricRegistryKey to __all__ exports --- .../localstack/utils/analytics/metrics/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/localstack-core/localstack/utils/analytics/metrics/__init__.py b/localstack-core/localstack/utils/analytics/metrics/__init__.py index 0dd4d6e1c9773..2d935429e982b 100644 --- a/localstack-core/localstack/utils/analytics/metrics/__init__.py +++ b/localstack-core/localstack/utils/analytics/metrics/__init__.py @@ -1,6 +1,6 @@ """LocalStack metrics instrumentation framework""" from .counter import Counter, LabeledCounter -from .registry import MetricRegistry +from .registry import MetricRegistry, MetricRegistryKey -__all__ = ["Counter", "LabeledCounter", "MetricRegistry"] +__all__ = ["Counter", "LabeledCounter", "MetricRegistry", "MetricRegistryKey"]