1 """Support for Prometheus metrics export."""
3 from __future__
import annotations
5 from collections.abc
import Callable
6 from contextlib
import suppress
9 from typing
import Any, cast
11 from aiohttp
import web
12 import prometheus_client
13 from prometheus_client.metrics
import MetricWrapperBase
14 import voluptuous
as vol
16 from homeassistant
import core
as hacore
19 ATTR_CURRENT_TEMPERATURE,
24 ATTR_TARGET_TEMP_HIGH,
29 ATTR_CURRENT_POSITION,
30 ATTR_CURRENT_TILT_POSITION,
51 ATTR_UNIT_OF_MEASUREMENT,
52 CONTENT_TYPE_TEXT_PLAIN,
68 EVENT_ENTITY_REGISTRY_UPDATED,
69 EventEntityRegistryUpdatedData,
76 _LOGGER = logging.getLogger(__name__)
78 API_ENDPOINT =
"/api/prometheus"
79 IGNORED_STATES = frozenset({STATE_UNAVAILABLE, STATE_UNKNOWN})
83 CONF_FILTER =
"filter"
84 CONF_REQUIRES_AUTH =
"requires_auth"
85 CONF_PROM_NAMESPACE =
"namespace"
86 CONF_COMPONENT_CONFIG =
"component_config"
87 CONF_COMPONENT_CONFIG_GLOB =
"component_config_glob"
88 CONF_COMPONENT_CONFIG_DOMAIN =
"component_config_domain"
89 CONF_DEFAULT_METRIC =
"default_metric"
90 CONF_OVERRIDE_METRIC =
"override_metric"
91 COMPONENT_CONFIG_SCHEMA_ENTRY = vol.Schema(
92 {vol.Optional(CONF_OVERRIDE_METRIC): cv.string}
94 ALLOWED_METRIC_CHARS = set(string.ascii_letters + string.digits +
"_:")
96 DEFAULT_NAMESPACE =
"homeassistant"
98 CONFIG_SCHEMA = vol.Schema(
102 vol.Optional(CONF_FILTER, default={}): entityfilter.FILTER_SCHEMA,
103 vol.Optional(CONF_PROM_NAMESPACE, default=DEFAULT_NAMESPACE): cv.string,
104 vol.Optional(CONF_REQUIRES_AUTH, default=
True): cv.boolean,
105 vol.Optional(CONF_DEFAULT_METRIC): cv.string,
106 vol.Optional(CONF_OVERRIDE_METRIC): cv.string,
107 vol.Optional(CONF_COMPONENT_CONFIG, default={}): vol.Schema(
108 {cv.entity_id: COMPONENT_CONFIG_SCHEMA_ENTRY}
110 vol.Optional(CONF_COMPONENT_CONFIG_GLOB, default={}): vol.Schema(
111 {cv.string: COMPONENT_CONFIG_SCHEMA_ENTRY}
113 vol.Optional(CONF_COMPONENT_CONFIG_DOMAIN, default={}): vol.Schema(
114 {cv.string: COMPONENT_CONFIG_SCHEMA_ENTRY}
119 extra=vol.ALLOW_EXTRA,
123 def setup(hass: HomeAssistant, config: ConfigType) -> bool:
124 """Activate Prometheus component."""
125 hass.http.register_view(
PrometheusView(config[DOMAIN][CONF_REQUIRES_AUTH]))
127 conf: dict[str, Any] = config[DOMAIN]
128 entity_filter: entityfilter.EntityFilter = conf[CONF_FILTER]
129 namespace: str = conf[CONF_PROM_NAMESPACE]
130 climate_units = hass.config.units.temperature_unit
131 override_metric: str |
None = conf.get(CONF_OVERRIDE_METRIC)
132 default_metric: str |
None = conf.get(CONF_DEFAULT_METRIC)
134 conf[CONF_COMPONENT_CONFIG],
135 conf[CONF_COMPONENT_CONFIG_DOMAIN],
136 conf[CONF_COMPONENT_CONFIG_GLOB],
148 hass.bus.listen(EVENT_STATE_CHANGED, metrics.handle_state_changed_event)
150 EVENT_ENTITY_REGISTRY_UPDATED,
151 metrics.handle_entity_registry_updated,
154 for state
in hass.states.all():
155 if entity_filter(state.entity_id):
156 metrics.handle_state(state)
162 """Model all of the metrics which should be exposed to Prometheus."""
166 entity_filter: entityfilter.EntityFilter,
168 climate_units: UnitOfTemperature,
169 component_config: EntityValues,
170 override_metric: str |
None,
171 default_metric: str |
None,
173 """Initialize Prometheus Metrics."""
178 self._sensor_metric_handlers: list[
179 Callable[[State, str |
None], str |
None]
193 self._metrics: dict[str, MetricWrapperBase] = {}
197 """Handle new messages from the bus."""
198 if (state := event.data.get(
"new_state"))
is None:
201 if not self.
_filter_filter(state.entity_id):
202 _LOGGER.debug(
"Filtered out entity %s", state.entity_id)
205 if (old_state := event.data.get(
"old_state"))
is not None and (
206 old_friendly_name := old_state.attributes.get(ATTR_FRIENDLY_NAME)
207 ) != state.attributes.get(ATTR_FRIENDLY_NAME):
213 """Add/update a state in Prometheus."""
214 entity_id = state.entity_id
215 _LOGGER.debug(
"Handling state update for %s", entity_id)
217 labels = self.
_labels_labels(state)
218 state_change = self._metric(
219 "state_change", prometheus_client.Counter,
"The number of state changes"
221 state_change.labels(**labels).inc()
223 entity_available = self._metric(
225 prometheus_client.Gauge,
226 "Entity is available (not in the unavailable or unknown state)",
228 entity_available.labels(**labels).set(
float(state.state
not in IGNORED_STATES))
230 last_updated_time_seconds = self._metric(
231 "last_updated_time_seconds",
232 prometheus_client.Gauge,
233 "The last_updated timestamp",
235 last_updated_time_seconds.labels(**labels).set(state.last_updated.timestamp())
237 if state.state
in IGNORED_STATES:
241 {state_change, entity_available, last_updated_time_seconds},
244 domain, _ = hacore.split_entity_id(entity_id)
245 handler = f
"_handle_{domain}"
246 if hasattr(self, handler)
and state.state:
247 getattr(self, handler)(state)
250 self, event: Event[EventEntityRegistryUpdatedData]
252 """Listen for deleted, disabled or renamed entities and remove them from the Prometheus Registry."""
253 if event.data[
"action"]
in (
None,
"create"):
256 entity_id = event.data.get(
"entity_id")
257 _LOGGER.debug(
"Handling entity update for %s", entity_id)
259 metrics_entity_id: str |
None =
None
261 if event.data[
"action"] ==
"remove":
262 metrics_entity_id = entity_id
263 elif event.data[
"action"] ==
"update":
264 changes = event.data[
"changes"]
266 if "entity_id" in changes:
267 metrics_entity_id = changes[
"entity_id"]
268 elif "disabled_by" in changes:
269 metrics_entity_id = entity_id
271 if metrics_entity_id:
277 friendly_name: str |
None =
None,
278 ignored_metrics: set[MetricWrapperBase] |
None =
None,
280 """Remove labelsets matching the given entity id from all non-ignored metrics."""
281 if ignored_metrics
is None:
282 ignored_metrics = set()
283 for metric
in list(self._metrics.values()):
284 if metric
in ignored_metrics:
286 for sample
in cast(list[prometheus_client.Metric], metric.collect())[
289 if sample.labels[
"entity"] == entity_id
and (
290 not friendly_name
or sample.labels[
"friendly_name"] == friendly_name
293 "Removing labelset from %s for entity_id: %s",
297 with suppress(KeyError):
298 metric.remove(*sample.labels.values())
301 for key, value
in state.attributes.items():
302 metric = self._metric(
303 f
"{state.domain}_attr_{key.lower()}",
304 prometheus_client.Gauge,
305 f
"{key} attribute of {state.domain} entity",
310 metric.labels(**self.
_labels_labels(state)).set(value)
311 except (ValueError, TypeError):
314 def _metric[_MetricBaseT: MetricWrapperBase](
317 factory: type[_MetricBaseT],
319 extra_labels: list[str] |
None =
None,
321 labels = [
"entity",
"friendly_name",
"domain"]
322 if extra_labels
is not None:
323 labels.extend(extra_labels)
326 return cast(_MetricBaseT, self._metrics[metric])
329 f
"{self.metrics_prefix}{metric}"
331 self._metrics[metric] = factory(
335 registry=prometheus_client.REGISTRY,
337 return cast(_MetricBaseT, self._metrics[metric])
342 [c
if c
in ALLOWED_METRIC_CHARS
else f
"u{hex(ord(c))}" for c
in metric]
347 """Return state as a float, or None if state cannot be converted."""
349 if state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TIMESTAMP:
352 value = state_helper.state_as_number(state)
354 _LOGGER.debug(
"Could not convert %s to float", state)
361 "entity": state.entity_id,
362 "domain": state.domain,
363 "friendly_name": state.attributes.get(ATTR_FRIENDLY_NAME),
367 if (battery_level := state.attributes.get(ATTR_BATTERY_LEVEL))
is not None:
368 metric = self._metric(
369 "battery_level_percent",
370 prometheus_client.Gauge,
371 "Battery level as a percentage of its capacity",
374 value =
float(battery_level)
375 metric.labels(**self.
_labels_labels(state)).set(value)
380 metric = self._metric(
381 "binary_sensor_state",
382 prometheus_client.Gauge,
383 "State of the binary sensor (0/1)",
386 metric.labels(**self.
_labels_labels(state)).set(value)
389 metric = self._metric(
390 "input_boolean_state",
391 prometheus_client.Gauge,
392 "State of the input boolean (0/1)",
395 metric.labels(**self.
_labels_labels(state)).set(value)
398 if unit := self.
_unit_string_unit_string(state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)):
399 metric = self._metric(
400 f
"{domain}_state_{unit}",
401 prometheus_client.Gauge,
402 f
"State of the {title} measured in {unit}",
405 metric = self._metric(
407 prometheus_client.Gauge,
408 f
"State of the {title}",
413 state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
414 == UnitOfTemperature.FAHRENHEIT
416 value = TemperatureConverter.convert(
417 value, UnitOfTemperature.FAHRENHEIT, UnitOfTemperature.CELSIUS
419 metric.labels(**self.
_labels_labels(state)).set(value)
422 self.
_numeric_handler_numeric_handler(state,
"input_number",
"input number")
428 metric = self._metric(
429 "device_tracker_state",
430 prometheus_client.Gauge,
431 "State of the device tracker (0/1)",
434 metric.labels(**self.
_labels_labels(state)).set(value)
437 metric = self._metric(
438 "person_state", prometheus_client.Gauge,
"State of the person (0/1)"
441 metric.labels(**self.
_labels_labels(state)).set(value)
444 metric = self._metric(
446 prometheus_client.Gauge,
447 "State of the cover (0/1)",
451 cover_states = [STATE_CLOSED, STATE_CLOSING, STATE_OPEN, STATE_OPENING]
452 for cover_state
in cover_states:
453 metric.labels(**
dict(self.
_labels_labels(state), state=cover_state)).set(
454 float(cover_state == state.state)
457 position = state.attributes.get(ATTR_CURRENT_POSITION)
458 if position
is not None:
459 position_metric = self._metric(
461 prometheus_client.Gauge,
462 "Position of the cover (0-100)",
464 position_metric.labels(**self.
_labels_labels(state)).set(
float(position))
466 tilt_position = state.attributes.get(ATTR_CURRENT_TILT_POSITION)
467 if tilt_position
is not None:
468 tilt_position_metric = self._metric(
469 "cover_tilt_position",
470 prometheus_client.Gauge,
471 "Tilt Position of the cover (0-100)",
473 tilt_position_metric.labels(**self.
_labels_labels(state)).set(
float(tilt_position))
476 metric = self._metric(
477 "light_brightness_percent",
478 prometheus_client.Gauge,
479 "Light brightness percentage (0..100)",
483 brightness = state.attributes.get(ATTR_BRIGHTNESS)
484 if state.state == STATE_ON
and brightness
is not None:
485 value =
float(brightness) / 255.0
487 metric.labels(**self.
_labels_labels(state)).set(value)
490 metric = self._metric(
491 "lock_state", prometheus_client.Gauge,
"State of the lock (0/1)"
494 metric.labels(**self.
_labels_labels(state)).set(value)
497 self, state: State, attr: str, metric_name: str, metric_description: str
499 if (temp := state.attributes.get(attr))
is not None:
500 if self.
_climate_units_climate_units == UnitOfTemperature.FAHRENHEIT:
501 temp = TemperatureConverter.convert(
502 temp, UnitOfTemperature.FAHRENHEIT, UnitOfTemperature.CELSIUS
504 metric = self._metric(
506 prometheus_client.Gauge,
509 metric.labels(**self.
_labels_labels(state)).set(temp)
515 "climate_target_temperature_celsius",
516 "Target temperature in degrees Celsius",
520 ATTR_TARGET_TEMP_HIGH,
521 "climate_target_temperature_high_celsius",
522 "Target high temperature in degrees Celsius",
526 ATTR_TARGET_TEMP_LOW,
527 "climate_target_temperature_low_celsius",
528 "Target low temperature in degrees Celsius",
532 ATTR_CURRENT_TEMPERATURE,
533 "climate_current_temperature_celsius",
534 "Current temperature in degrees Celsius",
537 if current_action := state.attributes.get(ATTR_HVAC_ACTION):
538 metric = self._metric(
540 prometheus_client.Gauge,
544 for action
in HVACAction:
545 metric.labels(**
dict(self.
_labels_labels(state), action=action.value)).set(
546 float(action == current_action)
549 current_mode = state.state
550 available_modes = state.attributes.get(ATTR_HVAC_MODES)
551 if current_mode
and available_modes:
552 metric = self._metric(
554 prometheus_client.Gauge,
558 for mode
in available_modes:
559 metric.labels(**
dict(self.
_labels_labels(state), mode=mode)).set(
560 float(mode == current_mode)
563 preset_mode = state.attributes.get(ATTR_PRESET_MODE)
564 available_preset_modes = state.attributes.get(ATTR_PRESET_MODES)
565 if preset_mode
and available_preset_modes:
566 preset_metric = self._metric(
567 "climate_preset_mode",
568 prometheus_client.Gauge,
572 for mode
in available_preset_modes:
573 preset_metric.labels(**
dict(self.
_labels_labels(state), mode=mode)).set(
574 float(mode == preset_mode)
577 fan_mode = state.attributes.get(ATTR_FAN_MODE)
578 available_fan_modes = state.attributes.get(ATTR_FAN_MODES)
579 if fan_mode
and available_fan_modes:
580 fan_mode_metric = self._metric(
582 prometheus_client.Gauge,
586 for mode
in available_fan_modes:
587 fan_mode_metric.labels(**
dict(self.
_labels_labels(state), mode=mode)).set(
588 float(mode == fan_mode)
592 humidifier_target_humidity_percent = state.attributes.get(ATTR_HUMIDITY)
593 if humidifier_target_humidity_percent:
594 metric = self._metric(
595 "humidifier_target_humidity_percent",
596 prometheus_client.Gauge,
597 "Target Relative Humidity",
599 metric.labels(**self.
_labels_labels(state)).set(humidifier_target_humidity_percent)
601 metric = self._metric(
603 prometheus_client.Gauge,
604 "State of the humidifier (0/1)",
607 metric.labels(**self.
_labels_labels(state)).set(value)
609 current_mode = state.attributes.get(ATTR_MODE)
610 available_modes = state.attributes.get(ATTR_AVAILABLE_MODES)
611 if current_mode
and available_modes:
612 metric = self._metric(
614 prometheus_client.Gauge,
618 for mode
in available_modes:
619 metric.labels(**
dict(self.
_labels_labels(state), mode=mode)).set(
620 float(mode == current_mode)
624 unit = self.
_unit_string_unit_string(state.attributes.get(ATTR_UNIT_OF_MEASUREMENT))
626 for metric_handler
in self._sensor_metric_handlers:
627 metric = metric_handler(state, unit)
628 if metric
is not None:
631 if metric
is not None:
632 documentation =
"State of the sensor"
634 documentation = f
"Sensor data measured in {unit}"
636 _metric = self._metric(metric, prometheus_client.Gauge, documentation)
640 state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
641 == UnitOfTemperature.FAHRENHEIT
643 value = TemperatureConverter.convert(
644 value, UnitOfTemperature.FAHRENHEIT, UnitOfTemperature.CELSIUS
646 _metric.labels(**self.
_labels_labels(state)).set(value)
651 """Get default metric."""
656 """Get metric based on device class attribute."""
657 metric = state.attributes.get(ATTR_DEVICE_CLASS)
658 if metric
is not None:
659 return f
"sensor_{metric}_{unit}"
664 """Get metric for timestamp sensors, which have no unit of measurement attribute."""
665 metric = state.attributes.get(ATTR_DEVICE_CLASS)
666 if metric == SensorDeviceClass.TIMESTAMP:
667 return f
"sensor_{metric}_seconds"
671 """Get metric from override in configuration."""
677 self, state: State, unit: str |
None
679 """Get metric from override in component configuration."""
684 """Get metric from fallback logic for compatibility."""
685 if unit
not in (
None,
""):
686 return f
"sensor_unit_{unit}"
687 return "sensor_state"
691 """Get a formatted string of the unit."""
696 UnitOfTemperature.CELSIUS:
"celsius",
697 UnitOfTemperature.FAHRENHEIT:
"celsius",
698 PERCENTAGE:
"percent",
700 default = unit.replace(
"/",
"_per_")
701 default = default.lower()
702 return units.get(unit, default)
705 metric = self._metric(
706 "switch_state", prometheus_client.Gauge,
"State of the switch (0/1)"
710 metric.labels(**self.
_labels_labels(state)).set(value)
715 metric = self._metric(
716 "fan_state", prometheus_client.Gauge,
"State of the fan (0/1)"
720 metric.labels(**self.
_labels_labels(state)).set(value)
722 fan_speed_percent = state.attributes.get(ATTR_PERCENTAGE)
723 if fan_speed_percent
is not None:
724 fan_speed_metric = self._metric(
726 prometheus_client.Gauge,
727 "Fan speed percent (0-100)",
729 fan_speed_metric.labels(**self.
_labels_labels(state)).set(
float(fan_speed_percent))
731 fan_is_oscillating = state.attributes.get(ATTR_OSCILLATING)
732 if fan_is_oscillating
is not None:
733 fan_oscillating_metric = self._metric(
734 "fan_is_oscillating",
735 prometheus_client.Gauge,
736 "Whether the fan is oscillating (0/1)",
738 fan_oscillating_metric.labels(**self.
_labels_labels(state)).set(
739 float(fan_is_oscillating)
742 fan_preset_mode = state.attributes.get(ATTR_PRESET_MODE)
743 available_modes = state.attributes.get(ATTR_PRESET_MODES)
744 if fan_preset_mode
and available_modes:
745 fan_preset_metric = self._metric(
747 prometheus_client.Gauge,
748 "Fan preset mode enum",
751 for mode
in available_modes:
752 fan_preset_metric.labels(**
dict(self.
_labels_labels(state), mode=mode)).set(
753 float(mode == fan_preset_mode)
756 fan_direction = state.attributes.get(ATTR_DIRECTION)
757 if fan_direction
is not None:
758 fan_direction_metric = self._metric(
759 "fan_direction_reversed",
760 prometheus_client.Gauge,
761 "Fan direction reversed (bool)",
763 if fan_direction == DIRECTION_FORWARD:
764 fan_direction_metric.labels(**self.
_labels_labels(state)).set(0)
765 elif fan_direction == DIRECTION_REVERSE:
766 fan_direction_metric.labels(**self.
_labels_labels(state)).set(1)
772 metric = self._metric(
773 "automation_triggered_count",
774 prometheus_client.Counter,
775 "Count of times an automation has been triggered",
778 metric.labels(**self.
_labels_labels(state)).inc()
781 metric = self._metric(
783 prometheus_client.Gauge,
784 "Value of counter entities",
787 metric.labels(**self.
_labels_labels(state)).set(value)
790 metric = self._metric(
792 prometheus_client.Gauge,
793 "Update state, indicating if an update is available (0/1)",
796 metric.labels(**self.
_labels_labels(state)).set(value)
799 current_state = state.state
802 metric = self._metric(
803 "alarm_control_panel_state",
804 prometheus_client.Gauge,
805 "State of the alarm control panel (0/1)",
809 for alarm_state
in AlarmControlPanelState:
810 metric.labels(**
dict(self.
_labels_labels(state), state=alarm_state.value)).set(
811 float(alarm_state.value == current_state)
816 """Handle Prometheus requests."""
819 name =
"api:prometheus"
822 """Initialize Prometheus view."""
825 async
def get(self, request: web.Request) -> web.Response:
826 """Handle request for Prometheus metrics."""
827 _LOGGER.debug(
"Received Prometheus metrics request")
829 hass = request.app[KEY_HASS]
830 body = await hass.async_add_executor_job(
831 prometheus_client.generate_latest, prometheus_client.REGISTRY
835 content_type=CONTENT_TYPE_TEXT_PLAIN,
None _battery(self, State state)
None __init__(self, entityfilter.EntityFilter entity_filter, str namespace, UnitOfTemperature climate_units, EntityValues component_config, str|None override_metric, str|None default_metric)
None handle_state_changed_event(self, Event[EventStateChangedData] event)
None handle_state(self, State state)
str|None _sensor_timestamp_metric(State state, str|None unit)
None _handle_input_number(self, State state)
None _handle_binary_sensor(self, State state)
None _handle_number(self, State state)
None _handle_alarm_control_panel(self, State state)
None _handle_input_boolean(self, State state)
None _numeric_handler(self, State state, str domain, str title)
None _handle_climate(self, State state)
str|None _unit_string(str|None unit)
str|None _sensor_override_metric(self, State state, str|None unit)
dict[str, Any] _labels(State state)
str|None _sensor_override_component_metric(self, State state, str|None unit)
str|None _sensor_fallback_metric(State state, str|None unit)
str|None _sensor_default_metric(self, State state, str|None unit)
None _handle_update(self, State state)
None _handle_light(self, State state)
None _handle_zwave(self, State state)
None handle_entity_registry_updated(self, Event[EventEntityRegistryUpdatedData] event)
None _handle_device_tracker(self, State state)
None _handle_cover(self, State state)
str _sanitize_metric_name(str metric)
float|None state_as_number(State state)
None _handle_sensor(self, State state)
None _handle_climate_temp(self, State state, str attr, str metric_name, str metric_description)
None _handle_lock(self, State state)
None _handle_fan(self, State state)
None _handle_person(self, State state)
None _handle_attributes(self, State state)
None _handle_automation(self, State state)
None _handle_counter(self, State state)
str|None _sensor_attribute_metric(State state, str|None unit)
None _handle_switch(self, State state)
None _handle_humidifier(self, State state)
None _remove_labelsets(self, str entity_id, str|None friendly_name=None, set[MetricWrapperBase]|None ignored_metrics=None)
None __init__(self, bool requires_auth)
web.Response get(self, web.Request request)
web.Response get(self, web.Request request, str config_key)
bool setup(HomeAssistant hass, ConfigType config)
float as_timestamp(dt.datetime|str dt_value)