diff --git a/homeassistant/components/prometheus/__init__.py b/homeassistant/components/prometheus/__init__.py index d3d0a834257e..7690ee5d1fc4 100644 --- a/homeassistant/components/prometheus/__init__.py +++ b/homeassistant/components/prometheus/__init__.py @@ -40,6 +40,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import entityfilter, state as state_helper import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_registry import EVENT_ENTITY_REGISTRY_UPDATED from homeassistant.helpers.entity_values import EntityValues from homeassistant.helpers.typing import ConfigType from homeassistant.util.temperature import fahrenheit_to_celsius @@ -112,7 +113,10 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: default_metric, ) - hass.bus.listen(EVENT_STATE_CHANGED, metrics.handle_event) + hass.bus.listen(EVENT_STATE_CHANGED, metrics.handle_state_changed) + hass.bus.listen( + EVENT_ENTITY_REGISTRY_UPDATED, metrics.handle_entity_registry_updated + ) return True @@ -150,7 +154,7 @@ class PrometheusMetrics: self._metrics = {} self._climate_units = climate_units - def handle_event(self, event): + def handle_state_changed(self, event): """Listen for new messages on the bus, and add them to Prometheus.""" if (state := event.data.get("new_state")) is None: return @@ -162,6 +166,11 @@ class PrometheusMetrics: if not self._filter(state.entity_id): return + if (old_state := event.data.get("old_state")) is not None and ( + old_friendly_name := old_state.attributes.get(ATTR_FRIENDLY_NAME) + ) != state.attributes.get(ATTR_FRIENDLY_NAME): + self._remove_labelsets(old_state.entity_id, old_friendly_name) + ignored_states = (STATE_UNAVAILABLE, STATE_UNKNOWN) handler = f"_handle_{domain}" @@ -189,6 +198,46 @@ class PrometheusMetrics: ) last_updated_time_seconds.labels(**labels).set(state.last_updated.timestamp()) + def handle_entity_registry_updated(self, event): + """Listen for deleted, disabled or renamed entities and remove them from the Prometheus Registry.""" + if (action := event.data.get("action")) in (None, "create"): + return + + entity_id = event.data.get("entity_id") + _LOGGER.debug("Handling entity update for %s", entity_id) + + metrics_entity_id = None + + if action == "remove": + metrics_entity_id = entity_id + elif action == "update": + changes = event.data.get("changes") + + if "entity_id" in changes: + metrics_entity_id = changes["entity_id"] + elif "disabled_by" in changes: + metrics_entity_id = entity_id + + if metrics_entity_id: + self._remove_labelsets(metrics_entity_id) + + def _remove_labelsets(self, entity_id, friendly_name=None): + """Remove labelsets matching the given entity id from all metrics.""" + for _, metric in self._metrics.items(): + for sample in metric.collect()[0].samples: + if sample.labels["entity"] == entity_id and ( + not friendly_name or sample.labels["friendly_name"] == friendly_name + ): + _LOGGER.debug( + "Removing labelset from %s for entity_id: %s", + sample.name, + entity_id, + ) + try: + metric.remove(*sample.labels.values()) + except KeyError: + pass + def _handle_attributes(self, state): for key, value in state.attributes.items(): metric = self._metric( diff --git a/tests/components/prometheus/test_init.py b/tests/components/prometheus/test_init.py index edbb770eb7dd..d1b0c7c2130c 100644 --- a/tests/components/prometheus/test_init.py +++ b/tests/components/prometheus/test_init.py @@ -24,6 +24,7 @@ from homeassistant.const import ( TEMP_FAHRENHEIT, ) from homeassistant.core import split_entity_id +from homeassistant.helpers import entity_registry from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -726,6 +727,392 @@ async def test_counter(hass, hass_client): ) +async def test_renaming_entity_name(hass, hass_client): + """Test renaming entity name.""" + assert await async_setup_component( + hass, + "conversation", + {}, + ) + client = await setup_prometheus_client(hass, hass_client, "") + + assert await async_setup_component( + hass, climate.DOMAIN, {"climate": [{"platform": "demo"}]} + ) + + assert await async_setup_component( + hass, sensor.DOMAIN, {"sensor": [{"platform": "demo"}]} + ) + + await hass.async_block_till_done() + body = await generate_latest_metrics(client) + + assert ( + 'sensor_temperature_celsius{domain="sensor",' + 'entity="sensor.outside_temperature",' + 'friendly_name="Outside Temperature"} 15.6' in body + ) + + assert ( + 'entity_available{domain="sensor",' + 'entity="sensor.outside_temperature",' + 'friendly_name="Outside Temperature"} 1.0' in body + ) + + assert ( + 'sensor_humidity_percent{domain="sensor",' + 'entity="sensor.outside_humidity",' + 'friendly_name="Outside Humidity"} 54.0' in body + ) + + assert ( + 'entity_available{domain="sensor",' + 'entity="sensor.outside_humidity",' + 'friendly_name="Outside Humidity"} 1.0' in body + ) + + assert ( + 'climate_action{action="heating",' + 'domain="climate",' + 'entity="climate.heatpump",' + 'friendly_name="HeatPump"} 1.0' in body + ) + + assert ( + 'climate_action{action="cooling",' + 'domain="climate",' + 'entity="climate.heatpump",' + 'friendly_name="HeatPump"} 0.0' in body + ) + + registry = entity_registry.async_get(hass) + assert "sensor.outside_temperature" in registry.entities + assert "climate.heatpump" in registry.entities + registry.async_update_entity( + entity_id="sensor.outside_temperature", + name="Outside Temperature Renamed", + ) + registry.async_update_entity( + entity_id="climate.heatpump", + name="HeatPump Renamed", + ) + + await hass.async_block_till_done() + body = await generate_latest_metrics(client) + + # Check if old metrics deleted + body_line = "\n".join(body) + assert 'friendly_name="Outside Temperature"' not in body_line + assert 'friendly_name="HeatPump"' not in body_line + + # Check if new metrics created + assert ( + 'sensor_temperature_celsius{domain="sensor",' + 'entity="sensor.outside_temperature",' + 'friendly_name="Outside Temperature Renamed"} 15.6' in body + ) + + assert ( + 'entity_available{domain="sensor",' + 'entity="sensor.outside_temperature",' + 'friendly_name="Outside Temperature Renamed"} 1.0' in body + ) + + assert ( + 'climate_action{action="heating",' + 'domain="climate",' + 'entity="climate.heatpump",' + 'friendly_name="HeatPump Renamed"} 1.0' in body + ) + + assert ( + 'climate_action{action="cooling",' + 'domain="climate",' + 'entity="climate.heatpump",' + 'friendly_name="HeatPump Renamed"} 0.0' in body + ) + + # Keep other sensors + assert ( + 'sensor_humidity_percent{domain="sensor",' + 'entity="sensor.outside_humidity",' + 'friendly_name="Outside Humidity"} 54.0' in body + ) + + assert ( + 'entity_available{domain="sensor",' + 'entity="sensor.outside_humidity",' + 'friendly_name="Outside Humidity"} 1.0' in body + ) + + +async def test_renaming_entity_id(hass, hass_client): + """Test renaming entity id.""" + assert await async_setup_component( + hass, + "conversation", + {}, + ) + client = await setup_prometheus_client(hass, hass_client, "") + + assert await async_setup_component( + hass, sensor.DOMAIN, {"sensor": [{"platform": "demo"}]} + ) + + await hass.async_block_till_done() + body = await generate_latest_metrics(client) + + assert ( + 'sensor_temperature_celsius{domain="sensor",' + 'entity="sensor.outside_temperature",' + 'friendly_name="Outside Temperature"} 15.6' in body + ) + + assert ( + 'entity_available{domain="sensor",' + 'entity="sensor.outside_temperature",' + 'friendly_name="Outside Temperature"} 1.0' in body + ) + + assert ( + 'sensor_humidity_percent{domain="sensor",' + 'entity="sensor.outside_humidity",' + 'friendly_name="Outside Humidity"} 54.0' in body + ) + + assert ( + 'entity_available{domain="sensor",' + 'entity="sensor.outside_humidity",' + 'friendly_name="Outside Humidity"} 1.0' in body + ) + + registry = entity_registry.async_get(hass) + assert "sensor.outside_temperature" in registry.entities + registry.async_update_entity( + entity_id="sensor.outside_temperature", + new_entity_id="sensor.outside_temperature_renamed", + ) + + await hass.async_block_till_done() + body = await generate_latest_metrics(client) + + # Check if old metrics deleted + body_line = "\n".join(body) + assert 'entity="sensor.outside_temperature"' not in body_line + + # Check if new metrics created + assert ( + 'sensor_temperature_celsius{domain="sensor",' + 'entity="sensor.outside_temperature_renamed",' + 'friendly_name="Outside Temperature"} 15.6' in body + ) + + assert ( + 'entity_available{domain="sensor",' + 'entity="sensor.outside_temperature_renamed",' + 'friendly_name="Outside Temperature"} 1.0' in body + ) + + # Keep other sensors + assert ( + 'sensor_humidity_percent{domain="sensor",' + 'entity="sensor.outside_humidity",' + 'friendly_name="Outside Humidity"} 54.0' in body + ) + + assert ( + 'entity_available{domain="sensor",' + 'entity="sensor.outside_humidity",' + 'friendly_name="Outside Humidity"} 1.0' in body + ) + + +async def test_deleting_entity(hass, hass_client): + """Test deleting a entity.""" + assert await async_setup_component( + hass, + "conversation", + {}, + ) + client = await setup_prometheus_client(hass, hass_client, "") + + await async_setup_component( + hass, climate.DOMAIN, {"climate": [{"platform": "demo"}]} + ) + + assert await async_setup_component( + hass, sensor.DOMAIN, {"sensor": [{"platform": "demo"}]} + ) + + await hass.async_block_till_done() + body = await generate_latest_metrics(client) + + assert ( + 'sensor_temperature_celsius{domain="sensor",' + 'entity="sensor.outside_temperature",' + 'friendly_name="Outside Temperature"} 15.6' in body + ) + + assert ( + 'entity_available{domain="sensor",' + 'entity="sensor.outside_temperature",' + 'friendly_name="Outside Temperature"} 1.0' in body + ) + + assert ( + 'sensor_humidity_percent{domain="sensor",' + 'entity="sensor.outside_humidity",' + 'friendly_name="Outside Humidity"} 54.0' in body + ) + + assert ( + 'entity_available{domain="sensor",' + 'entity="sensor.outside_humidity",' + 'friendly_name="Outside Humidity"} 1.0' in body + ) + + assert ( + 'climate_action{action="heating",' + 'domain="climate",' + 'entity="climate.heatpump",' + 'friendly_name="HeatPump"} 1.0' in body + ) + + assert ( + 'climate_action{action="cooling",' + 'domain="climate",' + 'entity="climate.heatpump",' + 'friendly_name="HeatPump"} 0.0' in body + ) + + registry = entity_registry.async_get(hass) + assert "sensor.outside_temperature" in registry.entities + assert "climate.heatpump" in registry.entities + registry.async_remove("sensor.outside_temperature") + registry.async_remove("climate.heatpump") + + await hass.async_block_till_done() + body = await generate_latest_metrics(client) + + # Check if old metrics deleted + body_line = "\n".join(body) + assert 'entity="sensor.outside_temperature"' not in body_line + assert 'friendly_name="Outside Temperature"' not in body_line + assert 'entity="climate.heatpump"' not in body_line + assert 'friendly_name="HeatPump"' not in body_line + + # Keep other sensors + assert ( + 'sensor_humidity_percent{domain="sensor",' + 'entity="sensor.outside_humidity",' + 'friendly_name="Outside Humidity"} 54.0' in body + ) + + assert ( + 'entity_available{domain="sensor",' + 'entity="sensor.outside_humidity",' + 'friendly_name="Outside Humidity"} 1.0' in body + ) + + +async def test_disabling_entity(hass, hass_client): + """Test disabling a entity.""" + assert await async_setup_component( + hass, + "conversation", + {}, + ) + client = await setup_prometheus_client(hass, hass_client, "") + + await async_setup_component( + hass, climate.DOMAIN, {"climate": [{"platform": "demo"}]} + ) + + assert await async_setup_component( + hass, sensor.DOMAIN, {"sensor": [{"platform": "demo"}]} + ) + + await hass.async_block_till_done() + body = await generate_latest_metrics(client) + + assert ( + 'sensor_temperature_celsius{domain="sensor",' + 'entity="sensor.outside_temperature",' + 'friendly_name="Outside Temperature"} 15.6' in body + ) + + assert ( + 'state_change_total{domain="sensor",' + 'entity="sensor.outside_temperature",' + 'friendly_name="Outside Temperature"} 1.0' in body + ) + + assert any( + 'state_change_created{domain="sensor",' + 'entity="sensor.outside_temperature",' + 'friendly_name="Outside Temperature"}' in metric + for metric in body + ) + + assert ( + 'sensor_humidity_percent{domain="sensor",' + 'entity="sensor.outside_humidity",' + 'friendly_name="Outside Humidity"} 54.0' in body + ) + + assert ( + 'entity_available{domain="sensor",' + 'entity="sensor.outside_humidity",' + 'friendly_name="Outside Humidity"} 1.0' in body + ) + + assert ( + 'climate_action{action="heating",' + 'domain="climate",' + 'entity="climate.heatpump",' + 'friendly_name="HeatPump"} 1.0' in body + ) + + assert ( + 'climate_action{action="cooling",' + 'domain="climate",' + 'entity="climate.heatpump",' + 'friendly_name="HeatPump"} 0.0' in body + ) + + registry = entity_registry.async_get(hass) + assert "sensor.outside_temperature" in registry.entities + assert "climate.heatpump" in registry.entities + registry.async_update_entity( + entity_id="sensor.outside_temperature", + disabled_by="user", + ) + registry.async_update_entity(entity_id="climate.heatpump", disabled_by="user") + + await hass.async_block_till_done() + body = await generate_latest_metrics(client) + + # Check if old metrics deleted + body_line = "\n".join(body) + assert 'entity="sensor.outside_temperature"' not in body_line + assert 'friendly_name="Outside Temperature"' not in body_line + assert 'entity="climate.heatpump"' not in body_line + assert 'friendly_name="HeatPump"' not in body_line + + # Keep other sensors + assert ( + 'sensor_humidity_percent{domain="sensor",' + 'entity="sensor.outside_humidity",' + 'friendly_name="Outside Humidity"} 54.0' in body + ) + + assert ( + 'entity_available{domain="sensor",' + 'entity="sensor.outside_humidity",' + 'friendly_name="Outside Humidity"} 1.0' in body + ) + + @pytest.fixture(name="mock_client") def mock_client_fixture(): """Mock the prometheus client."""