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
This commit is contained in:
Per Osbäck 2019-08-10 14:35:04 +02:00 committed by Martin Hjelmare
parent 2e4905981e
commit e9705af055
5 changed files with 207 additions and 39 deletions

View File

@ -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)"

View File

@ -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"

View File

@ -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

View File

@ -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

View File

@ -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
)