Home Assistant Unofficial Reference 2024.12.1
__init__.py
Go to the documentation of this file.
1 """The profiler integration."""
2 
3 import asyncio
4 from collections.abc import Generator
5 import contextlib
6 from contextlib import suppress
7 from datetime import timedelta
8 from functools import _lru_cache_wrapper
9 import logging
10 import reprlib
11 import sys
12 import threading
13 import time
14 import traceback
15 from typing import Any, cast
16 
17 from lru import LRU
18 import voluptuous as vol
19 
20 from homeassistant.components import persistent_notification
21 from homeassistant.config_entries import ConfigEntry
22 from homeassistant.const import CONF_SCAN_INTERVAL, CONF_TYPE
23 from homeassistant.core import HomeAssistant, ServiceCall, callback
24 from homeassistant.exceptions import HomeAssistantError
26 from homeassistant.helpers.event import async_track_time_interval
27 from homeassistant.helpers.service import async_register_admin_service
28 
29 from .const import DOMAIN
30 
31 SERVICE_START = "start"
32 SERVICE_MEMORY = "memory"
33 SERVICE_START_LOG_OBJECTS = "start_log_objects"
34 SERVICE_STOP_LOG_OBJECTS = "stop_log_objects"
35 SERVICE_START_LOG_OBJECT_SOURCES = "start_log_object_sources"
36 SERVICE_STOP_LOG_OBJECT_SOURCES = "stop_log_object_sources"
37 SERVICE_DUMP_LOG_OBJECTS = "dump_log_objects"
38 SERVICE_LRU_STATS = "lru_stats"
39 SERVICE_LOG_THREAD_FRAMES = "log_thread_frames"
40 SERVICE_LOG_EVENT_LOOP_SCHEDULED = "log_event_loop_scheduled"
41 SERVICE_SET_ASYNCIO_DEBUG = "set_asyncio_debug"
42 SERVICE_LOG_CURRENT_TASKS = "log_current_tasks"
43 
44 _LRU_CACHE_WRAPPER_OBJECT = _lru_cache_wrapper.__name__
45 _SQLALCHEMY_LRU_OBJECT = "LRUCache"
46 
47 _KNOWN_LRU_CLASSES = (
48  "EventDataManager",
49  "EventTypeManager",
50  "StatesMetaManager",
51  "StateAttributesManager",
52  "StatisticsMetaManager",
53 )
54 
55 SERVICES = (
56  SERVICE_START,
57  SERVICE_MEMORY,
58  SERVICE_START_LOG_OBJECTS,
59  SERVICE_STOP_LOG_OBJECTS,
60  SERVICE_DUMP_LOG_OBJECTS,
61  SERVICE_LRU_STATS,
62  SERVICE_LOG_THREAD_FRAMES,
63  SERVICE_LOG_EVENT_LOOP_SCHEDULED,
64  SERVICE_SET_ASYNCIO_DEBUG,
65  SERVICE_LOG_CURRENT_TASKS,
66 )
67 
68 DEFAULT_SCAN_INTERVAL = timedelta(seconds=30)
69 
70 DEFAULT_MAX_OBJECTS = 5
71 
72 CONF_ENABLED = "enabled"
73 CONF_SECONDS = "seconds"
74 CONF_MAX_OBJECTS = "max_objects"
75 
76 LOG_INTERVAL_SUB = "log_interval_subscription"
77 
78 
79 _LOGGER = logging.getLogger(__name__)
80 
81 
82 async def async_setup_entry( # noqa: C901
83  hass: HomeAssistant, entry: ConfigEntry
84 ) -> bool:
85  """Set up Profiler from a config entry."""
86  lock = asyncio.Lock()
87  domain_data = hass.data[DOMAIN] = {}
88 
89  async def _async_run_profile(call: ServiceCall) -> None:
90  async with lock:
91  await _async_generate_profile(hass, call)
92 
93  async def _async_run_memory_profile(call: ServiceCall) -> None:
94  async with lock:
95  await _async_generate_memory_profile(hass, call)
96 
97  async def _async_start_log_objects(call: ServiceCall) -> None:
98  if LOG_INTERVAL_SUB in domain_data:
99  raise HomeAssistantError("Object logging already started")
100 
101  persistent_notification.async_create(
102  hass,
103  (
104  "Object growth logging has started. See [the logs](/config/logs) to"
105  " track the growth of new objects."
106  ),
107  title="Object growth logging started",
108  notification_id="profile_object_logging",
109  )
110  await hass.async_add_executor_job(_log_objects)
111  domain_data[LOG_INTERVAL_SUB] = async_track_time_interval(
112  hass, _log_objects, call.data[CONF_SCAN_INTERVAL]
113  )
114 
115  async def _async_stop_log_objects(call: ServiceCall) -> None:
116  if LOG_INTERVAL_SUB not in domain_data:
117  raise HomeAssistantError("Object logging not running")
118 
119  persistent_notification.async_dismiss(hass, "profile_object_logging")
120  domain_data.pop(LOG_INTERVAL_SUB)()
121 
122  async def _async_start_object_sources(call: ServiceCall) -> None:
123  if LOG_INTERVAL_SUB in domain_data:
124  raise HomeAssistantError("Object logging already started")
125 
126  persistent_notification.async_create(
127  hass,
128  (
129  "Object source logging has started. See [the logs](/config/logs) to"
130  " track the growth of new objects."
131  ),
132  title="Object source logging started",
133  notification_id="profile_object_source_logging",
134  )
135 
136  last_ids: set[int] = set()
137  last_stats: dict[str, int] = {}
138 
139  async def _log_object_sources_with_max(*_: Any) -> None:
140  await hass.async_add_executor_job(
141  _log_object_sources, call.data[CONF_MAX_OBJECTS], last_ids, last_stats
142  )
143 
144  await _log_object_sources_with_max()
145  cancel_track = async_track_time_interval(
146  hass, _log_object_sources_with_max, call.data[CONF_SCAN_INTERVAL]
147  )
148 
149  @callback
150  def _cancel():
151  cancel_track()
152  last_ids.clear()
153  last_stats.clear()
154 
155  domain_data[LOG_INTERVAL_SUB] = _cancel
156 
157  @callback
158  def _async_stop_object_sources(call: ServiceCall) -> None:
159  if LOG_INTERVAL_SUB not in domain_data:
160  raise HomeAssistantError("Object logging not running")
161 
162  persistent_notification.async_dismiss(hass, "profile_object_source_logging")
163  domain_data.pop(LOG_INTERVAL_SUB)()
164 
165  def _dump_log_objects(call: ServiceCall) -> None:
166  # Imports deferred to avoid loading modules
167  # in memory since usually only one part of this
168  # integration is used at a time
169  import objgraph # pylint: disable=import-outside-toplevel
170 
171  obj_type = call.data[CONF_TYPE]
172 
173  for obj in objgraph.by_type(obj_type):
174  _LOGGER.critical(
175  "%s object in memory: %s",
176  obj_type,
177  _safe_repr(obj),
178  )
179 
180  persistent_notification.create(
181  hass,
182  (
183  f"Objects with type {obj_type} have been dumped to the log. See [the"
184  " logs](/config/logs) to review the repr of the objects."
185  ),
186  title="Object dump completed",
187  notification_id="profile_object_dump",
188  )
189 
190  def _lru_stats(call: ServiceCall) -> None:
191  """Log the stats of all lru caches."""
192  # Imports deferred to avoid loading modules
193  # in memory since usually only one part of this
194  # integration is used at a time
195  import objgraph # pylint: disable=import-outside-toplevel
196 
197  for lru in objgraph.by_type(_LRU_CACHE_WRAPPER_OBJECT):
198  lru = cast(_lru_cache_wrapper, lru)
199  _LOGGER.critical(
200  "Cache stats for lru_cache %s at %s: %s",
201  lru.__wrapped__,
202  _get_function_absfile(lru.__wrapped__) or "unknown",
203  lru.cache_info(),
204  )
205 
206  for _class in _KNOWN_LRU_CLASSES:
207  for class_with_lru_attr in objgraph.by_type(_class):
208  for maybe_lru in class_with_lru_attr.__dict__.values():
209  if isinstance(maybe_lru, LRU):
210  _LOGGER.critical(
211  "Cache stats for LRU %s at %s: %s",
212  type(class_with_lru_attr),
213  _get_function_absfile(class_with_lru_attr) or "unknown",
214  maybe_lru.get_stats(),
215  )
216 
217  for lru in objgraph.by_type(_SQLALCHEMY_LRU_OBJECT):
218  if (data := getattr(lru, "_data", None)) and isinstance(data, dict):
219  for key, value in dict(data).items():
220  _LOGGER.critical(
221  "Cache data for sqlalchemy LRUCache %s: %s: %s", lru, key, value
222  )
223 
224  persistent_notification.create(
225  hass,
226  (
227  "LRU cache states have been dumped to the log. See [the"
228  " logs](/config/logs) to review the stats."
229  ),
230  title="LRU stats completed",
231  notification_id="profile_lru_stats",
232  )
233 
234  async def _async_dump_thread_frames(call: ServiceCall) -> None:
235  """Log all thread frames."""
236  frames = sys._current_frames() # noqa: SLF001
237  main_thread = threading.main_thread()
238  for thread in threading.enumerate():
239  if thread == main_thread:
240  continue
241  ident = cast(int, thread.ident)
242  _LOGGER.critical(
243  "Thread [%s]: %s",
244  thread.name,
245  "".join(traceback.format_stack(frames.get(ident))).strip(),
246  )
247 
248  async def _async_dump_current_tasks(call: ServiceCall) -> None:
249  """Log all current tasks in the event loop."""
250  with _increase_repr_limit():
251  for task in asyncio.all_tasks():
252  if not task.cancelled():
253  _LOGGER.critical("Task: %s", _safe_repr(task))
254 
255  async def _async_dump_scheduled(call: ServiceCall) -> None:
256  """Log all scheduled in the event loop."""
257  with _increase_repr_limit():
258  handle: asyncio.Handle
259  for handle in getattr(hass.loop, "_scheduled"):
260  if not handle.cancelled():
261  _LOGGER.critical("Scheduled: %s", handle)
262 
263  async def _async_asyncio_debug(call: ServiceCall) -> None:
264  """Enable or disable asyncio debug."""
265  enabled = call.data[CONF_ENABLED]
266  # Always log this at critical level so we know when
267  # it's been changed when reviewing logs
268  _LOGGER.critical("Setting asyncio debug to %s", enabled)
269  # Make sure the logger is set to at least INFO or
270  # we won't see the messages
271  base_logger = logging.getLogger()
272  if enabled and base_logger.getEffectiveLevel() > logging.INFO:
273  base_logger.setLevel(logging.INFO)
274  hass.loop.set_debug(enabled)
275 
277  hass,
278  DOMAIN,
279  SERVICE_START,
280  _async_run_profile,
281  schema=vol.Schema(
282  {vol.Optional(CONF_SECONDS, default=60.0): vol.Coerce(float)}
283  ),
284  )
285 
287  hass,
288  DOMAIN,
289  SERVICE_MEMORY,
290  _async_run_memory_profile,
291  schema=vol.Schema(
292  {vol.Optional(CONF_SECONDS, default=60.0): vol.Coerce(float)}
293  ),
294  )
295 
297  hass,
298  DOMAIN,
299  SERVICE_START_LOG_OBJECTS,
300  _async_start_log_objects,
301  schema=vol.Schema(
302  {
303  vol.Optional(
304  CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL
305  ): cv.time_period
306  }
307  ),
308  )
309 
311  hass,
312  DOMAIN,
313  SERVICE_STOP_LOG_OBJECTS,
314  _async_stop_log_objects,
315  )
316 
318  hass,
319  DOMAIN,
320  SERVICE_START_LOG_OBJECT_SOURCES,
321  _async_start_object_sources,
322  schema=vol.Schema(
323  {
324  vol.Optional(
325  CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL
326  ): cv.time_period,
327  vol.Optional(CONF_MAX_OBJECTS, default=DEFAULT_MAX_OBJECTS): vol.Range(
328  min=1, max=1024
329  ),
330  }
331  ),
332  )
333 
335  hass,
336  DOMAIN,
337  SERVICE_STOP_LOG_OBJECT_SOURCES,
338  _async_stop_object_sources,
339  )
340 
342  hass,
343  DOMAIN,
344  SERVICE_DUMP_LOG_OBJECTS,
345  _dump_log_objects,
346  schema=vol.Schema({vol.Required(CONF_TYPE): str}),
347  )
348 
350  hass,
351  DOMAIN,
352  SERVICE_LRU_STATS,
353  _lru_stats,
354  )
355 
357  hass,
358  DOMAIN,
359  SERVICE_LOG_THREAD_FRAMES,
360  _async_dump_thread_frames,
361  )
362 
364  hass,
365  DOMAIN,
366  SERVICE_LOG_EVENT_LOOP_SCHEDULED,
367  _async_dump_scheduled,
368  )
369 
371  hass,
372  DOMAIN,
373  SERVICE_SET_ASYNCIO_DEBUG,
374  _async_asyncio_debug,
375  schema=vol.Schema({vol.Optional(CONF_ENABLED, default=True): cv.boolean}),
376  )
377 
379  hass,
380  DOMAIN,
381  SERVICE_LOG_CURRENT_TASKS,
382  _async_dump_current_tasks,
383  )
384 
385  return True
386 
387 
388 async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
389  """Unload a config entry."""
390  for service in SERVICES:
391  hass.services.async_remove(domain=DOMAIN, service=service)
392  if LOG_INTERVAL_SUB in hass.data[DOMAIN]:
393  hass.data[DOMAIN][LOG_INTERVAL_SUB]()
394  hass.data.pop(DOMAIN)
395  return True
396 
397 
398 async def _async_generate_profile(hass: HomeAssistant, call: ServiceCall):
399  # Imports deferred to avoid loading modules
400  # in memory since usually only one part of this
401  # integration is used at a time
402  import cProfile # pylint: disable=import-outside-toplevel
403 
404  start_time = int(time.time() * 1000000)
405  persistent_notification.async_create(
406  hass,
407  (
408  "The profile has started. This notification will be updated when it is"
409  " complete."
410  ),
411  title="Profile Started",
412  notification_id=f"profiler_{start_time}",
413  )
414  profiler = cProfile.Profile()
415  profiler.enable()
416  await asyncio.sleep(float(call.data[CONF_SECONDS]))
417  profiler.disable()
418 
419  cprofile_path = hass.config.path(f"profile.{start_time}.cprof")
420  callgrind_path = hass.config.path(f"callgrind.out.{start_time}")
421  await hass.async_add_executor_job(
422  _write_profile, profiler, cprofile_path, callgrind_path
423  )
424  persistent_notification.async_create(
425  hass,
426  (
427  f"Wrote cProfile data to {cprofile_path} and callgrind data to"
428  f" {callgrind_path}"
429  ),
430  title="Profile Complete",
431  notification_id=f"profiler_{start_time}",
432  )
433 
434 
435 async def _async_generate_memory_profile(hass: HomeAssistant, call: ServiceCall):
436  # Imports deferred to avoid loading modules
437  # in memory since usually only one part of this
438  # integration is used at a time
439  if sys.version_info >= (3, 13):
440  raise HomeAssistantError(
441  "Memory profiling is not supported on Python 3.13. Please use Python 3.12."
442  )
443  from guppy import hpy # pylint: disable=import-outside-toplevel
444 
445  start_time = int(time.time() * 1000000)
446  persistent_notification.async_create(
447  hass,
448  (
449  "The memory profile has started. This notification will be updated when it"
450  " is complete."
451  ),
452  title="Profile Started",
453  notification_id=f"memory_profiler_{start_time}",
454  )
455  heap_profiler = hpy()
456  heap_profiler.setref()
457  await asyncio.sleep(float(call.data[CONF_SECONDS]))
458  heap = heap_profiler.heap()
459 
460  heap_path = hass.config.path(f"heap_profile.{start_time}.hpy")
461  await hass.async_add_executor_job(_write_memory_profile, heap, heap_path)
462  persistent_notification.async_create(
463  hass,
464  f"Wrote heapy memory profile to {heap_path}",
465  title="Profile Complete",
466  notification_id=f"memory_profiler_{start_time}",
467  )
468 
469 
470 def _write_profile(profiler, cprofile_path, callgrind_path):
471  # Imports deferred to avoid loading modules
472  # in memory since usually only one part of this
473  # integration is used at a time
474  from pyprof2calltree import convert # pylint: disable=import-outside-toplevel
475 
476  profiler.create_stats()
477  profiler.dump_stats(cprofile_path)
478  convert(profiler.getstats(), callgrind_path)
479 
480 
481 def _write_memory_profile(heap, heap_path):
482  heap.byrcs.dump(heap_path)
483 
484 
485 def _log_objects(*_):
486  # Imports deferred to avoid loading modules
487  # in memory since usually only one part of this
488  # integration is used at a time
489  import objgraph # pylint: disable=import-outside-toplevel
490 
491  _LOGGER.critical("Memory Growth: %s", objgraph.growth(limit=1000))
492 
493 
494 def _get_function_absfile(func: Any) -> str | None:
495  """Get the absolute file path of a function."""
496  import inspect # pylint: disable=import-outside-toplevel
497 
498  abs_file: str | None = None
499  with suppress(Exception):
500  abs_file = inspect.getabsfile(func)
501  return abs_file
502 
503 
504 def _safe_repr(obj: Any) -> str:
505  """Get the repr of an object but keep going if there is an exception.
506 
507  We wrap repr to ensure if one object cannot be serialized, we can
508  still get the rest.
509  """
510  try:
511  return repr(obj)
512  except Exception: # noqa: BLE001
513  return f"Failed to serialize {type(obj)}"
514 
515 
516 def _find_backrefs_not_to_self(_object: Any) -> list[str]:
517  import objgraph # pylint: disable=import-outside-toplevel
518 
519  return [
520  _safe_repr(backref)
521  for backref in objgraph.find_backref_chain(
522  _object, lambda obj: obj is not _object
523  )
524  ]
525 
526 
528  max_objects: int, last_ids: set[int], last_stats: dict[str, int]
529 ) -> None:
530  # Imports deferred to avoid loading modules
531  # in memory since usually only one part of this
532  # integration is used at a time
533  import gc # pylint: disable=import-outside-toplevel
534 
535  gc.collect()
536 
537  objects = gc.get_objects()
538  new_objects: list[object] = []
539  new_objects_overflow: dict[str, int] = {}
540  current_ids = set()
541  new_stats: dict[str, int] = {}
542  had_new_object_growth = False
543  try:
544  for _object in objects:
545  object_type = type(_object).__name__
546  new_stats[object_type] = new_stats.get(object_type, 0) + 1
547 
548  for _object in objects:
549  id_ = id(_object)
550  current_ids.add(id_)
551  if id_ in last_ids:
552  continue
553  object_type = type(_object).__name__
554  if last_stats.get(object_type, 0) < new_stats[object_type]:
555  if len(new_objects) < max_objects:
556  new_objects.append(_object)
557  else:
558  new_objects_overflow.setdefault(object_type, 0)
559  new_objects_overflow[object_type] += 1
560 
561  for _object in new_objects:
562  had_new_object_growth = True
563  object_type = type(_object).__name__
564  _LOGGER.critical(
565  "New object %s (%s/%s) at %s: %s",
566  object_type,
567  last_stats.get(object_type, 0),
568  new_stats[object_type],
570  _safe_repr(_object),
571  )
572 
573  for object_type, count in last_stats.items():
574  new_stats[object_type] = max(new_stats.get(object_type, 0), count)
575  finally:
576  # Break reference cycles
577  del objects
578  del new_objects
579  last_ids.clear()
580  last_ids.update(current_ids)
581  last_stats.clear()
582  last_stats.update(new_stats)
583  del new_stats
584  del current_ids
585 
586  if new_objects_overflow:
587  _LOGGER.critical("New objects overflowed by %s", new_objects_overflow)
588  elif not had_new_object_growth:
589  _LOGGER.critical("No new object growth found")
590 
591 
592 @contextlib.contextmanager
593 def _increase_repr_limit() -> Generator[None]:
594  """Increase the repr limit."""
595  arepr = reprlib.aRepr
596  original_maxstring = arepr.maxstring
597  original_maxother = arepr.maxother
598  arepr.maxstring = 300
599  arepr.maxother = 300
600  try:
601  yield
602  finally:
603  arepr.maxstring = original_maxstring
604  arepr.maxother = original_maxother
def _async_generate_memory_profile(HomeAssistant hass, ServiceCall call)
Definition: __init__.py:435
def _async_generate_profile(HomeAssistant hass, ServiceCall call)
Definition: __init__.py:398
None _log_object_sources(int max_objects, set[int] last_ids, dict[str, int] last_stats)
Definition: __init__.py:529
bool async_setup_entry(HomeAssistant hass, ConfigEntry entry)
Definition: __init__.py:84
str|None _get_function_absfile(Any func)
Definition: __init__.py:494
Generator[None] _increase_repr_limit()
Definition: __init__.py:593
bool async_unload_entry(HomeAssistant hass, ConfigEntry entry)
Definition: __init__.py:388
def _write_memory_profile(heap, heap_path)
Definition: __init__.py:481
list[str] _find_backrefs_not_to_self(Any _object)
Definition: __init__.py:516
def _write_profile(profiler, cprofile_path, callgrind_path)
Definition: __init__.py:470
CALLBACK_TYPE async_track_time_interval(HomeAssistant hass, Callable[[datetime], Coroutine[Any, Any, None]|None] action, timedelta interval, *str|None name=None, bool|None cancel_on_shutdown=None)
Definition: event.py:1679
None async_register_admin_service(HomeAssistant hass, str domain, str service, Callable[[ServiceCall], Awaitable[None]|None] service_func, VolSchemaType schema=vol.Schema({}, extra=vol.PREVENT_EXTRA))
Definition: service.py:1121