Skip to content

Commit 6ddfc41

Browse files
committed
refactor counter metric to use external class, fix labels handling
1 parent ac24756 commit 6ddfc41

File tree

2 files changed

+88
-98
lines changed

2 files changed

+88
-98
lines changed

localstack-core/localstack/utils/analytics/metrics.py

Lines changed: 62 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ class MetricRegistry:
2222
Provides methods for retrieving and collecting metrics.
2323
"""
2424

25-
_instance = None
25+
_instance: "MetricRegistry" = None
2626
_mutex: threading.Lock = threading.Lock()
2727

2828
def __new__(cls):
@@ -107,34 +107,22 @@ def collect(self) -> List[Dict[str, Union[str, int]]]:
107107
pass
108108

109109

110-
class _SimpleCounter(Metric):
110+
class _Counter:
111111
"""
112-
A thread-safe counter for tracking occurrences of an event without labels.
112+
A thread-safe counter for any kind of tracking.
113113
"""
114114

115115
_mutex: threading.Lock
116-
_namespace: Optional[str]
117-
_name: str
118-
_type: str
119116
_count: int
120117

121-
@property
122-
def mutex(self) -> threading.Lock:
123-
"""
124-
Provides thread-safe access to the internal lock.
125-
"""
126-
return self._mutex
127-
128-
def __init__(self, name: str, namespace: Optional[str] = ""):
129-
if not name:
130-
raise ValueError("Name is required and cannot be empty.")
131-
118+
def __init__(self):
119+
super(_Counter, self).__init__()
132120
self._mutex = threading.Lock()
133-
self._name = name.strip()
134-
self._namespace = namespace.strip() if namespace else ""
135-
self._type = "counter"
136121
self._count = 0
137-
MetricRegistry().register(self)
122+
123+
@property
124+
def count(self) -> int:
125+
return self._count
138126

139127
def increment(self, value: int = 1) -> None:
140128
"""Increments the counter unless events are disabled."""
@@ -155,38 +143,56 @@ def reset(self) -> None:
155143
with self._mutex:
156144
self._count = 0
157145

146+
147+
class _CounterMetric(Metric, _Counter):
148+
"""
149+
A thread-safe counter for tracking occurrences of an event without labels.
150+
"""
151+
152+
_namespace: Optional[str]
153+
_name: str
154+
_type: str
155+
156+
def __init__(self, name: str, namespace: Optional[str] = ""):
157+
super(_CounterMetric, self).__init__()
158+
if not name:
159+
raise ValueError("Name is required and cannot be empty.")
160+
161+
self._name = name.strip()
162+
self._namespace = namespace.strip() if namespace else ""
163+
self._type = "counter"
164+
MetricRegistry().register(self)
165+
158166
def collect(self) -> List[Dict[str, Union[str, int]]]:
159167
"""Collects the metric unless events are disabled."""
160168
if config.DISABLE_EVENTS:
161169
return list()
162170

163-
with self._mutex:
164-
if self._count == 0:
165-
# Return an empty list if the count is 0, as there are no metrics to send to the analytics backend.
166-
return list()
167-
return [
168-
{
169-
"namespace": self._namespace,
170-
"name": self._name,
171-
"value": self._count,
172-
"type": self._type,
173-
}
174-
]
171+
if self._count == 0:
172+
# Return an empty list if the count is 0, as there are no metrics to send to the analytics backend.
173+
return list()
174+
return [
175+
{
176+
"namespace": self._namespace,
177+
"name": self._name,
178+
"value": self._count,
179+
"type": self._type,
180+
}
181+
]
175182

176183

177-
class _LabeledCounter(Metric):
184+
class _LabeledCounterMetric(Metric):
178185
"""
179186
A labeled counter that tracks occurrences of an event across different label combinations.
180187
"""
181188

182-
_mutex: threading.Lock
183189
_namespace: Optional[str]
184190
_name: str
185191
_type: str
186192
_unit: str
187-
_labels: List[str]
193+
_labels: list[str]
188194
_label_values: tuple[Optional[str], ...]
189-
_count_by_labels: defaultdict[Tuple[str, ...], int]
195+
_counters_by_label_values: defaultdict[Tuple[str, ...], _Counter]
190196

191197
def __init__(self, name: str, labels: List[str] = list, namespace: Optional[str] = ""):
192198
if not name:
@@ -198,32 +204,19 @@ def __init__(self, name: str, labels: List[str] = list, namespace: Optional[str]
198204
if len(labels) > 8:
199205
raise ValueError("A maximum of 8 labels are allowed.")
200206

201-
self._mutex = threading.Lock()
202207
self._name = name.strip()
203208
self._namespace = namespace.strip() if namespace else ""
204209
self._type = "counter"
205210
self._labels = labels
206-
self._label_values = tuple()
207-
self._count_by_labels = defaultdict(int)
211+
self._counters_by_label_values = defaultdict(_Counter)
208212
MetricRegistry().register(self)
209213

210-
@property
211-
def mutex(self) -> threading.Lock:
212-
"""
213-
Provides thread-safe access to the internal lock.
214-
"""
215-
return self._mutex
216-
217-
@property
218-
def count_by_labels(self) -> defaultdict[Tuple[str, ...], int]:
219-
return self._count_by_labels
220-
221-
def labels(self, **kwargs: str) -> _LabeledCounterProxy:
214+
def labels(self, **kwargs: str) -> _Counter:
222215
"""
223216
Create a scoped counter instance with specific label values.
224217
225218
This method assigns values to the predefined labels of a labeled counter and returns
226-
a proxy object (`_LabeledCounterProxy`) that allows tracking metrics for that specific
219+
a _Counter object (``) that allows tracking metrics for that specific
227220
combination of label values.
228221
229222
The proxy ensures that increments and resets are scoped to the given label values,
@@ -233,15 +226,15 @@ def labels(self, **kwargs: str) -> _LabeledCounterProxy:
233226
- If the number of provided labels does not match the expected count.
234227
- If any of the provided labels are empty strings.
235228
"""
236-
self._label_values = tuple(label_value for label_value in kwargs.values())
229+
if set(self._labels) != set(kwargs.keys()):
230+
raise ValueError(f"Expected labels {self._labels}, got {list(kwargs.keys())}")
237231

238-
if len(kwargs) != len(self._label_values):
239-
raise ValueError(f"Expected labels {self._label_values}, got {list(kwargs.values())}")
232+
_label_values = tuple(kwargs[label] for label in self._labels)
240233

241-
if any(not label for label in self._label_values):
234+
if any(not label for label in _label_values):
242235
raise ValueError("Label values must be non-empty strings.")
243236

244-
return _LabeledCounterProxy(counter=self, label_values=self._label_values)
237+
return self._counters_by_label_values[_label_values]
245238

246239
def _as_list(self) -> List[Dict[str, Union[str, int]]]:
247240
num_labels = len(self._labels)
@@ -251,8 +244,8 @@ def _as_list(self) -> List[Dict[str, Union[str, int]]]:
251244

252245
collected_metrics = []
253246

254-
for label_values, count in self._count_by_labels.items():
255-
if count == 0:
247+
for label_values, counter in self._counters_by_label_values.items():
248+
if counter.count == 0:
256249
continue # Skip items with a count of 0, as they should not be sent to the analytics backend.
257250

258251
if len(label_values) != num_labels:
@@ -265,7 +258,7 @@ def _as_list(self) -> List[Dict[str, Union[str, int]]]:
265258
{
266259
"namespace": self._namespace,
267260
"name": self._name,
268-
"value": count,
261+
"value": counter.count,
269262
"type": self._type,
270263
**dict(zip(static_key_label_value, label_values)),
271264
**dict(zip(static_key_label, self._labels)),
@@ -277,36 +270,7 @@ def _as_list(self) -> List[Dict[str, Union[str, int]]]:
277270
def collect(self) -> List[Dict[str, Union[str, int]]]:
278271
if config.DISABLE_EVENTS:
279272
return list()
280-
281-
with self._mutex:
282-
return self._as_list()
283-
284-
285-
class _LabeledCounterProxy:
286-
"""A proxy for a labeled counter, enforcing scoped label values."""
287-
288-
def __init__(self, counter: _LabeledCounter, label_values: Tuple[str, ...]):
289-
self._counter = counter
290-
self._label_values = label_values
291-
292-
def increment(self, value: int = 1) -> None:
293-
"""Increments the counter for the assigned labels unless events are disabled."""
294-
if config.DISABLE_EVENTS:
295-
return
296-
297-
if value <= 0:
298-
raise ValueError("Increment value must be positive.")
299-
300-
with self._counter.mutex:
301-
self._counter.count_by_labels[self._label_values] += value
302-
303-
def reset(self) -> None:
304-
"""Resets the counter to zero for the assigned labels unless events are disabled."""
305-
if config.DISABLE_EVENTS:
306-
return
307-
308-
with self._counter.mutex:
309-
self._counter.count_by_labels[self._label_values] = 0
273+
return self._as_list()
310274

311275

312276
class Counter:
@@ -319,21 +283,21 @@ class Counter:
319283
"""
320284

321285
@overload
322-
def __new__(cls, name: str, namespace: Optional[str] = "") -> _SimpleCounter:
323-
return _SimpleCounter(namespace=namespace, name=name)
286+
def __new__(cls, name: str, namespace: Optional[str] = "") -> _CounterMetric:
287+
return _CounterMetric(namespace=namespace, name=name)
324288

325289
@overload
326290
def __new__(
327291
cls, name: str, labels: List[str], namespace: Optional[str] = ""
328-
) -> _LabeledCounter:
329-
return _LabeledCounter(namespace=namespace, name=name, labels=labels)
292+
) -> _LabeledCounterMetric:
293+
return _LabeledCounterMetric(namespace=namespace, name=name, labels=labels)
330294

331295
def __new__(
332296
cls, name: str, namespace: Optional[str] = "", labels: Optional[List[str]] = None
333-
) -> Union[_SimpleCounter, _LabeledCounter]:
297+
) -> Union[_CounterMetric, _LabeledCounterMetric]:
334298
if labels:
335-
return _LabeledCounter(namespace=namespace, name=name, labels=labels)
336-
return _SimpleCounter(namespace=namespace, name=name)
299+
return _LabeledCounterMetric(namespace=namespace, name=name, labels=labels)
300+
return _CounterMetric(namespace=namespace, name=name)
337301

338302

339303
@hooks.on_infra_shutdown()

tests/unit/utils/analytics/test_metrics.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,3 +138,29 @@ def test_labels_method_raises_error_if_label_value_is_empty():
138138
def test_counter_raises_error_if_name_is_empty():
139139
with pytest.raises(ValueError, match="Name is required and cannot be empty."):
140140
Counter(name="")
141+
142+
143+
def test_counter_raises_if_label_values_off():
144+
with pytest.raises(ValueError):
145+
Counter(name="test_counter", labels=["l1", "l2"]).labels(l1="a", non_existing="asdf")
146+
147+
with pytest.raises(ValueError):
148+
Counter(name="test_counter", labels=["l1", "l2"]).labels(l1="a")
149+
150+
with pytest.raises(ValueError):
151+
Counter(name="test_counter", labels=["l1", "l2"]).labels(l1="a", l2="b", l3="c")
152+
153+
154+
def test_label_kwargs_order_independent():
155+
labeled_counter = Counter(name="test_multilabel_counter", labels=["status", "type"])
156+
labeled_counter.labels(status="success", type="counter").increment(value=2)
157+
labeled_counter.labels(type="counter", status="success").increment(value=3)
158+
labeled_counter.labels(type="counter", status="error").increment(value=3)
159+
collected_metrics = labeled_counter.collect()
160+
161+
assert any(
162+
metric["value"] == 5 for metric in collected_metrics if metric["label_1_value"] == "success"
163+
), "Unexpected counter value for label success"
164+
assert any(
165+
metric["value"] == 3 for metric in collected_metrics if metric["label_1_value"] == "error"
166+
), "Unexpected counter value for label error"

0 commit comments

Comments
 (0)