├── src ├── __init__.py ├── options │ ├── __init__.py │ ├── boolean_option.py │ ├── string_option.py │ ├── integer_option.py │ ├── choices_option.py │ └── option.py ├── collectors │ ├── __init__.py │ ├── gc_collector.py │ ├── platform_collector.py │ └── mailman3_collector.py ├── metric_processing_time.py ├── cache.py ├── api.py └── config.py ├── .gitignore ├── requirements.txt ├── mailman3_exporter ├── Dockerfile ├── mailman_exporter.py ├── docker.md └── README.md /src/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/options/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/collectors/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /pyvenv.cfg 2 | /bin 3 | /lib 4 | /lib64 5 | share/python-wheels 6 | 7 | /.idea -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | certifi==2023.7.22 2 | charset-normalizer==2.1.1 3 | idna==3.4 4 | prometheus-client==0.17.1 5 | requests==2.31.0 6 | six==1.16.0 7 | urllib3==2.0.4 8 | -------------------------------------------------------------------------------- /mailman3_exporter: -------------------------------------------------------------------------------- 1 | #!/opt/mailman3_exporter-0.9/bin/python3 2 | # -*- coding: utf-8 -*- 3 | 4 | import sys 5 | import mailman_exporter 6 | 7 | if __name__ == '__main__': 8 | try: 9 | mailman_exporter.main() 10 | except KeyboardInterrupt: 11 | sys.exit(0) 12 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM docker.io/alpine:3.20.0 2 | 3 | MAINTAINER Jules Stähli 4 | 5 | WORKDIR /home/mailman3_exporter 6 | 7 | ENV PYTHONUNBUFFERED=1 8 | 9 | COPY ["./mailman3_exporter", "./mailman_exporter.py", "./requirements.txt", "./"] 10 | COPY ./src ./src 11 | 12 | RUN apk add --update --no-cache python3 py-pip \ 13 | && ln -sf python3 /usr/bin/python \ 14 | && pip3 install --break-system-packages -r requirements.txt 15 | 16 | EXPOSE 9934 17 | 18 | ENTRYPOINT ["python3", "mailman_exporter.py"] 19 | -------------------------------------------------------------------------------- /src/options/boolean_option.py: -------------------------------------------------------------------------------- 1 | from src.options.choices_option import ChoicesOption 2 | from argparse import ArgumentParser, Namespace 3 | 4 | ENABLED_TEXT = 'true' 5 | DISABLED_TEXT = 'false' 6 | BOOLEAN_CHOICES = [ENABLED_TEXT, DISABLED_TEXT] 7 | 8 | 9 | class BooleanOption(ChoicesOption): 10 | def __init__(self, parser: ArgumentParser, name: str, default_value: bool, env_var_name: str, help_text: str, name_and_flags: list[str]): 11 | super().__init__(parser, name, BOOLEAN_CHOICES, str(default_value).lower(), env_var_name, help_text, name_and_flags) 12 | 13 | def value(self, args: Namespace) -> bool: 14 | return super().value(args) == ENABLED_TEXT 15 | -------------------------------------------------------------------------------- /src/metric_processing_time.py: -------------------------------------------------------------------------------- 1 | import time 2 | from prometheus_client.core import GaugeMetricFamily 3 | import logging 4 | 5 | 6 | class metric_processing_time: 7 | def __init__(self, name: str, processing_time: GaugeMetricFamily): 8 | self.start = None 9 | self.name = name 10 | self.processing_time = processing_time 11 | 12 | def __enter__(self): 13 | self.start = time.process_time() 14 | 15 | def __exit__(self, exc_type, exc_val, exc_tb): 16 | elapsed = (time.process_time() - self.start) * 1000 17 | logging.debug('Processing %s took %s miliseconds' % (self.name, elapsed)) 18 | self.processing_time.add_metric([self.name], elapsed) -------------------------------------------------------------------------------- /src/options/string_option.py: -------------------------------------------------------------------------------- 1 | from typing import Self 2 | from src.options.option import Option 3 | from argparse import ArgumentParser 4 | 5 | 6 | class StringOption(Option[str]): 7 | def __init__(self, parser: ArgumentParser, name: str, default_value: str, env_var_name: str, help_text: str, name_and_flags: list[str]): 8 | super().__init__(parser, name, default_value, env_var_name, help_text, name_and_flags) 9 | 10 | def _add_argument(self) -> Self: 11 | self.parser.add_argument( 12 | *self.name_and_flags, 13 | dest=self.name, 14 | type=str, 15 | help=self.help_text 16 | ) 17 | return self 18 | 19 | def _validator(self, value: str) -> bool: 20 | return True 21 | 22 | def _parse_value(self, value: str) -> str: 23 | return value 24 | 25 | def _env_var_name_validation_error_message(self, env_var_name: str, value: str) -> str: 26 | return f"{env_var_name} must be a valid value: {value}" 27 | -------------------------------------------------------------------------------- /src/options/integer_option.py: -------------------------------------------------------------------------------- 1 | from typing import Self 2 | from src.options.option import Option 3 | from argparse import ArgumentParser 4 | 5 | 6 | class IntegerOption(Option[int]): 7 | def __init__(self, parser: ArgumentParser, name: str, default_value: int, env_var_name: str, help_text: str, name_and_flags: list[str]): 8 | super().__init__(parser, name, default_value, env_var_name, help_text, name_and_flags) 9 | 10 | def _add_argument(self) -> Self: 11 | self.parser.add_argument( 12 | *self.name_and_flags, 13 | dest=self.name, 14 | type=int, 15 | help=self.help_text 16 | ) 17 | return self 18 | 19 | def _validator(self, value: str) -> bool: 20 | return value.isdigit() 21 | 22 | def _parse_value(self, value: str) -> int: 23 | return int(value) 24 | 25 | def _env_var_name_validation_error_message(self, env_var_name: str, value: str) -> str: 26 | return f"{env_var_name} must be an integer: {value}" 27 | -------------------------------------------------------------------------------- /src/options/choices_option.py: -------------------------------------------------------------------------------- 1 | from typing import Self 2 | from src.options.option import Option 3 | from argparse import ArgumentParser 4 | 5 | 6 | class ChoicesOption(Option[str]): 7 | def __init__(self, parser: ArgumentParser, name: str, choices: list[str], default_value: str, env_var_name: str, help_text: str, name_and_flags: list[str]): 8 | self.choices = choices 9 | super().__init__(parser, name, default_value, env_var_name, help_text, name_and_flags) 10 | 11 | def _add_argument(self) -> Self: 12 | self.parser.add_argument( 13 | *self.name_and_flags, 14 | dest=self.name, 15 | type=str, 16 | choices=self.choices, 17 | help=self.help_text 18 | ) 19 | return self 20 | 21 | def _validator(self, value: str) -> bool: 22 | return value in self.choices 23 | 24 | def _parse_value(self, value: str) -> str: 25 | return value 26 | 27 | def _env_var_name_validation_error_message(self, env_var_name: str, value: str) -> str: 28 | return f"{env_var_name} must be one of ({', '.join(self.choices)}): {value}" 29 | -------------------------------------------------------------------------------- /src/cache.py: -------------------------------------------------------------------------------- 1 | import time 2 | import logging 3 | from src.api import Api 4 | from typing import Any 5 | from src.config import Config 6 | 7 | 8 | class Cache: 9 | def __init__(self, api: Api, config: Config): 10 | self.api = api 11 | self.config = config 12 | self.last_data_refresh_time = 0 13 | self.refresh_data = True 14 | 15 | self._domains_status = 0 16 | self._domains = [] 17 | self._lists_status = 0 18 | self._lists = [] 19 | 20 | def refresh_time(self): 21 | if not self.config.enable_caching: 22 | return 23 | now = time.monotonic() 24 | elapsed_time = now - self.last_data_refresh_time 25 | if elapsed_time > self.config.cache_duration_in_seconds: 26 | logging.debug("refresh cache data") 27 | self.refresh_data = True 28 | self.last_data_refresh_time = now 29 | else: 30 | self.refresh_data = False 31 | 32 | def domains(self) -> tuple[int, Any]: 33 | if self.refresh_data: 34 | self._domains_status, self._domains = self.api.domains() 35 | return self._domains_status, self._domains 36 | 37 | def lists(self) -> tuple[int, Any]: 38 | if self.refresh_data: 39 | self._lists_status, self._lists = self.api.lists() 40 | return self._lists_status, self._lists 41 | -------------------------------------------------------------------------------- /src/collectors/gc_collector.py: -------------------------------------------------------------------------------- 1 | import gc 2 | import platform 3 | from typing import Iterable 4 | 5 | from prometheus_client.core import CounterMetricFamily, Metric 6 | from prometheus_client.registry import Collector, CollectorRegistry, REGISTRY 7 | 8 | 9 | class GCCollector(Collector): 10 | """Collector for Garbage collection statistics.""" 11 | 12 | def __init__(self, namespace: str = '', registry: CollectorRegistry = REGISTRY): 13 | self.prefix = f"{namespace}_" if namespace else '' 14 | if not hasattr(gc, 'get_stats') or platform.python_implementation() != 'CPython': 15 | return 16 | registry.register(self) 17 | 18 | def collect(self) -> Iterable[Metric]: 19 | collected = CounterMetricFamily( 20 | f"{self.prefix}python_gc_objects_collected", 21 | 'Objects collected during gc', 22 | labels=['generation'], 23 | ) 24 | uncollectable = CounterMetricFamily( 25 | f"{self.prefix}python_gc_objects_uncollectable", 26 | 'Uncollectable objects found during GC', 27 | labels=['generation'], 28 | ) 29 | 30 | collections = CounterMetricFamily( 31 | f"{self.prefix}python_gc_collections", 32 | 'Number of times this generation was collected', 33 | labels=['generation'], 34 | ) 35 | 36 | for gen, stat in enumerate(gc.get_stats()): 37 | generation = str(gen) 38 | collected.add_metric([generation], value=stat['collected']) 39 | uncollectable.add_metric([generation], value=stat['uncollectable']) 40 | collections.add_metric([generation], value=stat['collections']) 41 | 42 | return [collected, uncollectable, collections] 43 | -------------------------------------------------------------------------------- /src/api.py: -------------------------------------------------------------------------------- 1 | from requests import get, Response 2 | import logging 3 | from typing import Any 4 | from src.config import Config 5 | 6 | DEFAULT_RESPONSE = {'status_code': 0} 7 | 8 | 9 | class Api: 10 | def __init__(self, config: Config): 11 | self.config = config 12 | url = self.mailman_url('/') 13 | logging.info(f"Querying Mailman at URL: <{url}>") 14 | 15 | def mailman_url(self, uri: str = "") -> str: 16 | return f"{self.config.mailman_address}/{self.config.mailman_api_version}{uri}" 17 | 18 | def make_request(self, name: str, endpoint: str) -> tuple[int, Any]: 19 | url = self.mailman_url(endpoint) 20 | try: 21 | response = get(url, auth=(self.config.mailman_user, self.config.mailman_password)) 22 | if 200 <= response.status_code < 220: 23 | return response.status_code, response.json() 24 | else: 25 | logging.debug(f"{name}: url {url}") 26 | logging.debug(f"{name}: content {response.content[:160]}") 27 | return response.status_code, {} 28 | except Exception as e: 29 | logging.error(f"{name}(exception): {e}") 30 | return 500, {} 31 | 32 | def usercount(self) -> tuple[int, Any]: 33 | return self.make_request('usercount', '/users?count=1&page=1') 34 | 35 | def versions(self) -> tuple[int, Any]: 36 | return self.make_request('versions', '/system/versions') 37 | 38 | def domains(self) -> tuple[int, Any]: 39 | return self.make_request('domains', '/domains') 40 | 41 | def lists(self) -> tuple[int, Any]: 42 | return self.make_request('lists', '/lists') 43 | 44 | def queues(self) -> tuple[int, Any]: 45 | return self.make_request('queues', '/queues') 46 | -------------------------------------------------------------------------------- /src/collectors/platform_collector.py: -------------------------------------------------------------------------------- 1 | import platform as pf 2 | from typing import Any, Iterable, Optional 3 | 4 | from prometheus_client.core import GaugeMetricFamily, Metric 5 | from prometheus_client.registry import (Collector, CollectorRegistry, REGISTRY) 6 | 7 | 8 | class PlatformCollector(Collector): 9 | """Collector for python platform information""" 10 | 11 | def __init__(self, 12 | namespace: str = '', 13 | registry: Optional[CollectorRegistry] = REGISTRY, 14 | platform: Optional[Any] = None, 15 | ): 16 | self.prefix = f"{namespace}_" if namespace else '' 17 | self._platform = pf if platform is None else platform 18 | info = self._info() 19 | system = self._platform.system() 20 | if system == "Java": 21 | info.update(self._java()) 22 | self._metrics = [ 23 | self._add_metric(f"{self.prefix}python_info", "Python platform information", info) 24 | ] 25 | if registry: 26 | registry.register(self) 27 | 28 | def collect(self) -> Iterable[Metric]: 29 | return self._metrics 30 | 31 | @staticmethod 32 | def _add_metric(name, documentation, data): 33 | labels = data.keys() 34 | values = [data[k] for k in labels] 35 | g = GaugeMetricFamily(name, documentation, labels=labels) 36 | g.add_metric(values, 1) 37 | return g 38 | 39 | def _info(self): 40 | major, minor, patchlevel = self._platform.python_version_tuple() 41 | return { 42 | "version": self._platform.python_version(), 43 | "implementation": self._platform.python_implementation(), 44 | "major": major, 45 | "minor": minor, 46 | "patchlevel": patchlevel 47 | } 48 | 49 | def _java(self): 50 | java_version, _, vminfo, osinfo = self._platform.java_ver() 51 | vm_name, vm_release, vm_vendor = vminfo 52 | return { 53 | "jvm_version": java_version, 54 | "jvm_release": vm_release, 55 | "jvm_vendor": vm_vendor, 56 | "jvm_name": vm_name 57 | } 58 | -------------------------------------------------------------------------------- /mailman_exporter.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | """ 4 | Prometheus mailman3 exporter using rest api's. 5 | Created by rivimey. 6 | """ 7 | import logging 8 | import sys 9 | import signal 10 | import time 11 | from prometheus_client import start_http_server, CollectorRegistry, ProcessCollector 12 | from src.collectors.platform_collector import PlatformCollector 13 | from src.collectors.gc_collector import GCCollector 14 | from src.config import Config, DEFAULT_WAIT_FOR_MAILMAN_SLEEP_INTERVAL_IN_SECONDS 15 | from src.collectors.mailman3_collector import Mailman3Collector 16 | from src.api import Api 17 | from time import sleep 18 | 19 | 20 | def signal_handler(_sig: int, _frame: None) -> None: 21 | shutdown(1) 22 | 23 | 24 | def shutdown(code: int) -> None: 25 | logging.info('Shutting down') 26 | sys.exit(code) 27 | 28 | 29 | def wait_for_mailman(api: Api, interval_in_seconds: float = DEFAULT_WAIT_FOR_MAILMAN_SLEEP_INTERVAL_IN_SECONDS) -> None: 30 | while True: 31 | status, resp = api.versions() 32 | if 200 <= status < 220: 33 | return 34 | else: 35 | logging.info(f"Mailman connection failed, sleeping... (status: {status})") 36 | sleep(interval_in_seconds) 37 | 38 | 39 | def main() -> None: 40 | signal.signal(signal.SIGTERM, signal_handler) 41 | 42 | config = Config() 43 | api = Api(config) 44 | 45 | wait_for_mailman(api) 46 | 47 | logging.info('Starting server...') 48 | registry = CollectorRegistry() 49 | 50 | if config.enable_gc_metrics: 51 | GCCollector(namespace=config.namespace, registry=registry) 52 | if config.enable_platform_metrics: 53 | PlatformCollector(namespace=config.namespace, registry=registry) 54 | if config.enable_process_metrics: 55 | ProcessCollector(namespace=config.namespace, registry=registry) 56 | Mailman3Collector(api=api, config=config, registry=registry) 57 | 58 | start_http_server(addr=config.hostname, port=config.port, registry=registry) 59 | logging.info(f"Server started on port {config.port}") 60 | 61 | while True: 62 | time.sleep(1) 63 | 64 | 65 | if __name__ == '__main__': 66 | try: 67 | main() 68 | except KeyboardInterrupt: 69 | sys.exit(0) 70 | -------------------------------------------------------------------------------- /src/options/option.py: -------------------------------------------------------------------------------- 1 | from typing import TypeVar, Generic, Self 2 | from argparse import ArgumentParser, Namespace 3 | from os import environ 4 | from abc import ABC, abstractmethod 5 | import logging 6 | 7 | T = TypeVar('T') 8 | 9 | 10 | class Option(Generic[T], ABC): 11 | def __init__(self, parser: ArgumentParser, name: str, default_value: T, env_var_name: str, help_text: str, name_and_flags: list[str]): 12 | self.parser = parser 13 | self.name = name 14 | self.default_value = default_value 15 | self.env_var_name = env_var_name 16 | self.help_text = help_text 17 | self.name_and_flags = name_and_flags 18 | self._add_argument() 19 | 20 | def value(self, args: Namespace) -> T: 21 | env_var_value = self._retrieve_env_var_value() 22 | option_value = self._retrieve_option_value(args) 23 | if option_value is not None: 24 | return option_value 25 | elif env_var_value is not None: 26 | return env_var_value 27 | return self.default_value 28 | 29 | @abstractmethod 30 | def _add_argument(self) -> Self: 31 | raise NotImplementedError() 32 | 33 | @abstractmethod 34 | def _validator(self, value: str) -> bool: 35 | raise NotImplementedError() 36 | 37 | @abstractmethod 38 | def _parse_value(self, value: str) -> T: 39 | raise NotImplementedError() 40 | 41 | @abstractmethod 42 | def _env_var_name_validation_error_message(self, env_var_name: str, value: str) -> str: 43 | raise NotImplementedError() 44 | 45 | def _retrieve_env_var_value(self) -> T | None: 46 | # Check env var name is set 47 | if self.env_var_name is None: 48 | return None 49 | # Check env var exists 50 | if self.env_var_name not in environ: 51 | return None 52 | value = environ[self.env_var_name] 53 | # Check value is valid 54 | if not self._validator(value): 55 | logging.error(self._env_var_name_validation_error_message(self.env_var_name, value)) 56 | # Parse and return value 57 | return self._parse_value(value) 58 | 59 | def _retrieve_option_value(self, args: Namespace) -> T | None: 60 | value = getattr(args, self.name) 61 | if value is None or value == '': 62 | return None 63 | return value 64 | -------------------------------------------------------------------------------- /docker.md: -------------------------------------------------------------------------------- 1 | # Mailman3 Exporter for Prometheus (docker) 2 | 3 | > Don't forget `--web.listen` 4 | 5 | ## Build image 6 | 7 | ```shell 8 | docker build -t {image_name}:{version} . 9 | ``` 10 | 11 | ## Run 12 | 13 | ```shell 14 | docker run --rm -p 9934:9934 --name mailman3_exporter {image_name}:{version} --mailman.user {mailman_user} --mailman.password {mailman_password} --mailman.address {mailman_address} --web.listen 0.0.0.0:9934 15 | ``` 16 | 17 | The metrics are accessible at `http://127.0.0.1:9934` 18 | 19 | ## Publish image 20 | 21 | ### Tag version 22 | 23 | ```shell 24 | docker tag {image_name}:{version} {docker_username}/{image_name}:{version} 25 | ``` 26 | 27 | ### Push image 28 | 29 | ```shell 30 | docker push {docker_username}/{image_name}:{version} 31 | ``` 32 | 33 | ## k8s 34 | 35 | ```yml 36 | # Mailman 3 prometheus exporter 37 | apiVersion: apps/v1 38 | kind: Deployment 39 | metadata: 40 | name: mailman-prometheus-exporter-deployment 41 | labels: 42 | app: mailman-prometheus-exporter 43 | spec: 44 | replicas: 1 45 | selector: 46 | matchLabels: 47 | app: mailman-prometheus-exporter 48 | template: 49 | metadata: 50 | labels: 51 | app: mailman-prometheus-exporter 52 | spec: 53 | containers: 54 | - name: mailman-prometheus-exporter 55 | image: docker.io/{docker_username}/{image_name}:{version} 56 | resources: 57 | limits: 58 | memory: "2Gi" 59 | cpu: "2" 60 | args: ["--mailman.user", "$(MAILMAN_USER)", "--mailman.password", "$(MAILMAN_PASSWORD)", "--mailman.address", "http://mailman-core:8001", "--web.listen", "0.0.0.0:9934"] 61 | ports: 62 | - containerPort: 9934 63 | name: metrics 64 | env: 65 | - name: MAILMAN_USER 66 | valueFrom: 67 | secretKeyRef: 68 | name: mailman-core-secret 69 | key: mailman-rest-user 70 | - name: MAILMAN_PASSWORD 71 | valueFrom: 72 | secretKeyRef: 73 | name: mailman-core-secret 74 | key: mailman-rest-password 75 | --- 76 | # Mailman 3 prometheus exporter service 77 | apiVersion: v1 78 | kind: Service 79 | metadata: 80 | name: mailman-prometheus-exporter 81 | namespace: mailing-list 82 | spec: 83 | type: NodePort 84 | ports: 85 | - name: metrics 86 | port: 9934 87 | targetPort: 9934 88 | nodePort: 30705 89 | selector: 90 | app: mailman-prometheus-exporter 91 | 92 | ``` 93 | 94 | ## Troubleshooting 95 | 96 | ### Error: This site can’t be reached 97 | 98 | You probably forgot `--web.listen 0.0.0.0:9934` -------------------------------------------------------------------------------- /src/collectors/mailman3_collector.py: -------------------------------------------------------------------------------- 1 | from prometheus_client.registry import Collector, CollectorRegistry, REGISTRY 2 | from prometheus_client.core import GaugeMetricFamily, CounterMetricFamily 3 | from src.metric_processing_time import metric_processing_time 4 | import logging 5 | from src.api import Api 6 | from src.cache import Cache 7 | from src.config import Config 8 | 9 | 10 | class Mailman3Collector(Collector): 11 | 12 | def __init__(self, api: Api, config: Config, registry: CollectorRegistry = REGISTRY): 13 | self.api = api 14 | self.config = config 15 | self.cache = Cache(api, config) 16 | 17 | if registry: 18 | registry.register(self) 19 | 20 | def collect_domains(self, processing_time: GaugeMetricFamily) -> None: 21 | with metric_processing_time('domains', processing_time): 22 | mailman3_domains = GaugeMetricFamily(f"{self.config.prefix}mailman3_domains", 'Number of configured list domains') 23 | domains_status, domains = self.cache.domains() 24 | if 200 <= domains_status < 220: 25 | mailman3_domains.add_metric(['count'], domains['total_size']) 26 | else: 27 | mailman3_domains.add_metric(['count'], 0) 28 | yield mailman3_domains 29 | 30 | def collect_lists(self, processing_time: GaugeMetricFamily) -> None: 31 | with metric_processing_time('lists', processing_time): 32 | mailman3_lists = GaugeMetricFamily(f"{self.config.prefix}mailman3_lists", 'Number of configured lists') 33 | no_lists = False 34 | lists_status, lists = self.cache.lists() 35 | if 200 <= lists_status < 220: 36 | mailman3_lists.add_metric(['count'], lists['total_size']) 37 | else: 38 | mailman3_lists.add_metric(['count'], 0) 39 | no_lists = True 40 | yield mailman3_lists 41 | 42 | mlabels = ['list'] 43 | if not no_lists: 44 | for e in lists['entries']: 45 | logging.debug("members: label %s" % e['fqdn_listname']) 46 | mlabels.append(e['fqdn_listname']) 47 | mailman3_list_members = CounterMetricFamily(f"{self.config.prefix}mailman3_list_members", 'Count members per list', 48 | labels=mlabels) 49 | if not no_lists: 50 | for e in lists['entries']: 51 | logging.debug("members metric %s value %s", e['fqdn_listname'], str(e['member_count'])) 52 | mailman3_list_members.add_metric([e['fqdn_listname']], value=e['member_count']) 53 | yield mailman3_list_members 54 | 55 | def collect_up(self, processing_time: GaugeMetricFamily) -> None: 56 | with metric_processing_time('up', processing_time): 57 | mailman3_up = GaugeMetricFamily(f"{self.config.prefix}mailman3_up", 'Status of mailman-core; 1 if accessible, 0 otherwise') 58 | status, resp = self.api.versions() 59 | if 200 <= status < 220: 60 | mailman3_up.add_metric(['up'], 1) 61 | else: 62 | mailman3_up.add_metric(['up'], 0) 63 | yield mailman3_up 64 | 65 | def collect_users(self, processing_time: GaugeMetricFamily) -> None: 66 | with metric_processing_time('users', processing_time): 67 | mailman3_users = CounterMetricFamily(f"{self.config.prefix}mailman3_users", 'Number of list users recorded in mailman-core') 68 | status, resp = self.api.usercount() 69 | if 200 <= status < 220: 70 | mailman3_users.add_metric(['count'], resp['total_size']) 71 | else: 72 | mailman3_users.add_metric(['count'], 0) 73 | yield mailman3_users 74 | 75 | def collect_queue(self, processing_time: GaugeMetricFamily) -> None: 76 | with metric_processing_time('queue', processing_time): 77 | qlabels = ['queue', 78 | "archive", "bad", "bounces", "command", 79 | "digest", "in", "nntp", "out", "pipeline", 80 | "retry", "shunt", "virgin" 81 | ] 82 | mailman3_queue = GaugeMetricFamily(f"{self.config.prefix}mailman3_queues", 'Queue length for mailman-core internal queues', 83 | labels=qlabels) 84 | mailman3_queue_status = GaugeMetricFamily(f"{self.config.prefix}mailman3_queues_status", 'HTTP code for queue status request') 85 | status, resp = self.api.queues() 86 | if 200 <= status < 220: 87 | for e in resp['entries']: 88 | logging.debug("queue metric %s value %s", e['name'], str(e['count'])) 89 | mailman3_queue.add_metric([e['name']], value=e['count']) 90 | mailman3_queue_status.add_metric(['status'], value=status) 91 | else: 92 | mailman3_queue_status.add_metric(['status'], value=status) 93 | yield mailman3_queue 94 | 95 | def proc_labels(self): 96 | labels = [ 97 | ('up', self.config.enable_up_metrics), 98 | ('queue', self.config.enable_queue_metrics), 99 | ('domains', self.config.enable_domains_metrics), 100 | ('lists', self.config.enable_lists_metrics), 101 | ('users', self.config.enable_users_metrics), 102 | ] 103 | labels = filter(lambda label: label[1] is True, labels) 104 | return ['method', *[label[0] for label in labels]] 105 | 106 | def collect(self) -> None: 107 | processing_time = GaugeMetricFamily(f"{self.config.prefix}processing_time_ms", 'Time taken to collect metrics', labels=self.proc_labels()) 108 | 109 | self.cache.refresh_time() 110 | 111 | if self.config.enable_domains_metrics: 112 | yield from self.collect_domains(processing_time) 113 | if self.config.enable_lists_metrics: 114 | yield from self.collect_lists(processing_time) 115 | if self.config.enable_up_metrics: 116 | yield from self.collect_up(processing_time) 117 | if self.config.enable_users_metrics: 118 | yield from self.collect_users(processing_time) 119 | if self.config.enable_queue_metrics: 120 | yield from self.collect_queue(processing_time) 121 | yield processing_time 122 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Mailman3 Exporter for Prometheus 2 | 3 | This prometheus exporter monitors the [mailman3](https://www.mailman.org/) mailing list server. 4 | Stats are collected using mailman3 core process own REST API and include status, number of lists, 5 | list names, number of users per list, and more. 6 | 7 | ## Installing 8 | 9 | `git clone` this repository. Create a virtual environment, e.g.: 10 | 11 | ```shell script 12 | python3 -m venv . 13 | ``` 14 | 15 | and then install the required packages: 16 | 17 | ```shell script 18 | pip3 install -r requirements.txt 19 | ``` 20 | 21 | The program can then be run, e.g. by: 22 | 23 | ```shell script 24 | python3 ./mailman3_exporter.py -p PASS -u USER 25 | ``` 26 | 27 | If python complains packages are missing, check that you are invoking the 28 | program with the correct virtual environment. 29 | 30 | ## Usage 31 | 32 | By default, the exporter serves on port `9934` at `/metrics`. The help message 33 | includes: 34 | 35 | ``` 36 | usage: mailman_exporter.py [-h] 37 | [--log-level {debug,info,warning,error,critical}] 38 | [--log-config {true,false}] 39 | [--web.listen WEB_LISTEN] [-m MAILMAN_ADDRESS] 40 | [-u MAILMAN_USER] [-p MAILMAN_PASSWORD] 41 | [--namespace NAMESPACE] [--cache {true,false}] 42 | [--cache.duration CACHE_DURATION] 43 | [--enable.gc {true,false}] 44 | [--metrics.platform {true,false}] 45 | [--metrics.process {true,false}] 46 | [--metrics.domains {true,false}] 47 | [--metrics.lists {true,false}] 48 | [--metrics.up {true,false}] 49 | [--metrics.users {true,false}] 50 | [--metrics.queue {true,false}] 51 | 52 | Mailman3 Prometheus metrics exporter 53 | 54 | options: 55 | -h, --help show this help message and exit 56 | --log-level {debug,info,warning,error,critical} 57 | Detail level to log. (default: info) 58 | --log-config {true,false} 59 | Log the current configuration except for sensitive 60 | information (log level: info). Can be used for 61 | debugging purposes. (default: false) 62 | --web.listen WEB_LISTEN 63 | HTTPServer metrics listen address (default: 64 | localhost:9934) 65 | -m MAILMAN_ADDRESS, --mailman.address MAILMAN_ADDRESS 66 | Mailman3 Core REST API address (default: 67 | http://mailman-core:8001) 68 | -u MAILMAN_USER, --mailman.user MAILMAN_USER 69 | Mailman3 Core REST API username (default: restadmin) 70 | -p MAILMAN_PASSWORD, --mailman.password MAILMAN_PASSWORD 71 | Mailman3 Core REST API password (default: restpass) 72 | --namespace NAMESPACE 73 | Metrics namespace (default: ) 74 | --cache {true,false} Enable caching (default: true) 75 | --cache.duration CACHE_DURATION 76 | Cache duration in seconds (default: 30) 77 | --enable.gc {true,false} 78 | Enable garbage collection metrics (default: true) 79 | --metrics.platform {true,false} 80 | Enable platform metrics (default: true) 81 | --metrics.process {true,false} 82 | Enable process metrics (default: true) 83 | --metrics.domains {true,false} 84 | Enable domains metrics (default: true) 85 | --metrics.lists {true,false} 86 | Enable lists metrics (default: true) 87 | --metrics.up {true,false} 88 | Enable up metrics (default: true) 89 | --metrics.users {true,false} 90 | Enable users metrics (default: true) 91 | --metrics.queue {true,false} 92 | Enable queue metrics (default: true) 93 | 94 | ``` 95 | 96 | The following environment variables can also be used: 97 | 98 | ``` 99 | ME_LOG_LEVEL 100 | ME_LOG_CONFIG 101 | ME_WEB_LISTEN 102 | ME_MAILMAN_ADDRESS 103 | ME_MAILMAN_USERNAME 104 | ME_MAILMAN_PASSWORD 105 | ME_NAMESPACE 106 | ME_ENABLE_CACHING 107 | ME_CACHE_DURATION_IN_SECONDS 108 | ME_ENABLE_GC_METRICS 109 | ME_ENABLE_PLATFORM_METRICS 110 | ME_ENABLE_PROCESS_METRICS 111 | ME_ENABLE_DOMAINS_METRICS 112 | ME_ENABLE_LISTS_METRICS 113 | ME_ENABLE_UP_METRICS 114 | ME_ENABLE_USERS_METRICS 115 | ME_ENABLE_QUEUE_METRICS 116 | ``` 117 | 118 | ## Metrics 119 | 120 | ``` 121 | # HELP mailman3_domains Number of configured list domains 122 | # TYPE mailman3_domains gauge 123 | mailman3_domains 1.0 124 | # HELP mailman3_lists Number of configured lists 125 | # TYPE mailman3_lists gauge 126 | mailman3_lists 8.0 127 | # HELP mailman3_list_members_total Count members per list 128 | # TYPE mailman3_list_members_total counter 129 | mailman3_list_members_total{list="list1@example.com"} 104.0 130 | mailman3_list_members_total{list="list2@example.com"} 26.0 131 | mailman3_list_members_total{list="list3@example.com"} 7.0 132 | mailman3_list_members_total{list="list4@example.com"} 74.0 133 | mailman3_list_members_total{list="list5@example.com"} 30.0 134 | mailman3_list_members_total{list="list6@example.com"} 6.0 135 | mailman3_list_members_total{list="list7@example.com"} 1.0 136 | mailman3_list_members_total{list="list8@example.com"} 1.0 137 | # HELP mailman3_up Status of mailman-core; 1 if accessible, 0 otherwise 138 | # TYPE mailman3_up gauge 139 | mailman3_up 1.0 140 | # HELP mailman3_users_total Number of list users recorded in mailman-core 141 | # TYPE mailman3_users_total counter 142 | mailman3_users_total 288.0 143 | # HELP mailman3_queues Queue length for mailman-core internal queues 144 | # TYPE mailman3_queues gauge 145 | mailman3_queues{queue="archive"} 10.0 146 | mailman3_queues{queue="bad"} 0.0 147 | mailman3_queues{queue="bounces"} 0.0 148 | mailman3_queues{queue="command"} 0.0 149 | mailman3_queues{queue="digest"} 0.0 150 | mailman3_queues{queue="in"} 1.0 151 | mailman3_queues{queue="nntp"} 0.0 152 | mailman3_queues{queue="out"} 0.0 153 | mailman3_queues{queue="pipeline"} 0.0 154 | mailman3_queues{queue="retry"} 0.0 155 | mailman3_queues{queue="shunt"} 1.0 156 | mailman3_queues{queue="virgin"} 0.0 157 | # HELP processing_time_ms Time taken to collect metrics 158 | # TYPE processing_time_ms gauge 159 | processing_time_ms{method="domains"} 0.04233299999967244 160 | processing_time_ms{method="lists"} 0.22640099999993168 161 | processing_time_ms{method="up"} 5.324605999999843 162 | processing_time_ms{method="users"} 7.315147000000355 163 | processing_time_ms{method="queue"} 3.1242169999998737 164 | ``` 165 | 166 | ## Docker 167 | 168 | See: [docker.md](./docker.md) 169 | 170 | -------------------------------------------------------------------------------- /src/config.py: -------------------------------------------------------------------------------- 1 | from argparse import ArgumentParser 2 | import logging 3 | import re 4 | from src.options.choices_option import ChoicesOption 5 | from src.options.boolean_option import BooleanOption 6 | from src.options.string_option import StringOption 7 | from src.options.integer_option import IntegerOption 8 | 9 | DEFAULT_MAILMAN_API_VERSION = "3.1" 10 | 11 | DEFAULT_WAIT_FOR_MAILMAN_SLEEP_INTERVAL_IN_SECONDS = 1 12 | 13 | 14 | def parse_host_port(web_listen: str, default_hostname: str = 'localhost', default_port: int = 9934) -> tuple[str, int]: 15 | uri_info = re.split(r':', web_listen) 16 | match len(uri_info): 17 | case 0: 18 | hostname = default_hostname 19 | port = default_port 20 | case 1: 21 | hostname = uri_info[0] 22 | port = default_port 23 | case 2: 24 | hostname = uri_info[0] 25 | port = int(uri_info[1]) 26 | case _: 27 | logging.error(f"Listen address in unexpected form (got '{web_listen}')", ) 28 | raise ValueError(f"listen address in unexpected form (got '{web_listen}')") 29 | return hostname, port 30 | 31 | 32 | class Config: 33 | def __init__(self): 34 | log_format = '[%(asctime)s] %(name)s.%(levelname)s %(threadName)s %(message)s' 35 | log_handler = logging.StreamHandler() 36 | log_handler.setFormatter(logging.Formatter(log_format)) 37 | logging.basicConfig(handlers=[log_handler], level='INFO') 38 | logging.captureWarnings(True) 39 | parser = ArgumentParser(description='Mailman3 Prometheus metrics exporter') 40 | log_level_option = ChoicesOption( 41 | parser=parser, 42 | name='log_level', 43 | choices=['debug', 'info', 'warning', 'error', 'critical'], 44 | default_value='info', 45 | env_var_name='ME_LOG_LEVEL', 46 | help_text=f"Detail level to log. (default: info)", 47 | name_and_flags=['--log-level'] 48 | ) 49 | log_config_option = BooleanOption( 50 | parser=parser, 51 | name='log_config', 52 | default_value=False, 53 | env_var_name='ME_LOG_CONFIG', 54 | help_text="Log the current configuration except for sensitive information (log level: info). " 55 | f"Can be used for debugging purposes. (default: false)", 56 | name_and_flags=['--log-config'] 57 | ) 58 | web_listen_option = StringOption( 59 | parser=parser, 60 | name='web_listen', 61 | default_value='localhost:9934', 62 | env_var_name='ME_WEB_LISTEN', 63 | help_text=f"HTTPServer metrics listen address (default: localhost:9934)", 64 | name_and_flags=['--web.listen'] 65 | ) 66 | mailman_address_option = StringOption( 67 | parser=parser, 68 | name='mailman_address', 69 | default_value='http://mailman-core:8001', 70 | env_var_name='ME_MAILMAN_ADDRESS', 71 | help_text=f"Mailman3 Core REST API address (default: http://mailman-core:8001)", 72 | name_and_flags=['-m', '--mailman.address'] 73 | ) 74 | mailman_user_option = StringOption( 75 | parser=parser, 76 | name='mailman_user', 77 | default_value='restadmin', 78 | env_var_name='ME_MAILMAN_USERNAME', 79 | help_text=f"Mailman3 Core REST API username (default: restadmin)", 80 | name_and_flags=['-u', '--mailman.user'] 81 | ) 82 | mailman_password_option = StringOption( 83 | parser=parser, 84 | name='mailman_password', 85 | default_value='restpass', 86 | env_var_name='ME_MAILMAN_PASSWORD', 87 | help_text=f"Mailman3 Core REST API password (default: restpass)", 88 | name_and_flags=['-p', '--mailman.password'] 89 | ) 90 | namespace_option = StringOption( 91 | parser=parser, 92 | name='namespace', 93 | default_value='', 94 | env_var_name='ME_NAMESPACE', 95 | help_text=f"Metrics namespace (default: )", 96 | name_and_flags=['--namespace'] 97 | ) 98 | enable_caching_option = BooleanOption( 99 | parser=parser, 100 | name='enable_caching', 101 | default_value=True, 102 | env_var_name='ME_ENABLE_CACHING', 103 | help_text=f"Enable caching (default: true)", 104 | name_and_flags=['--cache'] 105 | ) 106 | cache_duration_option = IntegerOption( 107 | parser=parser, 108 | name='cache_duration', 109 | default_value=30, 110 | env_var_name='ME_CACHE_DURATION_IN_SECONDS', 111 | help_text=f"Cache duration in seconds (default: 30)", 112 | name_and_flags=['--cache.duration'] 113 | ) 114 | enable_gc_metrics_option = BooleanOption( 115 | parser=parser, 116 | name='enable_gc_metrics', 117 | default_value=True, 118 | env_var_name='ME_ENABLE_GC_METRICS', 119 | help_text=f"Enable garbage collection metrics (default: true)", 120 | name_and_flags=['--enable.gc'] 121 | ) 122 | enable_platform_metrics_option = BooleanOption( 123 | parser=parser, 124 | name='enable_platform_metrics', 125 | default_value=True, 126 | env_var_name='ME_ENABLE_PLATFORM_METRICS', 127 | help_text=f"Enable platform metrics (default: true)", 128 | name_and_flags=['--metrics.platform'] 129 | ) 130 | enable_process_metrics_option = BooleanOption( 131 | parser=parser, 132 | name='enable_process_metrics', 133 | default_value=True, 134 | env_var_name='ME_ENABLE_PROCESS_METRICS', 135 | help_text=f"Enable process metrics (default: true)", 136 | name_and_flags=['--metrics.process'] 137 | ) 138 | enable_domains_metrics_option = BooleanOption( 139 | parser=parser, 140 | name='enable_domains_metrics', 141 | default_value=True, 142 | env_var_name='ME_ENABLE_DOMAINS_METRICS', 143 | help_text=f"Enable domains metrics (default: true)", 144 | name_and_flags=['--metrics.domains'] 145 | ) 146 | enable_lists_metrics_option = BooleanOption( 147 | parser=parser, 148 | name='enable_lists_metrics', 149 | default_value=True, 150 | env_var_name='ME_ENABLE_LISTS_METRICS', 151 | help_text=f"Enable lists metrics (default: true)", 152 | name_and_flags=['--metrics.lists'] 153 | ) 154 | enable_up_metrics_option = BooleanOption( 155 | parser=parser, 156 | name='enable_up_metrics', 157 | default_value=True, 158 | env_var_name='ME_ENABLE_UP_METRICS', 159 | help_text=f"Enable up metrics (default: true)", 160 | name_and_flags=['--metrics.up'] 161 | ) 162 | enable_users_metrics_option = BooleanOption( 163 | parser=parser, 164 | name='enable_users_metrics', 165 | default_value=True, 166 | env_var_name='ME_ENABLE_USERS_METRICS', 167 | help_text=f"Enable users metrics (default: true)", 168 | name_and_flags=['--metrics.users'] 169 | ) 170 | enable_queue_metrics_option = BooleanOption( 171 | parser=parser, 172 | name='enable_queue_metrics', 173 | default_value=True, 174 | env_var_name='ME_ENABLE_QUEUE_METRICS', 175 | help_text=f"Enable queue metrics (default: true)", 176 | name_and_flags=['--metrics.queue'] 177 | ) 178 | 179 | args = parser.parse_args() 180 | 181 | self.log_level = log_level_option.value(args) 182 | logging.basicConfig(handlers=[log_handler], level=self.log_level.upper()) 183 | self.mailman_api_version = DEFAULT_MAILMAN_API_VERSION 184 | self.hostname, self.port = parse_host_port(web_listen_option.value(args)) 185 | self.mailman_address = mailman_address_option.value(args).strip('/') 186 | self.mailman_user = mailman_user_option.value(args) 187 | self.mailman_password = mailman_password_option.value(args) 188 | self.namespace = namespace_option.value(args).strip() 189 | self.cache_duration_in_seconds = cache_duration_option.value(args) 190 | self.enable_caching = enable_caching_option.value(args) and self.cache_duration_in_seconds >= 0 191 | self.enable_gc_metrics = enable_gc_metrics_option.value(args) 192 | self.enable_platform_metrics = enable_platform_metrics_option.value(args) 193 | self.enable_process_metrics = enable_process_metrics_option.value(args) 194 | self.enable_domains_metrics = enable_domains_metrics_option.value(args) 195 | self.enable_lists_metrics = enable_lists_metrics_option.value(args) 196 | self.enable_up_metrics = enable_up_metrics_option.value(args) 197 | self.enable_users_metrics = enable_users_metrics_option.value(args) 198 | self.enable_queue_metrics = enable_queue_metrics_option.value(args) 199 | if log_config_option.value(args): 200 | self.log_config() 201 | 202 | def log_config(self, prefix: str = 'config') -> None: 203 | no_format = lambda value: value 204 | obfusacte = lambda value: '*****' 205 | bool_to_string = lambda value: str(value).lower() 206 | entries = { 207 | 'log_level': (self.log_level, no_format), 208 | 'mailman_api_version': (self.mailman_api_version, no_format), 209 | 'hostname': (self.hostname, no_format), 210 | 'port': (self.port, no_format), 211 | 'mailman_address': (self.mailman_address, no_format), 212 | 'mailman_user': (self.mailman_user, obfusacte), 213 | 'mailman_password': (self.mailman_password, obfusacte), 214 | 'namespace': (self.namespace, no_format), 215 | 'enable_caching': (self.enable_caching, bool_to_string), 216 | 'cache_duration_in_seconds': (self.cache_duration_in_seconds, no_format), 217 | 'enable_gc_metrics': (self.enable_gc_metrics, bool_to_string), 218 | 'enable_platform_metrics': (self.enable_platform_metrics, bool_to_string), 219 | 'enable_process_metrics': (self.enable_process_metrics, bool_to_string), 220 | 'enable_domains_metrics': (self.enable_domains_metrics, bool_to_string), 221 | 'enable_lists_metrics': (self.enable_lists_metrics, bool_to_string), 222 | 'enable_up_metrics': (self.enable_up_metrics, bool_to_string), 223 | 'enable_users_metrics': (self.enable_users_metrics, bool_to_string), 224 | 'enable_queue_metrics': (self.enable_queue_metrics, bool_to_string), 225 | } 226 | for key in entries.keys(): 227 | logging.info(f"{prefix}({key}): {entries[key][1](entries[key][0])}") 228 | 229 | @property 230 | def prefix(self) -> str: 231 | return f"{self.namespace}_" if self.namespace else "" 232 | --------------------------------------------------------------------------------