diff --git a/docs/configuration.md b/docs/configuration.md index 9b0cb80b3..025cb2d47 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -2094,6 +2094,24 @@ of `distro.id()`, the fall back on the values reported by `distro.like()`. Following this logic, the `debian` key will be applied to Debian, Raspberry Pi OS, Ubuntu, and likely other Debian derived distributions. +### `[prometheus]` + +Enables Prometheus metrics endpoint at `/server/prometheus/metrics`. + +The endpoint returns the metrics in the "normal" format (not openmetrics) +without gzip compression and without filtering metrics by name. +Parameters or headers coming from the scraping client which would +control the behavior above are ignored. + +Note that in the future they might be respected, but this would require +a major refactoring of request handling code. + +```ini +# moonraker.conf + +[prometheus] +``` + ### `[mqtt]` Enables an MQTT Client. When configured most of Moonraker's APIs are available diff --git a/moonraker/components/prometheus.py b/moonraker/components/prometheus.py new file mode 100644 index 000000000..0d70a59b0 --- /dev/null +++ b/moonraker/components/prometheus.py @@ -0,0 +1,157 @@ +# Prometheus client implementation for Moonraker +# +# Copyright (C) 2024 Kamil DomaƄski +# +# This file may be distributed under the terms of the GNU GPLv3 license. + +from __future__ import annotations +from asyncio import gather +import logging +from prometheus_client import ( + exposition, registry, + Info, Gauge +) +from prometheus_client.metrics import MetricWrapperBase + +from ..common import ( + KlippyState, + RequestType, + TransportType, + WebRequest +) + +from .klippy_apis import KlippyAPI + +from typing import ( + TYPE_CHECKING, + Dict, + Any +) + +if TYPE_CHECKING: + from ..confighelper import ConfigHelper + +class PrometheusExporter: + def __init__(self, config: ConfigHelper) -> None: + self.server = config.get_server() + app_args = self.server.get_app_args() + + # Not saved in the object, because it should not be changed or cleared. + i = Info('moonraker_instance', '') + i.info({ + 'version': app_args['software_version'], + 'instance_uuid': app_args['instance_uuid'], + 'python_version': app_args['python_version'], + }) + + # metrics + self.m_temp = Gauge( + 'temp', 'Current temperature of a heater or sensor', ['sensor']) + self.m_target_temp = Gauge( + 'target_temp', 'Target temperature of a heater or fan', ['sensor']) + self.m_heater_power = Gauge( + 'heater_power', 'Current power setting of a heater', ['heater']) + + self.server.register_endpoint( + "/server/prometheus/metrics", RequestType.GET, + self._handle_metrics_endpoint, transports=TransportType.HTTP, + wrap_result=False, content_type=exposition.CONTENT_TYPE_LATEST + ) + self.server.register_event_handler( + "server:klippy_started", self._handle_klippy_started + ) + + # clear metrics to stop providing metrics when their value is simply unknown + self.server.register_event_handler( + "server:klippy_shutdown", self._clear_metrics + ) + self.server.register_event_handler( + "server:klippy_disconnect", self._clear_metrics + ) + self.server.register_event_handler( + "server:klippy_disconnected", self._clear_metrics + ) + + async def _get_objects_to_subscribe(self) -> Dict[str, Any]: + kapi: KlippyAPI = self.server.lookup_component("klippy_apis") + result = await kapi.query_objects({'heaters': None}) + heaters_dict = result.get("heaters", {}) + + heaters = set(heaters_dict.get("available_heaters", [])) + sensors = set(heaters_dict.get("available_sensors", [])) + + return {s: None for s in heaters.union(sensors)} + + async def _init_metrics(self, objs: Dict[str, Any]) -> None: + """Gets the current status of all the objects. Without it, we'd only export + the metrics which have changed since Moonraker statup.""" + kapi: KlippyAPI = self.server.lookup_component("klippy_apis") + result = await kapi.query_objects(objs) + await self._handle_status_update(result, None) + + async def _handle_klippy_started(self, state: KlippyState) -> None: + """Upon klippy startup, it queries current statuses + and subscribes for updates.""" + self._clear_metrics() + + kapi: KlippyAPI = self.server.lookup_component("klippy_apis") + subs = await self._get_objects_to_subscribe() + + await gather( + self._init_metrics(subs), + kapi.subscribe_objects(subs, self._handle_status_update) + ) + + logging.info("Prometheus handler registered and subscribed to status updates") + + async def _handle_status_update(self, status: Dict[str, Dict[str, Any]], + eventtime: float | None) -> None: + for key, value in status.items(): + module = key.split()[0] + if module in ['heater_bed', 'extruder', 'heater_generic']: + self._status_update_heater(key, value) + elif module in ['temperature_combined', 'temperature_sensor', 'tmc2240', + 'temperature_fan']: + self._status_update_temp_sensor(key, value) + else: + logging.debug("[prometheus]: unhandled status for object %s" % key) + + def _status_update_temp_sensor(self, sensor_name: str, + status: Dict[str, Any]) -> None: + temp = status.get('temperature', None) + if temp is not None: + self.m_temp.labels(sensor_name).set(temp) + + def _status_update_heater(self, heater_name: str, + status: Dict[str, Any]) -> None: + temp = status.get('temperature', None) + if temp is not None: + self.m_temp.labels(heater_name).set(temp) + + target = status.get('target', None) + if target is not None: + self.m_target_temp.labels(heater_name).set(target) + + power = status.get('power', None) + if power is not None: + self.m_heater_power.labels(heater_name).set(power) + + def _clear_metrics(self) -> None: + for attr_name, attr_value in self.__dict__.items(): + if isinstance(attr_value, MetricWrapperBase): + attr_value.clear() + + async def _handle_metrics_endpoint(self, web_request: WebRequest) -> bytes: + """Writes metrics in response to the scrape. + + Usually some properties of the response depend on request headers. + To make request headers available here, a serious refactoring would be needed. + Instead, we make some assumptions: + - Response will be in the "normal" format and not openmetrics + - No filtering by name will be done + - gzip won't be used (also sparing the CPU cycles in return for bandwidth) + """ + return exposition.generate_latest(registry.REGISTRY) + +def load_component(config: ConfigHelper) -> PrometheusExporter: + return PrometheusExporter(config) diff --git a/pyproject.toml b/pyproject.toml index 40a87c274..d5cd15dec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ dependencies = [ "jinja2==3.1.3", "dbus-next==0.2.3", "apprise==1.7.0", + "prometheus_client~=0.20.0", "ldap3==2.9.1", "python-periphery==2.4.1" ] diff --git a/scripts/moonraker-requirements.txt b/scripts/moonraker-requirements.txt index af985632c..5314ca2ed 100644 --- a/scripts/moonraker-requirements.txt +++ b/scripts/moonraker-requirements.txt @@ -18,5 +18,6 @@ preprocess-cancellation==0.2.1 jinja2==3.1.3 dbus-next==0.2.3 apprise==1.7.1 +prometheus_client~=0.20.0 ldap3==2.9.1 python-periphery==2.4.1