You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

pretty_logger.py 13KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417
  1. # pretty_logger.py
  2. import json
  3. import logging
  4. import logging.handlers
  5. import sys
  6. import threading
  7. import time
  8. from contextlib import ContextDecorator
  9. from functools import wraps
  10. # ANSI escape codes for colors
  11. RESET = "\x1b[0m"
  12. BLACK, RED, GREEN, YELLOW, BLUE, MAGENTA, CYAN, WHITE = (
  13. "\x1b[30m",
  14. "\x1b[31m",
  15. "\x1b[32m",
  16. "\x1b[33m",
  17. "\x1b[34m",
  18. "\x1b[35m",
  19. "\x1b[36m",
  20. "\x1b[37m",
  21. )
  22. _LEVEL_COLORS = {
  23. logging.DEBUG: CYAN,
  24. logging.INFO: GREEN,
  25. logging.WARNING: YELLOW,
  26. logging.ERROR: RED,
  27. logging.CRITICAL: MAGENTA,
  28. }
  29. def _supports_color() -> bool:
  30. """
  31. Detect whether the running terminal supports ANSI colors.
  32. """
  33. if hasattr(sys.stdout, "isatty") and sys.stdout.isatty():
  34. platform = sys.platform
  35. if platform == "win32":
  36. # On Windows, modern terminals may support ANSI; assume yes for Windows 10+
  37. return True
  38. return True
  39. return False
  40. class PrettyFormatter(logging.Formatter):
  41. """
  42. A logging.Formatter that outputs colorized logs to the console
  43. (if enabled) and supports JSON formatting (if requested).
  44. """
  45. def __init__(
  46. self,
  47. fmt: str = None,
  48. datefmt: str = "%Y-%m-%d %H:%M:%S",
  49. use_colors: bool = True,
  50. json_output: bool = False,
  51. ):
  52. super().__init__(fmt=fmt, datefmt=datefmt)
  53. self.use_colors = use_colors and _supports_color()
  54. self.json_output = json_output
  55. def format(self, record: logging.LogRecord) -> str:
  56. # If JSON output is requested, dump a dict of relevant fields
  57. if self.json_output:
  58. log_record = {
  59. "timestamp": self.formatTime(record, self.datefmt),
  60. "name": record.name,
  61. "level": record.levelname,
  62. "message": record.getMessage(),
  63. }
  64. # Include extra/contextual fields
  65. for k, v in record.__dict__.get("extra_fields", {}).items():
  66. log_record[k] = v
  67. if record.exc_info:
  68. log_record["exception"] = self.formatException(record.exc_info)
  69. return json.dumps(log_record, ensure_ascii=False)
  70. # Otherwise, build a colorized/“pretty” line
  71. level_color = _LEVEL_COLORS.get(record.levelno, WHITE) if self.use_colors else ""
  72. reset = RESET if self.use_colors else ""
  73. timestamp = self.formatTime(record, self.datefmt)
  74. name = record.name
  75. level = record.levelname
  76. # Basic format: [timestamp] [LEVEL] [name]: message
  77. base = f"[{timestamp}] [{level}] [{name}]: {record.getMessage()}"
  78. # If there are any extra fields attached via LoggerAdapter, append them
  79. extra_fields = record.__dict__.get("extra_fields", {})
  80. if extra_fields:
  81. # key1=val1 key2=val2 …
  82. extras_str = " ".join(f"{k}={v}" for k, v in extra_fields.items())
  83. base = f"{base} :: {extras_str}"
  84. # If exception info is present, include traceback
  85. if record.exc_info:
  86. exc_text = self.formatException(record.exc_info)
  87. base = f"{base}\n{exc_text}"
  88. if self.use_colors:
  89. return f"{level_color}{base}{reset}"
  90. else:
  91. return base
  92. class JsonFormatter(PrettyFormatter):
  93. """
  94. Shortcut to force JSON formatting (ignores color flags).
  95. """
  96. def __init__(self, datefmt: str = "%Y-%m-%d %H:%M:%S"):
  97. super().__init__(fmt=None, datefmt=datefmt, use_colors=False, json_output=True)
  98. class ContextFilter(logging.Filter):
  99. """
  100. Inject extra_fields from a LoggerAdapter into the LogRecord, so the Formatter can find them.
  101. """
  102. def filter(self, record: logging.LogRecord) -> bool:
  103. extra = getattr(record, "extra", None)
  104. if isinstance(extra, dict):
  105. record.extra_fields = extra
  106. else:
  107. record.extra_fields = {}
  108. return True
  109. class Timer(ContextDecorator):
  110. """
  111. Context manager & decorator to measure execution time of a code block or function.
  112. Usage as context manager:
  113. with Timer(logger, "block name"):
  114. # code …
  115. Usage as decorator:
  116. @Timer(logger, "function foo")
  117. def foo(...):
  118. ...
  119. """
  120. def __init__(self, logger: logging.Logger, name: str = None, level: int = logging.INFO):
  121. self.logger = logger
  122. self.name = name or ""
  123. self.level = level
  124. def __enter__(self):
  125. self.start_time = time.time()
  126. return self
  127. def __exit__(self, exc_type, exc, exc_tb):
  128. elapsed = time.time() - self.start_time
  129. label = f"[Timer:{self.name}]" if self.name else "[Timer]"
  130. self.logger.log(self.level, f"{label} elapsed {elapsed:.4f} sec")
  131. return False # do not suppress exceptions
  132. def log_function(logger: logging.Logger, level: int = logging.DEBUG):
  133. """
  134. Decorator factory to log function entry/exit and execution time.
  135. Usage:
  136. @log_function(logger, level=logging.INFO)
  137. def my_func(...):
  138. ...
  139. """
  140. def decorator(func):
  141. @wraps(func)
  142. def wrapper(*args, **kwargs):
  143. logger.log(level, f"➤ Entering {func.__name__}()")
  144. start = time.time()
  145. try:
  146. result = func(*args, **kwargs)
  147. except Exception:
  148. logger.exception(f"✖ Exception in {func.__name__}()")
  149. raise
  150. elapsed = time.time() - start
  151. logger.log(level, f"✔ Exiting {func.__name__}(), elapsed {elapsed:.4f} sec")
  152. return result
  153. return wrapper
  154. return decorator
  155. class PrettyLogger:
  156. """
  157. Encapsulates a logging.Logger with “pretty” formatting, colorized console output,
  158. rotating file handler, JSON option, contextual fields, etc.
  159. """
  160. _instances = {}
  161. _lock = threading.Lock()
  162. def __new__(cls, name: str = "root", **kwargs):
  163. """
  164. Implements a simple singleton: multiple calls with the same name return the same instance.
  165. """
  166. with cls._lock:
  167. if name not in cls._instances:
  168. instance = super().__new__(cls)
  169. cls._instances[name] = instance
  170. return cls._instances[name]
  171. def __init__(
  172. self,
  173. name: str = "root",
  174. level: int = logging.DEBUG,
  175. log_to_console: bool = True,
  176. console_level: int = logging.DEBUG,
  177. log_to_file: bool = False,
  178. log_file: str = "app.log",
  179. file_level: int = logging.INFO,
  180. max_bytes: int = 10 * 1024 * 1024, # 10 MB
  181. backup_count: int = 5,
  182. formatter: PrettyFormatter = None,
  183. json_output: bool = False,
  184. ):
  185. """
  186. Initialize (or re‐configure) a PrettyLogger.
  187. Args:
  188. name: logger name.
  189. level: root level for the logger (lowest level that will be processed).
  190. log_to_console: whether to attach a console (StreamHandler).
  191. console_level: level for console output.
  192. log_to_file: whether to attach a rotating file handler.
  193. log_file: path to the log file.
  194. file_level: level for file output.
  195. max_bytes: rotation threshold in bytes.
  196. backup_count: number of backup files to keep.
  197. formatter: an instance of PrettyFormatter. If None, a default is created.
  198. json_output: if True, forces JSON formatting on all handlers.
  199. """
  200. # Avoid re‐initializing if already done
  201. logger = logging.getLogger(name)
  202. if getattr(logger, "_pretty_logger_inited", False):
  203. return
  204. self.logger = logger
  205. self.logger.setLevel(level)
  206. self.logger.propagate = False # avoid double‐logging if root logger is configured elsewhere
  207. # Create a default formatter if not supplied
  208. if formatter is None:
  209. fmt = "[%(asctime)s] [%(levelname)s] [%(name)s]: %(message)s"
  210. formatter = PrettyFormatter(
  211. fmt=fmt, use_colors=not json_output, json_output=json_output
  212. )
  213. # Add ContextFilter so extra_fields is always present
  214. context_filter = ContextFilter()
  215. self.logger.addFilter(context_filter)
  216. # Console handler
  217. if log_to_console:
  218. ch = logging.StreamHandler(sys.stdout)
  219. ch.setLevel(console_level)
  220. ch.setFormatter(formatter)
  221. self.logger.addHandler(ch)
  222. # Rotating file handler
  223. if log_to_file:
  224. fh = logging.handlers.RotatingFileHandler(
  225. filename=log_file,
  226. maxBytes=max_bytes,
  227. backupCount=backup_count,
  228. encoding="utf-8",
  229. )
  230. fh.setLevel(file_level)
  231. # For file, typically we don’t colorize
  232. file_formatter = (
  233. JsonFormatter(datefmt="%Y-%m-%d %H:%M:%S")
  234. if json_output
  235. else PrettyFormatter(fmt=fmt, use_colors=False, json_output=False)
  236. )
  237. fh.setFormatter(file_formatter)
  238. self.logger.addHandler(fh)
  239. # Mark as initialized
  240. setattr(self.logger, "_pretty_logger_inited", True)
  241. def addHandler(self, handler: logging.Handler):
  242. """
  243. Add a custom handler to the logger.
  244. This can be used to add any logging.Handler instance.
  245. """
  246. self.logger.addHandler(handler)
  247. def bind(self, **kwargs):
  248. """
  249. Return a LoggerAdapter that automatically injects extra fields into all log records.
  250. Example:
  251. ctx_logger = pretty_logger.bind(request_id=123, user="alice")
  252. ctx_logger.info("Something happened") # will include request_id and user in the output
  253. """
  254. return logging.LoggerAdapter(self.logger, {"extra": kwargs})
  255. def add_stream_handler(
  256. self, stream=None, level: int = logging.DEBUG, formatter: PrettyFormatter = None
  257. ):
  258. """
  259. Add an additional stream handler (e.g. to stderr).
  260. """
  261. handler = logging.StreamHandler(stream or sys.stdout)
  262. handler.setLevel(level)
  263. if formatter is None:
  264. fmt = "[%(asctime)s] [%(levelname)s] [%(name)s]: %(message)s"
  265. formatter = PrettyFormatter(fmt=fmt)
  266. handler.setFormatter(formatter)
  267. self.logger.addHandler(handler)
  268. return handler
  269. def add_rotating_file_handler(
  270. self,
  271. log_file: str,
  272. level: int = logging.INFO,
  273. max_bytes: int = 10 * 1024 * 1024,
  274. backup_count: int = 5,
  275. json_output: bool = False,
  276. ):
  277. """
  278. Add another rotating file handler at runtime.
  279. """
  280. fh = logging.handlers.RotatingFileHandler(
  281. filename=log_file,
  282. maxBytes=max_bytes,
  283. backupCount=backup_count,
  284. encoding="utf-8",
  285. )
  286. fh.setLevel(level)
  287. if json_output:
  288. formatter = JsonFormatter()
  289. else:
  290. fmt = "[%(asctime)s] [%(levelname)s] [%(name)s]: %(message)s"
  291. formatter = PrettyFormatter(fmt=fmt, use_colors=False, json_output=False)
  292. fh.setFormatter(formatter)
  293. self.logger.addHandler(fh)
  294. return fh
  295. def remove_handler(self, handler: logging.Handler):
  296. """
  297. Remove a handler from the logger.
  298. """
  299. self.logger.removeHandler(handler)
  300. def set_level(self, level: int):
  301. """
  302. Change the logger’s root level at runtime.
  303. """
  304. self.logger.setLevel(level)
  305. def get_logger(self) -> logging.Logger:
  306. return self.logger
  307. # Convenience methods to expose common logging calls directly from this object:
  308. def debug(self, msg, *args, **kwargs):
  309. self.logger.debug(msg, *args, **kwargs)
  310. def info(self, msg, *args, **kwargs):
  311. self.logger.info(msg, *args, **kwargs)
  312. def warning(self, msg, *args, **kwargs):
  313. self.logger.warning(msg, *args, **kwargs)
  314. def error(self, msg, *args, **kwargs):
  315. self.logger.error(msg, *args, **kwargs)
  316. def critical(self, msg, *args, **kwargs):
  317. self.logger.critical(msg, *args, **kwargs)
  318. def exception(self, msg, *args, exc_info=True, **kwargs):
  319. """
  320. Log an error message with exception traceback. By default exc_info=True.
  321. """
  322. self.logger.error(msg, *args, exc_info=exc_info, **kwargs)
  323. # Convenience function for a module‐level “global” pretty logger:
  324. _global_loggers = {}
  325. _global_lock = threading.Lock()
  326. def get_pretty_logger(
  327. name: str = "root",
  328. level: int = logging.DEBUG,
  329. log_to_console: bool = True,
  330. console_level: int = logging.DEBUG,
  331. log_to_file: bool = False,
  332. log_file: str = "app.log",
  333. file_level: int = logging.INFO,
  334. max_bytes: int = 10 * 1024 * 1024,
  335. backup_count: int = 5,
  336. json_output: bool = False,
  337. ) -> PrettyLogger:
  338. """
  339. Return a PrettyLogger instance for `name`, creating/configuring it on first use.
  340. Subsequent calls with the same `name` will return the same logger (singleton‐style).
  341. """
  342. with _global_lock:
  343. if name not in _global_loggers:
  344. pl = PrettyLogger(
  345. name=name,
  346. level=level,
  347. log_to_console=log_to_console,
  348. console_level=console_level,
  349. log_to_file=log_to_file,
  350. log_file=log_file,
  351. file_level=file_level,
  352. max_bytes=max_bytes,
  353. backup_count=backup_count,
  354. json_output=json_output,
  355. )
  356. _global_loggers[name] = pl
  357. return _global_loggers[name]