Skip to content

Commit 548db62

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

7 files changed

Lines changed: 530 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: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
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+
for key, value in zip(keys, values):
43+
yield (key, value)
44+
if cursor == 0:
45+
break
46+
47+
def collect(self) -> Iterable[Metric]:
48+
metrics: dict[str, Metric] = {}
49+
histograms: set[str] = set()
50+
51+
for key, value_s in self._iter_values():
52+
# FIXME: Catch ValueError here, just in case?
53+
prefix_b, typ_b, mmap_key = key.split(b":", 2)
54+
assert prefix_b == b"value"
55+
typ = typ_b.decode()
56+
value = float(value_s)
57+
58+
metric_name, name, labels, help_text = json.loads(mmap_key)
59+
60+
metric = metrics.get(metric_name)
61+
if metric is None:
62+
metric = Metric(metric_name, help_text, typ)
63+
metrics[metric_name] = metric
64+
if typ in ("histogram", "gaugehistogram"):
65+
histograms.add(metric_name)
66+
67+
metric.add_sample(name, labels, value)
68+
69+
for name in histograms:
70+
self._fix_histogram(metrics[name])
71+
72+
return metrics.values()
73+
74+
def _fix_histogram(self, metric: Metric) -> None:
75+
"""
76+
Fix-up histogram samples.
77+
78+
Sort the buckets as expected by a client, and accumulate the values.
79+
The Histogram class is optimized to only increment the bucket that a
80+
value first appears in, not larger ones that would also contain it.
81+
"""
82+
by_label: dict[tuple[tuple[str, ...], str], list[Sample]] = {}
83+
84+
# Organize into lists of samples by label
85+
for sample in metric.samples:
86+
if "le" in sample.labels:
87+
labels_without_le = sample.labels.copy()
88+
labels_without_le.pop("le")
89+
key = (tuple(labels_without_le.values()), sample.name)
90+
else:
91+
key = (tuple(sample.labels.values()), sample.name)
92+
by_label.setdefault(key, []).append(sample)
93+
94+
metric.samples = []
95+
96+
for (labels, name), samples in sorted(by_label.items()):
97+
if name.endswith("_bucket"):
98+
# Sort buckets within each label
99+
samples.sort(key=lambda sample: float(sample.labels["le"]))
100+
101+
# Accumulate values into larger buckets
102+
value = 0.0
103+
for sample in samples:
104+
value += sample.value
105+
metric.samples.append(Sample(sample.name, sample.labels, value))
106+
107+
else:
108+
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)