diff --git a/py_air_control_exporter/app.py b/py_air_control_exporter/app.py index eb38982..c46ea70 100644 --- a/py_air_control_exporter/app.py +++ b/py_air_control_exporter/app.py @@ -5,10 +5,10 @@ from py_air_control_exporter import metrics -def create_app(targets: dict[str, metrics.Target]): +def create_app(readings_source: metrics.ReadingsSource): app = Flask(__name__) metrics_collector_registry = prometheus_client.CollectorRegistry(auto_describe=True) - metrics_collector_registry.register(metrics.PyAirControlCollector(targets)) + metrics_collector_registry.register(metrics.PyAirControlCollector(readings_source)) app.wsgi_app = DispatcherMiddleware( app.wsgi_app, {"/metrics": prometheus_client.make_wsgi_app(metrics_collector_registry)}, diff --git a/py_air_control_exporter/fetcher_registry.py b/py_air_control_exporter/fetcher_registry.py new file mode 100644 index 0000000..6bc9d0d --- /dev/null +++ b/py_air_control_exporter/fetcher_registry.py @@ -0,0 +1,25 @@ +from collections.abc import Iterable + +from py_air_control_exporter import fetchers_api +from py_air_control_exporter.fetchers import http_philips + +_KNOWN_FETCHERS: dict[str, fetchers_api.FetcherCreator] = { + "http": http_philips.create_fetcher, +} + + +class UnknownProtocolError(Exception): + pass + + +def get_known_protocols() -> Iterable[str]: + return _KNOWN_FETCHERS.keys() + + +def create_fetcher( + protocol: str, fetcher_config: fetchers_api.FetcherCreatorArgs +) -> fetchers_api.Fetcher: + fetcher_creator = _KNOWN_FETCHERS.get(protocol) + if fetcher_creator is None: + raise UnknownProtocolError + return fetcher_creator(fetcher_config) diff --git a/py_air_control_exporter/fetchers/fetcher_registry.py b/py_air_control_exporter/fetchers/fetcher_registry.py deleted file mode 100644 index b8ae6e8..0000000 --- a/py_air_control_exporter/fetchers/fetcher_registry.py +++ /dev/null @@ -1,6 +0,0 @@ -from py_air_control_exporter import fetchers_api -from py_air_control_exporter.fetchers import http_philips - -KNOWN_FETCHERS: dict[str, fetchers_api.FetcherCreator] = { - "http": http_philips.create_fetcher, -} diff --git a/py_air_control_exporter/fetchers/http_philips.py b/py_air_control_exporter/fetchers/http_philips.py index 0f6fbe6..5a39921 100644 --- a/py_air_control_exporter/fetchers/http_philips.py +++ b/py_air_control_exporter/fetchers/http_philips.py @@ -13,9 +13,7 @@ def create_fetcher(config: fetchers_api.FetcherCreatorArgs) -> fetchers_api.Fetc return lambda target_host=config.target_host: get_reading(target_host) -def get_reading( - host: str, -) -> fetchers_api.TargetReading | None: +def get_reading(host: str) -> fetchers_api.TargetReading: client = http_client.HTTPAirClient(host) try: @@ -23,16 +21,19 @@ def get_reading( filters_data = client.get_filters() or {} return fetchers_api.TargetReading( + host=host, + has_errors=False, air_quality=create_air_quality(status_data), control_info=create_control_info(status_data), filters=create_filter_info(filters_data), ) + except Exception as ex: LOG.error( "Could not read values from air control device %s. Error: %s", host, ex ) LOG.debug("Exception stack trace:", exc_info=True) - return None + return fetchers_api.TargetReading(host=host, has_errors=True) def create_air_quality(status_data: dict) -> fetchers_api.AirQuality: diff --git a/py_air_control_exporter/fetchers_api.py b/py_air_control_exporter/fetchers_api.py index 03b6efa..c3b65a4 100644 --- a/py_air_control_exporter/fetchers_api.py +++ b/py_air_control_exporter/fetchers_api.py @@ -23,12 +23,14 @@ class Filter: @dataclass(frozen=True) class TargetReading: - air_quality: AirQuality | None - control_info: ControlInfo | None - filters: dict[str, Filter] | None + host: str + has_errors: bool = False + air_quality: AirQuality | None = None + control_info: ControlInfo | None = None + filters: dict[str, Filter] | None = None -Fetcher = Callable[[], TargetReading | None] +Fetcher = Callable[[], TargetReading] @dataclass(frozen=True) diff --git a/py_air_control_exporter/main.py b/py_air_control_exporter/main.py index e07f582..85b74f1 100644 --- a/py_air_control_exporter/main.py +++ b/py_air_control_exporter/main.py @@ -1,13 +1,20 @@ import logging import sys +from dataclasses import dataclass from pathlib import Path from typing import Any import click import yaml -from py_air_control_exporter import app, fetchers_api, metrics -from py_air_control_exporter.fetchers import fetcher_registry +from py_air_control_exporter import app, fetcher_registry, fetchers_api, metrics + + +@dataclass(frozen=True) +class Target: + host: str + fetcher: fetchers_api.Fetcher + LOG = logging.getLogger(__name__) @@ -34,8 +41,7 @@ "--protocol", default="http", type=click.Choice( - tuple(fetcher_registry.KNOWN_FETCHERS.keys()), - case_sensitive=False, + tuple(fetcher_registry.get_known_protocols()), case_sensitive=False ), show_default=True, help="The protocol to use when communicating with the air purifier " @@ -76,7 +82,8 @@ def main(host, name, protocol, listen_address, listen_port, config, verbose, qui LOG.error("No targets specified. Please specify at least one target.") sys.exit(1) - app.create_app(targets).run(host=listen_address, port=listen_port) + readings_source = create_readings_source(targets) + app.create_app(readings_source).run(host=listen_address, port=listen_port) def setup_logging(verbosity_level: int) -> None: @@ -98,9 +105,36 @@ def load_config(config_path: Path | None) -> dict[str, Any]: return yaml.safe_load(f) +def create_readings_source( + targets: dict[str, Target], +) -> metrics.ReadingsSource: + def _fetch() -> dict[str, fetchers_api.TargetReading]: + target_readings = {} + for name, target in targets.items(): + target_reading = None + try: + target_reading = target.fetcher() + except Exception as ex: + LOG.error( + "Failed to sample the air quality from target '%s'. Error: %s", + name, + ex, + ) + LOG.debug("Exception stack trace:", exc_info=True) + continue + + if target_reading is None: + continue + + target_readings[name] = target_reading + return target_readings + + return _fetch + + def create_targets( targets_config: dict[str, dict] | None = None, -) -> dict[str, metrics.Target] | None: +) -> dict[str, Target] | None: targets = {} if targets_config: @@ -111,19 +145,18 @@ def create_targets( ) protocol = target_config["protocol"] - if protocol not in fetcher_registry.KNOWN_FETCHERS: + try: + targets[name] = Target( + host=fetcher_config.target_host, + fetcher=fetcher_registry.create_fetcher(protocol, fetcher_config), + ) + except fetcher_registry.UnknownProtocolError: LOG.error( "Unknown protocol '%s' for target '%s'. Known protocols: %s", protocol, name, - ", ".join(fetcher_registry.KNOWN_FETCHERS.keys()), + ", ".join(fetcher_registry.get_known_protocols()), ) return None - targets[name] = metrics.Target( - host=fetcher_config.target_host, - name=fetcher_config.target_name, - fetcher=fetcher_registry.KNOWN_FETCHERS[protocol](fetcher_config), - ) - return targets diff --git a/py_air_control_exporter/metrics.py b/py_air_control_exporter/metrics.py index 05af949..27afd35 100644 --- a/py_air_control_exporter/metrics.py +++ b/py_air_control_exporter/metrics.py @@ -1,7 +1,7 @@ import itertools import logging -from collections.abc import Iterable -from dataclasses import dataclass +from collections import defaultdict +from collections.abc import Callable, Iterable import prometheus_client.core from prometheus_client import Metric, registry @@ -11,62 +11,43 @@ LOG = logging.getLogger(__name__) -@dataclass(frozen=True) -class Target: - host: str - name: str - fetcher: fetchers_api.Fetcher +ReadingsSource = Callable[[], dict[str, fetchers_api.TargetReading]] class PyAirControlCollector(registry.Collector): - def __init__(self, targets: dict[str, Target]): - self._targets = targets - self._error_counters = {target_name: 0 for target_name in targets} + def __init__(self, readings_source: ReadingsSource): + self._readings_source = readings_source + self._error_counters = defaultdict(int) def collect(self): - targets_with_errors: set[str] = set() - target_readings: dict[str, fetchers_api.TargetReading] = {} - for name, target in self._targets.items(): - target_reading = None - try: - target_reading = target.fetcher() - except Exception as ex: - LOG.error( - "Failed to sample the air quality from target '%s'. Error: %s", - name, - ex, - ) - LOG.debug("Exception stack trace:", exc_info=True) - targets_with_errors.add(name) - continue - - if target_reading is None: - targets_with_errors.add(name) - continue - - target_readings[name] = target_reading + target_readings = self._readings_source() return itertools.chain( - self._sampling_error(targets_with_errors), + self._sampling_error(target_readings), self._air_quality_metrics(target_readings), self._control_info_metrics(target_readings), self._get_filters_metrics(target_readings), ) - def _sampling_error(self, targets_with_errors: set[str]) -> Iterable[Metric]: + def _sampling_error( + self, target_readings: dict[str, fetchers_api.TargetReading] + ) -> Iterable[Metric]: sampling_error = prometheus_client.core.CounterMetricFamily( "py_air_control_sampling_error", "Counts the number of times sampling air quality metrics failed.", labels=["host", "name"], ) - for name in targets_with_errors: - error_counter = self._error_counters[name] + 1 - self._error_counters[name] = error_counter - target = self._targets[name] - sampling_error.add_metric([target.host, target.name], error_counter) + for name, target_reading in target_readings.items(): + if target_reading.has_errors: + self._error_counters[name] += 1 + sampling_error.add_metric( + [target_reading.host, name], self._error_counters[name] + ) return [sampling_error] - def _air_quality_metrics(self, statuses: dict[str, fetchers_api.TargetReading]): + def _air_quality_metrics( + self, target_readings: dict[str, fetchers_api.TargetReading] + ) -> Iterable[Metric]: iaql = prometheus_client.core.GaugeMetricFamily( "py_air_control_air_quality", "IAI allergen index from 1 to 12, where 1 indicates best air quality.", @@ -79,24 +60,27 @@ def _air_quality_metrics(self, statuses: dict[str, fetchers_api.TargetReading]): labels=["host", "name"], ) - for name, full_status in statuses.items(): - air_quality = full_status.air_quality + for name, target_reading in target_readings.items(): + if target_reading is None: + continue + air_quality = target_reading.air_quality if air_quality is None: LOG.info("No air quality information from air quality host '%s'.", name) continue - target = self._targets[name] LOG.debug( "Got the following air quality information from host '%s': %s", - target.host, + target_reading.host, air_quality, ) - iaql.add_metric([target.host, target.name], air_quality.iaql) - pm25.add_metric([target.host, target.name], air_quality.pm25) + iaql.add_metric([target_reading.host, name], air_quality.iaql) + pm25.add_metric([target_reading.host, name], air_quality.pm25) return [iaql, pm25] - def _control_info_metrics(self, statuses: dict[str, fetchers_api.TargetReading]): + def _control_info_metrics( + self, target_readings: dict[str, fetchers_api.TargetReading] + ) -> Iterable[Metric]: is_manual = prometheus_client.core.GaugeMetricFamily( "py_air_control_is_manual", "Value '1' indicates manual mode while value '0' indicates automatic mode.", @@ -117,52 +101,56 @@ def _control_info_metrics(self, statuses: dict[str, fetchers_api.TargetReading]) labels=["host", "name"], ) - for name, full_status in statuses.items(): - control_info = full_status.control_info + for name, target_reading in target_readings.items(): + if target_reading is None: + continue + control_info = target_reading.control_info if control_info is None: LOG.info("No control info for air quality host '%s'.", name) continue - target = self._targets[name] LOG.debug( "Got the following control info from host '%s': %s", - target.host, + target_reading.host, control_info, ) is_manual.add_metric( - [target.host, target.name], 1 if control_info.is_manual else 0 + [target_reading.host, name], 1 if control_info.is_manual else 0 + ) + is_on.add_metric( + [target_reading.host, name], 1 if control_info.is_on else 0 ) - is_on.add_metric([target.host, target.name], 1 if control_info.is_on else 0) - speed.add_metric([target.host, target.name], control_info.fan_speed) + speed.add_metric([target_reading.host, name], control_info.fan_speed) return [is_manual, is_on, speed] def _get_filters_metrics( self, target_readings: dict[str, fetchers_api.TargetReading] - ): + ) -> Iterable[Metric]: filter_metric_family = prometheus_client.core.GaugeMetricFamily( "py_air_control_filter_hours", "The number of values left before the filter has to be replaced or cleaned", labels=["host", "name", "id", "type"], ) - for name, status in target_readings.items(): - filters = status.filters + for name, target_reading in target_readings.items(): + if target_reading is None: + continue + filters = target_reading.filters if filters is None: LOG.info("No filter information for air quality host '%s'.", name) continue - target = self._targets[name] LOG.debug( "Got the following filters for host '%s': %s", - target.host, + target_reading.host, filters, ) for filter_id, filter_info in filters.items(): filter_metric_family.add_metric( [ - target.host, - target.name, + target_reading.host, + name, filter_id, filter_info.filter_type, ], diff --git a/test/conftest.py b/test/conftest.py index a981e18..5825336 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -1,11 +1,40 @@ +from collections.abc import Iterable from unittest import mock import pytest +from flask.testing import FlaskClient +from prometheus_client import Metric +from prometheus_client.parser import text_string_to_metric_families +from prometheus_client.samples import Sample -from py_air_control_exporter import metrics +from py_air_control_exporter import fetchers_api, metrics -@pytest.fixture(name="mock_target") -def _mock_target(): - mock_func = mock.MagicMock() - return metrics.Target(host="foo", name="some-name", fetcher=mock_func), mock_func +def get_samples(client: FlaskClient) -> list[Sample]: + actual_metrics = _response_to_metrics(client.get("/metrics")) + return [sample for metric in actual_metrics for sample in metric.samples] + + +def _response_to_metrics(response) -> Iterable[Metric]: + return text_string_to_metric_families(response.data.decode("utf-8")) + + +@pytest.fixture(name="mock_readings_source") +def _mock_readings_source(): + some_readings = { + "broken": fetchers_api.TargetReading(host="1.2.3.1", has_errors=True), + "empty": fetchers_api.TargetReading(host="1.2.3.2"), + "full": fetchers_api.TargetReading( + host="1.2.3.4", + air_quality=fetchers_api.AirQuality(iaql=3, pm25=5), + control_info=fetchers_api.ControlInfo( + fan_speed=2, is_manual=True, is_on=True + ), + filters={ + "0": fetchers_api.Filter(hours=42, filter_type=""), + "1": fetchers_api.Filter(hours=185, filter_type="A3"), + "2": fetchers_api.Filter(hours=9001, filter_type="C7"), + }, + ), + } + return mock.Mock(spec=metrics.ReadingsSource, return_value=some_readings) diff --git a/test/data/simple_config.yaml b/test/data/simple_config.yaml index 802abea..0c5c265 100644 --- a/test/data/simple_config.yaml +++ b/test/data/simple_config.yaml @@ -1,7 +1,7 @@ targets: foo: host: 1.2.3.4 - protocol: coap + protocol: http bar: host: 1.2.3.5 protocol: http diff --git a/test/fetchers/test_http_philips.py b/test/fetchers/test_http_philips.py index bc64a4a..a307b20 100644 --- a/test/fetchers/test_http_philips.py +++ b/test/fetchers/test_http_philips.py @@ -11,7 +11,10 @@ def test_metrics_pyairctrl_failure(mock_http_client, caplog): """Error logs explain that there was a failure getting the status from pyairctrl""" caplog.set_level(logging.ERROR) mock_http_client["get_status"].side_effect = Exception("Some foobar error") - assert http_philips.get_reading(host="1.2.3.4") is None + assert http_philips.get_reading(host="1.2.3.4") == fetchers_api.TargetReading( + host="1.2.3.4", + has_errors=True, + ) assert "Could not read values from air control device" in caplog.text assert "Some foobar error" in caplog.text @@ -19,6 +22,7 @@ def test_metrics_pyairctrl_failure(mock_http_client, caplog): @pytest.mark.usefixtures("mock_http_client") def test_get_reading(): assert http_philips.get_reading("1.2.3.4") == fetchers_api.TargetReading( + host="1.2.3.4", air_quality=fetchers_api.AirQuality(iaql=1, pm25=2), control_info=fetchers_api.ControlInfo(fan_speed=0, is_manual=True, is_on=True), filters={ diff --git a/test/test_app.py b/test/test_app.py index 1434d2f..e263f03 100644 --- a/test/test_app.py +++ b/test/test_app.py @@ -1,39 +1,25 @@ -from collections.abc import Iterable - -from flask.testing import FlaskClient -from prometheus_client import Metric -from prometheus_client.parser import text_string_to_metric_families from prometheus_client.samples import Sample -from py_air_control_exporter import app, fetchers_api +from py_air_control_exporter import app +from test import conftest -def test_metrics(mock_target): +def test_metrics(mock_readings_source): """Metrics endpoint produces the expected metrics""" - target, mock_func = mock_target - mock_func.return_value = fetchers_api.TargetReading( - air_quality=fetchers_api.AirQuality(iaql=1, pm25=2), - control_info=fetchers_api.ControlInfo(fan_speed=0, is_manual=True, is_on=True), - filters={ - "0": fetchers_api.Filter(hours=0, filter_type=""), - "1": fetchers_api.Filter(hours=185, filter_type="A3"), - "2": fetchers_api.Filter(hours=2228, filter_type="C7"), - }, - ) - - samples = _get_metrics(app.create_app({"foo": target}).test_client()) + client = app.create_app(mock_readings_source).test_client() + samples = conftest.get_samples(client) - common_labels = {"host": "foo", "name": "some-name"} + common_labels = {"host": "1.2.3.4", "name": "full"} expected_samples = [ - Sample("py_air_control_air_quality", common_labels, value=1.0), + Sample("py_air_control_air_quality", common_labels, value=3.0), Sample("py_air_control_is_manual", common_labels, value=1.0), Sample("py_air_control_is_on", common_labels, value=1.0), - Sample("py_air_control_pm25", common_labels, value=2.0), - Sample("py_air_control_speed", common_labels, value=0.0), + Sample("py_air_control_pm25", common_labels, value=5.0), + Sample("py_air_control_speed", common_labels, value=2.0), Sample( "py_air_control_filter_hours", {**common_labels, "id": "0", "type": ""}, - value=0.0, + value=42.0, ), Sample( "py_air_control_filter_hours", @@ -43,7 +29,7 @@ def test_metrics(mock_target): Sample( "py_air_control_filter_hours", {**common_labels, "id": "2", "type": "C7"}, - value=2228.0, + value=9001.0, ), ] @@ -51,39 +37,24 @@ def test_metrics(mock_target): assert expected_sample in samples -def test_metrics_failure(mock_target): - """Metrics endpoint should produce a sampling error counter on error""" - target, mock_func = mock_target - mock_func.side_effect = Exception() - test_client = app.create_app({"foo": target}).test_client() - response = test_client.get("/metrics") - assert ( - b'py_air_control_sampling_error_total{host="foo",name="some-name"} 2.0\n' - in response.data - ) - response = test_client.get("/metrics") - assert ( - b'py_air_control_sampling_error_total{host="foo",name="some-name"} 3.0\n' - in response.data - ) +def test_metrics_failure(mock_readings_source): + """Metrics endpoint should increment a sampling error counter on error""" + test_client = app.create_app(mock_readings_source).test_client() + labels = {"host": "1.2.3.1", "name": "broken"} + assert Sample( + "py_air_control_sampling_error_total", labels, value=2.0 + ) in conftest.get_samples(test_client) + assert Sample( + "py_air_control_sampling_error_total", labels, value=3.0 + ) in conftest.get_samples(test_client) -def test_metrics_fetched_again(mock_target): +def test_metrics_fetched_again(mock_readings_source): """Check that status is fetched every time metrics are pulled""" - target, mock_func = mock_target - assert mock_func.call_count == 0 - test_client = app.create_app({"foo": target}).test_client() - assert mock_func.call_count == 1 + assert mock_readings_source.call_count == 0 + test_client = app.create_app(mock_readings_source).test_client() + assert mock_readings_source.call_count == 1 test_client.get("/metrics") - assert mock_func.call_count == 2 + assert mock_readings_source.call_count == 2 test_client.get("/metrics") - assert mock_func.call_count == 3 - - -def _get_metrics(client: FlaskClient) -> list[Sample]: - actual_metrics = _to_metrics(client.get("/metrics")) - return [sample for metric in actual_metrics for sample in metric.samples] - - -def _to_metrics(response) -> Iterable[Metric]: - return text_string_to_metric_families(response.data.decode("utf-8")) + assert mock_readings_source.call_count == 3 diff --git a/test/test_main.py b/test/test_main.py index d9774f0..5314c94 100644 --- a/test/test_main.py +++ b/test/test_main.py @@ -34,26 +34,26 @@ def test_unknown_protocol_exit_code(tmp_path): assert result.exit_code == 1 -def test_main(mock_create_app, mock_create_targets): +def test_main(mock_create_app, mock_create_readings_source, mock_create_fetcher): """Check that the exporter Flask app is created with all the given parameters""" result = CliRunner().invoke( main.main, [ - "--host", - "192.168.1.123", - "--protocol", - "http", - "--listen-address", - "1.2.3.4", - "--listen-port", - "12345", + "--host=192.168.1.123", + "--protocol=http", + "--listen-address=1.2.3.4", + "--listen-port=12345", ], ) assert result.exit_code == 0 - mock_create_targets.assert_called_once_with( - {"192.168.1.123": {"host": "192.168.1.123", "protocol": "http"}} + mock_create_readings_source.assert_called_once_with( + { + "192.168.1.123": main.Target( + host="192.168.1.123", fetcher=mock_create_fetcher.return_value + ) + } ) - expected_targets = mock_create_targets.return_value + expected_targets = mock_create_readings_source.return_value mock_create_app.assert_called_once_with(expected_targets) mock_create_app.return_value.run.assert_called_once_with(host="1.2.3.4", port=12345) @@ -78,7 +78,7 @@ def test_unknown_protocol_in_config(caplog): @pytest.mark.usefixtures("mock_create_app") -def test_config_file(mock_create_targets): +def test_config_file(mock_create_readings_source, mock_create_fetcher): """Check that the exporter Flask app is created with config file parameters""" result = CliRunner().invoke( main.main, @@ -92,23 +92,26 @@ def test_config_file(mock_create_targets): ], ) assert result.exit_code == 0 - mock_create_targets.assert_called_once_with( + mock_create_readings_source.assert_called_once_with( { - "foo": {"host": "1.2.3.4", "protocol": "coap"}, - "bar": {"host": "1.2.3.5", "protocol": "http"}, + "foo": main.Target( + host="1.2.3.4", fetcher=mock_create_fetcher.return_value + ), + "bar": main.Target( + host="1.2.3.5", fetcher=mock_create_fetcher.return_value + ), } ) -def test_default_parameters(mock_create_app, mock_create_targets): +@pytest.mark.usefixtures("mock_create_fetcher") +def test_default_parameters(mock_create_app, mock_create_readings_source): """Check that the exporter Flask app is created with the given hostname and default parameters """ - result = CliRunner().invoke(main.main, ["--host", "192.168.1.123"]) + result = CliRunner().invoke(main.main, ["--host=192.168.1.123", "--name=foo"]) assert result.exit_code == 0 - mock_create_targets.assert_called_once_with( - {"192.168.1.123": {"host": "192.168.1.123", "protocol": "http"}} - ) + mock_create_app.assert_called_once_with(mock_create_readings_source.return_value) mock_create_app.return_value.run.assert_called_once_with( host="127.0.0.1", port=9896 ) @@ -149,9 +152,17 @@ def _mock_create_app(mocker): ) -@pytest.fixture(name="mock_create_targets") -def _mock_create_targets(mocker): +@pytest.fixture(name="mock_create_readings_source") +def _mock_create_readings_source(mocker): + return mocker.patch( + "py_air_control_exporter.main.create_readings_source", + autospec=True, + ) + + +@pytest.fixture(name="mock_create_fetcher") +def _mock_create_fetcher(mocker): return mocker.patch( - "py_air_control_exporter.main.create_targets", + "py_air_control_exporter.fetcher_registry.create_fetcher", autospec=True, ) diff --git a/test/test_metrics.py b/test/test_metrics.py index ad7e3ec..7160962 100644 --- a/test/test_metrics.py +++ b/test/test_metrics.py @@ -1,24 +1,42 @@ +from prometheus_client.samples import Sample + from py_air_control_exporter import app, fetchers_api +from test.conftest import get_samples + +def test_metrics(mock_readings_source): + """Metrics endpoint should produce some metrics""" + assert Sample( + "py_air_control_pm25", {"host": "1.2.3.4", "name": "full"}, value=5.0 + ) in get_samples(app.create_app(mock_readings_source).test_client()) -def test_metrics_no_data(mock_target): + +def test_metrics_error(mock_readings_source): """Metrics endpoint should produce a sampling error counter on error""" - target, mock_func = mock_target - mock_func.return_value = None - test_client = app.create_app({"foo": target}).test_client() - response = test_client.get("/metrics") - assert ( - b'py_air_control_sampling_error_total{host="foo",name="some-name"} 2.0\n' - in response.data - ) - - -def test_metrics_empty(mock_target): - """Metrics endpoint should produce empty metrics on empty status""" - target, mock_func = mock_target - mock_func.return_value = fetchers_api.TargetReading( - air_quality=None, control_info=None, filters=None - ) - test_client = app.create_app({"foo": target}).test_client() - response = test_client.get("/metrics") - assert b"HELP py_air_control_sampling_error_total" in response.data + assert Sample( + "py_air_control_sampling_error_total", + {"host": "1.2.3.1", "name": "broken"}, + value=2.0, + ) in get_samples(app.create_app(mock_readings_source).test_client()) + + +def test_metrics_no_data(mock_readings_source): + """ + Metrics endpoint should produce only the error counter when target produces no data + """ + mock_readings_source.return_value = { + "empty": fetchers_api.TargetReading(host="1.2.3.1") + } + assert [ + Sample( + "py_air_control_sampling_error_total", + {"host": "1.2.3.1", "name": "empty"}, + value=0.0, + ) + ] == get_samples(app.create_app(mock_readings_source).test_client()) + + +def test_metrics_empty(mock_readings_source): + """Metrics endpoint should produce no metrics when there are no targets""" + mock_readings_source.return_value = {} + assert not get_samples(app.create_app(mock_readings_source).test_client())