Skip to content

Commit 6d3a833

Browse files
committed
feat: add impact metric counter
1 parent 7951d32 commit 6d3a833

File tree

4 files changed

+196
-0
lines changed

4 files changed

+196
-0
lines changed
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
from .metric_types import (
2+
CollectedMetric,
3+
Counter,
4+
CounterImpl,
5+
MetricLabels,
6+
MetricOptions,
7+
MetricType,
8+
NumericMetricSample,
9+
)
10+
11+
__all__ = [
12+
"CollectedMetric",
13+
"Counter",
14+
"CounterImpl",
15+
"MetricLabels",
16+
"MetricOptions",
17+
"MetricType",
18+
"NumericMetricSample",
19+
]
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass, field
4+
from enum import Enum
5+
from threading import RLock
6+
from typing import Any, Dict, List, Optional, Protocol
7+
8+
9+
class MetricType(str, Enum):
10+
COUNTER = "counter"
11+
GAUGE = "gauge"
12+
HISTOGRAM = "histogram"
13+
14+
15+
MetricLabels = Dict[str, str]
16+
17+
18+
@dataclass
19+
class MetricOptions:
20+
name: str
21+
help: str
22+
label_names: List[str] = field(default_factory=list)
23+
24+
25+
@dataclass
26+
class NumericMetricSample:
27+
labels: MetricLabels
28+
value: int
29+
30+
def to_dict(self) -> Dict[str, Any]:
31+
return {"labels": self.labels, "value": self.value}
32+
33+
34+
@dataclass
35+
class CollectedMetric:
36+
name: str
37+
help: str
38+
type: MetricType
39+
samples: List[NumericMetricSample]
40+
41+
def to_dict(self) -> Dict[str, Any]:
42+
return {
43+
"name": self.name,
44+
"help": self.help,
45+
"type": self.type.value,
46+
"samples": [s.to_dict() for s in self.samples],
47+
}
48+
49+
50+
def _get_label_key(labels: Optional[MetricLabels]) -> str:
51+
if not labels:
52+
return ""
53+
return ",".join(f"{k}={v}" for k, v in sorted(labels.items()))
54+
55+
56+
def _parse_label_key(key: str) -> MetricLabels:
57+
if not key:
58+
return {}
59+
labels: MetricLabels = {}
60+
for pair in key.split(","):
61+
if "=" in pair:
62+
k, v = pair.split("=", 1)
63+
labels[k] = v
64+
return labels
65+
66+
67+
class Counter(Protocol):
68+
def inc(self, value: int = 1, labels: Optional[MetricLabels] = None) -> None: ...
69+
70+
71+
class CounterImpl:
72+
def __init__(self, opts: MetricOptions) -> None:
73+
self._opts = opts
74+
self._values: Dict[str, int] = {}
75+
self._lock = RLock()
76+
77+
def inc(self, value: int = 1, labels: Optional[MetricLabels] = None) -> None:
78+
key = _get_label_key(labels)
79+
with self._lock:
80+
current = self._values.get(key, 0)
81+
self._values[key] = current + value
82+
83+
def collect(self) -> CollectedMetric:
84+
samples: List[NumericMetricSample] = []
85+
86+
with self._lock:
87+
for key in list(self._values.keys()):
88+
value = self._values.pop(key)
89+
samples.append(
90+
NumericMetricSample(labels=_parse_label_key(key), value=value)
91+
)
92+
93+
if not samples:
94+
samples.append(NumericMetricSample(labels={}, value=0))
95+
96+
return CollectedMetric(
97+
name=self._opts.name,
98+
help=self._opts.help,
99+
type=MetricType.COUNTER,
100+
samples=samples,
101+
)

tests/unit_tests/impact_metrics/__init__.py

Whitespace-only changes.
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
from UnleashClient.impact_metrics.metric_types import (
2+
CounterImpl,
3+
MetricOptions,
4+
)
5+
6+
7+
def test_counter_increments_by_default_value():
8+
counter = CounterImpl(MetricOptions(name="test_counter", help="testing"))
9+
10+
counter.inc()
11+
12+
result = counter.collect()
13+
14+
assert result.to_dict() == {
15+
"name": "test_counter",
16+
"help": "testing",
17+
"type": "counter",
18+
"samples": [{"labels": {}, "value": 1}],
19+
}
20+
21+
22+
def test_counter_increments_with_custom_value_and_labels():
23+
counter = CounterImpl(MetricOptions(name="labeled_counter", help="with labels"))
24+
25+
counter.inc(3, {"foo": "bar"})
26+
counter.inc(2, {"foo": "bar"})
27+
28+
result = counter.collect()
29+
30+
assert result.to_dict() == {
31+
"name": "labeled_counter",
32+
"help": "with labels",
33+
"type": "counter",
34+
"samples": [{"labels": {"foo": "bar"}, "value": 5}],
35+
}
36+
37+
38+
def test_different_label_combinations_are_stored_separately():
39+
counter = CounterImpl(MetricOptions(name="multi_label", help="label test"))
40+
41+
counter.inc(1, {"a": "x"})
42+
counter.inc(2, {"b": "y"})
43+
counter.inc(3)
44+
45+
result = counter.collect()
46+
47+
result.samples.sort(key=lambda s: s.value)
48+
49+
assert result.to_dict() == {
50+
"name": "multi_label",
51+
"help": "label test",
52+
"type": "counter",
53+
"samples": [
54+
{"labels": {"a": "x"}, "value": 1},
55+
{"labels": {"b": "y"}, "value": 2},
56+
{"labels": {}, "value": 3},
57+
],
58+
}
59+
60+
61+
def test_collect_returns_counter_with_zero_value_after_flushing_previous_values():
62+
counter = CounterImpl(MetricOptions(name="flush_test", help="flush"))
63+
64+
counter.inc(1)
65+
first = counter.collect()
66+
assert first is not None
67+
assert len(first.samples) == 1
68+
69+
second = counter.collect()
70+
71+
assert second.to_dict() == {
72+
"name": "flush_test",
73+
"help": "flush",
74+
"type": "counter",
75+
"samples": [{"labels": {}, "value": 0}],
76+
}

0 commit comments

Comments
 (0)