From e9705af055a3d5882a62f6059910c21fb4c2c2f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Per=20Osb=C3=A4ck?= Date: Sat, 10 Aug 2019 14:35:04 +0200 Subject: [PATCH] Prometheus metrics naming based on device_class and unit_of_measurement (#24103) * - Change how we extract the metrics for sensors - Add component filtering as seen in influxdb - Add metric override as seen in influxdb - Add more unit tests with actual device data * Extract sensor metric logic to separate handlers * Update prometheus dependency * Format using black * Format using black * Fix flake8 * Move sensor metric handler list to init * Use f strings instead of .format --- .../components/prometheus/__init__.py | 146 +++++++++++++++--- .../components/prometheus/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/prometheus/test_init.py | 94 +++++++++-- 5 files changed, 207 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/prometheus/__init__.py b/homeassistant/components/prometheus/__init__.py index 80b68e52885..1ba2c4809b6 100644 --- a/homeassistant/components/prometheus/__init__.py +++ b/homeassistant/components/prometheus/__init__.py @@ -10,13 +10,16 @@ from homeassistant.components.http import HomeAssistantView from homeassistant.const import ( ATTR_TEMPERATURE, ATTR_UNIT_OF_MEASUREMENT, + ATTR_DEVICE_CLASS, CONTENT_TYPE_TEXT_PLAIN, EVENT_STATE_CHANGED, TEMP_FAHRENHEIT, + TEMP_CELSIUS, ) from homeassistant.helpers import entityfilter, state as state_helper import homeassistant.helpers.config_validation as cv from homeassistant.util.temperature import fahrenheit_to_celsius +from homeassistant.helpers.entity_values import EntityValues _LOGGER = logging.getLogger(__name__) @@ -25,6 +28,14 @@ API_ENDPOINT = "/api/prometheus" DOMAIN = "prometheus" CONF_FILTER = "filter" CONF_PROM_NAMESPACE = "namespace" +CONF_COMPONENT_CONFIG = "component_config" +CONF_COMPONENT_CONFIG_GLOB = "component_config_glob" +CONF_COMPONENT_CONFIG_DOMAIN = "component_config_domain" +CONF_DEFAULT_METRIC = "default_metric" +CONF_OVERRIDE_METRIC = "override_metric" +COMPONENT_CONFIG_SCHEMA_ENTRY = vol.Schema( + {vol.Optional(CONF_OVERRIDE_METRIC): cv.string} +) CONFIG_SCHEMA = vol.Schema( { @@ -32,6 +43,17 @@ CONFIG_SCHEMA = vol.Schema( { vol.Optional(CONF_FILTER, default={}): entityfilter.FILTER_SCHEMA, vol.Optional(CONF_PROM_NAMESPACE): cv.string, + vol.Optional(CONF_DEFAULT_METRIC): cv.string, + vol.Optional(CONF_OVERRIDE_METRIC): cv.string, + vol.Optional(CONF_COMPONENT_CONFIG, default={}): vol.Schema( + {cv.entity_id: COMPONENT_CONFIG_SCHEMA_ENTRY} + ), + vol.Optional(CONF_COMPONENT_CONFIG_GLOB, default={}): vol.Schema( + {cv.string: COMPONENT_CONFIG_SCHEMA_ENTRY} + ), + vol.Optional(CONF_COMPONENT_CONFIG_DOMAIN, default={}): vol.Schema( + {cv.string: COMPONENT_CONFIG_SCHEMA_ENTRY} + ), } ) }, @@ -49,8 +71,22 @@ def setup(hass, config): entity_filter = conf[CONF_FILTER] namespace = conf.get(CONF_PROM_NAMESPACE) climate_units = hass.config.units.temperature_unit + override_metric = conf.get(CONF_OVERRIDE_METRIC) + default_metric = conf.get(CONF_DEFAULT_METRIC) + component_config = EntityValues( + conf[CONF_COMPONENT_CONFIG], + conf[CONF_COMPONENT_CONFIG_DOMAIN], + conf[CONF_COMPONENT_CONFIG_GLOB], + ) + metrics = PrometheusMetrics( - prometheus_client, entity_filter, namespace, climate_units + prometheus_client, + entity_filter, + namespace, + climate_units, + component_config, + override_metric, + default_metric, ) hass.bus.listen(EVENT_STATE_CHANGED, metrics.handle_event) @@ -60,12 +96,32 @@ def setup(hass, config): class PrometheusMetrics: """Model all of the metrics which should be exposed to Prometheus.""" - def __init__(self, prometheus_client, entity_filter, namespace, climate_units): + def __init__( + self, + prometheus_client, + entity_filter, + namespace, + climate_units, + component_config, + override_metric, + default_metric, + ): """Initialize Prometheus Metrics.""" self.prometheus_client = prometheus_client + self._component_config = component_config + self._override_metric = override_metric + self._default_metric = default_metric self._filter = entity_filter + self._sensor_metric_handlers = [ + self._sensor_override_component_metric, + self._sensor_override_metric, + self._sensor_attribute_metric, + self._sensor_default_metric, + self._sensor_fallback_metric, + ] + if namespace: - self.metrics_prefix = "{}_".format(namespace) + self.metrics_prefix = f"{namespace}_" else: self.metrics_prefix = "" self._metrics = {} @@ -84,7 +140,7 @@ class PrometheusMetrics: if not self._filter(state.entity_id): return - handler = "_handle_{}".format(domain) + handler = f"_handle_{domain}" if hasattr(self, handler): getattr(self, handler)(state) @@ -103,7 +159,7 @@ class PrometheusMetrics: try: return self._metrics[metric] except KeyError: - full_metric_name = "{}{}".format(self.metrics_prefix, metric) + full_metric_name = f"{self.metrics_prefix}{metric}" self._metrics[metric] = factory(full_metric_name, documentation, labels) return self._metrics[metric] @@ -229,31 +285,73 @@ class PrometheusMetrics: pass def _handle_sensor(self, state): + unit = self._unit_string(state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)) - unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - metric = state.entity_id.split(".")[1] + for metric_handler in self._sensor_metric_handlers: + metric = metric_handler(state, unit) + if metric is not None: + break - if "_" not in str(metric): - metric = state.entity_id.replace(".", "_") + if metric is not None: + _metric = self._metric( + metric, self.prometheus_client.Gauge, f"Sensor data measured in {unit}" + ) - try: - int(metric.split("_")[-1]) - metric = "_".join(metric.split("_")[:-1]) - except ValueError: - pass - - _metric = self._metric(metric, self.prometheus_client.Gauge, state.entity_id) - - try: - value = self.state_as_number(state) - if unit == TEMP_FAHRENHEIT: - value = fahrenheit_to_celsius(value) - _metric.labels(**self._labels(state)).set(value) - except ValueError: - pass + try: + value = self.state_as_number(state) + if unit == TEMP_FAHRENHEIT: + value = fahrenheit_to_celsius(value) + _metric.labels(**self._labels(state)).set(value) + except ValueError: + pass self._battery(state) + def _sensor_default_metric(self, state, unit): + """Get default metric.""" + return self._default_metric + + @staticmethod + def _sensor_attribute_metric(state, unit): + """Get metric based on device class attribute.""" + metric = state.attributes.get(ATTR_DEVICE_CLASS) + if metric is not None: + return f"{metric}_{unit}" + return None + + def _sensor_override_metric(self, state, unit): + """Get metric from override in configuration.""" + if self._override_metric: + return self._override_metric + return None + + def _sensor_override_component_metric(self, state, unit): + """Get metric from override in component confioguration.""" + return self._component_config.get(state.entity_id).get(CONF_OVERRIDE_METRIC) + + @staticmethod + def _sensor_fallback_metric(state, unit): + """Get metric from fallback logic for compatability.""" + if unit in (None, ""): + _LOGGER.debug("Unsupported sensor: %s", state.entity_id) + return None + return f"sensor_unit_{unit}" + + @staticmethod + def _unit_string(unit): + """Get a formatted string of the unit.""" + if unit is None: + return + + units = { + TEMP_CELSIUS: "c", + TEMP_FAHRENHEIT: "c", # F should go into C metric + "%": "percent", + } + default = unit.replace("/", "_per_") + default = default.lower() + return units.get(unit, default) + def _handle_switch(self, state): metric = self._metric( "switch_state", self.prometheus_client.Gauge, "State of the switch (0/1)" diff --git a/homeassistant/components/prometheus/manifest.json b/homeassistant/components/prometheus/manifest.json index d9699be6bf7..cab1228aa56 100644 --- a/homeassistant/components/prometheus/manifest.json +++ b/homeassistant/components/prometheus/manifest.json @@ -3,7 +3,7 @@ "name": "Prometheus", "documentation": "https://www.home-assistant.io/components/prometheus", "requirements": [ - "prometheus_client==0.2.0" + "prometheus_client==0.7.1" ], "dependencies": [ "http" diff --git a/requirements_all.txt b/requirements_all.txt index 66d6cba8eba..e56077423ef 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -965,7 +965,7 @@ prezzibenzina-py==1.1.4 proliphix==0.4.1 # homeassistant.components.prometheus -prometheus_client==0.2.0 +prometheus_client==0.7.1 # homeassistant.components.tensorflow protobuf==3.6.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cb63f138d36..ab6c1bde776 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -244,7 +244,7 @@ pilight==0.1.1 pmsensor==0.4 # homeassistant.components.prometheus -prometheus_client==0.2.0 +prometheus_client==0.7.1 # homeassistant.components.ptvsd ptvsd==4.2.8 diff --git a/tests/components/prometheus/test_init.py b/tests/components/prometheus/test_init.py index e9f92b2d6f6..9e313fd3694 100644 --- a/tests/components/prometheus/test_init.py +++ b/tests/components/prometheus/test_init.py @@ -2,17 +2,46 @@ import asyncio import pytest +from homeassistant.const import ENERGY_KILO_WATT_HOUR, DEVICE_CLASS_POWER + +from homeassistant import setup +from homeassistant.components import climate, sensor +from homeassistant.components.demo.sensor import DemoSensor from homeassistant.setup import async_setup_component import homeassistant.components.prometheus as prometheus @pytest.fixture -def prometheus_client(loop, hass, hass_client): +async def prometheus_client(loop, hass, hass_client): """Initialize an hass_client with Prometheus component.""" - assert loop.run_until_complete( - async_setup_component(hass, prometheus.DOMAIN, {prometheus.DOMAIN: {}}) + await async_setup_component(hass, prometheus.DOMAIN, {prometheus.DOMAIN: {}}) + + await setup.async_setup_component( + hass, sensor.DOMAIN, {"sensor": [{"platform": "demo"}]} ) - return loop.run_until_complete(hass_client()) + + await setup.async_setup_component( + hass, climate.DOMAIN, {"climate": [{"platform": "demo"}]} + ) + + sensor1 = DemoSensor("Television Energy", 74, None, ENERGY_KILO_WATT_HOUR, None) + sensor1.hass = hass + sensor1.entity_id = "sensor.television_energy" + await sensor1.async_update_ha_state() + + sensor2 = DemoSensor( + "Radio Energy", 14, DEVICE_CLASS_POWER, ENERGY_KILO_WATT_HOUR, None + ) + sensor2.hass = hass + sensor2.entity_id = "sensor.radio_energy" + await sensor2.async_update_ha_state() + + sensor3 = DemoSensor("Electricity price", 0.123, None, "SEK/kWh", None) + sensor3.hass = hass + sensor3.entity_id = "sensor.electricity_price" + await sensor3.async_update_ha_state() + + return await hass_client() @asyncio.coroutine @@ -25,11 +54,52 @@ def test_view(prometheus_client): # pylint: disable=redefined-outer-name body = yield from resp.text() body = body.split("\n") - assert len(body) > 3 # At least two comment lines and a metric - for line in body: - if line: - assert ( - line.startswith("# ") - or line.startswith("process_") - or line.startswith("python_info") - ) + assert len(body) > 3 + + assert "# HELP python_info Python platform information" in body + assert ( + "# HELP python_gc_objects_collected_total " + "Objects collected during gc" in body + ) + + assert ( + 'temperature_c{domain="sensor",' + 'entity="sensor.outside_temperature",' + 'friendly_name="Outside Temperature"} 15.6' in body + ) + + assert ( + 'battery_level_percent{domain="sensor",' + 'entity="sensor.outside_temperature",' + 'friendly_name="Outside Temperature"} 12.0' in body + ) + + assert ( + 'current_temperature_c{domain="climate",' + 'entity="climate.heatpump",' + 'friendly_name="HeatPump"} 25.0' in body + ) + + assert ( + 'humidity_percent{domain="sensor",' + 'entity="sensor.outside_humidity",' + 'friendly_name="Outside Humidity"} 54.0' in body + ) + + assert ( + 'sensor_unit_kwh{domain="sensor",' + 'entity="sensor.television_energy",' + 'friendly_name="Television Energy"} 74.0' in body + ) + + assert ( + 'power_kwh{domain="sensor",' + 'entity="sensor.radio_energy",' + 'friendly_name="Radio Energy"} 14.0' in body + ) + + assert ( + 'sensor_unit_sek_per_kwh{domain="sensor",' + 'entity="sensor.electricity_price",' + 'friendly_name="Electricity price"} 0.123' in body + )