logger.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355
  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 types import TracebackType
  9. from typing import Any, Literal, Mapping
  10. from termcolor import colored
  11. LOG_LEVEL = os.getenv('LOG_LEVEL', 'INFO').upper()
  12. DEBUG = os.getenv('DEBUG', 'False').lower() in ['true', '1', 'yes']
  13. if DEBUG:
  14. LOG_LEVEL = 'DEBUG'
  15. LOG_TO_FILE = os.getenv('LOG_TO_FILE', 'False').lower() in ['true', '1', 'yes']
  16. DISABLE_COLOR_PRINTING = False
  17. LOG_ALL_EVENTS = os.getenv('LOG_ALL_EVENTS', 'False').lower() in ['true', '1', 'yes']
  18. ColorType = Literal[
  19. 'red',
  20. 'green',
  21. 'yellow',
  22. 'blue',
  23. 'magenta',
  24. 'cyan',
  25. 'light_grey',
  26. 'dark_grey',
  27. 'light_red',
  28. 'light_green',
  29. 'light_yellow',
  30. 'light_blue',
  31. 'light_magenta',
  32. 'light_cyan',
  33. 'white',
  34. ]
  35. LOG_COLORS: Mapping[str, ColorType] = {
  36. 'ACTION': 'green',
  37. 'USER_ACTION': 'light_red',
  38. 'OBSERVATION': 'yellow',
  39. 'USER_OBSERVATION': 'light_green',
  40. 'DETAIL': 'cyan',
  41. 'ERROR': 'red',
  42. 'PLAN': 'light_magenta',
  43. }
  44. class NoColorFormatter(logging.Formatter):
  45. """Formatter for non-colored logging in files."""
  46. def format(self, record: logging.LogRecord) -> str:
  47. # Create a deep copy of the record to avoid modifying the original
  48. new_record: logging.LogRecord = copy.deepcopy(record)
  49. # Strip ANSI color codes from the message
  50. new_record.msg = strip_ansi(new_record.msg)
  51. return super().format(new_record)
  52. def strip_ansi(s: str) -> str:
  53. """Remove ANSI escape sequences (terminal color/formatting codes) from string.
  54. Removes ANSI escape sequences from str, as defined by ECMA-048 in
  55. http://www.ecma-international.org/publications/files/ECMA-ST/Ecma-048.pdf
  56. # https://github.com/ewen-lbh/python-strip-ansi/blob/master/strip_ansi/__init__.py
  57. """
  58. pattern = re.compile(r'\x1B\[\d+(;\d+){0,2}m')
  59. stripped = pattern.sub('', s)
  60. return stripped
  61. class ColoredFormatter(logging.Formatter):
  62. def format(self, record):
  63. msg_type = record.__dict__.get('msg_type')
  64. event_source = record.__dict__.get('event_source')
  65. if event_source:
  66. new_msg_type = f'{event_source.upper()}_{msg_type}'
  67. if new_msg_type in LOG_COLORS:
  68. msg_type = new_msg_type
  69. if msg_type in LOG_COLORS and not DISABLE_COLOR_PRINTING:
  70. msg_type_color = colored(msg_type, LOG_COLORS[msg_type])
  71. msg = colored(record.msg, LOG_COLORS[msg_type])
  72. time_str = colored(
  73. self.formatTime(record, self.datefmt), LOG_COLORS[msg_type]
  74. )
  75. name_str = colored(record.name, LOG_COLORS[msg_type])
  76. level_str = colored(record.levelname, LOG_COLORS[msg_type])
  77. if msg_type in ['ERROR'] or DEBUG:
  78. return f'{time_str} - {name_str}:{level_str}: {record.filename}:{record.lineno}\n{msg_type_color}\n{msg}'
  79. return f'{time_str} - {msg_type_color}\n{msg}'
  80. elif msg_type == 'STEP':
  81. if LOG_ALL_EVENTS:
  82. msg = '\n\n==============\n' + record.msg + '\n'
  83. return f'{msg}'
  84. else:
  85. return record.msg
  86. return super().format(record)
  87. file_formatter = NoColorFormatter(
  88. '%(asctime)s - %(name)s:%(levelname)s: %(filename)s:%(lineno)s - %(message)s',
  89. datefmt='%H:%M:%S',
  90. )
  91. llm_formatter = logging.Formatter('%(message)s')
  92. class RollingLogger:
  93. max_lines: int
  94. char_limit: int
  95. log_lines: list[str]
  96. def __init__(self, max_lines=10, char_limit=80):
  97. self.max_lines = max_lines
  98. self.char_limit = char_limit
  99. self.log_lines = [''] * self.max_lines
  100. def is_enabled(self):
  101. return DEBUG and sys.stdout.isatty()
  102. def start(self, message=''):
  103. if message:
  104. print(message)
  105. self._write('\n' * self.max_lines)
  106. self._flush()
  107. def add_line(self, line):
  108. self.log_lines.pop(0)
  109. self.log_lines.append(line[: self.char_limit])
  110. self.print_lines()
  111. def write_immediately(self, line):
  112. self._write(line)
  113. self._flush()
  114. def print_lines(self):
  115. """Display the last n log_lines in the console (not for file logging).
  116. This will create the effect of a rolling display in the console.
  117. """
  118. self.move_back()
  119. for line in self.log_lines:
  120. self.replace_current_line(line)
  121. def move_back(self, amount=-1):
  122. r"""'\033[F' moves the cursor up one line."""
  123. if amount == -1:
  124. amount = self.max_lines
  125. self._write('\033[F' * (self.max_lines))
  126. self._flush()
  127. def replace_current_line(self, line=''):
  128. r"""'\033[2K\r' clears the line and moves the cursor to the beginning of the line."""
  129. self._write('\033[2K' + line + '\n')
  130. self._flush()
  131. def _write(self, line):
  132. if not self.is_enabled():
  133. return
  134. sys.stdout.write(line)
  135. def _flush(self):
  136. if not self.is_enabled():
  137. return
  138. sys.stdout.flush()
  139. class SensitiveDataFilter(logging.Filter):
  140. def filter(self, record):
  141. # start with attributes
  142. sensitive_patterns = [
  143. 'api_key',
  144. 'aws_access_key_id',
  145. 'aws_secret_access_key',
  146. 'e2b_api_key',
  147. 'github_token',
  148. 'jwt_secret',
  149. 'modal_api_token_id',
  150. 'modal_api_token_secret',
  151. ]
  152. # add env var names
  153. env_vars = [attr.upper() for attr in sensitive_patterns]
  154. sensitive_patterns.extend(env_vars)
  155. # and some special cases
  156. sensitive_patterns.append('JWT_SECRET')
  157. sensitive_patterns.append('LLM_API_KEY')
  158. sensitive_patterns.append('GITHUB_TOKEN')
  159. sensitive_patterns.append('SANDBOX_ENV_GITHUB_TOKEN')
  160. # this also formats the message with % args
  161. msg = record.getMessage()
  162. record.args = ()
  163. for attr in sensitive_patterns:
  164. pattern = rf"{attr}='?([\w-]+)'?"
  165. msg = re.sub(pattern, f"{attr}='******'", msg)
  166. # passed with msg
  167. record.msg = msg
  168. return True
  169. def get_console_handler(log_level: int = logging.INFO, extra_info: str | None = None):
  170. """Returns a console handler for logging."""
  171. console_handler = logging.StreamHandler()
  172. console_handler.setLevel(log_level)
  173. formatter_str = '\033[92m%(asctime)s - %(name)s:%(levelname)s\033[0m: %(filename)s:%(lineno)s - %(message)s'
  174. if extra_info:
  175. formatter_str = f'{extra_info} - ' + formatter_str
  176. console_handler.setFormatter(ColoredFormatter(formatter_str, datefmt='%H:%M:%S'))
  177. return console_handler
  178. def get_file_handler(log_dir: str, log_level: int = logging.INFO):
  179. """Returns a file handler for logging."""
  180. os.makedirs(log_dir, exist_ok=True)
  181. timestamp = datetime.now().strftime('%Y-%m-%d')
  182. file_name = f'openhands_{timestamp}.log'
  183. file_handler = logging.FileHandler(os.path.join(log_dir, file_name))
  184. file_handler.setLevel(log_level)
  185. file_handler.setFormatter(file_formatter)
  186. return file_handler
  187. # Set up logging
  188. logging.basicConfig(level=logging.ERROR)
  189. def log_uncaught_exceptions(
  190. ex_cls: type[BaseException], ex: BaseException, tb: TracebackType | None
  191. ) -> Any:
  192. """Logs uncaught exceptions along with the traceback.
  193. Args:
  194. ex_cls: The type of the exception.
  195. ex: The exception instance.
  196. tb: The traceback object.
  197. Returns:
  198. None
  199. """
  200. if tb: # Add check since tb can be None
  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)