logger.py 11 KB

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