log_monitor.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293
  1. """
  2. Copyright (c) Contributors to the Open 3D Engine Project.
  3. For complete copyright and license terms please see the LICENSE at the root of this distribution.
  4. SPDX-License-Identifier: Apache-2.0 OR MIT
  5. Functions to aid in monitoring log files being actively written to for a set of lines to read for.
  6. """
  7. import logging
  8. import os
  9. import re
  10. import ly_test_tools.environment.waiter as waiter
  11. import ly_test_tools.launchers.platforms.base
  12. logger = logging.getLogger(__name__)
  13. LOG_MONITOR_INTERVAL = 0.1 # seconds
  14. class LogMonitorException(Exception):
  15. """Custom exception class for errors related to log_monitor.py"""
  16. pass
  17. def check_exact_match(line, expected_line):
  18. """
  19. Uses regular expressions to find an exact (not partial) match for 'expected_line' in 'line', i.e.
  20. in the example below it matches 'foo' and succeeds:
  21. line value: '66118.999958 - INFO - [MainThread] - ly_test_tools.o3de.asset_processor - foo'
  22. expected_line: 'foo'
  23. :param line: The log line string to search,
  24. i.e. '9189.9998188 - INFO - [MainThread] - example.tests.test_system_example - Log Monitoring test 1'
  25. :param expected_line: The exact string to match when searching the line param,
  26. i.e. 'Log Monitoring test 1'
  27. :return: An exact match for the string if one is found, None otherwise.
  28. """
  29. # Look for either start of line or whitespace, then the expected_line, then either end of the line or whitespace.
  30. # This way we don't partial match inside of a string. So for example, 'foo' matches 'foo bar' but not 'foobar'
  31. regex_pattern = re.compile("(^|\\s){}($|\\s)".format(re.escape(expected_line)), re.UNICODE)
  32. if regex_pattern.search(line) is not None:
  33. return expected_line
  34. return None
  35. class LogMonitor(object):
  36. def __init__(self, launcher, log_file_path, log_creation_max_wait_time=5):
  37. """
  38. Log monitor object for monitoring a single log file for expected or unexpected line values.
  39. Requires a launcher class & valid log file path.
  40. :param launcher: Launcher class object that opens a locally-accessible log file to write to.
  41. :param log_file_path: string representing the path to the file to open.
  42. :param log_creation_max_wait_time: max time to wait in seconds for log to exist
  43. """
  44. self.unexpected_lines_found = []
  45. self.expected_lines_not_found = []
  46. self.launcher = launcher
  47. self.log_file_path = log_file_path
  48. self.py_log = ""
  49. self.log_creation_max_wait_time = log_creation_max_wait_time
  50. def monitor_log_for_lines(self,
  51. expected_lines=None,
  52. unexpected_lines=None,
  53. halt_on_unexpected=False,
  54. timeout=30):
  55. """
  56. Monitor for expected or unexpected lines for the log file attached to this LogMonitor object.
  57. Returns True on success or raises LogMonitorException on failure.
  58. Will search for X seconds where X is the value of the timeout parameter.
  59. :param expected_lines: list of strings that the user wants to find in the self.log_file_path file.
  60. :param unexpected_lines: list of strings that must not be present in the self.log_file_path file.
  61. :param halt_on_unexpected: boolean to determine whether to raise LogMonitorException on the first
  62. unexpected line found (True) or not (False)
  63. :param timeout: int time in seconds to search for expected/unexpected lines before raising LogMonitorException.
  64. :return: True if monitoring succeeded, raises a LogMonitorException otherwise.
  65. """
  66. waiter.wait_for(
  67. lambda: os.path.exists(self.log_file_path),
  68. timeout=self.log_creation_max_wait_time,
  69. exc=LogMonitorException(f"Log file '{self.log_file_path}' was never created by another process."),
  70. interval=1,
  71. )
  72. # Validation checks before monitoring the log file writes.
  73. launcher_class = ly_test_tools.launchers.platforms.base.Launcher
  74. if not os.path.exists(self.log_file_path):
  75. raise LogMonitorException(
  76. "Referenced self.log_file_path file does not exist: {}".format(self.log_file_path))
  77. if not isinstance(self.launcher, launcher_class):
  78. raise LogMonitorException(
  79. "Referenced launcher type: '{}' is not a valid launcher class. Must be of type: '{}'".format(
  80. type(self.launcher), launcher_class))
  81. if not expected_lines and not unexpected_lines:
  82. logger.warning("Requested log monitoring for no lines, aborting")
  83. return
  84. # Enforce list typing for expected_lines & unexpected_lines
  85. if unexpected_lines is None:
  86. unexpected_lines = []
  87. if expected_lines is None:
  88. expected_lines = []
  89. logger.warning(
  90. "Requested log monitoring without providing any expected lines. "
  91. "Log monitoring will continue for '{}' seconds to search for unexpected lines.".format(timeout))
  92. if type(expected_lines) is not list or type(unexpected_lines) is not list:
  93. raise LogMonitorException(
  94. "expected_lines or unexpected_lines must be 'list' type variables. "
  95. "Got types: type(expected_lines) == {} & type(unexpected_lines) == {}".format(
  96. type(expected_lines), type(unexpected_lines)))
  97. # Make sure the expected_lines don't have any common lines with unexpected_lines
  98. expected_lines_in_unexpected = [line for line in unexpected_lines if line in expected_lines]
  99. if expected_lines_in_unexpected:
  100. raise LogMonitorException("Found unexpected_lines in expected_lines:\n{}".format("\n".join(expected_lines_in_unexpected)))
  101. unexpected_lines_in_expected = [line for line in expected_lines if line in unexpected_lines]
  102. if unexpected_lines_in_expected:
  103. raise LogMonitorException("Found expected_lines in unexpected_lines:\n{}".format("\n".join(unexpected_lines_in_expected)))
  104. # Log file is now opened by our process, start monitoring log lines:
  105. self.py_log = ""
  106. try:
  107. with open(self.log_file_path, mode='r', encoding='utf-8') as log:
  108. logger.info(
  109. "Monitoring log file '{}' for '{}' seconds".format(self.log_file_path, timeout))
  110. search_expected_lines = expected_lines.copy()
  111. search_unexpected_lines = unexpected_lines.copy()
  112. waiter.wait_for( # Sets the values for self.unexpected_lines_found & self.expected_lines_not_found
  113. lambda: self._find_lines(log, search_expected_lines, search_unexpected_lines, halt_on_unexpected),
  114. timeout=timeout,
  115. interval=LOG_MONITOR_INTERVAL)
  116. except AssertionError: # Raised by waiter when timeout is reached.
  117. logger.warning(f"Timeout of '{timeout}' seconds was reached, log lines may not have been found")
  118. # exception will be raised below by _validate_results with failure analysis
  119. finally:
  120. logger.info("Python log output:\n" + self.py_log)
  121. logger.info(
  122. "Finished log monitoring for '{}' seconds, validating results.\n"
  123. "expected_lines_not_found: {}\n unexpected_lines_found: {}".format(
  124. timeout, self.expected_lines_not_found, self.unexpected_lines_found))
  125. return self._validate_results(self.expected_lines_not_found, self.unexpected_lines_found, expected_lines, unexpected_lines)
  126. def _find_expected_lines(self, line, expected_lines):
  127. """
  128. Checks for any matches between the 'line' string and strings in the 'expected_lines' list.
  129. Removes expected_lines strings that are found from the main expected_lines list and returns the remaining
  130. expected_lines list values.
  131. :param line: string from a TextIO or BinaryIO file object being read line by line.
  132. :param expected_lines: list of strings to search for in each read line from the log file.
  133. :return: updated expected_lines list of strings after parsing the value of the line param.
  134. """
  135. expected_lines_to_remove = []
  136. for expected_line in expected_lines:
  137. searched_line = check_exact_match(line, expected_line)
  138. if expected_line == searched_line:
  139. logger.debug("Found expected line: {} from line: {}".format(expected_line, line))
  140. expected_lines_to_remove.append(expected_line)
  141. for expected_line in expected_lines_to_remove:
  142. expected_lines.remove(expected_line)
  143. return expected_lines
  144. def _find_unexpected_lines(self, line, unexpected_lines, halt_on_unexpected):
  145. """
  146. Checks for any matches between the 'line' string and strings in the 'unexpected_lines' list.
  147. Removes unexpected_lines strings that are found from the main unexpected_lines list and adds them to the
  148. unexpected_lines_found list.
  149. :param line: string from a TextIO or BinaryIO file object being read line by line.
  150. :param unexpected_lines: list of strings to search for in each read line from the log file.
  151. :param halt_on_unexpected: boolean to determine whether to raise ValueError on the first
  152. unexpected line found (True) or not (False)
  153. :return: unexpected_lines_found from the unexpected_lines searched for in the current log line.
  154. """
  155. unexpected_lines_found = self.unexpected_lines_found
  156. unexpected_lines_to_remove = []
  157. for unexpected_line in unexpected_lines:
  158. searched_line = check_exact_match(line, unexpected_line)
  159. if unexpected_line == searched_line:
  160. logger.debug("Found unexpected line: {} from line: {}".format(unexpected_line, line))
  161. if halt_on_unexpected:
  162. raise LogMonitorException(
  163. "Unexpected line appeared: {} from line: {}".format(unexpected_line, line))
  164. unexpected_lines_found.append(unexpected_line)
  165. unexpected_lines_to_remove.append(unexpected_line)
  166. for unexpected_line in unexpected_lines_to_remove:
  167. unexpected_lines.remove(unexpected_line)
  168. return unexpected_lines_found
  169. def _validate_results(self, expected_lines_not_found, unexpected_lines_found, expected_lines, unexpected_lines):
  170. """
  171. Parses the values in the expected_lines_not_found & unexpected_lines_found lists.
  172. If any expected lines were NOT found or unexpected lines WERE found, a LogMonitorException will be raised.
  173. The LogMonitorException message will detail the values that triggered the error.
  174. :param expected_lines_not_found: list of strings for expected lines that did NOT appeared in the log file.
  175. :param unexpected_lines_found: list of strings for unexpected lines that DID appear in the log file.
  176. :return: True if results are validated, but raises a LogMonitorException if any errors found.
  177. """
  178. failure_found = False
  179. fail_message = "While monitoring file '{}':\n".format(self.log_file_path)
  180. expected_line_failures = ''
  181. expected_lines_found = [line for line in expected_lines if line not in expected_lines_not_found]
  182. # Find out if any error strings need to be constructed.
  183. if expected_lines_not_found or unexpected_lines_found:
  184. failure_found = True
  185. # Add the constructed error strings and raise a LogMonitorException if any are found.
  186. expected_lines_info = ""
  187. for line in expected_lines:
  188. if line in expected_lines_found:
  189. expected_lines_info += "[ FOUND ] {}\n".format(line)
  190. else:
  191. expected_lines_info += "[ NOT FOUND ] {}\n".format(line)
  192. logger.info("LogMonitor Result:\n"
  193. "--- Expected lines ---\n"
  194. f"{expected_lines_info}"
  195. f"Found {len(expected_lines_found)}/{len(expected_lines)} expected lines")
  196. if failure_found:
  197. if expected_lines_not_found:
  198. expected_line_failures = "\n".join(expected_lines_not_found)
  199. logger.error('Following expected lines *NOT FOUND*:\n{}'.format(expected_line_failures))
  200. fail_message += "Failed to find expected line(s):\n{}".format(expected_line_failures)
  201. if unexpected_lines_found:
  202. unexpected_line_failures = "\n".join(unexpected_lines_found)
  203. logger.error('Following unexpected lines *FOUND*:\n{}'.format(unexpected_line_failures))
  204. fail_message += "Additionally, " if expected_line_failures else ""
  205. fail_message += "Found unexpected line(s):\n{}".format(unexpected_line_failures)
  206. raise LogMonitorException(fail_message)
  207. return True
  208. def _find_lines(self, log, expected_lines, unexpected_lines, halt_on_unexpected):
  209. """
  210. Given a list of strings in expected_lines, unexpected_lines, and a log file, read every line in the log file,
  211. and make sure all expected_lines strings appear & no unexpected_lines strings appear in the log file.
  212. NOTE: This loop will only end when a launcher process ends or if used as a callback function (i.e. waiter).
  213. :param log: TextIO or BinaryIO file object to read lines from.
  214. :param expected_lines: list of strings to search for in each read line from the log file.
  215. :param unexpected_lines: list of strings that must not be present in the log_file_path file.
  216. :param halt_on_unexpected: boolean to determine whether to raise LogMonitorException on the first
  217. unexpected line found (True) or not (False)
  218. :return: (wait_condition) Whether the log processing has finished(True: finished, False: unfinished)
  219. sets self.unexpected_lines_found & self.expected_lines_not_found
  220. """
  221. log_filename = os.path.basename(self.log_file_path)
  222. def process_line(line):
  223. self.py_log += ("|%s| %s\n" % (log_filename, line))
  224. expected_lines_not_found = self._find_expected_lines(line, expected_lines)
  225. unexpected_lines_found = self._find_unexpected_lines(line, unexpected_lines, halt_on_unexpected)
  226. self.unexpected_lines_found = unexpected_lines_found
  227. self.expected_lines_not_found = expected_lines_not_found
  228. exception_info = None
  229. # To avoid race conditions, we will check *before reading*
  230. # If in the mean time the file is closed, we will make sure we read everything by issuing an extra call
  231. # by returning the previous alive state
  232. process_runing = self.launcher.is_alive()
  233. for line in log:
  234. line = line[:-1] # remove /n
  235. try:
  236. process_line(line)
  237. except LogMonitorException as e:
  238. if exception_info is None:
  239. exception_info = e.args
  240. if exception_info is not None:
  241. raise LogMonitorException(*exception_info)
  242. return not process_runing # Will loop until the process ends