log.py 13 KB

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