blob: d603588d913979ffd0274ebda5073a59fe0bcddc [file] [log] [blame]
maruelea586f32016-04-05 11:11:33 -07001# Copyright 2015 The LUCI Authors. All rights reserved.
maruelf1f5e2a2016-05-25 17:10:39 -07002# 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 Ruelf74cffe2015-07-15 15:21:34 -04004
5"""Utility relating to logging."""
6
Marc-Antoine Ruelf899c482019-10-10 23:32:06 +00007from __future__ import print_function
8
Marc-Antoine Ruelf74cffe2015-07-15 15:21:34 -04009import argparse
10import codecs
maruel625c1ea2015-09-09 11:41:13 -070011import ctypes
Marc-Antoine Ruelf74cffe2015-07-15 15:21:34 -040012import logging
13import logging.handlers
14import optparse
15import os
16import sys
17import tempfile
18import time
19
nodir9130f072016-05-27 13:59:08 -070020from utils import file_path
Marc-Antoine Ruelf74cffe2015-07-15 15:21:34 -040021
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
32if sys.platform == 'win32':
maruel625c1ea2015-09-09 11:41:13 -070033 import ctypes
Marc-Antoine Ruelf74cffe2015-07-15 15:21:34 -040034 import msvcrt # pylint: disable=F0401
Marc-Antoine Ruelf74cffe2015-07-15 15:21:34 -040035
Marc-Antoine Ruel0eb2eb22019-01-29 21:00:16 +000036 FILE_ATTRIBUTE_NORMAL = 0x80
maruel625c1ea2015-09-09 11:41:13 -070037 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 Ruelf74cffe2015-07-15 15:21:34 -040044 # TODO(maruel): Make it work in cygwin too if necessary. This would have to
Momo Sasaki049125c2021-02-26 06:14:41 +000045 # use ctypes.cdll.kernel32 instead of msvcrt.
Marc-Antoine Ruelf74cffe2015-07-15 15:21:34 -040046
maruel625c1ea2015-09-09 11:41:13 -070047
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 """
maruel625c1ea2015-09-09 11:41:13 -070055 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 Ruelf74cffe2015-07-15 15:21:34 -040065
66
67 class NoInheritRotatingFileHandler(logging.handlers.RotatingFileHandler):
68 def _open(self):
maruel625c1ea2015-09-09 11:41:13 -070069 """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 Ruelf74cffe2015-07-15 15:21:34 -040082
83
84else: # Not Windows.
85
86
87 NoInheritRotatingFileHandler = logging.handlers.RotatingFileHandler
88
89
90# Levels used for logging.
91LEVELS = [logging.ERROR, logging.INFO, logging.DEBUG]
92
93
Junji Watanabeab2102a2022-01-12 01:44:04 +000094class CaptureLogs:
Marc-Antoine Ruelf74cffe2015-07-15 15:21:34 -040095 """Captures all the logs in a context."""
96 def __init__(self, prefix, root=None):
97 handle, self._path = tempfile.mkstemp(prefix=prefix, suffix='.log')
98 os.close(handle)
99 self._handler = logging.FileHandler(self._path, 'w')
100 self._handler.setLevel(logging.DEBUG)
101 formatter = UTCFormatter(
102 '%(process)d %(asctime)s: %(levelname)-5s %(message)s')
103 self._handler.setFormatter(formatter)
104 self._root = root or logging.getLogger()
105 self._root.addHandler(self._handler)
106 assert self._root.isEnabledFor(logging.DEBUG)
107
108 def read(self):
109 """Returns the current content of the logs.
110
111 This also closes the log capture so future logs will not be captured.
112 """
113 self._disconnect()
114 assert self._path
115 try:
116 with open(self._path, 'rb') as f:
117 return f.read()
118 except IOError as e:
119 return 'Failed to read %s: %s' % (self._path, e)
120
121 def close(self):
122 """Closes and delete the log."""
123 self._disconnect()
124 if self._path:
125 try:
126 os.remove(self._path)
127 except OSError as e:
128 logging.error('Failed to delete log file %s: %s', self._path, e)
129 self._path = None
130
131 def __enter__(self):
132 return self
133
134 def __exit__(self, _exc_type, _exc_value, _traceback):
135 self.close()
136
137 def _disconnect(self):
138 if self._handler:
139 self._root.removeHandler(self._handler)
140 self._handler.close()
141 self._handler = None
142
143
144class UTCFormatter(logging.Formatter):
145 converter = time.gmtime
146
147 def formatTime(self, record, datefmt=None):
148 """Change is ',' to '.'."""
149 ct = self.converter(record.created)
150 if datefmt:
151 return time.strftime(datefmt, ct)
Junji Watanabeab2102a2022-01-12 01:44:04 +0000152 t = time.strftime("%Y-%m-%d %H:%M:%S", ct)
153 return "%s.%03d" % (t, record.msecs)
Marc-Antoine Ruelf74cffe2015-07-15 15:21:34 -0400154
155
Junji Watanabeab2102a2022-01-12 01:44:04 +0000156class Filter:
Marc-Antoine Ruelf74cffe2015-07-15 15:21:34 -0400157 """Adds fields used by the infra-specific formatter.
158
159 Fields added:
160 - 'severity': one-letter indicator of log level (first letter of levelname).
161 """
162
163 def filter(self, record):
164 record.severity = record.levelname[0]
165 return True
166
167
168def find_stderr(root=None):
169 """Returns the logging.handler streaming to stderr, if any."""
170 for log in (root or logging.getLogger()).handlers:
171 if getattr(log, 'stream', None) is sys.stderr:
172 return log
173
174
175def prepare_logging(filename, root=None):
176 """Prepare logging for scripts.
177
178 Makes it log in UTC all the time. Prepare a rotating file based log.
179 """
180 assert not find_stderr(root)
Takuto Ikuta5decd1d2020-04-14 06:02:29 +0000181 formatter = UTCFormatter('%(process)d %(asctime)s %(severity)s: %(message)s')
Marc-Antoine Ruelf74cffe2015-07-15 15:21:34 -0400182
183 # It is a requirement that the root logger is set to DEBUG, so the messages
184 # are not lost. It defaults to WARNING otherwise.
185 logger = root or logging.getLogger()
maruel26cfc602015-09-04 19:12:55 -0700186 if not logger:
187 # Better print insanity than crash.
Marc-Antoine Ruelf899c482019-10-10 23:32:06 +0000188 print('OMG NO ROOT', file=sys.stderr)
maruel26cfc602015-09-04 19:12:55 -0700189 return
Marc-Antoine Ruelf74cffe2015-07-15 15:21:34 -0400190 logger.setLevel(logging.DEBUG)
191
192 stderr = logging.StreamHandler()
193 stderr.setFormatter(formatter)
194 stderr.addFilter(Filter())
195 # Default to ERROR.
196 stderr.setLevel(logging.ERROR)
197 logger.addHandler(stderr)
198
199 # Setup up logging to a constant file so we can debug issues where
200 # the results aren't properly sent to the result URL.
201 if filename:
Junji Watanabee1ab20f2022-01-13 03:51:15 +0000202 file_path.ensure_tree(os.path.dirname(os.path.abspath(filename)))
Marc-Antoine Ruelf74cffe2015-07-15 15:21:34 -0400203 try:
204 rotating_file = NoInheritRotatingFileHandler(
205 filename, maxBytes=10 * 1024 * 1024, backupCount=5,
206 encoding='utf-8')
207 rotating_file.setLevel(logging.DEBUG)
208 rotating_file.setFormatter(formatter)
209 rotating_file.addFilter(Filter())
210 logger.addHandler(rotating_file)
211 except Exception:
212 # May happen on cygwin. Do not crash.
213 logging.exception('Failed to open %s', filename)
214
215
216def set_console_level(level, root=None):
217 """Reset the console (stderr) logging level."""
218 handler = find_stderr(root)
maruel26cfc602015-09-04 19:12:55 -0700219 if not handler:
220 # Better print insanity than crash.
Marc-Antoine Ruelf899c482019-10-10 23:32:06 +0000221 print('OMG NO STDERR', file=sys.stderr)
maruel26cfc602015-09-04 19:12:55 -0700222 return
Marc-Antoine Ruelf74cffe2015-07-15 15:21:34 -0400223 handler.setLevel(level)
224
225
226class OptionParserWithLogging(optparse.OptionParser):
227 """Adds --verbose option."""
228
229 # Set to True to enable --log-file options.
230 enable_log_file = True
231
232 # Set in unit tests.
233 logger_root = None
234
235 def __init__(self, verbose=0, log_file=None, **kwargs):
236 kwargs.setdefault('description', sys.modules['__main__'].__doc__)
237 optparse.OptionParser.__init__(self, **kwargs)
238 self.group_logging = optparse.OptionGroup(self, 'Logging')
239 self.group_logging.add_option(
240 '-v', '--verbose',
241 action='count',
242 default=verbose,
243 help='Use multiple times to increase verbosity')
244 if self.enable_log_file:
245 self.group_logging.add_option(
246 '-l', '--log-file',
247 default=log_file,
248 help='The name of the file to store rotating log details')
249 self.group_logging.add_option(
250 '--no-log', action='store_const', const='', dest='log_file',
251 help='Disable log file')
252
253 def parse_args(self, *args, **kwargs):
254 # Make sure this group is always the last one.
255 self.add_option_group(self.group_logging)
256
257 options, args = optparse.OptionParser.parse_args(self, *args, **kwargs)
258 prepare_logging(self.enable_log_file and options.log_file, self.logger_root)
259 set_console_level(
260 LEVELS[min(len(LEVELS) - 1, options.verbose)], self.logger_root)
261 return options, args
262
263
264class ArgumentParserWithLogging(argparse.ArgumentParser):
265 """Adds --verbose option."""
266
267 # Set to True to enable --log-file options.
268 enable_log_file = True
269
270 def __init__(self, verbose=0, log_file=None, **kwargs):
271 kwargs.setdefault('description', sys.modules['__main__'].__doc__)
272 kwargs.setdefault('conflict_handler', 'resolve')
273 self.__verbose = verbose
274 self.__log_file = log_file
275 super(ArgumentParserWithLogging, self).__init__(**kwargs)
276
277 def _add_logging_group(self):
278 group = self.add_argument_group('Logging')
279 group.add_argument(
280 '-v', '--verbose',
281 action='count',
282 default=self.__verbose,
283 help='Use multiple times to increase verbosity')
284 if self.enable_log_file:
285 group.add_argument(
286 '-l', '--log-file',
287 default=self.__log_file,
288 help='The name of the file to store rotating log details')
289 group.add_argument(
290 '--no-log', action='store_const', const='', dest='log_file',
291 help='Disable log file')
292
293 def parse_args(self, *args, **kwargs):
294 # Make sure this group is always the last one.
295 self._add_logging_group()
296
297 args = super(ArgumentParserWithLogging, self).parse_args(*args, **kwargs)
298 prepare_logging(self.enable_log_file and args.log_file, self.logger_root)
299 set_console_level(
300 LEVELS[min(len(LEVELS) - 1, args.verbose)], self.logger_root)
301 return args