Skip to content

Commit ab78e2b

Browse files
committed
repo init
Signed-off-by: Zen <[email protected]>
0 parents  commit ab78e2b

File tree

10 files changed

+769
-0
lines changed

10 files changed

+769
-0
lines changed

.gitignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
*.swp
2+
*.swo
3+
__pycache__/*
4+
*.pyc
5+
build/*
6+
dist/*
7+
*.egg-info/

LICENSE

Lines changed: 339 additions & 0 deletions
Large diffs are not rendered by default.

config.toml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
2+
listen_ip = '0.0.0.0'
3+
listen_port = 9999
4+
5+
[metrics.test]
6+
type = "counter"
7+
value = 1
8+
help = "test counter"
9+
10+
[metrics.test.labels]
11+
asdf = "1234"
12+
13+
[metrics.abcd]
14+
type = "gauge"
15+
value = 2
16+
help = "test gauge"
17+
18+
[labels]
19+
test = "test"

pyproject.toml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
[build-system]
2+
requires = ["setuptools>=61.0"]
3+
build-backend = "setuptools.build_meta"
4+
5+
[project]
6+
name = "prometheus_exporter"
7+
version = "0.3.0"
8+
authors = [
9+
{ name="Desultory", email="[email protected]" },
10+
]
11+
description = "A Python library for exporting data as metrics for Prometheus"
12+
readme = "readme.md"
13+
requires-python = ">=3.11"
14+
classifiers = [
15+
"Programming Language :: Python :: 3",
16+
"License :: OSI Approved :: GNU General Public License v2 (GPLv2)",
17+
"Operating System :: OS Independent",
18+
]
19+
dependencies = [
20+
"zenlib >= 1.1.1"
21+
]
22+
23+
[project.scripts]
24+
prometheus_exporter = "prometheus_exporter.main:main"
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from .exporter import Exporter
2+
3+
__all__ = ['Exporter']
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
"""
2+
Basic exporter class for prometheus metrics.
3+
Runs a ThreadingHTTPServer with a PrometheusRequest handler.
4+
The PrometheusRequest handler processes requests from Promotheus, by default returns server.export().
5+
The server.export() method goes through all defined metrics and returns them as a string.
6+
If a dict is passed to the export method, it will be used to filter by that label.
7+
"""
8+
9+
from http.server import ThreadingHTTPServer
10+
from pathlib import Path
11+
12+
from zenlib.logging import loggify
13+
14+
from .labels import Labels
15+
from .metric import Metric
16+
from .prometheus_request import PrometheusRequest
17+
18+
DEFAULT_IP = '127.0.0.1'
19+
DEFAULT_PORT = 9809
20+
21+
22+
@loggify
23+
class Exporter(ThreadingHTTPServer):
24+
"""
25+
Basic prometheus metric exporter class.
26+
Extends the ThreadingHTTPServer class.
27+
Forces use of the PrometheusRequest RequestHandlerClass.
28+
Reads a config.toml file to read the server port and ip.
29+
If 'ip' and 'port' are passed as kwargs, they will override the config file.
30+
"""
31+
def __init__(self, config_file='config.toml', labels=Labels(), *args, **kwargs):
32+
self.labels = Labels(dict_items=labels, logger=self.logger, _log_init=False)
33+
self.metrics = []
34+
self.config_file = Path(config_file)
35+
self.read_config()
36+
37+
kwargs['RequestHandlerClass'] = PrometheusRequest
38+
ip = self.config['listen_ip'] if 'ip' not in kwargs else kwargs.pop('ip', DEFAULT_IP)
39+
port = self.config['listen_port'] if 'port' not in kwargs else kwargs.pop('port', DEFAULT_PORT)
40+
kwargs['server_address'] = (ip, port)
41+
42+
super().__init__(*args, **kwargs)
43+
44+
def __setattr__(self, name, value):
45+
if name == 'labels' and not isinstance(value, Labels):
46+
raise ValueError("Labels must be a dict.")
47+
super().__setattr__(name, value)
48+
49+
def read_config(self):
50+
""" Reads the config file defined in self.config_file """
51+
from tomllib import load
52+
with open(self.config_file, 'rb') as config:
53+
self.config = load(config)
54+
55+
self.logger.info("Read config file: %s", self.config_file)
56+
self.labels.update(self.config.get('labels', {}))
57+
58+
self.add_metrics()
59+
60+
def add_metrics(self):
61+
""" Adds all defined metrics to the exporter. """
62+
for name, values in self.config.get('metrics', {}).items():
63+
kwargs = {'metric_type': values.pop('type'), 'labels': self.labels.copy(),
64+
'logger': self.logger, '_log_init': False}
65+
66+
# Add labels specified under the metric to ones in the exporter
67+
if labels := values.pop('labels', None):
68+
kwargs['labels'].update(labels)
69+
70+
self.logger.info("Adding metric: %s", name)
71+
self.metrics.append(Metric(name=name, **kwargs, **values))
72+
73+
def get_metrics(self):
74+
"""
75+
Gets all defined metrics.
76+
"""
77+
return [metric for metric in Metric.metrics]
78+
79+
def _filter_metrics(self, metrics, label_filter):
80+
"""
81+
Filters a list of metrics by a label_filter.
82+
"""
83+
for label_name, label_value in label_filter.items():
84+
if label_name not in self.labels.global_labels:
85+
raise ValueError("label_filter contains unknown label: %s", label_name)
86+
if label_value not in self.labels.global_labels[label_name]:
87+
raise ValueError("[%s] label_filter contains unknown label value: %s" % (label_name, label_value))
88+
return [metric for metric in metrics if metric.labels[label_name] == label_value]
89+
90+
def export(self, label_filter=None):
91+
"""
92+
Go through ALL DEFINED metrics, turn them into a metric string for prometheus.
93+
If a label_filter is passed, only return metrics that match the label_filter.
94+
"""
95+
metrics = self.get_metrics()
96+
if label_filter:
97+
metrics = self._filter_metrics(metrics, label_filter)
98+
return "\n".join([str(metric) for metric in metrics])
99+
100+
def handle_error(self, request, client_address):
101+
""" Handle errors in the request handler. """
102+
from sys import exc_info
103+
from traceback import format_exception
104+
self.logger.warning("[%s:%d] Error in request: %s" % (*client_address, exc_info()[1]))
105+
exc = format_exception(*exc_info())
106+
self.logger.debug(''.join(exc).replace(r'\n', '\n'))

src/prometheus_exporter/labels.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
"""
2+
Labels dictionary, used by both Metrics and Exporters.
3+
4+
Each Labels dictionary only allows a label name to be set once.
5+
Labels are added to the global labels dictionary, which is used by
6+
the filter function in exporters.
7+
"""
8+
9+
from zenlib.logging import loggify
10+
11+
12+
@loggify
13+
class Labels(dict):
14+
""" A dictionary of labels, used by both Metrics and Exporters """
15+
global_labels = {}
16+
17+
def __init__(self, dict_items={}, **kwargs):
18+
""" Create a new Labels object from a dictionary """
19+
self.update(dict_items)
20+
21+
def __setitem__(self, key, value):
22+
self._check_label(key, value)
23+
super().__setitem__(key, value)
24+
self._update_global_labels(key, value)
25+
26+
def _update_global_labels(self, key, value):
27+
""" Update the global labels with the labels in this dictionary """
28+
if key not in Labels.global_labels:
29+
Labels.global_labels[key] = [value]
30+
else:
31+
Labels.global_labels[key].append(value)
32+
33+
def update(self, new_labels):
34+
""" Updates the labels with the new labels """
35+
for key, value in new_labels.items():
36+
self[key] = value
37+
self.logger.debug("Added label %s=%s", key, value)
38+
39+
def _check_label(self, name: str, value: str):
40+
""" Check that the label name and value are valid """
41+
# Check that the label name is a string
42+
if not isinstance(name, str):
43+
raise TypeError('Label names must be strings')
44+
45+
# Check that the label name is not already defined
46+
if name in self:
47+
raise ValueError("Label is already defined: %s" % name)
48+
# Check that the label value is a string
49+
if not isinstance(value, str):
50+
raise TypeError('Label values must be strings')
51+
52+
def __str__(self):
53+
return ','.join(['%s="%s"' % (name, value) for name, value in self.items()])
54+
55+
56+

src/prometheus_exporter/main.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
#!/usr/bin/env python3
2+
3+
from logging import getLogger, StreamHandler
4+
from argparse import ArgumentParser
5+
from signal import signal
6+
7+
from .exporter import Exporter
8+
9+
from zenlib.logging import ColorLognameFormatter
10+
11+
12+
def main():
13+
argparser = ArgumentParser(prog='json_exporter', description='JSON Exporter for Prometheus')
14+
15+
argparser.add_argument('-d', '--debug', action='store_true', help='Debug mode.')
16+
argparser.add_argument('-dd', '--verbose', action='store_true', help='Verbose debug mode.')
17+
18+
argparser.add_argument('-v', '--version', action='store_true', help='Print the version and exit.')
19+
20+
argparser.add_argument('-p', '--port', type=int, nargs='?', help='Port to listen on.')
21+
argparser.add_argument('-a', '--address', type=str, nargs='?', help='Address to listen on.')
22+
23+
args = argparser.parse_args()
24+
25+
if args.version:
26+
from importlib.metadata import version
27+
print(f"{__package__} {version(__package__)}")
28+
exit(0)
29+
30+
logger = getLogger(__package__)
31+
32+
if args.verbose:
33+
logger.setLevel(5)
34+
formatter = ColorLognameFormatter('%(levelname)s | %(name)-42s | %(message)s')
35+
elif args.debug:
36+
logger.setLevel(10)
37+
formatter = ColorLognameFormatter('%(levelname)s | %(name)-42s | %(message)s')
38+
else:
39+
logger.setLevel(20)
40+
formatter = ColorLognameFormatter()
41+
42+
handler = StreamHandler()
43+
handler.setFormatter(formatter)
44+
logger.addHandler(handler)
45+
46+
kwargs = {'logger': logger}
47+
48+
if args.port:
49+
kwargs['port'] = args.port
50+
if args.address:
51+
kwargs['ip'] = args.address
52+
53+
exporter = Exporter(**kwargs)
54+
55+
def handle_shutdown_signal(sig, frame):
56+
logger.info(f"Received signal: {sig}. Shutting down...")
57+
if hasattr(exporter, '__is_shut_down') and not exporter.__is_shut_down.is_set():
58+
exporter.shutdown()
59+
exit(0)
60+
61+
# Handle SIGINT, SIGTERM, SIGQUIT, SIGABRT
62+
for sig in [2, 15, 3, 6]:
63+
signal(sig, handle_shutdown_signal)
64+
65+
exporter.serve_forever()
66+
67+
68+
if __name__ == '__main__':
69+
main()

src/prometheus_exporter/metric.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
"""
2+
Basic prometheus metric class
3+
"""
4+
5+
from enum import Enum
6+
7+
from zenlib.logging import loggify
8+
9+
from .labels import Labels
10+
11+
12+
class MetricTypes(Enum):
13+
"""
14+
Prometheus metric types
15+
"""
16+
COUNTER = 'counter'
17+
GAUGE = 'gauge'
18+
UNTYPED = 'untyped'
19+
20+
21+
@loggify
22+
class Metric:
23+
"""
24+
A class used to represent a prometheus metric.
25+
Labels can be added to the metric by passing a dictionary as the labels argument.
26+
The value defaults to 0.
27+
"""
28+
metrics = []
29+
30+
def __init__(self, name, value=0, metric_type='untyped', help=None, labels=Labels(), *args, **kwargs):
31+
"""
32+
Initialize the metric
33+
"""
34+
if name in self.metrics:
35+
raise ValueError("Metric name already exists: %s" % name)
36+
37+
self.name = name
38+
self.type = metric_type
39+
self.help = help
40+
self.labels = Labels(labels, logger=self.logger, _log_init=False)
41+
self.value = value
42+
Metric.metrics.append(self)
43+
44+
def __del__(self):
45+
"""
46+
Delete the metric from the metrics dictionary
47+
"""
48+
Metric.metrics.remove(self)
49+
50+
def __setattr__(self, name, value):
51+
"""
52+
Turn spaces in the name into underscores.
53+
Set the metric type based on the MetricTypes enum.
54+
"""
55+
if name == 'name':
56+
value = value.replace(' ', '_')
57+
if name == 'type':
58+
value = MetricTypes[value.upper()]
59+
if name == 'value':
60+
if not isinstance(value, (int, float)):
61+
raise TypeError('Value must be an integer or float')
62+
63+
super().__setattr__(name, value)
64+
65+
def __str__(self):
66+
"""
67+
String representation of the metric
68+
"""
69+
if self.help:
70+
out_str = f'# HELP {self.name} {self.help}\n'
71+
else:
72+
out_str = ''
73+
74+
out_str += f'# TYPE {self.name} {self.type.value}\n{self.name}'
75+
76+
if self.labels:
77+
out_str = f"{out_str}{{{self.labels}}}"
78+
79+
out_str += f' {self.value}'
80+
return out_str

0 commit comments

Comments
 (0)