blob: 879e03c8a7a4e1fbe9037602d61ed1a247d31ca1 [file] [log] [blame] [edit]
# Copyright 2015 The LUCI Authors. All rights reserved.
# Use of this source code is governed under the Apache License, Version 2.0
# that can be found in the LICENSE file.
"""Utility relating to logging."""
from __future__ import print_function
import argparse
import codecs
import ctypes
import logging
import logging.handlers
import optparse
import os
import sys
import tempfile
import time
from utils import file_path
# This works around file locking issue on Windows specifically in the case of
# long lived child processes.
#
# Python opens files with inheritable handle and without file sharing by
# default. This causes the RotatingFileHandler file handle to be duplicated in
# the subprocesses even if the log file is not used in it. Because of this
# handle in the child process, when the RotatingFileHandler tries to os.rename()
# the file in the parent process, it fails with:
# WindowsError: [Error 32] The process cannot access the file because
# it is being used by another process
if sys.platform == 'win32':
import ctypes
import msvcrt # pylint: disable=F0401
FILE_ATTRIBUTE_NORMAL = 0x80
FILE_SHARE_READ = 1
FILE_SHARE_WRITE = 2
FILE_SHARE_DELETE = 4
GENERIC_READ = 0x80000000
GENERIC_WRITE = 0x40000000
OPEN_ALWAYS = 4
# TODO(maruel): Make it work in cygwin too if necessary. This would have to
# use ctypes.cdll.kernel32 instead of msvcrt.
def shared_open(path):
"""Opens a file with full sharing mode and without inheritance.
The file is open for both read and write.
See https://bugs.python.org/issue15244 for inspiration.
"""
handle = ctypes.windll.kernel32.CreateFileW(
path,
GENERIC_READ|GENERIC_WRITE,
FILE_SHARE_READ|FILE_SHARE_WRITE|FILE_SHARE_DELETE,
None,
OPEN_ALWAYS,
FILE_ATTRIBUTE_NORMAL,
None)
ctr_handle = msvcrt.open_osfhandle(handle, os.O_BINARY | os.O_NOINHERIT)
return os.fdopen(ctr_handle, 'r+b')
class NoInheritRotatingFileHandler(logging.handlers.RotatingFileHandler):
def _open(self):
"""Opens the log file without handle inheritance but with file sharing.
Ignores self.mode.
"""
f = shared_open(self.baseFilename)
if self.encoding:
# Do the equivalent of
# codecs.open(self.baseFilename, self.mode, self.encoding)
info = codecs.lookup(self.encoding)
f = codecs.StreamReaderWriter(
f, info.streamreader, info.streamwriter, 'replace')
f.encoding = self.encoding
return f
else: # Not Windows.
NoInheritRotatingFileHandler = logging.handlers.RotatingFileHandler
# INFO has a priority of 20
USER_LOGS = logging.INFO + 5
# Levels used for logging.
LEVELS = [logging.ERROR, logging.INFO, logging.DEBUG]
class CaptureLogs:
"""Captures all the logs in a context."""
def __init__(self, prefix, root=None):
handle, self._path = tempfile.mkstemp(prefix=prefix, suffix='.log')
os.close(handle)
self._handler = logging.FileHandler(self._path, 'w')
self._handler.setLevel(logging.DEBUG)
formatter = UTCFormatter(
'%(process)d %(asctime)s: %(levelname)-5s %(message)s')
self._handler.setFormatter(formatter)
self._root = root or logging.getLogger()
self._root.addHandler(self._handler)
assert self._root.isEnabledFor(logging.DEBUG)
def read(self):
"""Returns the current content of the logs.
This also closes the log capture so future logs will not be captured.
"""
self._disconnect()
assert self._path
try:
with open(self._path, 'rb') as f:
return f.read()
except IOError as e:
return 'Failed to read %s: %s' % (self._path, e)
def close(self):
"""Closes and delete the log."""
self._disconnect()
if self._path:
try:
os.remove(self._path)
except OSError as e:
logging.error('Failed to delete log file %s: %s', self._path, e)
self._path = None
def __enter__(self):
return self
def __exit__(self, _exc_type, _exc_value, _traceback):
self.close()
def _disconnect(self):
if self._handler:
self._root.removeHandler(self._handler)
self._handler.close()
self._handler = None
class UTCFormatter(logging.Formatter):
converter = time.gmtime
def formatTime(self, record, datefmt=None):
"""Change is ',' to '.'."""
ct = self.converter(record.created)
if datefmt:
return time.strftime(datefmt, ct)
t = time.strftime("%Y-%m-%d %H:%M:%S", ct)
return "%s.%03d" % (t, record.msecs)
class SeverityFilter(logging.Filter):
"""Adds fields used by the infra-specific formatter.
Fields added:
- 'severity': one-letter indicator of log level (first letter of levelname).
"""
def filter(self, record):
record.severity = record.levelname[0]
return True
def find_stderr(root=None):
"""Returns the logging.handler streaming to stderr, if any."""
for log in (root or logging.getLogger()).handlers:
if getattr(log, 'stream', None) is sys.stderr:
return log
def new_rotating_file_handler(
filename,
utc_formatter=UTCFormatter(
'%(process)d %(asctime)s %(severity)s: %(message)s')):
file_path.ensure_tree(os.path.dirname(os.path.abspath(filename)))
rotating_file = NoInheritRotatingFileHandler(filename,
maxBytes=10 * 1024 * 1024,
backupCount=5,
encoding='utf-8')
rotating_file.setLevel(logging.DEBUG)
rotating_file.setFormatter(utc_formatter)
rotating_file.addFilter(SeverityFilter())
return rotating_file
def prepare_logging(filename, root=None):
"""Prepare logging for scripts.
Makes it log in UTC all the time. Prepare a rotating file based log.
"""
assert not find_stderr(root)
# It is a requirement that the root logger is set to DEBUG, so the messages
# are not lost. It defaults to WARNING otherwise.
logger = root or logging.getLogger()
if not logger:
# Better print insanity than crash.
print('OMG NO ROOT', file=sys.stderr)
return
logger.setLevel(logging.DEBUG)
utc_formatter = UTCFormatter(
'%(process)d %(asctime)s %(severity)s: %(message)s')
stderr = logging.StreamHandler()
stderr.setFormatter(utc_formatter)
stderr.addFilter(SeverityFilter())
# Default to ERROR.
stderr.setLevel(logging.ERROR)
logger.addHandler(stderr)
# Setup up logging to a constant file so we can debug issues where
# the results aren't properly sent to the result URL.
if filename:
try:
logger.addHandler(new_rotating_file_handler(filename, utc_formatter))
except Exception:
# May happen on cygwin. Do not crash.
logging.exception('Failed to open %s', filename)
def set_console_level(level, root=None):
"""Reset the console (stderr) logging level."""
handler = find_stderr(root)
if not handler:
# Better print insanity than crash.
print('OMG NO STDERR', file=sys.stderr)
return
handler.setLevel(level)
class ExactLevelFilter(logging.Filter):
"""
A log filter which filters out log messages
which are not an exact level.
"""
def __init__(self, level):
super(ExactLevelFilter, self).__init__()
self._level = level
def filter(self, record):
return record.levelno == self._level
def set_user_level_logging(logger=None):
"""
Creates a logger which writes logger.level = USER_LOGS to stdout.
Some scripts (run_isolated.py) have all their output displayed to
users in the swarming UI. As a result we may want to display some
logging as more important than others. These are not errors so should
not be logged to stderr.
USER_LOGS are seen as more important than INFO logs but less
important than error logs.
That may lead to double logging if say another stream is already
configured to listen to logging for stdout.
"""
if logger is None:
logger = logging.getLogger()
logging.addLevelName(USER_LOGS, "USER_LOGS")
user_logs_formatter = UTCFormatter(
"swarming_bot_logs: %(asctime)s: %(message)s")
stdout_handler = logging.StreamHandler(stream=sys.stdout)
stdout_handler.setLevel(USER_LOGS)
stdout_handler.addFilter(ExactLevelFilter(USER_LOGS))
stdout_handler.addFilter(SeverityFilter())
stdout_handler.setFormatter(user_logs_formatter)
logger.addHandler(stdout_handler)
def user_logs(message, *args, **kwargs):
logging.log(USER_LOGS, message, *args, **kwargs)
class OptionParserWithLogging(optparse.OptionParser):
"""Adds --verbose option."""
# Set to True to enable --log-file options.
enable_log_file = True
# Set in unit tests.
logger_root = None
def __init__(self, verbose=0, log_file=None, **kwargs):
kwargs.setdefault('description', sys.modules['__main__'].__doc__)
optparse.OptionParser.__init__(self, **kwargs)
self.group_logging = optparse.OptionGroup(self, 'Logging')
self.group_logging.add_option(
'-v', '--verbose',
action='count',
default=verbose,
help='Use multiple times to increase verbosity')
if self.enable_log_file:
self.group_logging.add_option(
'-l', '--log-file',
default=log_file,
help='The name of the file to store rotating log details')
self.group_logging.add_option(
'--no-log', action='store_const', const='', dest='log_file',
help='Disable log file')
def parse_args(self, *args, **kwargs):
# Make sure this group is always the last one.
self.add_option_group(self.group_logging)
options, args = optparse.OptionParser.parse_args(self, *args, **kwargs)
prepare_logging(self.enable_log_file and options.log_file, self.logger_root)
set_console_level(
LEVELS[min(len(LEVELS) - 1, options.verbose)], self.logger_root)
return options, args
class ArgumentParserWithLogging(argparse.ArgumentParser):
"""Adds --verbose option."""
# Set to True to enable --log-file options.
enable_log_file = True
def __init__(self, verbose=0, log_file=None, **kwargs):
kwargs.setdefault('description', sys.modules['__main__'].__doc__)
kwargs.setdefault('conflict_handler', 'resolve')
self.__verbose = verbose
self.__log_file = log_file
super(ArgumentParserWithLogging, self).__init__(**kwargs)
def _add_logging_group(self):
group = self.add_argument_group('Logging')
group.add_argument(
'-v', '--verbose',
action='count',
default=self.__verbose,
help='Use multiple times to increase verbosity')
if self.enable_log_file:
group.add_argument(
'-l', '--log-file',
default=self.__log_file,
help='The name of the file to store rotating log details')
group.add_argument(
'--no-log', action='store_const', const='', dest='log_file',
help='Disable log file')
def parse_args(self, *args, **kwargs):
# Make sure this group is always the last one.
self._add_logging_group()
args = super(ArgumentParserWithLogging, self).parse_args(*args, **kwargs)
prepare_logging(self.enable_log_file and args.log_file, self.logger_root)
set_console_level(
LEVELS[min(len(LEVELS) - 1, args.verbose)], self.logger_root)
return args