Skip to content

Commit bf0503d

Browse files
tomquistclaude
andauthored
Add exception info to logger warnings for better debugging (#307)
* Include stacktraces in CT002 warning logs Pass exc_info=True so before_send, event_listener, and response-build failures include the underlying traceback, making issues like #305 diagnosable. * Auto-attach traceback to WARNING+ logs from except blocks Install a LogRecord filter in config/logger.py that, whenever a log record of WARNING level or above is emitted while an exception is in flight (sys.exc_info() is set) and no exc_info was passed explicitly, attaches the active exception so the traceback is included. Call sites that want to stay terse can opt out with exc_info=False; this is done in the MQTT and MQTT Insights reconnect loops where the message alone is enough. Also pass exc_info=True explicitly at the most important catch-all log sites (Shelly event listener, Home Assistant/HomeWizard WebSocket loops, throttled powermeter fetch, Marstek auto-registration) so the intent is obvious at the call site. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5432f4a commit bf0503d

10 files changed

Lines changed: 122 additions & 9 deletions

File tree

src/astrameter/config/logger.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,22 @@
11
import logging
2+
import sys
3+
4+
5+
class _AutoExcInfoFilter(logging.Filter):
6+
"""Attach the active traceback to WARNING+ records logged from except blocks.
7+
8+
When a log call happens while ``sys.exc_info()`` is set (i.e. inside an
9+
``except`` block) and the caller did not pass ``exc_info`` explicitly, we
10+
attach the current exception so the traceback is emitted. Call sites that
11+
want the old terse behavior can opt out by passing ``exc_info=False``.
12+
"""
13+
14+
def filter(self, record: logging.LogRecord) -> bool:
15+
if record.exc_info is None and record.levelno >= logging.WARNING:
16+
exc = sys.exc_info()
17+
if exc[0] is not None:
18+
record.exc_info = exc
19+
return True
220

321

422
def setLogLevel(inLevel: str):
@@ -11,6 +29,14 @@ def setLogLevel(inLevel: str):
1129
datefmt="%Y-%m-%d %H:%M:%S",
1230
force=True,
1331
)
32+
_install_auto_exc_info_filter()
33+
34+
35+
def _install_auto_exc_info_filter() -> None:
36+
"""Attach the auto-exc-info filter to every root handler exactly once."""
37+
for handler in logging.getLogger().handlers:
38+
if not any(isinstance(f, _AutoExcInfoFilter) for f in handler.filters):
39+
handler.addFilter(_AutoExcInfoFilter())
1440

1541

1642
levels = {

src/astrameter/config/logger_test.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import importlib
2+
import io
23
import logging
34
import re
45
from unittest.mock import patch
@@ -47,3 +48,74 @@ def test_set_log_level_configures_timestamped_log_output():
4748
formatted,
4849
)
4950
assert kwargs["force"] is True
51+
52+
53+
def test_warning_inside_except_block_includes_traceback():
54+
setLogLevel("warning")
55+
root = logging.getLogger()
56+
buffer = io.StringIO()
57+
handler = logging.StreamHandler(buffer)
58+
handler.setFormatter(logging.Formatter("%(levelname)s:%(message)s"))
59+
# Copy the auto-exc-info filter from the root stream handler installed by
60+
# setLogLevel so this test handler sees the same behavior.
61+
for existing in root.handlers:
62+
for flt in existing.filters:
63+
handler.addFilter(flt)
64+
root.addHandler(handler)
65+
try:
66+
try:
67+
raise RuntimeError("boom")
68+
except RuntimeError as exc:
69+
logging.getLogger("astrameter.test").warning("failed: %s", exc)
70+
finally:
71+
root.removeHandler(handler)
72+
73+
output = buffer.getvalue()
74+
assert "WARNING:failed: boom" in output
75+
assert "Traceback (most recent call last):" in output
76+
assert "RuntimeError: boom" in output
77+
78+
79+
def test_warning_outside_except_block_has_no_traceback():
80+
setLogLevel("warning")
81+
root = logging.getLogger()
82+
buffer = io.StringIO()
83+
handler = logging.StreamHandler(buffer)
84+
handler.setFormatter(logging.Formatter("%(levelname)s:%(message)s"))
85+
for existing in root.handlers:
86+
for flt in existing.filters:
87+
handler.addFilter(flt)
88+
root.addHandler(handler)
89+
try:
90+
logging.getLogger("astrameter.test").warning("plain warning")
91+
finally:
92+
root.removeHandler(handler)
93+
94+
output = buffer.getvalue()
95+
assert "WARNING:plain warning" in output
96+
assert "Traceback" not in output
97+
98+
99+
def test_exc_info_false_opts_out_of_auto_traceback():
100+
setLogLevel("warning")
101+
root = logging.getLogger()
102+
buffer = io.StringIO()
103+
handler = logging.StreamHandler(buffer)
104+
handler.setFormatter(logging.Formatter("%(levelname)s:%(message)s"))
105+
for existing in root.handlers:
106+
for flt in existing.filters:
107+
handler.addFilter(flt)
108+
root.addHandler(handler)
109+
try:
110+
try:
111+
raise RuntimeError("boom")
112+
except RuntimeError as exc:
113+
logging.getLogger("astrameter.test").warning(
114+
"suppressed: %s", exc, exc_info=False
115+
)
116+
finally:
117+
root.removeHandler(handler)
118+
119+
output = buffer.getvalue()
120+
assert "WARNING:suppressed: boom" in output
121+
assert "Traceback" not in output

src/astrameter/ct002/ct002.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -248,7 +248,9 @@ def _call_event_listener(self, consumer_id: str, data: dict[str, Any]) -> None:
248248
try:
249249
self.event_listener(self._device_id, consumer_id, data)
250250
except Exception as exc:
251-
logger.warning("event_listener failed for %s: %s", consumer_id, exc)
251+
logger.warning(
252+
"event_listener failed for %s: %s", consumer_id, exc, exc_info=True
253+
)
252254

253255
def _update_consumer_report(self, consumer_id, phase, power, device_type=""):
254256
normalized_phase = str(phase).upper() if phase else "A"
@@ -462,7 +464,7 @@ async def _call_before_send(self, addr, fields, consumer_id):
462464
try:
463465
return await self.before_send(addr, fields, consumer_id)
464466
except Exception as exc:
465-
logger.warning("before_send failed for %s: %s", addr, exc)
467+
logger.warning("before_send failed for %s: %s", addr, exc, exc_info=True)
466468
return None
467469

468470
def _validate_ct_mac(self, request_fields):
@@ -565,6 +567,7 @@ async def _handle_request(self, data, addr, transport):
565567
addr,
566568
fields,
567569
exc,
570+
exc_info=True,
568571
)
569572
return
570573
logger.debug(

src/astrameter/main.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -547,9 +547,11 @@ def main():
547547
"Credentials are only needed for one-time registration. You can remove MARSTEK mailbox/password from config now."
548548
)
549549
except MarstekApiError as exc:
550-
logger.error("Marstek auto-registration failed: %s", exc)
550+
logger.error("Marstek auto-registration failed: %s", exc, exc_info=True)
551551
except Exception as exc:
552-
logger.error("Unexpected Marstek auto-registration error: %s", exc)
552+
logger.error(
553+
"Unexpected Marstek auto-registration error: %s", exc, exc_info=True
554+
)
553555

554556
# Map SIGTERM to KeyboardInterrupt so asyncio.run cancels tasks and
555557
# runs finally-cleanup the same way it does for SIGINT (Ctrl+C).

src/astrameter/mqtt_insights/service.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,10 +228,12 @@ async def _run(self) -> None:
228228
raise
229229
except (aiomqtt.MqttError, OSError) as exc:
230230
self._connected.clear()
231+
# Reconnect loop — traceback would be noisy, keep it terse.
231232
logger.warning(
232233
"MQTT Insights connection error: %s. Reconnecting in %ss...",
233234
exc,
234235
RECONNECT_DELAY,
236+
exc_info=False,
235237
)
236238
await asyncio.sleep(RECONNECT_DELAY)
237239
except Exception:

src/astrameter/powermeter/homeassistant.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ async def _ws_loop(self) -> None:
112112
except asyncio.CancelledError:
113113
raise
114114
except Exception as e:
115-
logger.error(f"Home Assistant WebSocket error: {e}")
115+
logger.error("Home Assistant WebSocket error: %s", e, exc_info=True)
116116
# Reset protocol state for reconnection; keep _entity_values
117117
# (stale values are preferable to ValueError during brief disconnect;
118118
# subscribe_entities re-sends initial states on reconnect)

src/astrameter/powermeter/homewizard.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ async def _ws_loop(self) -> None:
9090
except asyncio.CancelledError:
9191
raise
9292
except Exception as e:
93-
logger.error(f"HomeWizard WebSocket error: {e}")
93+
logger.error("HomeWizard WebSocket error: %s", e, exc_info=True)
9494
await asyncio.sleep(5)
9595

9696
async def _handle_message(

src/astrameter/powermeter/mqtt.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,8 +130,12 @@ async def _run(self) -> None:
130130
)
131131
except aiomqtt.MqttError as e:
132132
self._connected_event.clear()
133+
# Reconnect loop — traceback would be noisy, keep it terse.
133134
logger.warning(
134-
f"MQTT connection error: {e}. Reconnecting in {RECONNECT_DELAY}s..."
135+
"MQTT connection error: %s. Reconnecting in %ss...",
136+
e,
137+
RECONNECT_DELAY,
138+
exc_info=False,
135139
)
136140
await asyncio.sleep(RECONNECT_DELAY)
137141

src/astrameter/powermeter/throttling.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,9 @@ async def get_powermeter_watts(self) -> list[float]:
7878
# interval before retrying — avoids hammering a failing source.
7979
self._last_update_time = time.monotonic()
8080
if self._last_values is not None:
81-
logger.warning("Throttling: Error getting fresh values: %s", e)
81+
logger.warning(
82+
"Throttling: Error getting fresh values: %s", e, exc_info=True
83+
)
8284
logger.debug(
8385
"Throttling: Using cached values due to error: %s",
8486
self._last_values,

src/astrameter/shelly/shelly.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,9 @@ def _call_event_listener(self, battery_ip: str, data: dict[str, Any]) -> None:
174174
try:
175175
self.event_listener(self._device_id, battery_ip, data)
176176
except Exception as exc:
177-
logger.warning("event_listener failed for %s: %s", battery_ip, exc)
177+
logger.warning(
178+
"event_listener failed for %s: %s", battery_ip, exc, exc_info=True
179+
)
178180

179181
async def _safe_handle_request(self, transport, data, addr):
180182
try:

0 commit comments

Comments
 (0)