Files
mongo/buildscripts/metrics/burn_in_tests.py

343 lines
13 KiB
Python

"""Metric tracking script for burn_in_tests."""
from __future__ import absolute_import
import argparse
import datetime
import json
import logging
import os
import sys
import requests
# Get relative imports to work when the package is not installed on the PYTHONPATH.
if __name__ == "__main__" and __package__ is None:
sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
from buildscripts.client import evergreen as evg_client # pylint: disable=wrong-import-position
LOGGER = logging.getLogger(__name__)
LOG_LEVELS = ["DEBUG", "INFO", "WARNING", "ERROR"]
BURN_IN_TESTS_TASK = "burn_in_tests"
BURN_IN_GENERATED_TASK_PREFIX = "burn_in:"
# Burn_in_tests are expected to run no more than 600 seconds (10 minutes). Overhead to start and
# stop the test framework is not included and the task time for each single test can exceed this
# amount. We add 60 seconds of overhead (10%) to the expected time of the burn_in_tests task for
# test setup and teardown.
BURN_IN_TIME_SEC = 600 + 60
BURN_IN_TIME_MS = BURN_IN_TIME_SEC * 1000
BURN_IN_TASKS_EXCEED = "burn_in_tasks_exceeding_{}s".format(BURN_IN_TIME_SEC)
DATE_FORMAT = "%Y-%m-%dT%H:%M:%S.%fZ"
DEFAULT_PROJECT = "mongodb-mongo-master"
DEFAULT_DAYS = 28
DEFAULT_REPORT_FILE = "burn_in_tests_metrics.json"
REPORT_COMMENT_FIELD = "_comment"
REPORT_TIME_FIELDS = ["report_start_time", "report_end_time"]
REPORT_COUNTER_FIELDS = [
"patch_builds_with_burn_in_task", "tasks", "tasks_succeeded", "tasks_failed",
"tasks_failed_burn_in", "tasks_failed_only_burn_in", "burn_in_generated_tasks", "burn_in_tests",
BURN_IN_TASKS_EXCEED
]
REPORT_FIELDS = REPORT_TIME_FIELDS + REPORT_COUNTER_FIELDS
def parse_command_line():
"""Parse command line options.
:return: Argparser object.
"""
parser = argparse.ArgumentParser(description=main.__doc__)
parser.add_argument(
"--days", dest="days", default=DEFAULT_DAYS, type=int,
help="The number of days from today for the report. Default is '%(default)d'.")
parser.add_argument("--project", dest="project", default=DEFAULT_PROJECT,
help="The name of the Evergreen project. Default is '%(default)s'.")
parser.add_argument("--reportFile", dest="report_file", default=DEFAULT_REPORT_FILE,
help="Output report JSON file. Default is '%(default)s'.")
parser.add_argument("--logLevel", dest="log_level", default=None, choices=LOG_LEVELS,
help="Set the log level.")
parser.add_argument("--evgClientLogLevel", dest="evg_client_log_level", default=None,
choices=LOG_LEVELS, help="Set the Evergreen client log level.")
return parser
def configure_logging(log_level, evg_client_log_level):
"""Enable logging for execution.
:param log_level: Set the log level of this script.
:param evg_client_log_level: Set the log level of the Evergreen client methods.
"""
if log_level:
logging.basicConfig(format="[%(asctime)s - %(name)s - %(levelname)s] %(message)s")
LOGGER.setLevel(log_level)
if evg_client_log_level:
evg_client.LOGGER.setLevel(evg_client_log_level)
def write_json_file(json_data, pathname):
"""Write out a JSON file.
:param json_data: Dict to save in JSON format.
:param pathname: Output file to save as JSON.
"""
with open(pathname, "w") as fstream:
json.dump(json_data, fstream, indent=4, sort_keys=True)
def str_to_datetime(time_str):
"""Return datetime from time_str.
:param time_str: Time string conforming to DATE_FORMAT format.
:return: dateteime.datetime object.
"""
return datetime.datetime.strptime(time_str, DATE_FORMAT)
def str_to_datetime_date(time_str):
"""Return datetime.date() from time_str.
:param time_str: Time string conforming to DATE_FORMAT format.
:return: dateteime.datetime.date() object.
"""
return str_to_datetime(time_str).date()
def get_burn_in_builds(evg_api, project, days):
"""List of builds from patches with a burn_in_tests task.
:param evg_api: The Evergreen client API instance.
:param project: The name of the project.
:param days: Number of days to go backwards to search for patches.
:return: List of version builds with a burn_in_tests task.
"""
burn_in_builds = []
end_date = (datetime.datetime.utcnow() - datetime.timedelta(days=days)).date()
for patch in evg_api.project_patches_gen(project):
if str_to_datetime_date(patch["create_time"]) < end_date:
break
# Skip patch builds with a created status, as they may not yet have an associated build,
# and are therefore not ready to be analyzed.
if patch["status"] == "created":
continue
for build in evg_api.version_builds(patch["patch_id"]):
for task in build["tasks"]:
if BURN_IN_TESTS_TASK in task:
burn_in_builds.append(build)
break
return burn_in_builds
def is_burn_in_display_task(display_name):
"""Return True if display_name is a burn_in task.
:param display_name: Task display name
:return: Boolean, True if name is a burn_in task.
"""
return display_name == BURN_IN_TESTS_TASK or display_name.startswith(
BURN_IN_GENERATED_TASK_PREFIX)
def get_burn_in_tasks(evg_api, builds):
"""List of burn_in tasks from the builds.
:param evg_api: The Evergreen client API instance.
:param patches: List of builds.
:return: List of build tasks.
"""
tasks = []
for build in builds:
for build_task in evg_api.tasks_by_build_id(build["_id"]):
if is_burn_in_display_task(build_task["display_name"]):
tasks.append(build_task)
return tasks
def get_tests_from_tasks(evg_api, tasks):
"""List of tests from the tasks.
:param evg_api: The Evergreen client API instance.
:param patches: List of tasks.
:return: List of tests.
"""
tests = []
for task in tasks:
try:
task_tests = evg_api.tests_by_task(task["task_id"], task["execution"])
tests.extend(task_tests)
except requests.exceptions.HTTPError as err:
# Not all tasks have associated tests, so we ignore HTTP 404 errors.
if err.response.status_code != 404:
raise err
return tests
class Report(object):
"""Report class to provide burn_in_tests report."""
def __init__(self, builds, tasks, tests, comment=None):
"""Initialize report class.
:param builds: List of burn_in builds.
:param tasks: List of burn_in tasks.
:param tests: List of burn_in tests.
:param comment: Optional comment field added to the report.
"""
self.burn_in_patch_builds = self._init_burn_in_patch_builds(builds)
self.burn_in_tasks = self._init_burn_in_tasks(tasks)
num_burn_in_tasks = len([
task for task in tasks if task["display_name"].startswith(BURN_IN_GENERATED_TASK_PREFIX)
])
self.report = self._init_report_fields(
len(self.burn_in_patch_builds), num_burn_in_tasks, len(tests), comment)
@staticmethod
def _init_burn_in_patch_builds(builds):
"""Group builds into a dict keyed by the patch version.
:param: builds: List of burn_in builds.
:return Dict of builds grouped by patch version.
"""
burn_in_patch_builds = {}
for build in builds:
build_version = build["version"]
if build_version not in burn_in_patch_builds:
burn_in_patch_builds[build_version] = {"builds": []}
burn_in_patch_builds[build_version]["builds"].append(build)
return burn_in_patch_builds
@staticmethod
def _init_burn_in_tasks(tasks):
"""Convert tasks into a dict for direct access by task_id.
:param tasks: List of burn_in tasks.
:return Dict of tasks keyed by task_id.
"""
return {task["task_id"]: task for task in tasks}
@staticmethod
def _init_report_fields(num_patch_builds, num_tasks, num_tests, comment=None):
"""Init the report fields.
:param num_patch_builds: Number of patch builds.
:param num_tasks: Number of of burn_in tasks.
:param num_tests: Number of burn_in tests.
:param comment: Optional comment field added to the report.
:return Dict of report fields.
"""
report = {}
if comment:
report[REPORT_COMMENT_FIELD] = comment
for field in REPORT_TIME_FIELDS:
report[field] = None
for field in REPORT_COUNTER_FIELDS:
report[field] = 0
report["patch_builds_with_burn_in_task"] = num_patch_builds
report["burn_in_generated_tasks"] = num_tasks
report["burn_in_tests"] = num_tests
return report
def _update_report_time(self, create_time):
"""Update report start_time and end_time.
:param create_time: The time used to compare against the report start and end time.
"""
start_time = self.report["report_start_time"]
end_time = self.report["report_end_time"]
create_dt = str_to_datetime(create_time)
if not start_time or create_dt < str_to_datetime(start_time):
self.report["report_start_time"] = create_time
if not end_time or create_dt > str_to_datetime(end_time):
self.report["report_end_time"] = create_time
def _update_report_burn_in(self, patch_builds, total_failures):
"""Update burn_in portion of report.
:param patch_builds: List of builds in the patch.
:param total_failures: Number of total failures for the patch.
"""
burn_in_failures = 0
for build in patch_builds:
for task_id in build["tasks"]:
burn_in_task = task_id in self.burn_in_tasks
if not burn_in_task:
continue
if self.burn_in_tasks[task_id]["status"] == "failed":
self.report["tasks_failed_burn_in"] += 1
burn_in_failures += 1
if self.burn_in_tasks[task_id]["time_taken_ms"] > BURN_IN_TIME_MS:
self.report[BURN_IN_TASKS_EXCEED] += 1
if self._is_patch_build_completed(
patch_builds) and burn_in_failures > 0 and burn_in_failures == total_failures:
LOGGER.debug("Patch build %s failed only burn_in_tests with %d failures",
patch_builds[0]["version"], total_failures)
self.report["tasks_failed_only_burn_in"] += 1
def _update_report_status(self, build):
"""Update task status of report.
:param build: The build object to analyze.
"""
self.report["tasks"] += len(build["tasks"])
self.report["tasks_succeeded"] += build["status_counts"]["succeeded"]
self.report["tasks_failed"] += build["status_counts"]["failed"]
@staticmethod
def _is_patch_build_completed(builds):
"""Return True if all builds are completed.
:param builds: List of builds.
:return: True if build status is 'failed' or 'success' for all builds.
"""
return all([build["status"] in ["failed", "success"] for build in builds])
def generate_report(self):
"""Generate report metrics for burn_in_tests task.
:return: Dict of report.
"""
for patch_build in self.burn_in_patch_builds.values():
build_failed_tasks = 0
for build in patch_build["builds"]:
self._update_report_time(build["create_time"])
self._update_report_status(build)
build_failed_tasks += build["status_counts"]["failed"]
self._update_report_burn_in(patch_build["builds"], build_failed_tasks)
return self.report
def main():
"""Execute Main program."""
options = parse_command_line().parse_args()
configure_logging(options.log_level, options.evg_client_log_level)
evg_api = evg_client.EvergreenApiV2(api_headers=evg_client.get_evergreen_headers())
LOGGER.info("Getting the patch version builds")
burn_in_builds = get_burn_in_builds(evg_api, options.project, options.days)
LOGGER.info("Getting the build tasks")
burn_in_tasks = get_burn_in_tasks(evg_api, burn_in_builds)
LOGGER.info("Getting the task tests")
burn_in_tests = get_tests_from_tasks(evg_api, burn_in_tasks)
comment = ("Metrics for patch builds running burn_in_tests in {} for the last {} days -"
" generated on {}Z").format(options.project, options.days,
datetime.datetime.utcnow().isoformat())
report = Report(burn_in_builds, burn_in_tasks, burn_in_tests, comment=comment)
report_result = report.generate_report()
write_json_file(report_result, options.report_file)
LOGGER.info("%s", report_result)
if __name__ == "__main__":
main()