log.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413
  1. """Main logging part"""
  2. # Friendly Telegram (telegram userbot)
  3. # Copyright (C) 2018-2021 The Authors
  4. # This program is free software: you can redistribute it and/or modify
  5. # it under the terms of the GNU Affero General Public License as published by
  6. # the Free Software Foundation, either version 3 of the License, or
  7. # (at your option) any later version.
  8. # This program is distributed in the hope that it will be useful,
  9. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  10. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  11. # GNU Affero General Public License for more details.
  12. # You should have received a copy of the GNU Affero General Public License
  13. # along with this program. If not, see <https://www.gnu.org/licenses/>.
  14. # █ █ ▀ █▄▀ ▄▀█ █▀█ ▀
  15. # █▀█ █ █ █ █▀█ █▀▄ █
  16. # © Copyright 2022
  17. # https://t.me/hikariatama
  18. #
  19. # 🔒 Licensed under the GNU AGPLv3
  20. # 🌐 https://www.gnu.org/licenses/agpl-3.0.html
  21. import asyncio
  22. import contextlib
  23. import inspect
  24. import json
  25. import logging
  26. import io
  27. import os
  28. import re
  29. import telethon
  30. import traceback
  31. from typing import List, Optional
  32. from logging.handlers import RotatingFileHandler
  33. from . import utils
  34. from .types import Module, BotInlineCall
  35. from .tl_cache import CustomTelegramClient
  36. class HikkaException:
  37. def __init__(self, message: str, local_vars: str, full_stack: str):
  38. self.message = message
  39. self.local_vars = local_vars
  40. self.full_stack = full_stack
  41. @classmethod
  42. def from_exc_info(
  43. cls,
  44. exc_type: object,
  45. exc_value: Exception,
  46. tb: traceback.TracebackException,
  47. stack: Optional[List[inspect.FrameInfo]] = None,
  48. ) -> "HikkaException":
  49. def to_hashable(dictionary: dict) -> dict:
  50. dictionary = dictionary.copy()
  51. for key, value in dictionary.items():
  52. if isinstance(value, dict):
  53. if (
  54. getattr(getattr(value, "__class__", None), "__name__", None)
  55. == "Database"
  56. ):
  57. dictionary[key] = "<Database>"
  58. if isinstance(
  59. value, (telethon.TelegramClient, CustomTelegramClient)
  60. ):
  61. dictionary[key] = f"<{value.__class__.__name__}>"
  62. dictionary[key] = to_hashable(value)
  63. else:
  64. try:
  65. json.dumps([value])
  66. except Exception:
  67. dictionary[key] = str(value)
  68. return dictionary
  69. full_stack = traceback.format_exc().replace(
  70. "Traceback (most recent call last):\n", ""
  71. )
  72. line_regex = r' File "(.*?)", line ([0-9]+), in (.+)'
  73. def format_line(line: str) -> str:
  74. filename_, lineno_, name_ = re.search(line_regex, line).groups()
  75. with contextlib.suppress(Exception):
  76. filename_ = os.path.basename(filename_)
  77. return (
  78. f"👉 <code>{utils.escape_html(filename_)}:{lineno_}</code> <b>in</b>"
  79. f" <code>{utils.escape_html(name_)}</code>"
  80. )
  81. filename, lineno, name = next(
  82. (
  83. re.search(line_regex, line).groups()
  84. for line in reversed(full_stack.splitlines())
  85. if re.search(line_regex, line)
  86. ),
  87. (None, None, None),
  88. )
  89. line = next(
  90. (
  91. line
  92. for line in reversed(full_stack.splitlines())
  93. if line.startswith(" ")
  94. ),
  95. "",
  96. )
  97. full_stack = "\n".join(
  98. [
  99. format_line(line)
  100. if re.search(line_regex, line)
  101. else f"<code>{utils.escape_html(line)}</code>"
  102. for line in full_stack.splitlines()
  103. ]
  104. )
  105. with contextlib.suppress(Exception):
  106. filename = os.path.basename(filename)
  107. caller = utils.find_caller(stack or inspect.stack())
  108. cause_mod = (
  109. "🪬 <b>Possible cause: method"
  110. f" </b><code>{utils.escape_html(caller.__name__)}</code><b> of module"
  111. f" </b><code>{utils.escape_html(caller.__self__.__class__.__name__)}</code>\n"
  112. if caller and hasattr(caller, "__self__") and hasattr(caller, "__name__")
  113. else ""
  114. )
  115. return HikkaException(
  116. message=(
  117. f"<b>🚫 Error!</b>\n{cause_mod}\n<b>🗄 Where:</b>"
  118. f" <code>{utils.escape_html(filename)}:{lineno}</code><b>"
  119. f" in </b><code>{utils.escape_html(name)}</code>\n😵"
  120. f" <code>{utils.escape_html(line)}</code>"
  121. " 👈\n<b>❓ What:</b>"
  122. f" <code>{utils.escape_html(''.join(traceback.format_exception_only(exc_type, exc_value)).strip())}</code>"
  123. ),
  124. local_vars=(
  125. f"<code>{utils.escape_html(json.dumps(to_hashable(tb.tb_frame.f_locals), indent=4))}</code>"
  126. ),
  127. full_stack=full_stack,
  128. )
  129. class TelegramLogsHandler(logging.Handler):
  130. """
  131. Keeps 2 buffers.
  132. One for dispatched messages.
  133. One for unused messages.
  134. When the length of the 2 together is 100
  135. truncate to make them 100 together,
  136. first trimming handled then unused.
  137. """
  138. def __init__(self, targets: list, capacity: int):
  139. super().__init__(0)
  140. self.targets = targets
  141. self.capacity = capacity
  142. self.buffer = []
  143. self.handledbuffer = []
  144. self.lvl = logging.NOTSET # Default loglevel
  145. self._queue = []
  146. self.tg_buff = []
  147. self._mods = {}
  148. self.force_send_all = False
  149. self.tg_level = 20
  150. def install_tg_log(self, mod: Module):
  151. if getattr(self, "_task", False):
  152. self._task.cancel()
  153. self._mods[mod.tg_id] = mod
  154. self._task = asyncio.ensure_future(self.queue_poller())
  155. async def queue_poller(self):
  156. while True:
  157. await self.sender()
  158. await asyncio.sleep(3)
  159. def setLevel(self, level: int):
  160. self.lvl = level
  161. def dump(self):
  162. """Return a list of logging entries"""
  163. return self.handledbuffer + self.buffer
  164. def dumps(self, lvl: int = 0, client_id: Optional[int] = None) -> list:
  165. """Return all entries of minimum level as list of strings"""
  166. return [
  167. self.targets[0].format(record)
  168. for record in (self.buffer + self.handledbuffer)
  169. if record.levelno >= lvl
  170. and (not record.hikka_caller or client_id == record.hikka_caller)
  171. ]
  172. async def _show_full_stack(
  173. self,
  174. call: BotInlineCall,
  175. bot: "aiogram.Bot", # type: ignore
  176. item: HikkaException,
  177. ):
  178. chunks = (
  179. item.message
  180. + "\n\n<b>🦝 Locals:</b>\n"
  181. + item.local_vars
  182. + "\n\n"
  183. + "<b>🪐 Full stack:</b>\n"
  184. + item.full_stack
  185. )
  186. chunks = list(utils.smart_split(*telethon.extensions.html.parse(chunks), 4096))
  187. await call.edit(chunks[0])
  188. for chunk in chunks[1:]:
  189. await bot.send_message(chat_id=call.chat_id, text=chunk)
  190. async def sender(self):
  191. self._queue = {
  192. client_id: utils.chunks(
  193. utils.escape_html(
  194. "".join(
  195. [
  196. item[0]
  197. for item in self.tg_buff
  198. if isinstance(item[0], str)
  199. and (
  200. not item[1]
  201. or item[1] == client_id
  202. or self.force_send_all
  203. )
  204. ]
  205. )
  206. ),
  207. 4096,
  208. )
  209. for client_id in self._mods
  210. }
  211. self._exc_queue = {
  212. client_id: [
  213. self._mods[client_id].inline.bot.send_message(
  214. self._mods[client_id]._logchat,
  215. item[0].message,
  216. reply_markup=self._mods[client_id].inline.generate_markup(
  217. {
  218. "text": "🪐 Full stack",
  219. "callback": self._show_full_stack,
  220. "args": (
  221. self._mods[client_id].inline.bot,
  222. item[0],
  223. ),
  224. "disable_security": True,
  225. }
  226. ),
  227. )
  228. for item in self.tg_buff
  229. if isinstance(item[0], HikkaException)
  230. and (not item[1] or item[1] == client_id or self.force_send_all)
  231. ]
  232. for client_id in self._mods
  233. }
  234. for client_id, exceptions in self._exc_queue.items():
  235. for exc in exceptions:
  236. await exc
  237. self.tg_buff = []
  238. for client_id in self._mods:
  239. if client_id not in self._queue:
  240. continue
  241. if len(self._queue[client_id]) > 5:
  242. logfile = io.BytesIO("".join(self._queue[client_id]).encode("utf-8"))
  243. logfile.name = "hikka-logs.txt"
  244. logfile.seek(0)
  245. await self._mods[client_id].inline.bot.send_document(
  246. self._mods[client_id]._logchat,
  247. logfile,
  248. caption=(
  249. "<b>🧳 Journals are too big to be sent as separate messages</b>"
  250. ),
  251. )
  252. self._queue[client_id] = []
  253. continue
  254. while self._queue[client_id]:
  255. if chunk := self._queue[client_id].pop(0):
  256. asyncio.ensure_future(
  257. self._mods[client_id].inline.bot.send_message(
  258. self._mods[client_id]._logchat,
  259. f"<code>{chunk}</code>",
  260. disable_notification=True,
  261. )
  262. )
  263. def emit(self, record: logging.LogRecord):
  264. try:
  265. caller = next(
  266. (
  267. frame_info.frame.f_locals["_hikka_client_id_logging_tag"]
  268. for frame_info in inspect.stack()
  269. if isinstance(
  270. getattr(getattr(frame_info, "frame", None), "f_locals", {}).get(
  271. "_hikka_client_id_logging_tag"
  272. ),
  273. int,
  274. )
  275. ),
  276. False,
  277. )
  278. if not isinstance(caller, int):
  279. caller = None
  280. except Exception:
  281. caller = None
  282. record.hikka_caller = caller
  283. if record.levelno >= self.tg_level:
  284. if record.exc_info:
  285. logging.debug(record.__dict__)
  286. self.tg_buff += [
  287. (
  288. HikkaException.from_exc_info(
  289. *record.exc_info,
  290. stack=record.__dict__.get("stack", None),
  291. ),
  292. caller,
  293. )
  294. ]
  295. else:
  296. self.tg_buff += [
  297. (
  298. _tg_formatter.format(record),
  299. caller,
  300. )
  301. ]
  302. if len(self.buffer) + len(self.handledbuffer) >= self.capacity:
  303. if self.handledbuffer:
  304. del self.handledbuffer[0]
  305. else:
  306. del self.buffer[0]
  307. self.buffer.append(record)
  308. if record.levelno >= self.lvl >= 0:
  309. self.acquire()
  310. try:
  311. for precord in self.buffer:
  312. for target in self.targets:
  313. if record.levelno >= target.level:
  314. target.handle(precord)
  315. self.handledbuffer = (
  316. self.handledbuffer[-(self.capacity - len(self.buffer)) :]
  317. + self.buffer
  318. )
  319. self.buffer = []
  320. finally:
  321. self.release()
  322. _main_formatter = logging.Formatter(
  323. fmt="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
  324. datefmt="%Y-%m-%d %H:%M:%S",
  325. style="%",
  326. )
  327. _tg_formatter = logging.Formatter(
  328. fmt="[%(levelname)s] %(name)s: %(message)s\n",
  329. datefmt=None,
  330. style="%",
  331. )
  332. rotating_handler = RotatingFileHandler(
  333. filename="hikka.log",
  334. mode="a",
  335. maxBytes=10 * 1024 * 1024,
  336. backupCount=1,
  337. encoding="utf-8",
  338. delay=0,
  339. )
  340. rotating_handler.setFormatter(_main_formatter)
  341. def init():
  342. handler = logging.StreamHandler()
  343. handler.setLevel(logging.INFO)
  344. handler.setFormatter(_main_formatter)
  345. logging.getLogger().handlers = []
  346. logging.getLogger().addHandler(
  347. TelegramLogsHandler((handler, rotating_handler), 7000)
  348. )
  349. logging.getLogger().setLevel(logging.NOTSET)
  350. logging.getLogger("telethon").setLevel(logging.WARNING)
  351. logging.getLogger("matplotlib").setLevel(logging.WARNING)
  352. logging.getLogger("aiohttp").setLevel(logging.WARNING)
  353. logging.getLogger("aiogram").setLevel(logging.WARNING)
  354. logging.captureWarnings(True)