mirror of
https://github.com/home-assistant/core.git
synced 2025-07-23 13:17:32 +00:00
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:
parent
2e4905981e
commit
e9705af055
@ -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)"
|
||||
|
@ -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"
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
)
|
||||
|
Loading…
x
Reference in New Issue
Block a user