Add logs to Cloud component support package (#138230)

* Add logs to Cloud component support package

* Add section for logs

* Replace list with deque

* Copy the deque to avoid mutation during iteration
This commit is contained in:
Abílio Costa 2025-02-11 22:05:19 +00:00 committed by GitHub
parent 0ffbe076be
commit 48b8ec01e3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 103 additions and 4 deletions

View File

@ -6,6 +6,7 @@ import asyncio
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from datetime import datetime, timedelta from datetime import datetime, timedelta
from enum import Enum from enum import Enum
import logging
from typing import cast from typing import cast
from hass_nabucasa import Cloud from hass_nabucasa import Cloud
@ -19,6 +20,7 @@ from homeassistant.const import (
CONF_NAME, CONF_NAME,
CONF_REGION, CONF_REGION,
EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_STOP,
FORMAT_DATETIME,
Platform, Platform,
) )
from homeassistant.core import Event, HassJob, HomeAssistant, ServiceCall, callback from homeassistant.core import Event, HassJob, HomeAssistant, ServiceCall, callback
@ -33,7 +35,7 @@ from homeassistant.helpers.dispatcher import (
from homeassistant.helpers.event import async_call_later from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.service import async_register_admin_service from homeassistant.helpers.service import async_register_admin_service
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import bind_hass from homeassistant.loader import async_get_integration, bind_hass
from homeassistant.util.signal_type import SignalType from homeassistant.util.signal_type import SignalType
# Pre-import backup to avoid it being imported # Pre-import backup to avoid it being imported
@ -62,11 +64,13 @@ from .const import (
CONF_THINGTALK_SERVER, CONF_THINGTALK_SERVER,
CONF_USER_POOL_ID, CONF_USER_POOL_ID,
DATA_CLOUD, DATA_CLOUD,
DATA_CLOUD_LOG_HANDLER,
DATA_PLATFORMS_SETUP, DATA_PLATFORMS_SETUP,
DOMAIN, DOMAIN,
MODE_DEV, MODE_DEV,
MODE_PROD, MODE_PROD,
) )
from .helpers import FixedSizeQueueLogHandler
from .prefs import CloudPreferences from .prefs import CloudPreferences
from .repairs import async_manage_legacy_subscription_issue from .repairs import async_manage_legacy_subscription_issue
from .subscription import async_subscription_info from .subscription import async_subscription_info
@ -245,6 +249,8 @@ def async_remote_ui_url(hass: HomeAssistant) -> str:
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Initialize the Home Assistant cloud.""" """Initialize the Home Assistant cloud."""
log_handler = hass.data[DATA_CLOUD_LOG_HANDLER] = await _setup_log_handler(hass)
# Process configs # Process configs
if DOMAIN in config: if DOMAIN in config:
kwargs = dict(config[DOMAIN]) kwargs = dict(config[DOMAIN])
@ -267,6 +273,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def _shutdown(event: Event) -> None: async def _shutdown(event: Event) -> None:
"""Shutdown event.""" """Shutdown event."""
await cloud.stop() await cloud.stop()
logging.root.removeHandler(log_handler)
del hass.data[DATA_CLOUD_LOG_HANDLER]
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown) hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown)
@ -405,3 +413,19 @@ def _setup_services(hass: HomeAssistant, prefs: CloudPreferences) -> None:
async_register_admin_service( async_register_admin_service(
hass, DOMAIN, SERVICE_REMOTE_DISCONNECT, _service_handler hass, DOMAIN, SERVICE_REMOTE_DISCONNECT, _service_handler
) )
async def _setup_log_handler(hass: HomeAssistant) -> FixedSizeQueueLogHandler:
fmt = (
"%(asctime)s.%(msecs)03d %(levelname)s (%(threadName)s) [%(name)s] %(message)s"
)
handler = FixedSizeQueueLogHandler()
handler.setFormatter(logging.Formatter(fmt, datefmt=FORMAT_DATETIME))
integration = await async_get_integration(hass, DOMAIN)
loggers: set[str] = {"snitun", integration.pkg_path, *(integration.loggers or [])}
for logger_name in loggers:
logging.getLogger(logger_name).addHandler(handler)
return handler

View File

@ -12,12 +12,14 @@ if TYPE_CHECKING:
from hass_nabucasa import Cloud from hass_nabucasa import Cloud
from .client import CloudClient from .client import CloudClient
from .helpers import FixedSizeQueueLogHandler
DOMAIN = "cloud" DOMAIN = "cloud"
DATA_CLOUD: HassKey[Cloud[CloudClient]] = HassKey(DOMAIN) DATA_CLOUD: HassKey[Cloud[CloudClient]] = HassKey(DOMAIN)
DATA_PLATFORMS_SETUP: HassKey[dict[str, asyncio.Event]] = HassKey( DATA_PLATFORMS_SETUP: HassKey[dict[str, asyncio.Event]] = HassKey(
"cloud_platforms_setup" "cloud_platforms_setup"
) )
DATA_CLOUD_LOG_HANDLER: HassKey[FixedSizeQueueLogHandler] = HassKey("cloud_log_handler")
EVENT_CLOUD_EVENT = "cloud_event" EVENT_CLOUD_EVENT = "cloud_event"
REQUEST_TIMEOUT = 10 REQUEST_TIMEOUT = 10

View File

@ -0,0 +1,31 @@
"""Helpers for the cloud component."""
from collections import deque
import logging
from homeassistant.core import HomeAssistant
class FixedSizeQueueLogHandler(logging.Handler):
"""Log handler to store messages, with auto rotation."""
MAX_RECORDS = 500
def __init__(self) -> None:
"""Initialize a new LogHandler."""
super().__init__()
self._records: deque[logging.LogRecord] = deque(maxlen=self.MAX_RECORDS)
def emit(self, record: logging.LogRecord) -> None:
"""Store log message."""
self._records.append(record)
async def get_logs(self, hass: HomeAssistant) -> list[str]:
"""Get stored logs."""
def _get_logs() -> list[str]:
# copy the queue since it can mutate while iterating
records = self._records.copy()
return [self.format(record) for record in records]
return await hass.async_add_executor_job(_get_logs)

View File

@ -43,6 +43,7 @@ from .assist_pipeline import async_create_cloud_pipeline
from .client import CloudClient from .client import CloudClient
from .const import ( from .const import (
DATA_CLOUD, DATA_CLOUD,
DATA_CLOUD_LOG_HANDLER,
EVENT_CLOUD_EVENT, EVENT_CLOUD_EVENT,
LOGIN_MFA_TIMEOUT, LOGIN_MFA_TIMEOUT,
PREF_ALEXA_REPORT_STATE, PREF_ALEXA_REPORT_STATE,
@ -397,8 +398,11 @@ class DownloadSupportPackageView(HomeAssistantView):
url = "/api/cloud/support_package" url = "/api/cloud/support_package"
name = "api:cloud:support_package" name = "api:cloud:support_package"
def _generate_markdown( async def _generate_markdown(
self, hass_info: dict[str, Any], domains_info: dict[str, dict[str, str]] self,
hass: HomeAssistant,
hass_info: dict[str, Any],
domains_info: dict[str, dict[str, str]],
) -> str: ) -> str:
def get_domain_table_markdown(domain_info: dict[str, Any]) -> str: def get_domain_table_markdown(domain_info: dict[str, Any]) -> str:
if len(domain_info) == 0: if len(domain_info) == 0:
@ -424,6 +428,17 @@ class DownloadSupportPackageView(HomeAssistantView):
"</details>\n\n" "</details>\n\n"
) )
log_handler = hass.data[DATA_CLOUD_LOG_HANDLER]
logs = "\n".join(await log_handler.get_logs(hass))
markdown += (
"## Full logs\n\n"
"<details><summary>Logs</summary>\n\n"
"```logs\n"
f"{logs}\n"
"```\n\n"
"</details>\n"
)
return markdown return markdown
async def get(self, request: web.Request) -> web.Response: async def get(self, request: web.Request) -> web.Response:
@ -433,7 +448,7 @@ class DownloadSupportPackageView(HomeAssistantView):
domain_health = await get_system_health_info(hass) domain_health = await get_system_health_info(hass)
hass_info = domain_health.pop("homeassistant", {}) hass_info = domain_health.pop("homeassistant", {})
markdown = self._generate_markdown(hass_info, domain_health) markdown = await self._generate_markdown(hass, hass_info, domain_health)
return web.Response( return web.Response(
body=markdown, body=markdown,

View File

@ -44,6 +44,17 @@
</details> </details>
## Full logs
<details><summary>Logs</summary>
```logs
2025-02-10 12:00:00.000 INFO (MainThread) [hass_nabucasa.iot] Hass nabucasa log
2025-02-10 12:00:00.000 WARNING (MainThread) [snitun.utils.aiohttp_client] Snitun log
2025-02-10 12:00:00.000 ERROR (MainThread) [homeassistant.components.cloud.client] Cloud log
```
</details>
''' '''
# --- # ---

View File

@ -2,12 +2,15 @@
from collections.abc import Callable, Coroutine from collections.abc import Callable, Coroutine
from copy import deepcopy from copy import deepcopy
import datetime
from http import HTTPStatus from http import HTTPStatus
import json import json
import logging
from typing import Any from typing import Any
from unittest.mock import AsyncMock, MagicMock, Mock, PropertyMock, patch from unittest.mock import AsyncMock, MagicMock, Mock, PropertyMock, patch
import aiohttp import aiohttp
from freezegun.api import FrozenDateTimeFactory
from hass_nabucasa import thingtalk from hass_nabucasa import thingtalk
from hass_nabucasa.auth import ( from hass_nabucasa.auth import (
InvalidTotpCode, InvalidTotpCode,
@ -1869,15 +1872,18 @@ async def test_logout_view_dispatch_event(
assert async_dispatcher_send_mock.mock_calls[0][1][2] == {"type": "logout"} assert async_dispatcher_send_mock.mock_calls[0][1][2] == {"type": "logout"}
@patch("homeassistant.components.cloud.helpers.FixedSizeQueueLogHandler.MAX_RECORDS", 3)
async def test_download_support_package( async def test_download_support_package(
hass: HomeAssistant, hass: HomeAssistant,
cloud: MagicMock, cloud: MagicMock,
set_cloud_prefs: Callable[[dict[str, Any]], Coroutine[Any, Any, None]], set_cloud_prefs: Callable[[dict[str, Any]], Coroutine[Any, Any, None]],
hass_client: ClientSessionGenerator, hass_client: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker, aioclient_mock: AiohttpClientMocker,
freezer: FrozenDateTimeFactory,
snapshot: SnapshotAssertion, snapshot: SnapshotAssertion,
) -> None: ) -> None:
"""Test downloading a support package file.""" """Test downloading a support package file."""
aioclient_mock.get("https://cloud.bla.com/status", text="") aioclient_mock.get("https://cloud.bla.com/status", text="")
aioclient_mock.get( aioclient_mock.get(
"https://cert-server/directory", exc=Exception("Unexpected exception") "https://cert-server/directory", exc=Exception("Unexpected exception")
@ -1936,6 +1942,16 @@ async def test_download_support_package(
} }
) )
now = dt_util.utcnow()
freezer.move_to(datetime.datetime.fromisoformat("2025-02-10T12:00:00.0+00:00"))
logging.getLogger("hass_nabucasa.iot").info(
"This message will be dropped since this test patches MAX_RECORDS"
)
logging.getLogger("hass_nabucasa.iot").info("Hass nabucasa log")
logging.getLogger("snitun.utils.aiohttp_client").warning("Snitun log")
logging.getLogger("homeassistant.components.cloud.client").error("Cloud log")
freezer.move_to(now) # Reset time otherwise hass_client auth fails
cloud_client = await hass_client() cloud_client = await hass_client()
with ( with (
patch.object(hass.config, "config_dir", new="config"), patch.object(hass.config, "config_dir", new="config"),