logger.py 11 KB

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