summaryrefslogtreecommitdiff
path: root/buildscripts/metrics/burn_in_tests.py
blob: 1279562291477eab8dc98128f56125363b0e6f52 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
"""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()