Skip to content

Commit c1722df

Browse files
committed
Implement a Redis mode
Signed-off-by: Stefano Rivera <stefano@rivera.za.net>
1 parent c7e74f3 commit c1722df

7 files changed

Lines changed: 529 additions & 1 deletion

File tree

.github/workflows/ci.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,9 @@ jobs:
5959
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
6060
with:
6161
python-version: ${{ matrix.python-version }}
62+
- name: Install Redis
63+
run: |
64+
apt-get -y install redis-server
6265
- name: Install dependencies
6366
run: |
6467
pip install --user tox "virtualenv<20.22.0"

prometheus_client/metrics.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,10 @@ def remove(self, *labelvalues: Any) -> None:
207207
warnings.warn(
208208
"Removal of labels has not been implemented in multi-process mode yet.",
209209
UserWarning)
210+
if 'PROMETHEUS_REDIS_URL' in os.environ:
211+
warnings.warn(
212+
"Removal of labels has not been implemented in redis mode yet.",
213+
UserWarning)
210214

211215
if not self._labelnames:
212216
raise ValueError('No label names were set when constructing %s' % self)
@@ -226,6 +230,10 @@ def remove_by_labels(self, labels: dict[str, str]) -> None:
226230
"Removal of labels has not been implemented in multi-process mode yet.",
227231
UserWarning
228232
)
233+
if 'PROMETHEUS_REDIS_URL' in os.environ:
234+
warnings.warn(
235+
"Removal of labels has not been implemented in redis mode yet.",
236+
UserWarning)
229237

230238
if not self._labelnames:
231239
raise ValueError('No label names were set when constructing %s' % self)
@@ -258,6 +266,10 @@ def clear(self) -> None:
258266
warnings.warn(
259267
"Clearing labels has not been implemented in multi-process mode yet",
260268
UserWarning)
269+
if 'PROMETHEUS_REDIS_URL' in os.environ:
270+
warnings.warn(
271+
"Clearing of labels has not been implemented in redis mode yet.",
272+
UserWarning)
261273
with self._lock:
262274
self._metrics = {}
263275

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
from collections.abc import Iterable
2+
import json
3+
import os
4+
from urllib.parse import urlsplit
5+
6+
from .metrics_core import Metric
7+
from .registry import Collector, CollectorRegistry
8+
from .samples import Sample
9+
10+
11+
def redis_client():
12+
"""
13+
Create a redis client for PROMETHEUS_REDIS_URL.
14+
15+
Configure the redis database via a URL in PROMETHEUS_REDIS_URL of the form
16+
redis://localhost:6379/0
17+
"""
18+
from redis import Redis
19+
20+
parsed_url = urlsplit(os.environ["PROMETHEUS_REDIS_URL"])
21+
assert parsed_url.scheme == "redis"
22+
assert parsed_url.path.startswith("/")
23+
assert parsed_url.path[1:].isdigit()
24+
port = parsed_url.port or 6379
25+
db = int(parsed_url.path[1:])
26+
return Redis(host=parsed_url.hostname, port=port, db=db)
27+
28+
29+
class RedisCollector(Collector):
30+
"""Collector for redis mode."""
31+
32+
def __init__(self, registry: CollectorRegistry) -> None:
33+
self._client = redis_client()
34+
if registry:
35+
registry.register(self)
36+
37+
def _iter_values(self) -> Iterable[tuple[bytes, str]]:
38+
cursor = 0
39+
while True:
40+
cursor, keys = self._client.scan(cursor=cursor, match="value:*")
41+
values = self._client.mget(keys)
42+
yield from zip(keys, values)
43+
if cursor == 0:
44+
break
45+
46+
def collect(self) -> Iterable[Metric]:
47+
metrics: dict[str, Metric] = {}
48+
histograms: set[str] = set()
49+
50+
for key, value_s in self._iter_values():
51+
# FIXME: Catch ValueError here, just in case?
52+
prefix_b, typ_b, mmap_key = key.split(b":", 2)
53+
assert prefix_b == b"value"
54+
typ = typ_b.decode()
55+
value = float(value_s)
56+
57+
metric_name, name, labels, help_text = json.loads(mmap_key)
58+
59+
metric = metrics.get(metric_name)
60+
if metric is None:
61+
metric = Metric(metric_name, help_text, typ)
62+
metrics[metric_name] = metric
63+
if typ in ("histogram", "gaugehistogram"):
64+
histograms.add(metric_name)
65+
66+
metric.add_sample(name, labels, value)
67+
68+
for name in histograms:
69+
self._fix_histogram(metrics[name])
70+
71+
return metrics.values()
72+
73+
def _fix_histogram(self, metric: Metric) -> None:
74+
"""
75+
Fix-up histogram samples.
76+
77+
Sort the buckets as expected by a client, and accumulate the values.
78+
The Histogram class is optimized to only increment the bucket that a
79+
value first appears in, not larger ones that would also contain it.
80+
"""
81+
by_label: dict[tuple[tuple[str, ...], str], list[Sample]] = {}
82+
83+
# Organize into lists of samples by label
84+
for sample in metric.samples:
85+
if "le" in sample.labels:
86+
labels_without_le = sample.labels.copy()
87+
labels_without_le.pop("le")
88+
key = (tuple(labels_without_le.values()), sample.name)
89+
else:
90+
key = (tuple(sample.labels.values()), sample.name)
91+
by_label.setdefault(key, []).append(sample)
92+
93+
metric.samples = []
94+
95+
for (labels, name), samples in sorted(by_label.items()):
96+
if name.endswith("_bucket"):
97+
# Sort buckets within each label
98+
samples.sort(key=lambda sample: float(sample.labels["le"]))
99+
100+
# Accumulate values into larger buckets
101+
value = 0.0
102+
for sample in samples:
103+
value += sample.value
104+
metric.samples.append(Sample(sample.name, sample.labels, value))
105+
106+
else:
107+
metric.samples.extend(samples)

prometheus_client/values.py

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import os
22
from threading import Lock
3+
from typing import Any
34
import warnings
45

56
from .mmap_dict import mmap_key, MmapedDict
7+
from .redis_collector import redis_client
8+
from .samples import Exemplar
69

710

811
class MutexValue:
@@ -125,12 +128,66 @@ def get_exemplar(self):
125128
return MmapedValue
126129

127130

131+
def RedisValue():
132+
"""
133+
A value implementation that stores data in a redis/valkey database.
134+
135+
Key scheme:
136+
* value:typ:MMAP_KEY
137+
"""
138+
client = redis_client()
139+
140+
class RedisValueImpl:
141+
"""A float stored by redis."""
142+
143+
_multiprocess = False
144+
145+
def __init__(
146+
self,
147+
typ: str,
148+
metric_name: str,
149+
name: str,
150+
labelnames: list[str],
151+
labelvalues: list[str],
152+
help_text: str,
153+
**kwargs: Any,
154+
) -> None:
155+
key = mmap_key(metric_name, name, labelnames, labelvalues, help_text)
156+
self._key = f"value:{typ}:{key}"
157+
self._redis = client
158+
self._redis.setnx(self._key, 0.0)
159+
160+
def inc(self, amount: float) -> None:
161+
print(f"inc {self._key=} {amount=}")
162+
self._redis.incrbyfloat(self._key, amount)
163+
164+
def set(self, value: float, timestamp: float | None = None) -> None:
165+
print(f"set {self._key=} {value=}")
166+
# TODO: Implement timestamps
167+
self._redis.set(self._key, value)
168+
169+
def set_exemplar(self, exemplar: Exemplar) -> None:
170+
# TODO: Implement exemplars for redis.
171+
return
172+
173+
def get_exemplar(self) -> Exemplar | None:
174+
# TODO: Implement exemplars for redis.
175+
return None
176+
177+
def get(self) -> float:
178+
return float(self._redis.get(self._key))
179+
180+
return RedisValueImpl
181+
182+
128183
def get_value_class():
129184
# Should we enable multi-process mode?
130185
# This needs to be chosen before the first metric is constructed,
131186
# and as that may be in some arbitrary library the user/admin has
132187
# no control over we use an environment variable.
133-
if 'prometheus_multiproc_dir' in os.environ or 'PROMETHEUS_MULTIPROC_DIR' in os.environ:
188+
if "PROMETHEUS_REDIS_URL" in os.environ:
189+
return RedisValue()
190+
elif 'prometheus_multiproc_dir' in os.environ or 'PROMETHEUS_MULTIPROC_DIR' in os.environ:
134191
return MultiProcessValue()
135192
else:
136193
return MutexValue

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ aiohttp = [
5050
django = [
5151
"django",
5252
]
53+
redis = [
54+
"redis",
55+
]
5356

5457
[project.urls]
5558
Homepage = "https://github.com/prometheus/client_python"

0 commit comments

Comments
 (0)