1
mirror of https://github.com/home-assistant/core synced 2024-07-12 07:21:24 +02:00

Fix duplicate metrics in prometheus (#61355)

* Fix duplicate metrics in prometheus

* Fix duplicate prometheus metrics for entities with multiple labelsets
- Move friendly_name detection to state_changed event
- Add additional test case

* Add review suggestions for prometheus friendly name update

* Remove commented out code in prometheus

* Update prometheus tests for deleted metrics

* Add review suggestions for prometheus
- Remove unnecessary firendly_name check in handle_entity_registry_updated
- Add assert in test
This commit is contained in:
alim4r 2022-01-25 17:05:52 +01:00 committed by GitHub
parent 51a04585e7
commit 3e0e9e54bf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 438 additions and 2 deletions

View File

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

View File

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