logger.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324
  1. import logging
  2. import os
  3. import re
  4. import sys
  5. import traceback
  6. from datetime import datetime
  7. from typing import Literal, Mapping
  8. from termcolor import colored
  9. LOG_LEVEL = os.getenv('LOG_LEVEL', 'INFO').upper()
  10. DEBUG = os.getenv('DEBUG', 'False').lower() in ['true', '1', 'yes']
  11. if DEBUG:
  12. LOG_LEVEL = 'DEBUG'
  13. LOG_TO_FILE = os.getenv('LOG_TO_FILE', 'False').lower() in ['true', '1', 'yes']
  14. DISABLE_COLOR_PRINTING = False
  15. ColorType = Literal[
  16. 'red',
  17. 'green',
  18. 'yellow',
  19. 'blue',
  20. 'magenta',
  21. 'cyan',
  22. 'light_grey',
  23. 'dark_grey',
  24. 'light_red',
  25. 'light_green',
  26. 'light_yellow',
  27. 'light_blue',
  28. 'light_magenta',
  29. 'light_cyan',
  30. 'white',
  31. ]
  32. LOG_COLORS: Mapping[str, ColorType] = {
  33. 'ACTION': 'green',
  34. 'USER_ACTION': 'light_red',
  35. 'OBSERVATION': 'yellow',
  36. 'USER_OBSERVATION': 'light_green',
  37. 'DETAIL': 'cyan',
  38. 'ERROR': 'red',
  39. 'PLAN': 'light_magenta',
  40. }
  41. class ColoredFormatter(logging.Formatter):
  42. def format(self, record):
  43. msg_type = record.__dict__.get('msg_type')
  44. event_source = record.__dict__.get('event_source')
  45. if event_source:
  46. new_msg_type = f'{event_source.upper()}_{msg_type}'
  47. if new_msg_type in LOG_COLORS:
  48. msg_type = new_msg_type
  49. if msg_type in LOG_COLORS and not DISABLE_COLOR_PRINTING:
  50. msg_type_color = colored(msg_type, LOG_COLORS[msg_type])
  51. msg = colored(record.msg, LOG_COLORS[msg_type])
  52. time_str = colored(
  53. self.formatTime(record, self.datefmt), LOG_COLORS[msg_type]
  54. )
  55. name_str = colored(record.name, LOG_COLORS[msg_type])
  56. level_str = colored(record.levelname, LOG_COLORS[msg_type])
  57. if msg_type in ['ERROR'] or DEBUG:
  58. return f'{time_str} - {name_str}:{level_str}: {record.filename}:{record.lineno}\n{msg_type_color}\n{msg}'
  59. return f'{time_str} - {msg_type_color}\n{msg}'
  60. elif msg_type == 'STEP':
  61. msg = '\n\n==============\n' + record.msg + '\n'
  62. return f'{msg}'
  63. return super().format(record)
  64. file_formatter = logging.Formatter(
  65. '%(asctime)s - %(name)s:%(levelname)s: %(filename)s:%(lineno)s - %(message)s',
  66. datefmt='%H:%M:%S',
  67. )
  68. llm_formatter = logging.Formatter('%(message)s')
  69. class RollingLogger:
  70. max_lines: int
  71. char_limit: int
  72. log_lines: list[str]
  73. def __init__(self, max_lines=10, char_limit=80):
  74. self.max_lines = max_lines
  75. self.char_limit = char_limit
  76. self.log_lines = [''] * self.max_lines
  77. def is_enabled(self):
  78. return DEBUG and sys.stdout.isatty()
  79. def start(self, message=''):
  80. if message:
  81. print(message)
  82. self._write('\n' * self.max_lines)
  83. self._flush()
  84. def add_line(self, line):
  85. self.log_lines.pop(0)
  86. self.log_lines.append(line[: self.char_limit])
  87. self.print_lines()
  88. def write_immediately(self, line):
  89. self._write(line)
  90. self._flush()
  91. def print_lines(self):
  92. """Display the last n log_lines in the console (not for file logging).
  93. This will create the effect of a rolling display in the console.
  94. """
  95. self.move_back()
  96. for line in self.log_lines:
  97. self.replace_current_line(line)
  98. def move_back(self, amount=-1):
  99. """
  100. '\033[F' moves the cursor up one line.
  101. """
  102. if amount == -1:
  103. amount = self.max_lines
  104. self._write('\033[F' * (self.max_lines))
  105. self._flush()
  106. def replace_current_line(self, line=''):
  107. """
  108. '\033[2K\r' clears the line and moves the cursor to the beginning of the line.
  109. """
  110. self._write('\033[2K' + line + '\n')
  111. self._flush()
  112. def _write(self, line):
  113. if not self.is_enabled():
  114. return
  115. sys.stdout.write(line)
  116. def _flush(self):
  117. if not self.is_enabled():
  118. return
  119. sys.stdout.flush()
  120. class SensitiveDataFilter(logging.Filter):
  121. def filter(self, record):
  122. # start with attributes
  123. sensitive_patterns = [
  124. 'api_key',
  125. 'aws_access_key_id',
  126. 'aws_secret_access_key',
  127. 'e2b_api_key',
  128. 'github_token',
  129. 'jwt_secret',
  130. 'modal_api_token_id',
  131. 'modal_api_token_secret',
  132. ]
  133. # add env var names
  134. env_vars = [attr.upper() for attr in sensitive_patterns]
  135. sensitive_patterns.extend(env_vars)
  136. # and some special cases
  137. sensitive_patterns.append('JWT_SECRET')
  138. sensitive_patterns.append('LLM_API_KEY')
  139. sensitive_patterns.append('GITHUB_TOKEN')
  140. sensitive_patterns.append('SANDBOX_ENV_GITHUB_TOKEN')
  141. # this also formats the message with % args
  142. msg = record.getMessage()
  143. record.args = ()
  144. for attr in sensitive_patterns:
  145. pattern = rf"{attr}='?([\w-]+)'?"
  146. msg = re.sub(pattern, f"{attr}='******'", msg)
  147. # passed with msg
  148. record.msg = msg
  149. return True
  150. def get_console_handler(log_level=logging.INFO, extra_info: str | None = None):
  151. """Returns a console handler for logging."""
  152. console_handler = logging.StreamHandler()
  153. console_handler.setLevel(log_level)
  154. formatter_str = '\033[92m%(asctime)s - %(name)s:%(levelname)s\033[0m: %(filename)s:%(lineno)s - %(message)s'
  155. if extra_info:
  156. formatter_str = f'{extra_info} - ' + formatter_str
  157. console_handler.setFormatter(ColoredFormatter(formatter_str, datefmt='%H:%M:%S'))
  158. return console_handler
  159. def get_file_handler(log_dir, log_level=logging.INFO):
  160. """Returns a file handler for logging."""
  161. os.makedirs(log_dir, exist_ok=True)
  162. timestamp = datetime.now().strftime('%Y-%m-%d')
  163. file_name = f'openhands_{timestamp}.log'
  164. file_handler = logging.FileHandler(os.path.join(log_dir, file_name))
  165. file_handler.setLevel(log_level)
  166. file_handler.setFormatter(file_formatter)
  167. return file_handler
  168. # Set up logging
  169. logging.basicConfig(level=logging.ERROR)
  170. def log_uncaught_exceptions(ex_cls, ex, tb):
  171. """Logs uncaught exceptions along with the traceback.
  172. Args:
  173. ex_cls (type): The type of the exception.
  174. ex (Exception): The exception instance.
  175. tb (traceback): The traceback object.
  176. Returns:
  177. None
  178. """
  179. logging.error(''.join(traceback.format_tb(tb)))
  180. logging.error('{0}: {1}'.format(ex_cls, ex))
  181. sys.excepthook = log_uncaught_exceptions
  182. openhands_logger = logging.getLogger('openhands')
  183. current_log_level = logging.INFO
  184. if LOG_LEVEL in logging.getLevelNamesMapping():
  185. current_log_level = logging.getLevelNamesMapping()[LOG_LEVEL]
  186. openhands_logger.setLevel(current_log_level)
  187. if current_log_level == logging.DEBUG:
  188. LOG_TO_FILE = True
  189. openhands_logger.debug('DEBUG mode enabled.')
  190. openhands_logger.addHandler(get_console_handler(current_log_level))
  191. openhands_logger.addFilter(SensitiveDataFilter(openhands_logger.name))
  192. openhands_logger.propagate = False
  193. openhands_logger.debug('Logging initialized')
  194. LOG_DIR = os.path.join(
  195. # parent dir of openhands/core (i.e., root of the repo)
  196. os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
  197. 'logs',
  198. )
  199. if LOG_TO_FILE:
  200. openhands_logger.addHandler(
  201. get_file_handler(LOG_DIR, current_log_level)
  202. ) # default log to project root
  203. openhands_logger.debug(f'Logging to file in: {LOG_DIR}')
  204. # Exclude LiteLLM from logging output
  205. logging.getLogger('LiteLLM').disabled = True
  206. logging.getLogger('LiteLLM Router').disabled = True
  207. logging.getLogger('LiteLLM Proxy').disabled = True
  208. class LlmFileHandler(logging.FileHandler):
  209. """# LLM prompt and response logging"""
  210. def __init__(self, filename, mode='a', encoding='utf-8', delay=False):
  211. """Initializes an instance of LlmFileHandler.
  212. Args:
  213. filename (str): The name of the log file.
  214. mode (str, optional): The file mode. Defaults to 'a'.
  215. encoding (str, optional): The file encoding. Defaults to None.
  216. delay (bool, optional): Whether to delay file opening. Defaults to False.
  217. """
  218. self.filename = filename
  219. self.message_counter = 1
  220. if DEBUG:
  221. self.session = datetime.now().strftime('%y-%m-%d_%H-%M')
  222. else:
  223. self.session = 'default'
  224. self.log_directory = os.path.join(LOG_DIR, 'llm', self.session)
  225. os.makedirs(self.log_directory, exist_ok=True)
  226. if not DEBUG:
  227. # Clear the log directory if not in debug mode
  228. for file in os.listdir(self.log_directory):
  229. file_path = os.path.join(self.log_directory, file)
  230. try:
  231. os.unlink(file_path)
  232. except Exception as e:
  233. openhands_logger.error(
  234. 'Failed to delete %s. Reason: %s', file_path, e
  235. )
  236. filename = f'{self.filename}_{self.message_counter:03}.log'
  237. self.baseFilename = os.path.join(self.log_directory, filename)
  238. super().__init__(self.baseFilename, mode, encoding, delay)
  239. def emit(self, record):
  240. """Emits a log record.
  241. Args:
  242. record (logging.LogRecord): The log record to emit.
  243. """
  244. filename = f'{self.filename}_{self.message_counter:03}.log'
  245. self.baseFilename = os.path.join(self.log_directory, filename)
  246. self.stream = self._open()
  247. super().emit(record)
  248. self.stream.close()
  249. openhands_logger.debug('Logging to %s', self.baseFilename)
  250. self.message_counter += 1
  251. def _get_llm_file_handler(name: str, log_level: int):
  252. # The 'delay' parameter, when set to True, postpones the opening of the log file
  253. # until the first log message is emitted.
  254. llm_file_handler = LlmFileHandler(name, delay=True)
  255. llm_file_handler.setFormatter(llm_formatter)
  256. llm_file_handler.setLevel(log_level)
  257. return llm_file_handler
  258. def _setup_llm_logger(name: str, log_level: int):
  259. logger = logging.getLogger(name)
  260. logger.propagate = False
  261. logger.setLevel(log_level)
  262. if LOG_TO_FILE:
  263. logger.addHandler(_get_llm_file_handler(name, log_level))
  264. return logger
  265. llm_prompt_logger = _setup_llm_logger('prompt', current_log_level)
  266. llm_response_logger = _setup_llm_logger('response', current_log_level)