Skip to content

Commit 3d37ccd

Browse files
authored
Merge pull request #5 from desultory/dev
improve/standardize the cached exporter, add tests
2 parents 76d0007 + 69ea75a commit 3d37ccd

File tree

4 files changed

+89
-45
lines changed

4 files changed

+89
-45
lines changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "prometheus_exporter"
7-
version = "1.1.0"
7+
version = "1.2.0"
88
authors = [
99
{ name="Desultory", email="[email protected]" },
1010
]

src/prometheus_exporter/cached_exporter.py

Lines changed: 49 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
1+
from time import time
2+
13
from .exporter import Exporter
24

5+
def is_positive_number(func):
6+
def wrapper(self, value):
7+
if not isinstance(value, int) and not isinstance(value, float):
8+
raise TypeError("%s must be an integer or float", func.__name__)
9+
if value < 0:
10+
raise ValueError("%s must be a positive number", func.__name__)
11+
return func(self, value)
12+
return wrapper
13+
314

415
def cached_exporter(cls):
516
if not isinstance(cls, Exporter) and not issubclass(cls, Exporter):
@@ -13,54 +24,59 @@ class CachedExporter(cls):
1324
"""
1425

1526
def __init__(self, *args, **kwargs):
27+
"""Call the super which reads the config.
28+
Prefer cache life setting from kwargs, then config, then default to 60 seconds.
29+
"""
1630
super().__init__(*args, **kwargs)
17-
if cache_life := kwargs.pop("cache_life", None):
18-
self.cache_life = cache_life
19-
elif not hasattr(self, "cache_life"):
20-
self.cache_life = 60
31+
self.cache_life = kwargs.pop("cache_life", self.config.get("cache_life", 60))
32+
self.logger.info("Cache life set to: %d seconds", self.cache_life)
33+
34+
@property
35+
def cache_life(self) -> int:
36+
return getattr(self, "_cache_life", 60)
37+
38+
@cache_life.setter
39+
@is_positive_number
40+
def cache_life(self, value) -> None:
41+
self.logger.info("Setting cache_life to: %ds", value)
42+
self._cache_life = value
43+
44+
@property
45+
def cache_time(self) -> int:
46+
return getattr(self, "_cache_time", 0)
2147

22-
def __setattr__(self, name, value):
23-
"""Override setattr for cache_life"""
24-
if name == "cache_life":
25-
if not isinstance(value, int):
26-
raise TypeError("cache_life must be an integer")
27-
if value < 0:
28-
raise ValueError("cache_life must be a positive integer")
29-
self.logger.info("Setting cache_life to: %ds", value)
30-
super().__setattr__(name, value)
48+
@cache_time.setter
49+
@is_positive_number
50+
def cache_time(self, value) -> None:
51+
self.logger.info("Setting cache_time to: %d", value)
52+
self._cache_time = value
3153

32-
def read_config(self):
33-
"""Override read_config to add cache_life"""
34-
super().read_config()
35-
if hasattr(self, "cache_life"):
36-
self.logger.debug("Cache life already set to: %ds", self.cache_life)
37-
return
38-
self.cache_life = self.config.get("cache_life", 60)
39-
self.logger.info("Set cache_life to: %d seconds", self.cache_life)
54+
@property
55+
def cache_age(self) -> int:
56+
""" Returns the age of the cache """
57+
cache_age = time() - getattr(self, "_cache_time", 0)
58+
self.logger.debug("[%s] Cache age: %d" % (self.name, cache_age))
59+
return time() - getattr(self, "_cache_time", 0)
4060

41-
async def get_metrics(self, label_filter={}):
42-
"""Get metrics from the exporter, caching the result."""
61+
async def get_metrics(self, label_filter={}) -> list:
62+
"""Get metrics from the exporter, respecting label filters and caching the result."""
4363
for key, value in label_filter.items():
4464
if key not in self.labels and self.labels[key] != value:
4565
self.logger.debug("Label filter check failed: %s != %s", self.labels, label_filter)
46-
return
47-
from time import time
66+
return []
4867

49-
cache_time = time() - getattr(self, "_cache_time", 0)
50-
name = getattr(self, "name", self.__class__.__name__)
51-
self.logger.debug("[%s] Cache time: %d" % (name, cache_time))
52-
if not hasattr(self, "_cached_metrics") or cache_time >= self.cache_life:
53-
self.metrics = []
68+
if not hasattr(self, "_cached_metrics") or self.cache_age >= self.cache_life:
5469
if new_metrics := await super().get_metrics(label_filter=label_filter):
5570
self.metrics = new_metrics
5671
self._cached_metrics = new_metrics
57-
self._cache_time = time()
72+
self.cache_time = time()
5873
elif hasattr(self, "_cached_metrics"):
59-
self.logger.warning("[%s] Exporter returned no metrics, returning cached metrics" % name)
74+
self.logger.warning("[%s] Exporter returned no metrics, returning cached metrics" % self.name)
6075
self.metrics = self._cached_metrics
6176
else:
62-
self.logger.log(5, "[%s] Returning cached metrics: %s" % (name, self._cached_metrics))
77+
self.logger.log(5, "[%s] Returning cached metrics: %s" % (self.name, self._cached_metrics))
6378
self.metrics = self._cached_metrics
79+
return self.metrics.copy()
6480

6581
CachedExporter.__name__ = f"Cached{cls.__name__}"
6682
CachedExporter.__module__ = cls.__module__

src/prometheus_exporter/exporter.py

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@
88
from asyncio import all_tasks, ensure_future
99
from pathlib import Path
1010
from signal import SIGHUP, SIGINT, signal
11+
from tomllib import load
1112

13+
from aiohttp import web
1214
from aiohttp.web import Application, Response, get
1315
from zenlib.logging import ClassLogger
1416

@@ -28,8 +30,10 @@ class Exporter(ClassLogger):
2830
Labels can be supplied as a dict as an argument, and in the config file.
2931
"""
3032

31-
def __init__(self, config_file="config.toml", labels=Labels(), no_config_file=False, *args, **kwargs):
33+
def __init__(self, config_file="config.toml", name=None, labels=Labels(), no_config_file=False, *args, **kwargs):
3234
super().__init__(*args, **kwargs)
35+
if name is not None:
36+
self.name = name
3337
self.labels = Labels(dict_items=labels, logger=self.logger)
3438
self.config_file = Path(config_file)
3539
if not no_config_file:
@@ -45,15 +49,24 @@ def __init__(self, config_file="config.toml", labels=Labels(), no_config_file=Fa
4549
self.app.add_routes([get("/metrics", self.handle_metrics)])
4650
self.app.on_shutdown.append(self.on_shutdown)
4751

52+
@property
53+
def name(self):
54+
return getattr(self, "_name", self.__class__.__name__)
55+
56+
@name.setter
57+
def name(self, value):
58+
if getattr(self, "_name", None) is not None:
59+
return self.logger.warning("[%s] Name already set, ignoring new name: %s", self.name, value)
60+
assert isinstance(value, str), "Name must be a string, not: %s" % type(value)
61+
self._name = value
62+
4863
def __setattr__(self, name, value):
4964
if name == "labels":
5065
assert isinstance(value, Labels), "Labels must be a 'Labels' object."
5166
super().__setattr__(name, value)
5267

5368
def read_config(self):
5469
"""Reads the config file defined in self.config_file"""
55-
from tomllib import load
56-
5770
with open(self.config_file, "rb") as config:
5871
self.config = load(config)
5972

@@ -62,8 +75,6 @@ def read_config(self):
6275

6376
def start(self):
6477
"""Starts the exporter server."""
65-
from aiohttp import web
66-
6778
self.logger.info("Exporter server address: %s:%d" % (self.listen_ip, self.listen_port))
6879
web.run_app(self.app, host=self.listen_ip, port=self.listen_port)
6980

@@ -77,13 +88,13 @@ async def on_shutdown(self, app):
7788
task.cancel()
7889

7990
def get_labels(self):
80-
""" Returns a copy of the labels dict.
91+
"""Returns a copy of the labels dict.
8192
This is designed to be extended, and the lables object may be modified by the caller.
8293
"""
8394
return self.labels.copy()
8495

8596
async def get_metrics(self, *args, **kwargs) -> list:
86-
""" Returns a copy of the metrics list.
97+
"""Returns a copy of the metrics list.
8798
This is designed to be extended in subclasses to get metrics from other sources.
8899
Clears the metric list before getting metrics, as layers may add metrics to the list.
89100
"""

tests/test_exporter.py

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
11
from asyncio import run
2-
from unittest import TestCase, expectedFailure, main
2+
from unittest import TestCase, main
33
from uuid import uuid4
44

55
from aiohttp.test_utils import AioHTTPTestCase
6-
from prometheus_exporter import Exporter
6+
from prometheus_exporter import Exporter, cached_exporter
77
from zenlib.logging import loggify
88

9+
@cached_exporter
10+
class TestCachedExporter(Exporter):
11+
async def get_metrics(self, *args, **kwargs) -> dict:
12+
metrics = await super().get_metrics(*args, **kwargs)
13+
print("Getting metrics:", metrics)
14+
return metrics
915

1016
def generate_random_metric_config(count: int) -> dict:
1117
"""Generate a random metric configuration"""
@@ -17,9 +23,9 @@ def generate_random_metric_config(count: int) -> dict:
1723

1824

1925
class TestExporter(TestCase):
20-
@expectedFailure
2126
def test_no_config(self):
22-
Exporter(config_file=str(uuid4())) # Pass a random string as config
27+
with self.assertRaises(FileNotFoundError):
28+
Exporter(config_file=str(uuid4())) # Pass a random string as config
2329

2430
def test_proper_no_config(self):
2531
e = Exporter(no_config_file=True)
@@ -36,6 +42,17 @@ def test_random_metrics(self):
3642
for metric in random_metrics:
3743
self.assertIn(f"{metric} 0", export1)
3844

45+
def test_cached_exporter(self):
46+
e = TestCachedExporter(no_config_file=True)
47+
e.config["metrics"] = generate_random_metric_config(100)
48+
export1 = run(e.export())
49+
e.config["metrics"] = generate_random_metric_config(100)
50+
export2 = run(e.export())
51+
self.assertEqual(export1, export2)
52+
e.cache_time = 0
53+
export3 = run(e.export())
54+
self.assertNotEqual(export1, export3)
55+
3956
def test_global_labels(self):
4057
"""Ensures that lables which are defined globally are applied to all metrics"""
4158
e = Exporter(labels={"global_label": "global_value"}, no_config_file=True)

0 commit comments

Comments
 (0)