maruel | ea586f3 | 2016-04-05 11:11:33 -0700 | [diff] [blame] | 1 | # Copyright 2015 The LUCI Authors. All rights reserved. |
maruel | f1f5e2a | 2016-05-25 17:10:39 -0700 | [diff] [blame] | 2 | # Use of this source code is governed under the Apache License, Version 2.0 |
| 3 | # that can be found in the LICENSE file. |
Marc-Antoine Ruel | f74cffe | 2015-07-15 15:21:34 -0400 | [diff] [blame] | 4 | |
| 5 | """Utility relating to logging.""" |
| 6 | |
Marc-Antoine Ruel | f899c48 | 2019-10-10 23:32:06 +0000 | [diff] [blame] | 7 | from __future__ import print_function |
| 8 | |
Marc-Antoine Ruel | f74cffe | 2015-07-15 15:21:34 -0400 | [diff] [blame] | 9 | import argparse |
| 10 | import codecs |
maruel | 625c1ea | 2015-09-09 11:41:13 -0700 | [diff] [blame] | 11 | import ctypes |
Marc-Antoine Ruel | f74cffe | 2015-07-15 15:21:34 -0400 | [diff] [blame] | 12 | import logging |
| 13 | import logging.handlers |
| 14 | import optparse |
| 15 | import os |
| 16 | import sys |
| 17 | import tempfile |
| 18 | import time |
| 19 | |
nodir | 9130f07 | 2016-05-27 13:59:08 -0700 | [diff] [blame] | 20 | from utils import file_path |
Marc-Antoine Ruel | f74cffe | 2015-07-15 15:21:34 -0400 | [diff] [blame] | 21 | |
| 22 | # This works around file locking issue on Windows specifically in the case of |
| 23 | # long lived child processes. |
| 24 | # |
| 25 | # Python opens files with inheritable handle and without file sharing by |
| 26 | # default. This causes the RotatingFileHandler file handle to be duplicated in |
| 27 | # the subprocesses even if the log file is not used in it. Because of this |
| 28 | # handle in the child process, when the RotatingFileHandler tries to os.rename() |
| 29 | # the file in the parent process, it fails with: |
| 30 | # WindowsError: [Error 32] The process cannot access the file because |
| 31 | # it is being used by another process |
| 32 | if sys.platform == 'win32': |
maruel | 625c1ea | 2015-09-09 11:41:13 -0700 | [diff] [blame] | 33 | import ctypes |
Marc-Antoine Ruel | f74cffe | 2015-07-15 15:21:34 -0400 | [diff] [blame] | 34 | import msvcrt # pylint: disable=F0401 |
Marc-Antoine Ruel | f74cffe | 2015-07-15 15:21:34 -0400 | [diff] [blame] | 35 | |
Marc-Antoine Ruel | 0eb2eb2 | 2019-01-29 21:00:16 +0000 | [diff] [blame] | 36 | FILE_ATTRIBUTE_NORMAL = 0x80 |
maruel | 625c1ea | 2015-09-09 11:41:13 -0700 | [diff] [blame] | 37 | FILE_SHARE_READ = 1 |
| 38 | FILE_SHARE_WRITE = 2 |
| 39 | FILE_SHARE_DELETE = 4 |
| 40 | GENERIC_READ = 0x80000000 |
| 41 | GENERIC_WRITE = 0x40000000 |
| 42 | OPEN_ALWAYS = 4 |
| 43 | |
Marc-Antoine Ruel | f74cffe | 2015-07-15 15:21:34 -0400 | [diff] [blame] | 44 | # TODO(maruel): Make it work in cygwin too if necessary. This would have to |
Momo Sasaki | 049125c | 2021-02-26 06:14:41 +0000 | [diff] [blame] | 45 | # use ctypes.cdll.kernel32 instead of msvcrt. |
Marc-Antoine Ruel | f74cffe | 2015-07-15 15:21:34 -0400 | [diff] [blame] | 46 | |
maruel | 625c1ea | 2015-09-09 11:41:13 -0700 | [diff] [blame] | 47 | |
| 48 | def shared_open(path): |
| 49 | """Opens a file with full sharing mode and without inheritance. |
| 50 | |
| 51 | The file is open for both read and write. |
| 52 | |
| 53 | See https://bugs.python.org/issue15244 for inspiration. |
| 54 | """ |
maruel | 625c1ea | 2015-09-09 11:41:13 -0700 | [diff] [blame] | 55 | handle = ctypes.windll.kernel32.CreateFileW( |
| 56 | path, |
| 57 | GENERIC_READ|GENERIC_WRITE, |
| 58 | FILE_SHARE_READ|FILE_SHARE_WRITE|FILE_SHARE_DELETE, |
| 59 | None, |
| 60 | OPEN_ALWAYS, |
| 61 | FILE_ATTRIBUTE_NORMAL, |
| 62 | None) |
| 63 | ctr_handle = msvcrt.open_osfhandle(handle, os.O_BINARY | os.O_NOINHERIT) |
| 64 | return os.fdopen(ctr_handle, 'r+b') |
Marc-Antoine Ruel | f74cffe | 2015-07-15 15:21:34 -0400 | [diff] [blame] | 65 | |
| 66 | |
| 67 | class NoInheritRotatingFileHandler(logging.handlers.RotatingFileHandler): |
| 68 | def _open(self): |
maruel | 625c1ea | 2015-09-09 11:41:13 -0700 | [diff] [blame] | 69 | """Opens the log file without handle inheritance but with file sharing. |
| 70 | |
| 71 | Ignores self.mode. |
| 72 | """ |
| 73 | f = shared_open(self.baseFilename) |
| 74 | if self.encoding: |
| 75 | # Do the equivalent of |
| 76 | # codecs.open(self.baseFilename, self.mode, self.encoding) |
| 77 | info = codecs.lookup(self.encoding) |
| 78 | f = codecs.StreamReaderWriter( |
| 79 | f, info.streamreader, info.streamwriter, 'replace') |
| 80 | f.encoding = self.encoding |
| 81 | return f |
Marc-Antoine Ruel | f74cffe | 2015-07-15 15:21:34 -0400 | [diff] [blame] | 82 | |
| 83 | |
| 84 | else: # Not Windows. |
| 85 | |
| 86 | |
| 87 | NoInheritRotatingFileHandler = logging.handlers.RotatingFileHandler |
| 88 | |
Jonah Hooper | 9b5bd8c | 2022-07-21 15:33:41 +0000 | [diff] [blame] | 89 | # INFO has a priority of 20 |
| 90 | USER_LOGS = logging.INFO + 5 |
Marc-Antoine Ruel | f74cffe | 2015-07-15 15:21:34 -0400 | [diff] [blame] | 91 | |
| 92 | # Levels used for logging. |
| 93 | LEVELS = [logging.ERROR, logging.INFO, logging.DEBUG] |
| 94 | |
| 95 | |
Junji Watanabe | ab2102a | 2022-01-12 01:44:04 +0000 | [diff] [blame] | 96 | class CaptureLogs: |
Marc-Antoine Ruel | f74cffe | 2015-07-15 15:21:34 -0400 | [diff] [blame] | 97 | """Captures all the logs in a context.""" |
| 98 | def __init__(self, prefix, root=None): |
| 99 | handle, self._path = tempfile.mkstemp(prefix=prefix, suffix='.log') |
| 100 | os.close(handle) |
| 101 | self._handler = logging.FileHandler(self._path, 'w') |
| 102 | self._handler.setLevel(logging.DEBUG) |
| 103 | formatter = UTCFormatter( |
| 104 | '%(process)d %(asctime)s: %(levelname)-5s %(message)s') |
| 105 | self._handler.setFormatter(formatter) |
| 106 | self._root = root or logging.getLogger() |
| 107 | self._root.addHandler(self._handler) |
| 108 | assert self._root.isEnabledFor(logging.DEBUG) |
| 109 | |
| 110 | def read(self): |
| 111 | """Returns the current content of the logs. |
| 112 | |
| 113 | This also closes the log capture so future logs will not be captured. |
| 114 | """ |
| 115 | self._disconnect() |
| 116 | assert self._path |
| 117 | try: |
| 118 | with open(self._path, 'rb') as f: |
| 119 | return f.read() |
| 120 | except IOError as e: |
| 121 | return 'Failed to read %s: %s' % (self._path, e) |
| 122 | |
| 123 | def close(self): |
| 124 | """Closes and delete the log.""" |
| 125 | self._disconnect() |
| 126 | if self._path: |
| 127 | try: |
| 128 | os.remove(self._path) |
| 129 | except OSError as e: |
| 130 | logging.error('Failed to delete log file %s: %s', self._path, e) |
| 131 | self._path = None |
| 132 | |
| 133 | def __enter__(self): |
| 134 | return self |
| 135 | |
| 136 | def __exit__(self, _exc_type, _exc_value, _traceback): |
| 137 | self.close() |
| 138 | |
| 139 | def _disconnect(self): |
| 140 | if self._handler: |
| 141 | self._root.removeHandler(self._handler) |
| 142 | self._handler.close() |
| 143 | self._handler = None |
| 144 | |
| 145 | |
| 146 | class UTCFormatter(logging.Formatter): |
| 147 | converter = time.gmtime |
| 148 | |
| 149 | def formatTime(self, record, datefmt=None): |
| 150 | """Change is ',' to '.'.""" |
| 151 | ct = self.converter(record.created) |
| 152 | if datefmt: |
| 153 | return time.strftime(datefmt, ct) |
Junji Watanabe | ab2102a | 2022-01-12 01:44:04 +0000 | [diff] [blame] | 154 | t = time.strftime("%Y-%m-%d %H:%M:%S", ct) |
| 155 | return "%s.%03d" % (t, record.msecs) |
Marc-Antoine Ruel | f74cffe | 2015-07-15 15:21:34 -0400 | [diff] [blame] | 156 | |
| 157 | |
Jonah Hooper | 9b5bd8c | 2022-07-21 15:33:41 +0000 | [diff] [blame] | 158 | class SeverityFilter(logging.Filter): |
Marc-Antoine Ruel | f74cffe | 2015-07-15 15:21:34 -0400 | [diff] [blame] | 159 | """Adds fields used by the infra-specific formatter. |
| 160 | |
| 161 | Fields added: |
| 162 | - 'severity': one-letter indicator of log level (first letter of levelname). |
| 163 | """ |
| 164 | |
| 165 | def filter(self, record): |
| 166 | record.severity = record.levelname[0] |
| 167 | return True |
| 168 | |
| 169 | |
| 170 | def find_stderr(root=None): |
| 171 | """Returns the logging.handler streaming to stderr, if any.""" |
| 172 | for log in (root or logging.getLogger()).handlers: |
| 173 | if getattr(log, 'stream', None) is sys.stderr: |
| 174 | return log |
| 175 | |
| 176 | |
Justin Luong | 716b4b0 | 2023-02-08 02:47:52 +0000 | [diff] [blame^] | 177 | def new_rotating_file_handler( |
| 178 | filename, |
| 179 | utc_formatter=UTCFormatter( |
| 180 | '%(process)d %(asctime)s %(severity)s: %(message)s')): |
| 181 | file_path.ensure_tree(os.path.dirname(os.path.abspath(filename))) |
| 182 | rotating_file = NoInheritRotatingFileHandler(filename, |
| 183 | maxBytes=10 * 1024 * 1024, |
| 184 | backupCount=5, |
| 185 | encoding='utf-8') |
| 186 | rotating_file.setLevel(logging.DEBUG) |
| 187 | rotating_file.setFormatter(utc_formatter) |
| 188 | rotating_file.addFilter(SeverityFilter()) |
| 189 | return rotating_file |
| 190 | |
Jonah Hooper | 9b5bd8c | 2022-07-21 15:33:41 +0000 | [diff] [blame] | 191 | |
Marc-Antoine Ruel | f74cffe | 2015-07-15 15:21:34 -0400 | [diff] [blame] | 192 | def prepare_logging(filename, root=None): |
| 193 | """Prepare logging for scripts. |
| 194 | |
| 195 | Makes it log in UTC all the time. Prepare a rotating file based log. |
| 196 | """ |
| 197 | assert not find_stderr(root) |
Marc-Antoine Ruel | f74cffe | 2015-07-15 15:21:34 -0400 | [diff] [blame] | 198 | |
| 199 | # It is a requirement that the root logger is set to DEBUG, so the messages |
| 200 | # are not lost. It defaults to WARNING otherwise. |
| 201 | logger = root or logging.getLogger() |
maruel | 26cfc60 | 2015-09-04 19:12:55 -0700 | [diff] [blame] | 202 | if not logger: |
| 203 | # Better print insanity than crash. |
Marc-Antoine Ruel | f899c48 | 2019-10-10 23:32:06 +0000 | [diff] [blame] | 204 | print('OMG NO ROOT', file=sys.stderr) |
maruel | 26cfc60 | 2015-09-04 19:12:55 -0700 | [diff] [blame] | 205 | return |
Marc-Antoine Ruel | f74cffe | 2015-07-15 15:21:34 -0400 | [diff] [blame] | 206 | logger.setLevel(logging.DEBUG) |
| 207 | |
Jonah Hooper | 9b5bd8c | 2022-07-21 15:33:41 +0000 | [diff] [blame] | 208 | utc_formatter = UTCFormatter( |
| 209 | '%(process)d %(asctime)s %(severity)s: %(message)s') |
| 210 | |
Marc-Antoine Ruel | f74cffe | 2015-07-15 15:21:34 -0400 | [diff] [blame] | 211 | stderr = logging.StreamHandler() |
Jonah Hooper | 9b5bd8c | 2022-07-21 15:33:41 +0000 | [diff] [blame] | 212 | stderr.setFormatter(utc_formatter) |
| 213 | stderr.addFilter(SeverityFilter()) |
Marc-Antoine Ruel | f74cffe | 2015-07-15 15:21:34 -0400 | [diff] [blame] | 214 | # Default to ERROR. |
| 215 | stderr.setLevel(logging.ERROR) |
| 216 | logger.addHandler(stderr) |
| 217 | |
| 218 | # Setup up logging to a constant file so we can debug issues where |
| 219 | # the results aren't properly sent to the result URL. |
| 220 | if filename: |
| 221 | try: |
Justin Luong | 716b4b0 | 2023-02-08 02:47:52 +0000 | [diff] [blame^] | 222 | logger.addHandler(new_rotating_file_handler(filename, utc_formatter)) |
Marc-Antoine Ruel | f74cffe | 2015-07-15 15:21:34 -0400 | [diff] [blame] | 223 | except Exception: |
| 224 | # May happen on cygwin. Do not crash. |
| 225 | logging.exception('Failed to open %s', filename) |
| 226 | |
| 227 | |
| 228 | def set_console_level(level, root=None): |
| 229 | """Reset the console (stderr) logging level.""" |
| 230 | handler = find_stderr(root) |
maruel | 26cfc60 | 2015-09-04 19:12:55 -0700 | [diff] [blame] | 231 | if not handler: |
| 232 | # Better print insanity than crash. |
Marc-Antoine Ruel | f899c48 | 2019-10-10 23:32:06 +0000 | [diff] [blame] | 233 | print('OMG NO STDERR', file=sys.stderr) |
maruel | 26cfc60 | 2015-09-04 19:12:55 -0700 | [diff] [blame] | 234 | return |
Marc-Antoine Ruel | f74cffe | 2015-07-15 15:21:34 -0400 | [diff] [blame] | 235 | handler.setLevel(level) |
| 236 | |
| 237 | |
Jonah Hooper | 9b5bd8c | 2022-07-21 15:33:41 +0000 | [diff] [blame] | 238 | class ExactLevelFilter(logging.Filter): |
| 239 | """ |
| 240 | A log filter which filters out log messages |
| 241 | which are not an exact level. |
| 242 | """ |
| 243 | |
| 244 | def __init__(self, level): |
| 245 | super(ExactLevelFilter, self).__init__() |
| 246 | self._level = level |
| 247 | |
| 248 | def filter(self, record): |
| 249 | return record.levelno == self._level |
| 250 | |
| 251 | |
| 252 | def set_user_level_logging(logger=None): |
| 253 | """ |
| 254 | Creates a logger which writes logger.level = USER_LOGS to stdout. |
| 255 | Some scripts (run_isolated.py) have all their output displayed to |
| 256 | users in the swarming UI. As a result we may want to display some |
| 257 | logging as more important than others. These are not errors so should |
| 258 | not be logged to stderr. |
| 259 | |
| 260 | USER_LOGS are seen as more important than INFO logs but less |
| 261 | important than error logs. |
| 262 | |
| 263 | That may lead to double logging if say another stream is already |
| 264 | configured to listen to logging for stdout. |
| 265 | """ |
| 266 | if logger is None: |
| 267 | logger = logging.getLogger() |
| 268 | |
| 269 | logging.addLevelName(USER_LOGS, "USER_LOGS") |
| 270 | user_logs_formatter = UTCFormatter( |
| 271 | "swarming_bot_logs: %(asctime)s: %(message)s") |
| 272 | |
| 273 | stdout_handler = logging.StreamHandler(stream=sys.stdout) |
| 274 | stdout_handler.setLevel(USER_LOGS) |
| 275 | stdout_handler.addFilter(ExactLevelFilter(USER_LOGS)) |
| 276 | stdout_handler.addFilter(SeverityFilter()) |
| 277 | stdout_handler.setFormatter(user_logs_formatter) |
| 278 | logger.addHandler(stdout_handler) |
| 279 | |
| 280 | |
| 281 | def user_logs(message, *args, **kwargs): |
| 282 | logging.log(USER_LOGS, message, *args, **kwargs) |
| 283 | |
| 284 | |
Marc-Antoine Ruel | f74cffe | 2015-07-15 15:21:34 -0400 | [diff] [blame] | 285 | class OptionParserWithLogging(optparse.OptionParser): |
| 286 | """Adds --verbose option.""" |
| 287 | |
| 288 | # Set to True to enable --log-file options. |
| 289 | enable_log_file = True |
| 290 | |
| 291 | # Set in unit tests. |
| 292 | logger_root = None |
| 293 | |
| 294 | def __init__(self, verbose=0, log_file=None, **kwargs): |
| 295 | kwargs.setdefault('description', sys.modules['__main__'].__doc__) |
| 296 | optparse.OptionParser.__init__(self, **kwargs) |
| 297 | self.group_logging = optparse.OptionGroup(self, 'Logging') |
| 298 | self.group_logging.add_option( |
| 299 | '-v', '--verbose', |
| 300 | action='count', |
| 301 | default=verbose, |
| 302 | help='Use multiple times to increase verbosity') |
| 303 | if self.enable_log_file: |
| 304 | self.group_logging.add_option( |
| 305 | '-l', '--log-file', |
| 306 | default=log_file, |
| 307 | help='The name of the file to store rotating log details') |
| 308 | self.group_logging.add_option( |
| 309 | '--no-log', action='store_const', const='', dest='log_file', |
| 310 | help='Disable log file') |
| 311 | |
| 312 | def parse_args(self, *args, **kwargs): |
| 313 | # Make sure this group is always the last one. |
| 314 | self.add_option_group(self.group_logging) |
| 315 | |
| 316 | options, args = optparse.OptionParser.parse_args(self, *args, **kwargs) |
| 317 | prepare_logging(self.enable_log_file and options.log_file, self.logger_root) |
| 318 | set_console_level( |
| 319 | LEVELS[min(len(LEVELS) - 1, options.verbose)], self.logger_root) |
Jonah Hooper | 9b5bd8c | 2022-07-21 15:33:41 +0000 | [diff] [blame] | 320 | |
Marc-Antoine Ruel | f74cffe | 2015-07-15 15:21:34 -0400 | [diff] [blame] | 321 | return options, args |
| 322 | |
| 323 | |
| 324 | class ArgumentParserWithLogging(argparse.ArgumentParser): |
| 325 | """Adds --verbose option.""" |
| 326 | |
| 327 | # Set to True to enable --log-file options. |
| 328 | enable_log_file = True |
| 329 | |
| 330 | def __init__(self, verbose=0, log_file=None, **kwargs): |
| 331 | kwargs.setdefault('description', sys.modules['__main__'].__doc__) |
| 332 | kwargs.setdefault('conflict_handler', 'resolve') |
| 333 | self.__verbose = verbose |
| 334 | self.__log_file = log_file |
| 335 | super(ArgumentParserWithLogging, self).__init__(**kwargs) |
| 336 | |
| 337 | def _add_logging_group(self): |
| 338 | group = self.add_argument_group('Logging') |
| 339 | group.add_argument( |
| 340 | '-v', '--verbose', |
| 341 | action='count', |
| 342 | default=self.__verbose, |
| 343 | help='Use multiple times to increase verbosity') |
| 344 | if self.enable_log_file: |
| 345 | group.add_argument( |
| 346 | '-l', '--log-file', |
| 347 | default=self.__log_file, |
| 348 | help='The name of the file to store rotating log details') |
| 349 | group.add_argument( |
| 350 | '--no-log', action='store_const', const='', dest='log_file', |
| 351 | help='Disable log file') |
| 352 | |
| 353 | def parse_args(self, *args, **kwargs): |
| 354 | # Make sure this group is always the last one. |
| 355 | self._add_logging_group() |
| 356 | |
| 357 | args = super(ArgumentParserWithLogging, self).parse_args(*args, **kwargs) |
| 358 | prepare_logging(self.enable_log_file and args.log_file, self.logger_root) |
| 359 | set_console_level( |
| 360 | LEVELS[min(len(LEVELS) - 1, args.verbose)], self.logger_root) |
Jonah Hooper | 9b5bd8c | 2022-07-21 15:33:41 +0000 | [diff] [blame] | 361 | |
Marc-Antoine Ruel | f74cffe | 2015-07-15 15:21:34 -0400 | [diff] [blame] | 362 | return args |