SilverElixir commited on
Commit
22981df
·
1 Parent(s): 2436bd7

Tag log lines with the chat, silence uvicorn access noise, log stack traces for unexpected failures

Browse files
bot.py CHANGED
@@ -15,6 +15,7 @@ del _sys
15
  import asyncio
16
  import atexit
17
  import contextlib
 
18
  import hashlib
19
  import json
20
  import logging
@@ -81,6 +82,11 @@ def _current_log_secrets() -> tuple[str, ...]:
81
  return tuple(sorted(set(values), key=len, reverse=True))
82
 
83
 
 
 
 
 
 
84
  class _SecretLogFormatter(logging.Formatter):
85
  def format(self, record: logging.LogRecord, _secrets=_current_log_secrets) -> str:
86
  # Секреты через замыкание, а не глобал: при финализации интерпретатора
@@ -88,6 +94,9 @@ class _SecretLogFormatter(logging.Formatter):
88
  # Остатки teardown глушим: строка без вычистки лучше шумного трейсбека.
89
  text = super().format(record)
90
  with contextlib.suppress(Exception):
 
 
 
91
  for secret in _secrets():
92
  text = text.replace(secret, "<REDACTED>")
93
  return text
@@ -127,7 +136,9 @@ def _setup_logging() -> logging.Logger:
127
  for name in ("httpx", "google_genai", "aiohttp", "uvicorn.access"):
128
  logging.getLogger(name).setLevel(logging.WARNING)
129
  # Единый логгер "bot": __main__ в проде расходился с lumen_*.py (видно в Sentry).
130
- return logging.getLogger("bot")
 
 
131
 
132
  logger = _setup_logging()
133
  log = logger
@@ -1287,6 +1298,16 @@ async def global_error_handler(event: Any) -> bool:
1287
 
1288
  @dp.message()
1289
  async def handle_message(message: Message) -> None:
 
 
 
 
 
 
 
 
 
 
1290
  # Владельческая блокировка: ни ответа, ни контекста группы, ни счётчиков —
1291
  # отсекаем раньше всего, включая медиагруппы и пассивный фон.
1292
  if message.from_user is not None and _is_banned(message.from_user.id):
@@ -1574,7 +1595,10 @@ async def main() -> None:
1574
  startup_task = asyncio.create_task(_webhook_startup(), name="webhook_startup")
1575
  flush_task = asyncio.create_task(_flush_dirty_state(), name="state_flush")
1576
  srv_config = uvicorn.Config(
1577
- app=app, host="0.0.0.0", port=7860, log_level="info", loop="asyncio", log_config=None,
 
 
 
1578
  )
1579
  server = uvicorn.Server(srv_config)
1580
  try:
 
15
  import asyncio
16
  import atexit
17
  import contextlib
18
+ import contextvars
19
  import hashlib
20
  import json
21
  import logging
 
82
  return tuple(sorted(set(values), key=len, reverse=True))
83
 
84
 
85
+ # Метка текущего чата на всех строках обработки: aiogram печатает только update
86
+ # id, и собрать путь одного запроса по логу было нельзя (прод 27.10.2026).
87
+ _LOG_CHAT: contextvars.ContextVar[str] = contextvars.ContextVar("log_chat", default="")
88
+
89
+
90
  class _SecretLogFormatter(logging.Formatter):
91
  def format(self, record: logging.LogRecord, _secrets=_current_log_secrets) -> str:
92
  # Секреты через замыкание, а не глобал: при финализации интерпретатора
 
94
  # Остатки teardown глушим: строка без вычистки лучше шумного трейсбека.
95
  text = super().format(record)
96
  with contextlib.suppress(Exception):
97
+ chat = _LOG_CHAT.get()
98
+ if chat:
99
+ text = f"[{chat}] {text}"
100
  for secret in _secrets():
101
  text = text.replace(secret, "<REDACTED>")
102
  return text
 
136
  for name in ("httpx", "google_genai", "aiohttp", "uvicorn.access"):
137
  logging.getLogger(name).setLevel(logging.WARNING)
138
  # Единый логгер "bot": __main__ в проде расходился с lumen_*.py (видно в Sentry).
139
+ logger = logging.getLogger("bot")
140
+ logger.info("[setup] Logging: level=%s, file=%s", logging.getLevelName(level), LOG_FILE_PATH)
141
+ return logger
142
 
143
  logger = _setup_logging()
144
  log = logger
 
1298
 
1299
  @dp.message()
1300
  async def handle_message(message: Message) -> None:
1301
+ # Метка чата на всех строках обработки (см. _LOG_CHAT); сброс в finally —
1302
+ # иначе значение утечёт в следующий апдейт этого же процесса.
1303
+ token = _LOG_CHAT.set(f"chat={message.chat.id if message.chat else 0}")
1304
+ try:
1305
+ await _handle_message(message)
1306
+ finally:
1307
+ _LOG_CHAT.reset(token)
1308
+
1309
+
1310
+ async def _handle_message(message: Message) -> None:
1311
  # Владельческая блокировка: ни ответа, ни контекста группы, ни счётчиков —
1312
  # отсекаем раньше всего, включая медиагруппы и пассивный фон.
1313
  if message.from_user is not None and _is_banned(message.from_user.id):
 
1595
  startup_task = asyncio.create_task(_webhook_startup(), name="webhook_startup")
1596
  flush_task = asyncio.create_task(_flush_dirty_state(), name="state_flush")
1597
  srv_config = uvicorn.Config(
1598
+ # log_level=None: uvicorn с любым уровнем сам выставляет uvicorn.access
1599
+ # обратно в INFO и перебивает глушение в _setup_logging — health-check
1600
+ # GET / шёл в лог раз в минуту (прод 27.10.2026).
1601
+ app=app, host="0.0.0.0", port=7860, log_level=None, loop="asyncio", log_config=None,
1602
  )
1603
  server = uvicorn.Server(srv_config)
1604
  try:
lumen_admin.py CHANGED
@@ -168,7 +168,7 @@ async def webhook_handler(request: Request) -> Any:
168
  log.warning("[webhook] Bot not initialized yet, asking Telegram to retry")
169
  return JSONResponse(status_code=503, content={"ok": False, "retry": True})
170
  except Exception as exc:
171
- log.warning("[webhook] Failed to process incoming update: %s", exc)
172
  # Битое тело — не успех: пусть Telegram повторит, а не считает дроп
173
  # обработанным (аудит M2, 10.2026).
174
  return JSONResponse(status_code=503, content={"ok": False, "retry": True})
 
168
  log.warning("[webhook] Bot not initialized yet, asking Telegram to retry")
169
  return JSONResponse(status_code=503, content={"ok": False, "retry": True})
170
  except Exception as exc:
171
+ log.warning("[webhook] Failed to process incoming update: %s", exc, exc_info=True)
172
  # Битое тело — не успех: пусть Telegram повторит, а не считает дроп
173
  # обработанным (аудит M2, 10.2026).
174
  return JSONResponse(status_code=503, content={"ok": False, "retry": True})
lumen_chat_state.py CHANGED
@@ -719,7 +719,7 @@ async def _flush_dirty_state_once() -> None:
719
  if _quota_generation == started_quota_generation:
720
  _quota_dirty = False
721
  except Exception as exc:
722
- log.warning('[state] Periodic state flush failed: %s', exc)
723
 
724
 
725
  async def _flush_dirty_state() -> None:
 
719
  if _quota_generation == started_quota_generation:
720
  _quota_dirty = False
721
  except Exception as exc:
722
+ log.warning('[state] Periodic state flush failed: %s', exc, exc_info=True)
723
 
724
 
725
  async def _flush_dirty_state() -> None:
lumen_commands.py CHANGED
@@ -327,7 +327,7 @@ async def _synthesize_tts_voice(text: str) -> tuple[bytes, str, int]:
327
  log.warning("[tts] ffmpeg OGG conversion failed, falling back to raw audio")
328
  final_filename = f"speech{src_ext}"
329
  except Exception as conv_exc:
330
- log.warning("[tts] ffmpeg conversion error: %s", conv_exc)
331
  final_filename = f"speech{src_ext}"
332
  return final_audio, final_filename, voice_duration
333
 
 
327
  log.warning("[tts] ffmpeg OGG conversion failed, falling back to raw audio")
328
  final_filename = f"speech{src_ext}"
329
  except Exception as conv_exc:
330
+ log.warning("[tts] ffmpeg conversion error: %s", conv_exc, exc_info=True)
331
  final_filename = f"speech{src_ext}"
332
  return final_audio, final_filename, voice_duration
333
 
lumen_devtrace.py CHANGED
@@ -88,7 +88,7 @@ def _append(kind: str, payload: dict) -> bool:
88
  f.write(json.dumps(payload, ensure_ascii=False) + "\n")
89
  return True
90
  except Exception as exc:
91
- log.warning("[devtrace] Failed to append trace entry: %s", exc.__class__.__name__)
92
  return False
93
 
94
 
 
88
  f.write(json.dumps(payload, ensure_ascii=False) + "\n")
89
  return True
90
  except Exception as exc:
91
+ log.warning("[devtrace] Failed to append trace entry: %s", exc.__class__.__name__, exc_info=True)
92
  return False
93
 
94
 
lumen_tiktok_flow.py CHANGED
@@ -149,7 +149,7 @@ async def _send_tiktok_music(session, media_data: dict, message: Message, author
149
  # Длительность — явно: без неё Telegram показывает 0:00 (та же история, что была с видео).
150
  music_duration = await bot._probe_audio_duration(tmp_mp3_path)
151
  except Exception as tag_err:
152
- log.warning("[tiktok] failed to write embedded tags to MP3: %s", tag_err)
153
 
154
  # Слэш и управляющие из чужого названия — в "_" (AUD-E-005),
155
  # иначе multipart-имя файла битое.
@@ -517,10 +517,11 @@ async def handle_tiktok(message: Message, url: str) -> None:
517
  # Полные подписанные URL не пишем, только счётчики (аудит M7, 10.2026).
518
  if images_debug := media_data.get("images"):
519
  log.info(
520
- '[tikwm][diag] slideshow post: response keys=%s, images=%d, has_live_images=%s, has_play=%s has_hdplay=%s has_wmplay=%s',
521
- sorted(media_data.keys()), len(images_debug), bool(media_data.get("live_images")),
522
  bool(media_data.get("play")), bool(media_data.get("hdplay")), bool(media_data.get("wmplay")),
523
  )
 
524
 
525
  author = (media_data.get("author") or {}).get("nickname") or bot._t(message.chat.id, "tiktok_author")
526
 
 
149
  # Длительность — явно: без неё Telegram показывает 0:00 (та же история, что была с видео).
150
  music_duration = await bot._probe_audio_duration(tmp_mp3_path)
151
  except Exception as tag_err:
152
+ log.warning("[tiktok] failed to write embedded tags to MP3: %s", tag_err, exc_info=True)
153
 
154
  # Слэш и управляющие из чужого названия — в "_" (AUD-E-005),
155
  # иначе multipart-имя файла битое.
 
517
  # Полные подписанные URL не пишем, только счётчики (аудит M7, 10.2026).
518
  if images_debug := media_data.get("images"):
519
  log.info(
520
+ '[tikwm][diag] slideshow post: images=%d, has_live_images=%s, has_play=%s has_hdplay=%s has_wmplay=%s',
521
+ len(images_debug), bool(media_data.get("live_images")),
522
  bool(media_data.get("play")), bool(media_data.get("hdplay")), bool(media_data.get("wmplay")),
523
  )
524
+ log.debug('[tikwm][diag] slideshow post keys=%s', sorted(media_data.keys()))
525
 
526
  author = (media_data.get("author") or {}).get("nickname") or bot._t(message.chat.id, "tiktok_author")
527
 
tests/test_bot_admin.py CHANGED
@@ -535,6 +535,20 @@ def test_secret_log_formatter_survives_interpreter_teardown():
535
  bot._SECRET_NAMES = orig
536
 
537
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
538
  def _sleep_then_cancel(loop_sleeps):
539
  # Первые loop_sleeps+1 вызовов sleep проходят (1.5с старта + тики),
540
  # дальше CancelledError останавливает бесконечный цикл стартапа.
 
535
  bot._SECRET_NAMES = orig
536
 
537
 
538
+ def test_secret_log_formatter_prefixes_and_clears_chat_context():
539
+ # Метка чата на строках обработки — единственная связь лога с чатом;
540
+ # сброс обязателен, иначе метка утечёт в следующий апдейт (прод 27.10.2026).
541
+ import logging
542
+ fmt = bot._SecretLogFormatter()
543
+ rec = logging.LogRecord("bot", logging.INFO, __file__, 1, "hello", (), None)
544
+ token = bot._LOG_CHAT.set("chat=42")
545
+ try:
546
+ assert fmt.format(rec) == "[chat=42] hello"
547
+ finally:
548
+ bot._LOG_CHAT.reset(token)
549
+ assert fmt.format(rec) == "hello"
550
+
551
+
552
  def _sleep_then_cancel(loop_sleeps):
553
  # Первые loop_sleeps+1 вызовов sleep проходят (1.5с старта + тики),
554
  # дальше CancelledError останавливает бесконечный цикл стартапа.