Coverage for scanpath_studio/debug_log.py: 98%

153 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-07 21:10 +0000

1"""In-app debug log + state inspector. 

2 

3Streamlit reruns the script top-to-bottom on every interaction, and plain 

4``logging`` / ``print`` output only reaches the *server* terminal — never the 

5browser. This module bridges that gap: a :class:`logging.Handler` captures log 

6records into a capped buffer in ``st.session_state`` so they can be rendered 

7inside the app, behind a debug toggle. 

8 

9Activation is a single toggle (**UX-37**), in the dialog the nav's ❓ Help → 

10🐛 **Debug** entry opens (**UX-179** — it was a block of the retired 💾 Session 

11dialog). Switching it on adds the log panel under it: a level filter, an 

12app/session-state snapshot, and a JSON export. It used to be two-stage and the 

13first stage was a URL param — ``?debug=1`` revealed the toggle — which meant the 

14whole feature was reachable only by someone who already knew it existed. The 

15param is still honoured as a *seed* so old links keep working, but it is no 

16longer the way in. 

17""" 

18 

19from __future__ import annotations 

20 

21import json 

22import logging 

23from collections import deque 

24from contextlib import contextmanager 

25from datetime import datetime 

26from time import perf_counter 

27from typing import Any 

28 

29import streamlit as st 

30from streamlit.runtime.scriptrunner import StopException 

31 

32from .constants import ICONS 

33from .crash_report import guarded 

34 

35# Keep the buffer small: it lives in session_state and is re-rendered every run. 

36_MAX_RECORDS = 500 

37_BUFFER_KEY = "_debug_log_records" 

38 

39#: Session key of the "🐛 Debug mode" toggle under ❓ Help. This *is* the gate — 

40#: :func:`debug_enabled` reads nothing else. 

41DEBUG_STATE_KEY = "_debug_mode_on" 

42 

43#: The legacy URL param. Kept as a one-shot seed for links already in the world; 

44#: see :func:`seed_debug_mode`. 

45DEBUG_URL_PARAM = "debug" 

46 

47#: The package's own logger. Everything the app logs is a child of this, which is 

48#: what :class:`_AppRecordsOnly` keys on. 

49_APP_LOGGER = "scanpath_studio" 

50 

51_LEVELS = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] 

52_LEVEL_COLOR = { 

53 "DEBUG": "#888", 

54 "INFO": "#3b82f6", 

55 "WARNING": "#d97706", 

56 "ERROR": "#dc2626", 

57 "CRITICAL": "#dc2626", 

58} 

59 

60 

61@contextmanager 

62def timed(what: str, level: int = logging.INFO, **fields: Any): 

63 """Log ``what`` with how long it took, and with any facts worth having. 

64 

65 The debug panel's whole value is answering "what did that click actually 

66 do?", and a log line per *computation* is what makes it answerable — the 

67 stages are otherwise invisible: Streamlit reruns the script top to bottom and 

68 a cache hit looks exactly like a cache miss. 

69 

70 Cheap enough to leave on: one ``perf_counter`` pair and one formatted string 

71 per stage, at stage granularity, not per row. An exception still logs (at 

72 WARNING, marked ``failed``) and then propagates — a stage that blew up is 

73 exactly the line you want in the report. 

74 

75 with timed("normalize", rows=len(df)): 

76 ... 

77 """ 

78 extra = " ".join(f"{k}={v}" for k, v in fields.items()) 

79 start = perf_counter() 

80 try: 

81 yield 

82 except Exception as exc: # re-raised below 

83 logging.getLogger("scanpath_studio").warning( 

84 "%s failed after %.0f ms%s: %s", 

85 what, 

86 (perf_counter() - start) * 1000, 

87 f" · {extra}" if extra else "", 

88 exc, 

89 ) 

90 raise 

91 logging.getLogger("scanpath_studio").log( 

92 level, 

93 "%s · %.0f ms%s", 

94 what, 

95 (perf_counter() - start) * 1000, 

96 f" · {extra}" if extra else "", 

97 ) 

98 

99 

100def log_event(what: str, **fields: Any) -> None: 

101 """Log a user-driven state change (a pick, a toggle, a step). 

102 

103 Interactions are logged when they *change something* — the data source, the 

104 trial, the view — not on every widget touch: Streamlit reruns the script for 

105 each one, so a line per widget would be a line per rerun per widget and the 

106 computation lines would drown in it. 

107 """ 

108 extra = " ".join(f"{k}={v}" for k, v in fields.items()) 

109 logging.getLogger("scanpath_studio").info( 

110 "%s%s", what, f" · {extra}" if extra else "" 

111 ) 

112 

113 

114def log_state_change(state_key: str, value: Any, what: str, **fields: Any) -> None: 

115 """:func:`log_event`, but only when ``value`` differs from the last run's. 

116 

117 A rerun re-executes everything, so logging a selection unconditionally would 

118 print the same line on every interaction anywhere in the app. This keeps one 

119 line per actual change, which is what makes the log readable. 

120 """ 

121 marker = f"_logged_{state_key}" 

122 if st.session_state.get(marker) == value: 

123 return 

124 st.session_state[marker] = value 

125 log_event(what, **fields) 

126 

127 

128def _buffer() -> deque[dict[str, Any]]: 

129 """The session-scoped ring buffer of captured log records.""" 

130 buf = st.session_state.get(_BUFFER_KEY) 

131 if buf is None: 

132 buf = deque(maxlen=_MAX_RECORDS) 

133 st.session_state[_BUFFER_KEY] = buf 

134 return buf 

135 

136 

137class _AppRecordsOnly(logging.Filter): 

138 """Keep the app's own records, plus anything that went wrong anywhere. 

139 

140 The handler sits on the *root* logger, so without a filter the panel fills 

141 with third-party chatter — tornado logs a line per websocket frame, watchdog 

142 per filesystem event, urllib3 per connection. That is what made the panel 

143 unreadable (UX-37 follow-up: "each log entry shows up identical like a 

144 hundred times"), and none of it answers "what did the app just do?". 

145 

146 ``WARNING`` and above is kept whatever its source: a library that failed is 

147 exactly what a bug report needs, and those arrive one at a time. 

148 """ 

149 

150 def filter(self, record: logging.LogRecord) -> bool: 

151 return ( 

152 record.name == _APP_LOGGER 

153 or record.name.startswith(f"{_APP_LOGGER}.") 

154 or record.levelno >= logging.WARNING 

155 ) 

156 

157 

158class _SessionStateHandler(logging.Handler): 

159 """Append each emitted record to the session-state ring buffer. 

160 

161 Identical lines are **collapsed** rather than repeated: a match anywhere in 

162 the buffer bumps that entry's ``count``, refreshes its timestamp and moves it 

163 to the newest position. Streamlit reruns the whole script per interaction, so 

164 anything logged outside a cache or a change-guard recurs verbatim — and 100 

165 copies of one line push the other 99 events out of a 500-entry buffer. The 

166 count keeps the "this happened a lot" signal without the noise. 

167 

168 The handler is attached to the root logger, so it can capture every module's 

169 ``logging`` output; :class:`_AppRecordsOnly` narrows that to the app's own 

170 records plus WARNING-and-above from anywhere. It never raises into the 

171 logging machinery: a failure to record a log line must not break the thing 

172 being logged. 

173 

174 One instance serves the whole process (see ``install_log_capture``): the 

175 buffer it appends to is resolved through ``st.session_state`` at emit time, 

176 which Streamlit binds to the *calling* thread's script-run context, so a 

177 record logged during a session's run lands in that session's buffer and 

178 nowhere else. A record emitted from a thread with no context raises inside 

179 ``_buffer`` and is swallowed here rather than being misfiled. 

180 """ 

181 

182 def emit(self, record: logging.LogRecord) -> None: 

183 try: 

184 buf = _buffer() 

185 stamp = datetime.fromtimestamp(record.created).strftime("%H:%M:%S.%f")[:-3] 

186 seen = (record.levelname, record.name, record.getMessage()) 

187 for index, entry in enumerate(buf): 

188 if (entry["level"], entry["logger"], entry["message"]) == seen: 

189 entry["count"] += 1 

190 entry["time"] = stamp 

191 # Move to the newest position. Safe mid-iteration only 

192 # because this returns immediately. 

193 del buf[index] 

194 buf.append(entry) 

195 return 

196 buf.append( 

197 { 

198 "time": stamp, 

199 "level": record.levelname, 

200 "logger": record.name, 

201 "message": record.getMessage(), 

202 "count": 1, 

203 } 

204 ) 

205 except (Exception, StopException): 

206 # UX-166: an abandoned run's session state raises StopException on 

207 # any access. Escaping here, it threw away the finished result of 

208 # every cached build that logs through `timed()` — the log line comes 

209 # after the value is computed. A stop stays requested, so the run 

210 # still stops at its next yield point; logging must never crash its 

211 # caller. RerunException is deliberately NOT caught here: unlike a 

212 # stop, `ScriptRequests.on_scriptrunner_yield` *consumes* a rerun 

213 # request as it hands it over, so swallowing it here — if `emit` is 

214 # the first checkpoint after the request — would discard the rerun 

215 # itself (reachable with `runner.fastReruns = false`). 

216 pass 

217 

218 

219class _TerminalHandler(logging.StreamHandler): 

220 """The app logger's own stderr handler (BUG-100), marked so it is added once.""" 

221 

222 

223def install_log_capture(level: int = logging.INFO) -> None: 

224 """Attach the session-state handler to the root logger (once per *process*). 

225 

226 Idempotent, and idempotent at the right scope: the guard reads the root 

227 logger's own handler list, not ``st.session_state``. The root logger is 

228 process-wide while session state is not, so a session-scoped flag let every 

229 new session add another handler — N sessions meant N handlers, each record 

230 appended N times to whichever session's buffer was live, and handlers piling 

231 up for the process's lifetime (S10). One handler is enough: it resolves the 

232 buffer per script-run context, so each session still sees only its own 

233 records. Call this early in ``main()``. 

234 

235 The level is raised on the **app's** logger, not the root one. Raising root 

236 to INFO switches on every library that hasn't set its own level — tornado, 

237 watchdog, urllib3, PIL, streamlit's internals — and they, not the app, were 

238 what flooded the panel. A record's level is tested where it is logged, and 

239 propagation to an ancestor's *handlers* ignores the ancestor's level, so 

240 scoping it here still delivers every ``scanpath_studio`` INFO line to this 

241 handler; WARNING and up also go to the terminal (BUG-100). 

242 """ 

243 app_logger = logging.getLogger(_APP_LOGGER) 

244 if app_logger.level == logging.NOTSET or app_logger.level > level: 

245 app_logger.setLevel(level) 

246 # BUG-100: a handler anywhere on the chain switches off logging's 

247 # ``lastResort`` — the stderr fallback that used to print the app's warnings 

248 # — so the handler below made every app warning and traceback (a dataset 

249 # whose normalization failed, say) vanish from the server terminal. Put the 

250 # terminal back, for WARNING and up: INFO stays in the in-app panel. 

251 if not any(isinstance(h, _TerminalHandler) for h in app_logger.handlers): 

252 terminal = _TerminalHandler() 

253 terminal.setLevel(logging.WARNING) 

254 terminal.setFormatter( 

255 logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s") 

256 ) 

257 app_logger.addHandler(terminal) 

258 root = logging.getLogger() 

259 if any(isinstance(h, _SessionStateHandler) for h in root.handlers): 

260 return 

261 handler = _SessionStateHandler() 

262 handler.setLevel(logging.DEBUG) 

263 handler.addFilter(_AppRecordsOnly()) 

264 root.addHandler(handler) 

265 

266 

267def seed_debug_mode() -> None: 

268 """Honour a legacy ``?debug=1`` link by pre-arming the toggle, once. 

269 

270 ``setdefault`` rather than a write: the toggle is the gate now, so a user who 

271 turns debug mode *off* on a ``?debug=1`` URL must stay off for the rest of 

272 the session instead of having the param switch it back on every rerun. Call 

273 from ``main`` before :func:`debug_enabled` is read. 

274 """ 

275 if (st.query_params.get(DEBUG_URL_PARAM) or "").lower() in {"1", "true", "yes"}: 

276 st.session_state.setdefault(DEBUG_STATE_KEY, True) 

277 

278 

279def debug_enabled() -> bool: 

280 """True when the ❓ Help → "🐛 Debug mode" toggle is on.""" 

281 return bool(st.session_state.get(DEBUG_STATE_KEY)) 

282 

283 

284#: UX-100 — the toggle's own widget key, mirrored into :data:`DEBUG_STATE_KEY`. 

285#: The two used to be the same key, which was fine while the toggle rendered on 

286#: every run; it now lives in the Debug **dialog**, whose body is a fragment 

287#: that runs only while the modal is open — and Streamlit drops a widget's key at 

288#: the end of any run in which it did not render, so debug mode switched itself 

289#: off the moment the modal was dismissed. Splitting them makes the durable flag 

290#: the source of truth and the widget a view of it, exactly as the persistence 

291#: pause toggle already worked. 

292_DEBUG_TOGGLE_KEY = "_debug_mode_toggle" 

293 

294 

295def _mirror_debug_toggle() -> None: 

296 """``on_change``: write the widget's value into the durable gate.""" 

297 st.session_state[DEBUG_STATE_KEY] = bool(st.session_state.get(_DEBUG_TOGGLE_KEY)) 

298 

299 

300def render_debug_toggle(host=None) -> None: 

301 """Render the "🐛 Debug mode" toggle into the ❓ Help → About → Debug dialog. 

302 

303 The single gate (**UX-37**). Flipping it on shows the log panel under it 

304 in the same dialog run (:func:`_debug_dialog`). 

305 

306 Seeded rather than passed a ``value=``: the widget key is a mirror of 

307 :data:`DEBUG_STATE_KEY` (see :data:`_DEBUG_TOGGLE_KEY`), and a widget 

308 carrying both a default and a session-state write logs Streamlit's "default 

309 value but also had its value set via the Session State API" warning on every 

310 run. 

311 """ 

312 st.session_state[_DEBUG_TOGGLE_KEY] = bool(st.session_state.get(DEBUG_STATE_KEY)) 

313 (host if host is not None else st).toggle( 

314 f"{ICONS['debug']} Debug mode", 

315 key=_DEBUG_TOGGLE_KEY, 

316 on_change=_mirror_debug_toggle, 

317 help="Show the captured log, a snapshot of what's loaded and a JSON " 

318 "for bug reports. Also lists the *Synthetic test trial* dataset.", 

319 ) 

320 

321 

322def _state_snapshot() -> list[dict[str, str]]: 

323 """A few high-signal facts about what's currently loaded, best-effort. 

324 

325 Reads optional session-state keys defensively — missing keys are simply 

326 omitted so this never crashes regardless of app state. 

327 """ 

328 ss = st.session_state 

329 rows: list[dict[str, str]] = [] 

330 

331 def add(label: str, value: Any) -> None: 

332 if value is not None: 

333 rows.append({"key": label, "value": str(value)}) 

334 

335 add("Active view", ss.get("active_view")) 

336 add("Dataset / source", ss.get("data_source") or ss.get("_active_source_name")) 

337 add("Participant", ss.get("_deeplink_participant")) 

338 

339 # Frame shapes for any cached DataFrame-like objects in session_state. 

340 for key, val in list(ss.items()): 

341 shape = getattr(val, "shape", None) 

342 if shape is not None and isinstance(shape, tuple) and len(shape) == 2: 

343 add(f"{key} (rows×cols)", f"{shape[0]} × {shape[1]}") 

344 

345 rows.append({"key": "session_state keys", "value": str(len(ss))}) 

346 return rows 

347 

348 

349def render_debug_panel(host=None) -> None: 

350 """Render the debug panel into the 🐛 Debug menu popover. 

351 

352 No-op unless the Debug toggle is on. The panel is the captured-log view, a 

353 state snapshot and a JSON download, drawn under the toggle in 

354 :func:`_debug_dialog`. 

355 """ 

356 if not debug_enabled(): 

357 return 

358 

359 panel = host if host is not None else st.container() 

360 records = list(_buffer()) 

361 

362 with panel: 

363 cols = st.columns([3, 1]) 

364 with cols[0]: 

365 min_level = st.selectbox( 

366 "Min level", _LEVELS, index=_LEVELS.index("INFO"), key="_debug_level" 

367 ) 

368 with cols[1]: 

369 st.write("") 

370 if st.button("Clear log", key="_debug_clear", width="stretch"): 

371 _buffer().clear() 

372 # The dialog body is a fragment: an app-scoped rerun would close 

373 # the modal on the empty log it just asked for. 

374 st.rerun(scope="fragment") 

375 

376 threshold = _LEVELS.index(min_level) 

377 shown = [ 

378 r 

379 for r in records 

380 if r["level"] in _LEVELS and _LEVELS.index(r["level"]) >= threshold 

381 ] 

382 

383 # "lines", not "records": identical ones are collapsed, so the two 

384 # numbers differ and saying which is which is the whole point. 

385 events = sum(int(r.get("count", 1)) for r in records) 

386 st.caption( 

387 f"{len(shown)} / {len(records)} lines" 

388 + ( 

389 f" · {events} events, identical lines collapsed" 

390 if events > len(records) 

391 else "" 

392 ) 

393 ) 

394 

395 if shown: 

396 lines = [] 

397 for r in reversed(shown): # newest first 

398 color = _LEVEL_COLOR.get(r["level"], "#888") 

399 # Collapsed repeats carry their tally; a one-off shows nothing, 

400 # so the common case reads exactly as it did before. 

401 repeats = int(r.get("count", 1)) 

402 tally = ( 

403 f' <span style="color:#888">× {repeats}</span>' 

404 if repeats > 1 

405 else "" 

406 ) 

407 lines.append( 

408 f'<div style="font-family:monospace;font-size:11px;' 

409 f'line-height:1.5;white-space:pre-wrap;word-break:break-word">' 

410 f'<span style="color:#888">{r["time"]}</span> ' 

411 f'<span style="color:{color};font-weight:600">' 

412 f"{r['level']:<7}</span> " 

413 f'<span style="color:#888">{r["logger"]}</span> ' 

414 f"{_escape(r['message'])}{tally}</div>" 

415 ) 

416 st.markdown( 

417 f'<div style="max-height:300px;overflow-y:auto">{"".join(lines)}</div>', 

418 unsafe_allow_html=True, 

419 ) 

420 else: 

421 st.caption("No log records at this level yet.") 

422 

423 st.divider() 

424 st.caption( 

425 "App / session state", 

426 help="What this browser session holds: the size of every table in " 

427 "memory (rows × columns) and how many session-state keys exist. A " 

428 "read-out only — nothing here changes the app. Send it with a bug " 

429 "report.", 

430 ) 

431 snapshot = _state_snapshot() 

432 st.dataframe(snapshot, hide_index=True, width="stretch") 

433 

434 export = { 

435 "exported_at": datetime.now().isoformat(timespec="seconds"), 

436 "records": records, 

437 "state": snapshot, 

438 } 

439 st.download_button( 

440 f"{ICONS['download']} Download logs (JSON)", 

441 data=json.dumps(export, indent=2, default=str), 

442 file_name="scanpath_studio_debug.json", 

443 mime="application/json", 

444 width="stretch", 

445 key="_debug_download", 

446 ) 

447 

448 

449#: UX-179 — the Debug drawer's request flag. #374 F31 moved its way in from a 

450#: ❓ Help nav entry to a button at the foot of About: one dialog cannot open 

451#: another, so the button sets this and reruns, and ``app.main`` serves it 

452#: early, beside FAQ and About. 

453_DEBUG_DIALOG_KEY = "_debug_dialog_requested" 

454 

455 

456def _arm_debug() -> None: 

457 """Request the Debug dialog. Called by About's Debug button.""" 

458 st.session_state[_DEBUG_DIALOG_KEY] = True 

459 

460 

461def maybe_show_debug() -> None: 

462 """Open the Debug dialog if About's Debug button armed it.""" 

463 if st.session_state.pop(_DEBUG_DIALOG_KEY, False): 

464 _debug_dialog() 

465 

466 

467@st.dialog(f"{ICONS['debug']} Debug", width="large", position="right") 

468@guarded() 

469def _debug_dialog() -> None: 

470 """The Debug drawer: the gate, then — once it is on — the log panel. 

471 

472 A right-side drawer (Streamlit 1.65) rather than a centred modal, so the 

473 view the log describes stays in sight beside it; the user can widen it. 

474 

475 The toggle is the whole of the feature's switch (UX-37), so it sits here 

476 even while it is off; the panel under it appears in the same fragment run 

477 that turns it on, because the toggle's ``on_change`` mirrors the gate before 

478 this body re-runs. 

479 """ 

480 render_debug_toggle() 

481 if debug_enabled(): 

482 render_debug_panel() 

483 

484 

485def _escape(text: str) -> str: 

486 """Minimal HTML escaping for log messages rendered via st.markdown.""" 

487 return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")