"""Diagnostics for debugging. A Diagnostics object can be used to track counts and latencies of various operations within a module. This can be useful for debugging performance issues or understanding usage patterns. This is an internal facing module and is not intended for public use. Diagnostics data is collected and exposed to clients via higher level APIs like the DeviceManager. """ import time from collections import Counter from collections.abc import Generator, Mapping from contextlib import contextmanager from typing import Any, Self, TypeVar, cast class Diagnostics: """A class that holds diagnostics information for a module. You can use this class to hold counter or for recording timing information that can be exported as a dictionary for debugging purposes. """ def __init__(self) -> None: """Initialize Diagnostics.""" self._counter: Counter = Counter() self._subkeys: dict[str, Self] = {} def increment(self, key: str, count: int = 1) -> None: """Increment a counter for the specified key/event.""" self._counter.update(Counter({key: count})) def elapsed(self, key_prefix: str, elapsed_ms: int = 1) -> None: """Track a latency event for the specified key/event prefix.""" self.increment(f"{key_prefix}_count", 1) self.increment(f"{key_prefix}_sum", elapsed_ms) def as_dict(self) -> Mapping[str, Any]: """Return diagnostics as a debug dictionary.""" data: dict[str, Any] = {k: self._counter[k] for k in self._counter} for k, d in self._subkeys.items(): v = d.as_dict() if not v: continue data[k] = v return data def subkey(self, key: str) -> Self: """Return sub-Diagnostics object with the specified subkey. This will create a new Diagnostics object if one does not already exist for the specified subkey. Stats from the sub-Diagnostics will be included in the parent Diagnostics when exported as a dictionary. Args: key: The subkey for the diagnostics. Returns: The Diagnostics object for the specified subkey. """ if key not in self._subkeys: self._subkeys[key] = type(self)() return self._subkeys[key] @contextmanager def timer(self, key_prefix: str) -> Generator[None, None, None]: """A context manager that records the timing of operations as a diagnostic.""" start = time.perf_counter() try: yield finally: end = time.perf_counter() ms = int((end - start) * 1000) self.elapsed(key_prefix, ms) def reset(self) -> None: """Clear all diagnostics, for testing.""" self._counter = Counter() for d in self._subkeys.values(): d.reset() T = TypeVar("T") REDACT_KEYS = { # Potential identifiers "localKey", "mac", "bssid", "sn", "ip", "u", "s", "h", "k", # Large binary blobs are entirely omitted "imageContent", "mapData", "rawApiResponse", # Home data "id", # We want to redact home_data.id but keep some other ids, see below "name", "productId", "ipAddress", "wifiName", "lat", "long", } KEEP_KEYS = { # Product information not unique per user "product.id", "product.schema.id", "product.schema.name", # Room ids are likely unique per user, but don't seem too sensitive and are # useful for debugging "rooms.id", } DEVICE_UID = "duid" REDACTED = "**REDACTED**" def redact_device_data(data: T, path: str = "") -> T | dict[str, Any]: """Redact sensitive data in a dict.""" if not isinstance(data, (Mapping, list)): return data if isinstance(data, list): return cast(T, [redact_device_data(item, path) for item in data]) redacted = {**data} for key, value in redacted.items(): curr_path = f"{path}.{key}" if path else key if key in KEEP_KEYS or curr_path in KEEP_KEYS: continue if key in REDACT_KEYS or curr_path in REDACT_KEYS: redacted[key] = REDACTED elif key == DEVICE_UID and isinstance(value, str): redacted[key] = redact_device_uid(value) elif isinstance(value, dict): redacted[key] = redact_device_data(value, curr_path) elif isinstance(value, list): redacted[key] = [redact_device_data(item, curr_path) for item in value] return redacted def redact_topic_name(topic: str) -> str: """Redact potentially identifying information from a topic name.""" parts = topic.split("/") redacted_parts = parts[:4] for part in parts[4:]: if len(part) <= 5: redacted_parts.append("*****") else: redacted_parts.append("*****" + part[-5:]) return "/".join(redacted_parts) def redact_device_uid(duid: str) -> str: """Redact a device UID to hide identifying information.""" return "******" + duid[-5:]