mirror of
https://github.com/home-assistant/core.git
synced 2025-07-24 21:57:51 +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 (
|
from homeassistant.const import (
|
||||||
ATTR_TEMPERATURE,
|
ATTR_TEMPERATURE,
|
||||||
ATTR_UNIT_OF_MEASUREMENT,
|
ATTR_UNIT_OF_MEASUREMENT,
|
||||||
|
ATTR_DEVICE_CLASS,
|
||||||
CONTENT_TYPE_TEXT_PLAIN,
|
CONTENT_TYPE_TEXT_PLAIN,
|
||||||
EVENT_STATE_CHANGED,
|
EVENT_STATE_CHANGED,
|
||||||
TEMP_FAHRENHEIT,
|
TEMP_FAHRENHEIT,
|
||||||
|
TEMP_CELSIUS,
|
||||||
)
|
)
|
||||||
from homeassistant.helpers import entityfilter, state as state_helper
|
from homeassistant.helpers import entityfilter, state as state_helper
|
||||||
import homeassistant.helpers.config_validation as cv
|
import homeassistant.helpers.config_validation as cv
|
||||||
from homeassistant.util.temperature import fahrenheit_to_celsius
|
from homeassistant.util.temperature import fahrenheit_to_celsius
|
||||||
|
from homeassistant.helpers.entity_values import EntityValues
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -25,6 +28,14 @@ API_ENDPOINT = "/api/prometheus"
|
|||||||
DOMAIN = "prometheus"
|
DOMAIN = "prometheus"
|
||||||
CONF_FILTER = "filter"
|
CONF_FILTER = "filter"
|
||||||
CONF_PROM_NAMESPACE = "namespace"
|
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(
|
CONFIG_SCHEMA = vol.Schema(
|
||||||
{
|
{
|
||||||
@ -32,6 +43,17 @@ CONFIG_SCHEMA = vol.Schema(
|
|||||||
{
|
{
|
||||||
vol.Optional(CONF_FILTER, default={}): entityfilter.FILTER_SCHEMA,
|
vol.Optional(CONF_FILTER, default={}): entityfilter.FILTER_SCHEMA,
|
||||||
vol.Optional(CONF_PROM_NAMESPACE): cv.string,
|
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]
|
entity_filter = conf[CONF_FILTER]
|
||||||
namespace = conf.get(CONF_PROM_NAMESPACE)
|
namespace = conf.get(CONF_PROM_NAMESPACE)
|
||||||
climate_units = hass.config.units.temperature_unit
|
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(
|
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)
|
hass.bus.listen(EVENT_STATE_CHANGED, metrics.handle_event)
|
||||||
@ -60,12 +96,32 @@ def setup(hass, config):
|
|||||||
class PrometheusMetrics:
|
class PrometheusMetrics:
|
||||||
"""Model all of the metrics which should be exposed to Prometheus."""
|
"""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."""
|
"""Initialize Prometheus Metrics."""
|
||||||
self.prometheus_client = prometheus_client
|
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._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:
|
if namespace:
|
||||||
self.metrics_prefix = "{}_".format(namespace)
|
self.metrics_prefix = f"{namespace}_"
|
||||||
else:
|
else:
|
||||||
self.metrics_prefix = ""
|
self.metrics_prefix = ""
|
||||||
self._metrics = {}
|
self._metrics = {}
|
||||||
@ -84,7 +140,7 @@ class PrometheusMetrics:
|
|||||||
if not self._filter(state.entity_id):
|
if not self._filter(state.entity_id):
|
||||||
return
|
return
|
||||||
|
|
||||||
handler = "_handle_{}".format(domain)
|
handler = f"_handle_{domain}"
|
||||||
|
|
||||||
if hasattr(self, handler):
|
if hasattr(self, handler):
|
||||||
getattr(self, handler)(state)
|
getattr(self, handler)(state)
|
||||||
@ -103,7 +159,7 @@ class PrometheusMetrics:
|
|||||||
try:
|
try:
|
||||||
return self._metrics[metric]
|
return self._metrics[metric]
|
||||||
except KeyError:
|
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)
|
self._metrics[metric] = factory(full_metric_name, documentation, labels)
|
||||||
return self._metrics[metric]
|
return self._metrics[metric]
|
||||||
|
|
||||||
@ -229,31 +285,73 @@ class PrometheusMetrics:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
def _handle_sensor(self, state):
|
def _handle_sensor(self, state):
|
||||||
|
unit = self._unit_string(state.attributes.get(ATTR_UNIT_OF_MEASUREMENT))
|
||||||
|
|
||||||
unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
|
for metric_handler in self._sensor_metric_handlers:
|
||||||
metric = state.entity_id.split(".")[1]
|
metric = metric_handler(state, unit)
|
||||||
|
if metric is not None:
|
||||||
|
break
|
||||||
|
|
||||||
if "_" not in str(metric):
|
if metric is not None:
|
||||||
metric = state.entity_id.replace(".", "_")
|
_metric = self._metric(
|
||||||
|
metric, self.prometheus_client.Gauge, f"Sensor data measured in {unit}"
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
int(metric.split("_")[-1])
|
value = self.state_as_number(state)
|
||||||
metric = "_".join(metric.split("_")[:-1])
|
if unit == TEMP_FAHRENHEIT:
|
||||||
except ValueError:
|
value = fahrenheit_to_celsius(value)
|
||||||
pass
|
_metric.labels(**self._labels(state)).set(value)
|
||||||
|
except ValueError:
|
||||||
_metric = self._metric(metric, self.prometheus_client.Gauge, state.entity_id)
|
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)
|
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):
|
def _handle_switch(self, state):
|
||||||
metric = self._metric(
|
metric = self._metric(
|
||||||
"switch_state", self.prometheus_client.Gauge, "State of the switch (0/1)"
|
"switch_state", self.prometheus_client.Gauge, "State of the switch (0/1)"
|
||||||
|
@ -3,7 +3,7 @@
|
|||||||
"name": "Prometheus",
|
"name": "Prometheus",
|
||||||
"documentation": "https://www.home-assistant.io/components/prometheus",
|
"documentation": "https://www.home-assistant.io/components/prometheus",
|
||||||
"requirements": [
|
"requirements": [
|
||||||
"prometheus_client==0.2.0"
|
"prometheus_client==0.7.1"
|
||||||
],
|
],
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"http"
|
"http"
|
||||||
|
@ -965,7 +965,7 @@ prezzibenzina-py==1.1.4
|
|||||||
proliphix==0.4.1
|
proliphix==0.4.1
|
||||||
|
|
||||||
# homeassistant.components.prometheus
|
# homeassistant.components.prometheus
|
||||||
prometheus_client==0.2.0
|
prometheus_client==0.7.1
|
||||||
|
|
||||||
# homeassistant.components.tensorflow
|
# homeassistant.components.tensorflow
|
||||||
protobuf==3.6.1
|
protobuf==3.6.1
|
||||||
|
@ -244,7 +244,7 @@ pilight==0.1.1
|
|||||||
pmsensor==0.4
|
pmsensor==0.4
|
||||||
|
|
||||||
# homeassistant.components.prometheus
|
# homeassistant.components.prometheus
|
||||||
prometheus_client==0.2.0
|
prometheus_client==0.7.1
|
||||||
|
|
||||||
# homeassistant.components.ptvsd
|
# homeassistant.components.ptvsd
|
||||||
ptvsd==4.2.8
|
ptvsd==4.2.8
|
||||||
|
@ -2,17 +2,46 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import pytest
|
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
|
from homeassistant.setup import async_setup_component
|
||||||
import homeassistant.components.prometheus as prometheus
|
import homeassistant.components.prometheus as prometheus
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def prometheus_client(loop, hass, hass_client):
|
async def prometheus_client(loop, hass, hass_client):
|
||||||
"""Initialize an hass_client with Prometheus component."""
|
"""Initialize an hass_client with Prometheus component."""
|
||||||
assert loop.run_until_complete(
|
await async_setup_component(hass, prometheus.DOMAIN, {prometheus.DOMAIN: {}})
|
||||||
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
|
@asyncio.coroutine
|
||||||
@ -25,11 +54,52 @@ def test_view(prometheus_client): # pylint: disable=redefined-outer-name
|
|||||||
body = yield from resp.text()
|
body = yield from resp.text()
|
||||||
body = body.split("\n")
|
body = body.split("\n")
|
||||||
|
|
||||||
assert len(body) > 3 # At least two comment lines and a metric
|
assert len(body) > 3
|
||||||
for line in body:
|
|
||||||
if line:
|
assert "# HELP python_info Python platform information" in body
|
||||||
assert (
|
assert (
|
||||||
line.startswith("# ")
|
"# HELP python_gc_objects_collected_total "
|
||||||
or line.startswith("process_")
|
"Objects collected during gc" in body
|
||||||
or line.startswith("python_info")
|
)
|
||||||
)
|
|
||||||
|
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