1 # Copyright (c) 2014 The Chromium OS Authors. All rights reserved.
2 # Use of this source code is governed by a BSD-style license that can be
3 # found in the LICENSE file.
5 """Script for gathering stats from builder runs."""
7 from __future__ import division
8 from __future__ import print_function
18 from chromite.cbuildbot import cbuildbot_config
19 from chromite.cbuildbot import metadata_lib
20 from chromite.cbuildbot import constants
21 from chromite.lib import commandline
22 from chromite.lib import cros_build_lib
23 from chromite.lib import gdata_lib
24 from chromite.lib import graphite
25 from chromite.lib import gs
26 from chromite.lib import table
29 # Useful config targets.
30 CQ_MASTER = constants.CQ_MASTER
31 PFQ_MASTER = 'x86-generic-chromium-pfq'
33 # Useful google storage locations.
34 PRE_CQ_GROUP_GS_LOCATION = constants.PRE_CQ_GROUP_GS_LOCATION
38 PRE_CQ = constants.PRE_CQ
39 PFQ = constants.PFQ_TYPE
41 # Number of parallel processes used when uploading/downloading GS files.
44 # The graphite graphs use seconds since epoch start as time value.
45 EPOCH_START = metadata_lib.EPOCH_START
47 # Formats we like for output.
48 NICE_DATE_FORMAT = metadata_lib.NICE_DATE_FORMAT
49 NICE_TIME_FORMAT = metadata_lib.NICE_TIME_FORMAT
50 NICE_DATETIME_FORMAT = metadata_lib.NICE_DATETIME_FORMAT
53 # CQ master and slaves both use the same spreadsheet
54 CQ_SS_KEY = '0AsXDKtaHikmcdElQWVFuT21aMlFXVTN5bVhfQ2ptVFE'
55 PFQ_SS_KEY = '0AhFPeDq6pmwxdDdrYXk3cnJJV05jN3Zja0s5VjFfNlE'
58 # These are the preferred base URLs we use to canonicalize bugs/CLs.
59 BUGANIZER_BASE_URL = 'b/'
61 CROS_BUG_BASE_URL = 'crbug.com/'
62 INTERNAL_CL_BASE_URL = 'crosreview.com/i/'
63 EXTERNAL_CL_BASE_URL = 'crosreview.com/'
66 class GatherStatsError(Exception):
67 """Base exception class for exceptions in this module."""
70 class DataError(GatherStatsError):
71 """Any exception raised when an error occured while collectring data."""
74 class SpreadsheetError(GatherStatsError):
75 """Raised when there is a problem with the stats spreadsheet."""
78 class BadData(DataError):
79 """Raised when a json file is still running."""
82 class NoDataError(DataError):
83 """Returned if a manifest file does not exist."""
86 def _SendToCarbon(builds, token_funcs):
87 """Send data for |builds| to Carbon/Graphite according to |token_funcs|.
90 builds: List of BuildData objects.
91 token_funcs: List of functors that each take a BuildData object as the only
92 argument and return a string. Each line of data to send to Carbon is
93 constructed by taking the strings returned from these functors and
94 concatenating them using single spaces.
96 lines = [' '.join([str(func(b)) for func in token_funcs]) for b in builds]
97 cros_build_lib.Info('Sending %d lines to Graphite now.', len(lines))
98 graphite.SendToCarbon(lines)
101 # TODO(dgarrett): Discover list from Json. Will better track slave changes.
102 def _GetSlavesOfMaster(master_target):
103 """Returns list of slave config names for given master config name.
106 master_target: Name of master config target.
109 List of names of slave config targets.
111 master_config = cbuildbot_config.config[master_target]
112 slave_configs = cbuildbot_config.GetSlavesForMaster(master_config)
113 return sorted(slave_config.name for slave_config in slave_configs)
116 class StatsTable(table.Table):
117 """Stats table for any specific target on a waterfall."""
119 LINK_ROOT = ('https://uberchromegw.corp.google.com/i/%(waterfall)s/builders/'
123 def _SSHyperlink(link, text):
124 return '=HYPERLINK("%s", "%s")' % (link, text)
126 def __init__(self, target, waterfall, columns):
127 super(StatsTable, self).__init__(columns, target)
129 self.waterfall = waterfall
131 def GetBuildLink(self, build_number):
132 target = self.target.replace(' ', '%20')
133 link = self.LINK_ROOT % {'waterfall': self.waterfall, 'target': target}
134 link += '/%s' % build_number
137 def GetBuildSSLink(self, build_number):
138 link = self.GetBuildLink(build_number)
139 return self._SSHyperlink(link, 'build %s' % build_number)
142 class SpreadsheetMasterTable(StatsTable):
143 """Stats table for master builder that puts results in a spreadsheet."""
144 # Bump this number whenever this class adds new data columns, or changes
145 # the values of existing data columns.
148 # These must match up with the column names on the spreadsheet.
149 COL_BUILD_NUMBER = 'build number'
150 COL_BUILD_LINK = 'build link'
151 COL_STATUS = 'status'
152 COL_START_DATETIME = 'start datetime'
153 COL_RUNTIME_MINUTES = 'runtime minutes'
154 COL_WEEKDAY = 'weekday'
155 COL_CHROMEOS_VERSION = 'chromeos version'
156 COL_CHROME_VERSION = 'chrome version'
157 COL_FAILED_STAGES = 'failed stages'
158 COL_FAILURE_MESSAGE = 'failure message'
160 # It is required that the ID_COL be an integer value.
161 ID_COL = COL_BUILD_NUMBER
170 COL_CHROMEOS_VERSION,
176 def __init__(self, target, waterfall, columns=None):
177 columns = columns or []
178 columns = list(SpreadsheetMasterTable.COLUMNS) + columns
179 super(SpreadsheetMasterTable, self).__init__(target,
185 def _CreateAbortedRowDict(self, build_number):
186 """Create a row dict to represent an aborted run of |build_number|."""
188 self.COL_BUILD_NUMBER: str(build_number),
189 self.COL_BUILD_LINK: self.GetBuildSSLink(build_number),
190 self.COL_STATUS: 'aborted',
193 def AppendGapRow(self, build_number):
194 """Append a row to represent a missing run of |build_number|."""
195 self.AppendRow(self._CreateAbortedRowDict(build_number))
197 def AppendBuildRow(self, build_data):
198 """Append a row from the given |build_data|.
201 build_data: A BuildData object.
203 # First see if any build number gaps are in the table, and if so fill
204 # them in. This happens when a CQ run is aborted and never writes metadata.
205 num_rows = self.GetNumRows()
207 last_row = self.GetRowByIndex(num_rows - 1)
208 last_build_number = int(last_row[self.COL_BUILD_NUMBER])
210 for bn in range(build_data.build_number + 1, last_build_number):
211 self.AppendGapRow(bn)
213 row = self._GetBuildRow(build_data)
215 #Use a separate column for each slave.
216 slaves = build_data.slaves
217 for slave_name in slaves:
218 # This adds the slave to our local data, but doesn't add a missing
219 # column to the spreadsheet itself.
220 self._EnsureSlaveKnown(slave_name)
222 # Now add the finished row to this table.
225 def _GetBuildRow(self, build_data):
226 """Fetch a row dictionary from |build_data|
229 A dictionary of the form {column_name : value}
231 build_number = build_data.build_number
232 build_link = self.GetBuildSSLink(build_number)
234 # For datetime.weekday(), 0==Monday and 6==Sunday.
235 is_weekday = build_data.start_datetime.weekday() in range(5)
238 self.COL_BUILD_NUMBER: str(build_number),
239 self.COL_BUILD_LINK: build_link,
240 self.COL_STATUS: build_data.status,
241 self.COL_START_DATETIME: build_data.start_datetime_str,
242 self.COL_RUNTIME_MINUTES: str(build_data.runtime_minutes),
243 self.COL_WEEKDAY: str(is_weekday),
244 self.COL_CHROMEOS_VERSION: build_data.chromeos_version,
245 self.COL_CHROME_VERSION: build_data.chrome_version,
246 self.COL_FAILED_STAGES: ' '.join(build_data.GetFailedStages()),
247 self.COL_FAILURE_MESSAGE: build_data.failure_message,
250 slaves = build_data.slaves
251 for slave_name in slaves:
252 slave = slaves[slave_name]
253 slave_url = slave.get('dashboard_url')
255 # For some reason status in slaves uses pass/fail instead of
256 # passed/failed used elsewhere in metadata.
257 translate_dict = {'fail': 'failed', 'pass': 'passed'}
258 slave_status = translate_dict.get(slave['status'], slave['status'])
260 # Bizarrely, dashboard_url is not always set for slaves that pass.
261 # Only sometimes. crbug.com/350939.
263 row[slave_name] = self._SSHyperlink(slave_url, slave_status)
265 row[slave_name] = slave_status
269 def _EnsureSlaveKnown(self, slave_name):
270 """Ensure that a slave builder name is known.
273 slave_name: The name of the slave builder (aka spreadsheet column name).
275 if not self.HasColumn(slave_name):
276 self._slaves.append(slave_name)
277 self.AppendColumn(slave_name)
280 """Get the list of slave builders which has been discovered so far.
282 This list is only fully populated when all row data has been fully
286 List of column names for slave builders.
288 return self._slaves[:]
291 class PFQMasterTable(SpreadsheetMasterTable):
292 """Stats table for the CQ Master."""
295 WATERFALL = 'chromeos'
296 # Must match up with name in waterfall.
297 TARGET = 'x86-generic nightly chromium PFQ'
299 WORKSHEET_NAME = 'PFQMasterData'
301 # Bump this number whenever this class adds new data columns, or changes
302 # the values of existing data columns.
303 SHEETS_VERSION = SpreadsheetMasterTable.SHEETS_VERSION + 1
305 # These columns are in addition to those inherited from
306 # SpreadsheetMasterTable
310 super(PFQMasterTable, self).__init__(PFQMasterTable.TARGET,
311 PFQMasterTable.WATERFALL,
312 list(PFQMasterTable.COLUMNS))
315 class CQMasterTable(SpreadsheetMasterTable):
316 """Stats table for the CQ Master."""
317 WATERFALL = 'chromeos'
318 TARGET = 'CQ master' # Must match up with name in waterfall.
320 WORKSHEET_NAME = 'CQMasterData'
322 # Bump this number whenever this class adds new data columns, or changes
323 # the values of existing data columns.
324 SHEETS_VERSION = SpreadsheetMasterTable.SHEETS_VERSION + 2
326 COL_CL_COUNT = 'cl count'
327 COL_CL_SUBMITTED_COUNT = 'cls submitted'
328 COL_CL_REJECTED_COUNT = 'cls rejected'
330 # These columns are in addition to those inherited from
331 # SpreadsheetMasterTable
334 COL_CL_SUBMITTED_COUNT,
335 COL_CL_REJECTED_COUNT,
339 super(CQMasterTable, self).__init__(CQMasterTable.TARGET,
340 CQMasterTable.WATERFALL,
341 list(CQMasterTable.COLUMNS))
343 def _GetBuildRow(self, build_data):
344 """Fetch a row dictionary from |build_data|
347 A dictionary of the form {column_name : value}
349 row = super(CQMasterTable, self)._GetBuildRow(build_data)
350 row[self.COL_CL_COUNT] = str(build_data.count_changes)
352 cl_actions = [metadata_lib.CLActionTuple(*x)
353 for x in build_data['cl_actions']]
354 submitted_cl_count = len([x for x in cl_actions if
355 x.action == constants.CL_ACTION_SUBMITTED])
356 rejected_cl_count = len([x for x in cl_actions
357 if x.action == constants.CL_ACTION_KICKED_OUT])
358 row[self.COL_CL_SUBMITTED_COUNT] = str(submitted_cl_count)
359 row[self.COL_CL_REJECTED_COUNT] = str(rejected_cl_count)
363 class SSUploader(object):
364 """Uploads data from table object to Google spreadsheet."""
366 __slots__ = ('_creds', # gdata_lib.Creds object
367 '_scomm', # gdata_lib.SpreadsheetComm object
368 'ss_key', # Spreadsheet key string
371 SOURCE = 'Gathered from builder metadata'
372 HYPERLINK_RE = re.compile(r'=HYPERLINK\("[^"]+", "([^"]+)"\)')
373 DATETIME_FORMATS = ('%m/%d/%Y %H:%M:%S', NICE_DATETIME_FORMAT)
375 def __init__(self, creds, ss_key):
381 def _ValsEqual(cls, val1, val2):
382 """Compare two spreadsheet values and return True if they are the same.
384 This is non-trivial because of the automatic changes that Google Sheets
388 val1: New or old spreadsheet value to compare.
389 val2: New or old spreadsheet value to compare.
392 True if val1 and val2 are effectively the same, False otherwise.
394 # An empty string sent to spreadsheet comes back as None. In any case,
395 # treat two false equivalents as equal.
396 if not (val1 or val2):
399 # If only one of the values is set to anything then they are not the same.
400 if bool(val1) != bool(val2):
403 # If values are equal then we are done.
407 # Ignore case differences. This is because, for example, the
408 # spreadsheet automatically changes "True" to "TRUE".
409 if val1 and val2 and val1.lower() == val2.lower():
412 # If either value is a HYPERLINK, then extract just the text for comparison
413 # because that is all the spreadsheet says the value is.
414 match = cls.HYPERLINK_RE.search(val1)
416 return match.group(1) == val2
417 match = cls.HYPERLINK_RE.search(val2)
419 return match.group(2) == val1
421 # See if the strings are two different representations of the same datetime.
422 dt1, dt2 = None, None
423 for dt_format in cls.DATETIME_FORMATS:
425 dt1 = datetime.datetime.strptime(val1, dt_format)
429 dt2 = datetime.datetime.strptime(val2, dt_format)
432 if dt1 and dt2 and dt1 == dt2:
435 # If we get this far then the values are just different.
438 def _Connect(self, ws_name):
439 """Establish connection to specific worksheet.
442 ws_name: Worksheet name.
445 self._scomm.SetCurrentWorksheet(ws_name)
447 self._scomm = gdata_lib.SpreadsheetComm()
448 self._scomm.Connect(self._creds, self.ss_key, ws_name, source=self.SOURCE)
450 def GetRowCacheByCol(self, ws_name, key):
451 """Fetch the row cache with id=|key|."""
452 self._Connect(ws_name)
453 ss_key = gdata_lib.PrepColNameForSS(key)
454 return self._scomm.GetRowCacheByCol(ss_key)
456 def _EnsureColumnsExist(self, data_columns):
457 """Ensures that |data_columns| exist in current spreadsheet worksheet.
459 Assumes spreadsheet worksheet is already connected.
462 SpreadsheetError if any column in |data_columns| is missing from
463 the spreadsheet's current worksheet.
465 ss_cols = self._scomm.GetColumns()
467 # Make sure all columns in data_table are supported in spreadsheet.
468 missing_cols = [c for c in data_columns
469 if gdata_lib.PrepColNameForSS(c) not in ss_cols]
471 raise SpreadsheetError('Spreadsheet missing column(s): %s' %
472 ', '.join(missing_cols))
474 def UploadColumnToWorksheet(self, ws_name, colIx, data):
475 """Upload list |data| to column number |colIx| in worksheet |ws_name|.
477 This will overwrite any existing data in that column.
479 self._Connect(ws_name)
480 self._scomm.WriteColumnToWorksheet(colIx, data)
482 def UploadSequentialRows(self, ws_name, data_table):
483 """Upload |data_table| to the |ws_name| worksheet of sheet at self.ss_key.
485 Data will be uploaded row-by-row in ascending ID_COL order. Missing
486 values of ID_COL will be filled in by filler rows.
489 ws_name: Worksheet name for identifying worksheet within spreadsheet.
490 data_table: table.Table object with rows to upload to worksheet.
492 self._Connect(ws_name)
493 cros_build_lib.Info('Uploading stats rows to worksheet "%s" of spreadsheet'
494 ' "%s" now.', self._scomm.ws_name, self._scomm.ss_key)
496 cros_build_lib.Debug('Getting cache of current spreadsheet contents.')
497 id_col = data_table.ID_COL
498 ss_id_col = gdata_lib.PrepColNameForSS(id_col)
499 ss_row_cache = self._scomm.GetRowCacheByCol(ss_id_col)
501 self._EnsureColumnsExist(data_table.GetColumns())
503 # First see if a build_number is being skipped. Allow the data_table to
504 # add default (filler) rows if it wants to. These rows may represent
505 # aborted runs, for example. ID_COL is assumed to hold integers.
506 first_id_val = int(data_table[-1][id_col])
507 prev_id_val = first_id_val - 1
508 while str(prev_id_val) not in ss_row_cache:
509 data_table.AppendGapRow(prev_id_val)
511 # Sanity check that we have not created an infinite loop.
512 assert prev_id_val >= 0
514 # Spreadsheet is organized with oldest runs at the top and newest runs
515 # at the bottom (because there is no interface for inserting new rows at
516 # the top). This is the reverse of data_table, so start at the end.
517 for row in data_table[::-1]:
518 row_dict = dict((gdata_lib.PrepColNameForSS(key), row[key])
521 # See if row with the same id_col value already exists.
523 ss_row = ss_row_cache.get(id_val)
527 # Row already exists in spreadsheet. See if contents any different.
528 # Create dict representing values in row_dict different from ss_row.
529 row_delta = dict((k, v) for k, v in row_dict.iteritems()
530 if not self._ValsEqual(v, ss_row[k]))
532 cros_build_lib.Debug('Updating existing spreadsheet row for %s %s.',
534 self._scomm.UpdateRowCellByCell(ss_row.ss_row_num, row_delta)
536 cros_build_lib.Debug('Unchanged existing spreadsheet row for'
537 ' %s %s.', id_col, id_val)
539 cros_build_lib.Debug('Adding spreadsheet row for %s %s.',
541 self._scomm.InsertRow(row_dict)
542 except gdata_lib.SpreadsheetError as e:
543 cros_build_lib.Error('Failure while uploading spreadsheet row for'
544 ' %s %s with data %s. Error: %s.', id_col, id_val,
548 class StatsManager(object):
549 """Abstract class for managing stats for one config target.
551 Subclasses should specify the config target by passing them in to __init__.
553 This class handles the following duties:
554 1) Read a bunch of metadata.json URLs for the config target that are
555 are no older than the given start date.
556 2) Upload data to a Google Sheet, if specified by the subclass.
557 3) Upload data to Graphite, if specified by the subclass.
559 # Subclasses can overwrite any of these.
561 UPLOAD_ROW_PER_BUILD = False
562 # To be overridden by subclass. A dictionary mapping a |key| from
563 # self.summary to (ws_name, colIx) tuples from the spreadsheet which
564 # should be overwritten with the data from the self.summary[key]
565 SUMMARY_SPREADSHEET_COLUMNS = {}
566 CARBON_FUNCS_BY_VERSION = None
569 # Whether to grab a count of what data has been written to sheets before.
570 # This is needed if you are writing data to the Google Sheets spreadsheet.
571 GET_SHEETS_VERSION = True
573 def __init__(self, config_target, ss_key=None,
574 no_sheets_version_filter=False):
576 self.gs_ctx = gs.GSContext()
577 self.config_target = config_target
579 self.no_sheets_version_filter = no_sheets_version_filter
583 #pylint: disable-msg=W0613
584 def Gather(self, start_date, sort_by_build_number=True,
585 starting_build_number=0, creds=None):
586 """Fetches build data into self.builds.
589 start_date: A datetime.date instance for the earliest build to
591 sort_by_build_number: Optional boolean. If True, builds will be
592 sorted by build number.
593 starting_build_number: The lowest build number to include in
595 creds: Login credentials as returned by _PrepareCreds. (optional)
597 self.builds = self._FetchBuildData(start_date, self.config_target,
600 if sort_by_build_number:
601 # Sort runs by build_number, from newest to oldest.
602 cros_build_lib.Info('Sorting by build number now.')
603 self.builds = sorted(self.builds, key=lambda b: b.build_number,
605 if starting_build_number:
606 cros_build_lib.Info('Filtering to include builds after %s (inclusive).',
607 starting_build_number)
608 self.builds = filter(lambda b: b.build_number >= starting_build_number,
611 def CollectActions(self):
612 """Collects the CL actions from the set of gathered builds.
614 Returns a list of CLActionWithBuildTuple for all the actions in the
618 for b in self.builds:
619 if not 'cl_actions' in b.metadata_dict:
620 logging.warn('No cl_actions for metadata at %s.', b.metadata_url)
622 for a in b.metadata_dict['cl_actions']:
623 actions.append(metadata_lib.CLActionWithBuildTuple(*a,
624 bot_type=self.BOT_TYPE, build=b))
628 def CollateActions(self, actions):
629 """Collates a list of actions into per-patch and per-cl actions.
631 Returns a tuple (per_patch_actions, per_cl_actions) where each are
632 a dictionary mapping patches or cls to a list of CLActionWithBuildTuple
633 sorted in order of ascending timestamp.
635 per_patch_actions = {}
638 change_dict = a.change.copy()
639 change_with_patch = metadata_lib.GerritPatchTuple(**change_dict)
640 change_dict.pop('patch_number')
641 change_no_patch = metadata_lib.GerritChangeTuple(**change_dict)
643 per_patch_actions.setdefault(change_with_patch, []).append(a)
644 per_cl_actions.setdefault(change_no_patch, []).append(a)
646 for p in [per_cl_actions, per_patch_actions]:
647 for k, v in p.iteritems():
648 p[k] = sorted(v, key=lambda x: x.timestamp)
650 return (per_patch_actions, per_cl_actions)
653 def _FetchBuildData(cls, start_date, config_target, gs_ctx):
654 """Fetches BuildData for builds of |config_target| since |start_date|.
657 start_date: A datetime.date instance.
658 config_target: String config name to fetch metadata for.
659 gs_ctx: A gs.GSContext instance.
662 A list of of metadata_lib.BuildData objects that were fetched.
664 cros_build_lib.Info('Gathering data for %s since %s', config_target,
666 urls = metadata_lib.GetMetadataURLsSince(config_target,
668 cros_build_lib.Info('Found %d metadata.json URLs to process.\n'
669 ' From: %s\n To : %s', len(urls), urls[0], urls[-1])
671 builds = metadata_lib.BuildData.ReadMetadataURLs(
672 urls, gs_ctx, get_sheets_version=cls.GET_SHEETS_VERSION)
673 cros_build_lib.Info('Read %d total metadata files.', len(builds))
676 # TODO(akeshet): Return statistics in dictionary rather than just printing
679 """Process and print a summary of statistics.
682 An empty dictionary. Note: subclasses can extend this method and return
683 non-empty dictionaries, with summarized statistics.
686 cros_build_lib.Info('%d total runs included, from build %d to %d.',
687 len(self.builds), self.builds[-1].build_number,
688 self.builds[0].build_number)
689 total_passed = len([b for b in self.builds if b.Passed()])
690 cros_build_lib.Info('%d of %d runs passed.', total_passed,
693 cros_build_lib.Info('No runs included.')
697 def sheets_version(self):
699 return self.TABLE_CLASS.SHEETS_VERSION
704 def carbon_version(self):
705 if self.CARBON_FUNCS_BY_VERSION:
706 return len(self.CARBON_FUNCS_BY_VERSION) - 1
710 def UploadToSheet(self, creds):
713 if self.UPLOAD_ROW_PER_BUILD:
714 self._UploadBuildsToSheet(creds)
716 if self.SUMMARY_SPREADSHEET_COLUMNS:
717 self._UploadSummaryColumns(creds)
719 def _UploadBuildsToSheet(self, creds):
720 """Upload row-per-build data to adsheet."""
721 if not self.TABLE_CLASS:
722 cros_build_lib.Debug('No Spreadsheet uploading configured for %s.',
726 # Filter for builds that need to send data to Sheets (unless overridden
727 # by command line flag.
728 if self.no_sheets_version_filter:
731 version = self.sheets_version
732 builds = [b for b in self.builds if b.sheets_version < version]
733 cros_build_lib.Info('Found %d builds that need to send Sheets v%d data.',
734 len(builds), version)
737 # Fill a data table of type table_class from self.builds.
738 # pylint: disable=E1102
739 data_table = self.TABLE_CLASS()
742 data_table.AppendBuildRow(build)
744 cros_build_lib.Error('Failed to add row for builder_number %s to'
745 ' table. It came from %s.', build.build_number,
749 # Upload data table to sheet.
750 uploader = SSUploader(creds, self.ss_key)
751 uploader.UploadSequentialRows(data_table.WORKSHEET_NAME, data_table)
753 def _UploadSummaryColumns(self, creds):
754 """Overwrite summary columns in spreadsheet with appropriate data."""
755 # Upload data table to sheet.
756 uploader = SSUploader(creds, self.ss_key)
757 for key, (ws_name, colIx) in self.SUMMARY_SPREADSHEET_COLUMNS.iteritems():
758 uploader.UploadColumnToWorksheet(ws_name, colIx, self.summary[key])
761 def SendToCarbon(self):
762 if self.CARBON_FUNCS_BY_VERSION:
763 for version, func in enumerate(self.CARBON_FUNCS_BY_VERSION):
764 # Filter for builds that need to send data to Graphite.
765 builds = [b for b in self.builds if b.carbon_version < version]
766 cros_build_lib.Info('Found %d builds that need to send Graphite v%d'
767 ' data.', len(builds), version)
771 def MarkGathered(self):
772 """Mark each metadata.json in self.builds as processed.
774 Applies only to StatsManager subclasses that have UPLOAD_ROW_PER_BUILD
775 True, as these do not want data from a given build to be re-uploaded.
777 if self.UPLOAD_ROW_PER_BUILD:
778 metadata_lib.BuildData.MarkBuildsGathered(self.builds,
784 # TODO(mtennant): This class is an untested placeholder.
785 class CQSlaveStats(StatsManager):
786 """Stats manager for all CQ slaves."""
787 # TODO(mtennant): Add Sheets support for each CQ slave.
789 GET_SHEETS_VERSION = True
791 def __init__(self, slave_target, **kwargs):
792 super(CQSlaveStats, self).__init__(slave_target, kwargs)
794 # TODO(mtennant): This is totally untested, but is a refactoring of the
795 # graphite code that was in place before for CQ slaves.
796 def _SendToCarbonV0(self, builds):
798 def _GetGraphName(build):
799 bot_id = build['bot-config'].replace('-', '.')
800 return 'buildbot.builders.%s.duration_seconds' % bot_id
802 _SendToCarbon(builds, (
804 lambda b : b.runtime_seconds,
805 lambda b : b.epoch_time_seconds,
809 class CQMasterStats(StatsManager):
810 """Manager stats gathering for the Commit Queue Master."""
811 TABLE_CLASS = CQMasterTable
812 UPLOAD_ROW_PER_BUILD = True
814 GET_SHEETS_VERSION = True
816 def __init__(self, **kwargs):
817 super(CQMasterStats, self).__init__(CQ_MASTER, **kwargs)
819 def _SendToCarbonV0(self, builds):
821 _SendToCarbon(builds, (
822 lambda b : 'buildbot.cq.run_time_seconds',
823 lambda b : b.runtime_seconds,
824 lambda b : b.epoch_time_seconds,
827 # Send CLs per run data.
828 _SendToCarbon(builds, (
829 lambda b : 'buildbot.cq.cls_per_run',
830 lambda b : b.count_changes,
831 lambda b : b.epoch_time_seconds,
834 # Organized by by increasing graphite version numbers, starting at 0.
835 CARBON_FUNCS_BY_VERSION = (
840 class PFQMasterStats(StatsManager):
841 """Manager stats gathering for the PFQ Master."""
842 TABLE_CLASS = PFQMasterTable
843 UPLOAD_ROW_PER_BUILD = True
845 GET_SHEETS_VERSION = True
847 def __init__(self, **kwargs):
848 super(PFQMasterStats, self).__init__(PFQ_MASTER, **kwargs)
851 # TODO(mtennant): Add Sheets support for PreCQ by creating a PreCQTable
852 # class modeled after CQMasterTable and then adding it as TABLE_CLASS here.
853 # TODO(mtennant): Add Graphite support for PreCQ by CARBON_FUNCS_BY_VERSION
855 class PreCQStats(StatsManager):
856 """Manager stats gathering for the Pre Commit Queue."""
859 GET_SHEETS_VERSION = False
861 def __init__(self, **kwargs):
862 super(PreCQStats, self).__init__(PRE_CQ_GROUP_GS_LOCATION, **kwargs)
865 class CLStats(StatsManager):
866 """Manager for stats about CL actions taken by the Commit Queue."""
867 PATCH_HANDLING_TIME_SUMMARY_KEY = 'patch_handling_time'
868 SUMMARY_SPREADSHEET_COLUMNS = {
869 PATCH_HANDLING_TIME_SUMMARY_KEY : ('PatchHistogram', 1)}
870 COL_FAILURE_CATEGORY = 'failure category'
871 COL_FAILURE_BLAME = 'bug or bad CL'
872 REASON_BAD_CL = 'Bad CL'
874 GET_SHEETS_VERSION = False
876 def __init__(self, email, **kwargs):
877 super(CLStats, self).__init__(CQ_MASTER, **kwargs)
879 self.per_patch_actions = {}
880 self.per_cl_actions = {}
885 self.pre_cq_stats = PreCQStats()
887 def GatherFailureReasons(self, creds):
888 """Gather the reasons why our builds failed and the blamed bugs or CLs.
891 creds: A gdata_lib.Creds object.
893 data_table = CQMasterStats.TABLE_CLASS()
894 uploader = SSUploader(creds, self.ss_key)
895 ss_failure_category = gdata_lib.PrepColNameForSS(self.COL_FAILURE_CATEGORY)
896 ss_failure_blame = gdata_lib.PrepColNameForSS(self.COL_FAILURE_BLAME)
897 rows = uploader.GetRowCacheByCol(data_table.WORKSHEET_NAME,
898 data_table.COL_BUILD_NUMBER)
899 for b in self.builds:
901 row = rows[str(b.build_number)]
903 self.reasons[b.build_number] = 'None'
904 self.blames[b.build_number] = []
906 self.reasons[b.build_number] = str(row[ss_failure_category])
907 self.blames[b.build_number] = self.ProcessBlameString(
908 str(row[ss_failure_blame]))
911 def ProcessBlameString(blame_string):
912 """Parse a human-created |blame_string| from the spreadsheet.
915 A list of canonicalized URLs for bugs or CLs that appear in the blame
916 string. Canonicalized form will be 'crbug.com/1234',
917 'crosreview.com/1234', 'b/1234', 't/1234', or 'crosreview.com/i/1234' as
921 tokens = blame_string.split()
923 # Format to generate the regex patterns. Matches one of provided domain
924 # names, followed by lazy wildcard, followed by greedy digit wildcard,
925 # followed by optional slash and optional comma.
926 general_regex = r'^.*(%s).*?([0-9]+)/?,?$'
928 crbug = general_regex % 'crbug.com|code.google.com'
929 internal_review = (general_regex %
930 'chrome-internal-review.googlesource.com|crosreview.com/i')
931 external_review = (general_regex %
932 'crosreview.com|chromium-review.googlesource.com')
933 guts = (general_regex % 't/|gutsv\d.corp.google.com/#ticket/')
935 # Buganizer regex is different, as buganizer urls do not end with the bug
937 buganizer = r'^.*(b/|b.corp.google.com/issue\?id=)([0-9]+).*$'
939 # Patterns need to be tried in a specific order -- internal review needs
940 # to be tried before external review, otherwise urls like crosreview.com/i
941 # will be incorrectly parsed as external.
947 url_patterns = [CROS_BUG_BASE_URL,
948 INTERNAL_CL_BASE_URL,
949 EXTERNAL_CL_BASE_URL,
954 for p, u in zip(patterns, url_patterns):
957 urls.append(u + m.group(2))
962 def Gather(self, start_date, sort_by_build_number=True,
963 starting_build_number=0, creds=None):
964 """Fetches build data and failure reasons.
967 start_date: A datetime.date instance for the earliest build to
969 sort_by_build_number: Optional boolean. If True, builds will be
970 sorted by build number.
971 starting_build_number: The lowest build number from the CQ to include in
973 creds: Login credentials as returned by _PrepareCreds. (optional)
976 creds = _PrepareCreds(self.email)
977 super(CLStats, self).Gather(start_date,
978 sort_by_build_number=sort_by_build_number,
979 starting_build_number=starting_build_number)
980 self.GatherFailureReasons(creds)
982 # Gather the pre-cq stats as well. The build number won't apply here since
983 # the pre-cq has different build numbers. We intentionally represent the
984 # Pre-CQ stats in a different object to help keep things simple.
985 self.pre_cq_stats.Gather(start_date,
986 sort_by_build_number=sort_by_build_number)
988 def GetSubmittedPatchNumber(self, actions):
989 """Get the patch number of the final patchset submitted.
991 This function only makes sense for patches that were submitted.
994 actions: A list of actions for a single change.
996 submit = [a for a in actions if a.action == constants.CL_ACTION_SUBMITTED]
997 assert len(submit) == 1, \
998 'Expected change to be submitted exactly once, got %r' % submit
999 return submit[-1].change['patch_number']
1001 def ClassifyRejections(self, submitted_changes):
1002 """Categorize rejected CLs, deciding whether the rejection was incorrect.
1004 We figure out what patches were falsely rejected by looking for patches
1005 which were later submitted unmodified after being rejected. These patches
1006 are considered to be likely good CLs.
1009 submitted_changes: A dict mapping submitted GerritChangeTuple objects to
1010 a list of associated actions.
1013 change: The GerritChangeTuple that was rejected.
1014 actions: A list of actions applicable to the CL.
1015 a: The reject action that kicked out the CL.
1016 falsely_rejected: Whether the CL was incorrectly rejected. A CL rejection
1017 is considered incorrect if the same patch is later submitted, with no
1018 changes. It's a heuristic.
1020 for change, actions in submitted_changes.iteritems():
1021 submitted_patch_number = self.GetSubmittedPatchNumber(actions)
1023 # If the patch wasn't included in the run, this means that it "failed
1024 # to apply" rather than "failed to validate". Ignore it.
1025 patch = metadata_lib.GerritPatchTuple(**a.change)
1026 if (a.action == constants.CL_ACTION_KICKED_OUT and
1027 patch in a.build.patches):
1028 # Check whether the patch was updated after submission.
1029 falsely_rejected = a.change['patch_number'] == submitted_patch_number
1030 yield change, actions, a, falsely_rejected
1032 def _PrintCounts(self, reasons, fmt):
1033 """Print a sorted list of reasons in descending order of frequency.
1036 reasons: A key/value mapping mapping the reason to the count.
1037 fmt: A format string for our log message, containing %(cnt)d
1041 for cnt, reason in sorted(((v, k) for (k, v) in d.items()), reverse=True):
1042 logging.info(fmt, dict(cnt=cnt, reason=reason))
1044 logging.info(' None')
1046 def CalculateStageFailures(self, reject_actions, submitted_changes,
1047 good_patch_rejections):
1048 """Calculate what stages correctly or incorrectly failed.
1051 reject_actions: A list of actions that reject CLs.
1052 submitted_changes: A dict mapping submitted GerritChangeTuple to a list
1053 of associated actions.
1054 good_patch_rejections: A dict mapping submitted GerritPatchTuple to a list
1055 of associated incorrect rejections.
1058 correctly_rejected_by_stage: A dict, where dict[bot_type][stage_name]
1059 counts the number of times a probably bad patch was rejected due to a
1060 failure in this stage.
1061 incorrectly_rejected_by_stage: A dict, where dict[bot_type][stage_name]
1062 counts the number of times a probably good patch was rejected due to a
1063 failure in this stage.
1065 # Keep track of a list of builds that were manually annotated as bad CL.
1066 # These are used to ensure that we don't treat real failures as being flaky.
1067 bad_cl_builds = set()
1068 for a in reject_actions:
1069 if a.bot_type == CQ:
1070 reason = self.reasons.get(a.build.build_number)
1071 if reason == self.REASON_BAD_CL:
1072 bad_cl_builds.add((a.build.bot_id, a.build.build_number))
1074 # Keep track of the stages that correctly detected a bad CL. We assume
1075 # here that all of the stages that are broken were broken by the bad CL.
1076 correctly_rejected_by_stage = {}
1077 for _, _, a, falsely_rejected in self.ClassifyRejections(submitted_changes):
1078 if not falsely_rejected:
1079 good = correctly_rejected_by_stage.setdefault(a.bot_type, {})
1080 for stage_name in a.build.GetFailedStages():
1081 good[stage_name] = good.get(stage_name, 0) + 1
1083 # Keep track of the stages that failed flakily.
1084 incorrectly_rejected_by_stage = {}
1085 for rejections in good_patch_rejections.values():
1086 for a in rejections:
1087 # A stage only failed flakily if it wasn't broken by another CL.
1088 build_tuple = (a.build.bot_id, a.build.build_number)
1089 if build_tuple not in bad_cl_builds:
1090 bad = incorrectly_rejected_by_stage.setdefault(a.bot_type, {})
1091 for stage_name in a.build.GetFailedStages():
1092 bad[stage_name] = bad.get(stage_name, 0) + 1
1094 return correctly_rejected_by_stage, incorrectly_rejected_by_stage
1096 def GoodPatchRejections(self, submitted_changes):
1097 """Find good patches that were incorrectly rejected.
1100 submitted_changes: A dict mapping submitted GerritChangeTuple objects to
1101 a list of associated actions.
1104 A dict, where d[patch] = reject_actions for each good patch that was
1105 incorrectly rejected.
1107 # falsely_rejected_changes maps GerritChangeTuple objects to their actions.
1108 # bad_cl_builds is a set of builds that contain a bad patch.
1109 falsely_rejected_changes = {}
1110 bad_cl_builds = set()
1111 for x in self.ClassifyRejections(submitted_changes):
1112 change, actions, a, falsely_rejected = x
1113 patch = metadata_lib.GerritPatchTuple(**a.change)
1114 if falsely_rejected:
1115 falsely_rejected_changes[change] = actions
1116 elif a.bot_type == PRE_CQ:
1117 # If a developer writes a bad patch and it fails the Pre-CQ, it
1118 # may cause many other patches from the same developer to be
1119 # rejected. This is expected and correct behavior. Treat all of
1120 # the patches in the Pre-CQ run as bad so that they don't skew our
1123 # Since we don't have a spreadsheet for the Pre-CQ, we guess what
1124 # CLs were bad by looking at what patches needed to be changed
1125 # before submission.
1127 # NOTE: We intentionally only apply this logic to the Pre-CQ here.
1128 # The CQ is different because it may have many innocent patches in
1129 # a single run which should not be treated as bad.
1130 bad_cl_builds.add((a.build.bot_id, a.build.build_number))
1132 # Make a list of candidate patches that got incorrectly rejected. We track
1133 # them in a dict, setting good_patch_rejections[patch] = rejections for
1135 good_patch_rejections = collections.defaultdict(list)
1136 for v in falsely_rejected_changes.itervalues():
1138 if (a.action == constants.CL_ACTION_KICKED_OUT and
1139 (a.build.bot_id, a.build.build_number) not in bad_cl_builds):
1140 patch = metadata_lib.GerritPatchTuple(**a.change)
1141 good_patch_rejections[patch].append(a)
1143 return good_patch_rejections
1145 def FalseRejectionRate(self, good_patch_count, good_patch_rejection_count):
1146 """Calculate the false rejection ratio.
1148 This is the chance that a good patch will be rejected by the Pre-CQ or CQ
1152 good_patch_count: The number of good patches in the run.
1153 good_patch_rejection_count: A dict containing the number of false
1154 rejections for the CQ and PRE_CQ.
1157 A dict containing the false rejection ratios for CQ, PRE_CQ, and combined.
1159 false_rejection_rate = dict()
1160 for bot, rejection_count in good_patch_rejection_count.iteritems():
1161 false_rejection_rate[bot] = (
1162 rejection_count * 100 / (rejection_count + good_patch_count)
1164 false_rejection_rate['combined'] = 0
1165 if good_patch_count:
1166 rejection_count = sum(good_patch_rejection_count.values())
1167 false_rejection_rate['combined'] = (
1168 rejection_count * 100 / (good_patch_count + rejection_count)
1170 return false_rejection_rate
1172 def Summarize(self):
1173 """Process, print, and return a summary of cl action statistics.
1175 As a side effect, save summary to self.summary.
1178 A dictionary summarizing the statistics.
1180 super_summary = super(CLStats, self).Summarize()
1182 self.actions = (self.CollectActions() +
1183 self.pre_cq_stats.CollectActions())
1185 (self.per_patch_actions,
1186 self.per_cl_actions) = self.CollateActions(self.actions)
1188 submit_actions = [a for a in self.actions
1189 if a.action == constants.CL_ACTION_SUBMITTED]
1190 reject_actions = [a for a in self.actions
1191 if a.action == constants.CL_ACTION_KICKED_OUT]
1192 sbfail_actions = [a for a in self.actions
1193 if a.action == constants.CL_ACTION_SUBMIT_FAILED]
1195 build_reason_counts = {}
1196 for reason in self.reasons.values():
1197 if reason != 'None':
1198 build_reason_counts[reason] = build_reason_counts.get(reason, 0) + 1
1200 unique_blames = set()
1201 for blames in self.blames.itervalues():
1202 unique_blames.update(blames)
1204 unique_cl_blames = {blame for blame in unique_blames if
1205 EXTERNAL_CL_BASE_URL in blame}
1207 submitted_changes = {k : v for k, v, in self.per_cl_actions.iteritems()
1208 if any(a.action==constants.CL_ACTION_SUBMITTED
1210 submitted_patches = {k : v for k, v, in self.per_patch_actions.iteritems()
1211 if any(a.action==constants.CL_ACTION_SUBMITTED
1214 patch_handle_times = [v[-1].timestamp - v[0].timestamp
1215 for v in submitted_patches.values()]
1217 # Count CLs that were rejected, then a subsequent patch was submitted.
1218 # These are good candidates for bad CLs. We track them in a dict, setting
1219 # submitted_after_new_patch[bot_type][patch] = actions for each bad patch.
1220 submitted_after_new_patch = {}
1221 for x in self.ClassifyRejections(submitted_changes):
1222 change, actions, a, falsely_rejected = x
1223 if not falsely_rejected:
1224 d = submitted_after_new_patch.setdefault(a.bot_type, {})
1227 # Sort the candidate bad CLs in order of submit time.
1228 bad_cl_candidates = {}
1229 for bot_type, patch_actions in submitted_after_new_patch.items():
1230 bad_cl_candidates[bot_type] = [
1231 k for k, _ in sorted(patch_actions.items(),
1232 key=lambda x: x[1][-1].timestamp)]
1234 # Calculate how many good patches were falsely rejected and why.
1235 # good_patch_rejections maps patches to the rejection actions.
1236 # patch_reason_counts maps failure reasons to counts.
1237 # patch_blame_counts maps blame targets to counts.
1238 good_patch_rejections = self.GoodPatchRejections(submitted_changes)
1239 patch_reason_counts = {}
1240 patch_blame_counts = {}
1241 for k, v in good_patch_rejections.iteritems():
1243 if a.action == constants.CL_ACTION_KICKED_OUT:
1244 if a.bot_type == CQ:
1245 reason = self.reasons[a.build.build_number]
1246 blames = self.blames[a.build.build_number]
1247 patch_reason_counts[reason] = patch_reason_counts.get(reason, 0) + 1
1248 for blame in blames:
1249 patch_blame_counts[blame] = patch_blame_counts.get(blame, 0) + 1
1251 # good_patch_count: The number of good patches.
1252 # good_patch_rejection_count maps the bot type (CQ or PRE_CQ) to the number
1253 # of times that bot has falsely rejected good patches.
1254 good_patch_count = len(submit_actions)
1255 good_patch_rejection_count = collections.defaultdict(int)
1256 for k, v in good_patch_rejections.iteritems():
1258 good_patch_rejection_count[a.bot_type] += 1
1259 false_rejection_rate = self.FalseRejectionRate(good_patch_count,
1260 good_patch_rejection_count)
1262 # This list counts how many times each good patch was rejected.
1263 rejection_counts = [0] * (good_patch_count - len(good_patch_rejections))
1264 rejection_counts += [len(x) for x in good_patch_rejections.values()]
1266 # Break down the frequency of how many times each patch is rejected.
1267 good_patch_rejection_breakdown = []
1268 if rejection_counts:
1269 for x in range(max(rejection_counts) + 1):
1270 good_patch_rejection_breakdown.append((x, rejection_counts.count(x)))
1272 correctly_rejected_by_stage, incorrectly_rejected_by_stage = \
1273 self.CalculateStageFailures(reject_actions, submitted_changes,
1274 good_patch_rejections)
1276 summary = {'total_cl_actions' : len(self.actions),
1277 'unique_cls' : len(self.per_cl_actions),
1278 'unique_patches' : len(self.per_patch_actions),
1279 'submitted_patches' : len(submit_actions),
1280 'rejections' : len(reject_actions),
1281 'submit_fails' : len(sbfail_actions),
1282 'good_patch_rejections' : sum(rejection_counts),
1283 'mean_good_patch_rejections' :
1284 numpy.mean(rejection_counts),
1285 'good_patch_rejection_breakdown' :
1286 good_patch_rejection_breakdown,
1287 'good_patch_rejection_count' :
1288 dict(good_patch_rejection_count),
1289 'false_rejection_rate' :
1290 false_rejection_rate,
1291 'median_handling_time' : numpy.median(patch_handle_times),
1292 self.PATCH_HANDLING_TIME_SUMMARY_KEY : patch_handle_times,
1293 'bad_cl_candidates' : bad_cl_candidates,
1294 'correctly_rejected_by_stage' : correctly_rejected_by_stage,
1295 'incorrectly_rejected_by_stage' : incorrectly_rejected_by_stage,
1296 'unique_blames_change_count' : len(unique_cl_blames),
1299 logging.info('CQ committed %s changes', summary['submitted_patches'])
1300 logging.info('CQ correctly rejected %s unique changes',
1301 summary['unique_blames_change_count'])
1302 logging.info('pre-CQ and CQ incorrectly rejected %s changes a total of '
1303 '%s times (pre-CQ: %s; CQ: %s)',
1304 len(good_patch_rejections),
1305 sum(good_patch_rejection_count.values()),
1306 good_patch_rejection_count[PRE_CQ],
1307 good_patch_rejection_count[CQ])
1309 logging.info(' Total CL actions: %d.', summary['total_cl_actions'])
1310 logging.info(' Unique CLs touched: %d.', summary['unique_cls'])
1311 logging.info('Unique patches touched: %d.', summary['unique_patches'])
1312 logging.info(' Total CLs submitted: %d.', summary['submitted_patches'])
1313 logging.info(' Total rejections: %d.', summary['rejections'])
1314 logging.info(' Total submit failures: %d.', summary['submit_fails'])
1315 logging.info(' Good patches rejected: %d.',
1316 len(good_patch_rejections))
1317 logging.info(' Mean rejections per')
1318 logging.info(' good patch: %.2f',
1319 summary['mean_good_patch_rejections'])
1320 logging.info(' False rejection rate for CQ: %.1f%%',
1321 summary['false_rejection_rate'].get(CQ, 0))
1322 logging.info(' False rejection rate for Pre-CQ: %.1f%%',
1323 summary['false_rejection_rate'].get(PRE_CQ, 0))
1324 logging.info(' Combined false rejection rate: %.1f%%',
1325 summary['false_rejection_rate']['combined'])
1327 for x, p in summary['good_patch_rejection_breakdown']:
1328 logging.info('%d good patches were rejected %d times.', p, x)
1329 logging.info(' Median good patch')
1330 logging.info(' handling time: %.2f hours',
1331 summary['median_handling_time']/3600.0)
1333 for bot_type, patches in summary['bad_cl_candidates'].items():
1334 logging.info('%d bad patch candidates were rejected by the %s',
1335 len(patches), bot_type)
1337 logging.info('Bad patch candidate in: CL:%s%s',
1338 constants.INTERNAL_CHANGE_PREFIX
1339 if k.internal else constants.EXTERNAL_CHANGE_PREFIX,
1342 fmt_fai = ' %(cnt)d failures in %(reason)s'
1343 fmt_rej = ' %(cnt)d rejections due to %(reason)s'
1345 logging.info('Reasons why good patches were rejected:')
1346 self._PrintCounts(patch_reason_counts, fmt_rej)
1348 logging.info('Bugs or CLs responsible for good patches rejections:')
1349 self._PrintCounts(patch_blame_counts, fmt_rej)
1351 logging.info('Reasons why builds failed:')
1352 self._PrintCounts(build_reason_counts, fmt_fai)
1354 logging.info('Stages from the Pre-CQ that caught real failures:')
1355 fmt = ' %(cnt)d broken patches were caught by %(reason)s'
1356 self._PrintCounts(correctly_rejected_by_stage.get(PRE_CQ, {}), fmt)
1358 logging.info('Stages from the Pre-CQ that failed but succeeded on retry')
1359 fmt = ' %(cnt)d good patches failed incorrectly in %(reason)s'
1360 self._PrintCounts(incorrectly_rejected_by_stage.get(PRE_CQ, {}), fmt)
1362 super_summary.update(summary)
1363 self.summary = super_summary
1364 return super_summary
1366 # TODO(mtennant): Add token file support. See upload_package_status.py.
1367 def _PrepareCreds(email, password=None):
1368 """Return a gdata_lib.Creds object from given credentials.
1371 email: Email address.
1372 password: Password string. If not specified then a password
1373 prompt will be used.
1376 A gdata_lib.Creds object.
1378 creds = gdata_lib.Creds()
1379 creds.SetCreds(email, password)
1383 def _CheckOptions(options):
1384 # Ensure that specified start date is in the past.
1385 now = datetime.datetime.now()
1386 if options.start_date and now.date() < options.start_date:
1387 cros_build_lib.Error('Specified start date is in the future: %s',
1391 # The --save option requires --email.
1392 if options.save and not options.email:
1393 cros_build_lib.Error('You must specify --email with --save.')
1396 # The --cl-actions option requires --email.
1397 if options.cl_actions and not options.email:
1398 cros_build_lib.Error('You must specify --email with --cl-actions.')
1405 """Creates the argparse parser."""
1406 parser = commandline.ArgumentParser(description=__doc__)
1408 # Put options that control the mode of script into mutually exclusive group.
1409 mode = parser.add_mutually_exclusive_group(required=True)
1410 mode.add_argument('--cq-master', action='store_true', default=False,
1411 help='Gather stats for the CQ master.')
1412 mode.add_argument('--pfq-master', action='store_true', default=False,
1413 help='Gather stats for the PFQ master.')
1414 mode.add_argument('--pre-cq', action='store_true', default=False,
1415 help='Gather stats for the Pre-CQ.')
1416 mode.add_argument('--cq-slaves', action='store_true', default=False,
1417 help='Gather stats for all CQ slaves.')
1418 mode.add_argument('--cl-actions', action='store_true', default=False,
1419 help='Gather stats about CL actions taken by the CQ '
1421 # TODO(mtennant): Other modes as they make sense, like maybe --release.
1423 mode = parser.add_mutually_exclusive_group(required=True)
1424 mode.add_argument('--start-date', action='store', type='date', default=None,
1425 help='Limit scope to a start date in the past.')
1426 mode.add_argument('--past-month', action='store_true', default=False,
1427 help='Limit scope to the past 30 days up to now.')
1428 mode.add_argument('--past-week', action='store_true', default=False,
1429 help='Limit scope to the past week up to now.')
1430 mode.add_argument('--past-day', action='store_true', default=False,
1431 help='Limit scope to the past day up to now.')
1433 parser.add_argument('--starting-build', action='store', type=int, default=0,
1434 help='Filter to builds after given number (inclusive).')
1436 parser.add_argument('--save', action='store_true', default=False,
1437 help='Save results to DB, if applicable.')
1438 parser.add_argument('--email', action='store', type=str, default=None,
1439 help='Specify email for Google Sheets account to use.')
1441 mode = parser.add_argument_group('Advanced (use at own risk)')
1442 mode.add_argument('--no-upload', action='store_false', default=True,
1444 help='Skip uploading results to spreadsheet')
1445 mode.add_argument('--no-carbon', action='store_false', default=True,
1447 help='Skip sending results to carbon/graphite')
1448 mode.add_argument('--no-mark-gathered', action='store_false', default=True,
1449 dest='mark_gathered',
1450 help='Skip marking results as gathered.')
1451 mode.add_argument('--no-sheets-version-filter', action='store_true',
1453 help='Upload all parsed metadata to spreasheet regardless '
1454 'of sheets version.')
1455 mode.add_argument('--override-ss-key', action='store', default=None,
1457 help='Override spreadsheet key.')
1463 parser = GetParser()
1464 options = parser.parse_args(argv)
1466 if not (_CheckOptions(options)):
1469 # Determine the start date to use, which is required.
1470 if options.start_date:
1471 start_date = options.start_date
1473 assert options.past_month or options.past_week or options.past_day
1474 now = datetime.datetime.now()
1475 if options.past_month:
1476 start_date = (now - datetime.timedelta(days=30)).date()
1477 elif options.past_week:
1478 start_date = (now - datetime.timedelta(days=7)).date()
1480 start_date = (now - datetime.timedelta(days=1)).date()
1482 # Prepare the rounds of stats gathering to do.
1485 if options.cq_master:
1486 stats_managers.append(
1488 ss_key=options.ss_key or CQ_SS_KEY,
1489 no_sheets_version_filter=options.no_sheets_version_filter))
1491 if options.cl_actions:
1492 # CL stats manager uses the CQ spreadsheet to fetch failure reasons
1493 stats_managers.append(
1496 ss_key=options.ss_key or CQ_SS_KEY,
1497 no_sheets_version_filter=options.no_sheets_version_filter))
1499 if options.pfq_master:
1500 stats_managers.append(
1502 ss_key=options.ss_key or PFQ_SS_KEY,
1503 no_sheets_version_filter=options.no_sheets_version_filter))
1506 # TODO(mtennant): Add spreadsheet and/or graphite support for pre-cq.
1507 stats_managers.append(PreCQStats())
1509 if options.cq_slaves:
1510 targets = _GetSlavesOfMaster(CQ_MASTER)
1511 for target in targets:
1512 # TODO(mtennant): Add spreadsheet and/or graphite support for cq-slaves.
1513 stats_managers.append(CQSlaveStats(target))
1515 # If options.save is set and any of the instructions include a table class,
1516 # or specify summary columns for upload, prepare spreadsheet creds object
1519 if options.save and any((stats.UPLOAD_ROW_PER_BUILD or
1520 stats.SUMMARY_SPREADSHEET_COLUMNS)
1521 for stats in stats_managers):
1522 # TODO(mtennant): See if this can work with two-factor authentication.
1523 # TODO(mtennant): Eventually, we probably want to use 90-day certs to
1524 # run this as a cronjob on a ganeti instance.
1525 creds = _PrepareCreds(options.email)
1527 # Now run through all the stats gathering that is requested.
1528 for stats_mgr in stats_managers:
1529 stats_mgr.Gather(start_date, starting_build_number=options.starting_build,
1531 stats_mgr.Summarize()
1534 # Send data to spreadsheet, if applicable.
1536 stats_mgr.UploadToSheet(creds)
1538 # Send data to Carbon/Graphite, if applicable.
1540 stats_mgr.SendToCarbon()
1542 # Mark these metadata.json files as processed.
1543 if options.mark_gathered:
1544 stats_mgr.MarkGathered()
1546 cros_build_lib.Info('Finished with %s.\n\n', stats_mgr.config_target)
1549 # Background: This function logs the number of tryjob runs, both internal
1550 # and external, to Graphite. It gets the data from git logs. It was in
1551 # place, in a very different form, before the migration to
1552 # gather_builder_stats. It is simplified here, but entirely untested and
1553 # not plumbed into gather_builder_stats anywhere.
1554 def GraphiteTryJobInfoUpToNow(internal, start_date):
1555 """Find the amount of tryjobs that finished on a particular day.
1558 internal: If true report for internal, if false report external.
1559 start_date: datetime.date object for date to start on.
1563 # Apparently scottz had 'trybot' and 'trybot-internal' checkouts in
1564 # his home directory which this code relied on. Any new solution that
1565 # also relies on git logs will need a place to look for them.
1567 repo_path = '/some/path/to/trybot-internal'
1570 repo_path = '/some/path/to/trybot'
1573 # Make sure the trybot checkout is up to date.
1575 cros_build_lib.RunCommand(['git', 'pull'], cwd=repo_path)
1577 # Now get a list of datetime objects, in hourly deltas.
1578 now = datetime.datetime.now()
1579 start = datetime.datetime(start_date.year, start_date.month, start_date.day)
1580 hour_delta = datetime.timedelta(hours=1)
1581 end = start + hour_delta
1583 git_cmd = ['git', 'log', '--since="%s"' % start,
1584 '--until="%s"' % end, '--name-only', '--pretty=format:']
1585 result = cros_build_lib.RunCommand(git_cmd, cwd=repo_path)
1587 # Expect one line per tryjob run in the specified hour.
1588 count = len(l for l in result.output.splitlines() if l.strip())
1590 carbon_lines.append('buildbot.tryjobs.%s.hourly %s %s' %
1591 (marker, count, (end - EPOCH_START).total_seconds()))
1593 graphite.SendToCarbon(carbon_lines)