Xinan Lin | c9f0115 | 2020-02-05 22:05:13 -0800 | [diff] [blame] | 1 | # Copyright 2020 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. |
| 4 | |
| 5 | """Module for collecting and uploading analytics metrics.""" |
| 6 | |
| 7 | import json |
| 8 | import logging |
| 9 | import re |
| 10 | |
| 11 | import constants |
| 12 | import rest_client |
| 13 | |
| 14 | from chromite.api.gen.test_platform.suite_scheduler import analytics_pb2 |
| 15 | from chromite.api.gen.chromite.api import artifacts_pb2 |
| 16 | from chromite.api.gen.chromiumos import branch_pb2 |
| 17 | from chromite.api.gen.chromiumos import common_pb2 |
| 18 | |
| 19 | from google.protobuf import timestamp_pb2 |
| 20 | from google.protobuf import json_format |
| 21 | |
| 22 | |
| 23 | class AnalyticsError(Exception): |
| 24 | """Raised when there is a general error.""" |
| 25 | |
| 26 | |
| 27 | class AnalyticsBase(object): |
| 28 | """Base class to handle analytics data.""" |
| 29 | |
| 30 | def __init__(self, table, message): |
| 31 | """Initialize a base event. |
| 32 | |
| 33 | Args: |
| 34 | table: string of the table name. |
| 35 | message: protobuf object to upload. |
| 36 | """ |
| 37 | self.bq_client = _gen_bq_client(table) |
| 38 | self.message = message |
| 39 | |
| 40 | def upload(self): |
| 41 | """Convey protobuf to json and insert to BQ.""" |
| 42 | rows = [{ |
| 43 | 'json': |
| 44 | json.loads( |
| 45 | json_format.MessageToJson( |
| 46 | self.message, |
| 47 | preserving_proto_field_name=True, |
| 48 | including_default_value_fields=True)) |
| 49 | }] |
| 50 | return self.bq_client.insert(rows) |
| 51 | |
| 52 | class ExecutionTask(AnalyticsBase): |
| 53 | """Track the execution for the queued tasks.""" |
| 54 | |
| 55 | def __init__(self, task_id): |
Xinan Lin | 1e8e791 | 2020-07-31 09:52:16 -0700 | [diff] [blame^] | 56 | """Initialize an ExecutionTask. |
Xinan Lin | c9f0115 | 2020-02-05 22:05:13 -0800 | [diff] [blame] | 57 | |
| 58 | Args: |
| 59 | task_id: string of uuid, assigned by the event which created this task. |
| 60 | """ |
| 61 | self.task = analytics_pb2.ExecutionTask( |
| 62 | queued_task_id=task_id, |
| 63 | request_sent=timestamp_pb2.Timestamp()) |
| 64 | self.task.request_sent.GetCurrentTime() |
| 65 | super(ExecutionTask, self).__init__(constants.Metrics.EXECUTION_TASK_TABLE, |
| 66 | self.task) |
| 67 | |
| 68 | def update_result(self, resp): |
| 69 | """Attach the buildbucket id to task_execution. |
| 70 | |
| 71 | Args: |
| 72 | resp: build_pb2.Build object of Buildbucket response. |
| 73 | """ |
| 74 | try: |
| 75 | if resp.id > 0: |
| 76 | self.task.response.ctp_build_id = str(resp.id) |
| 77 | return |
| 78 | # If id is absent, we should log resp as an error message. |
| 79 | logging.warning('No build id in buildbucket response %s.', str(resp)) |
| 80 | except AttributeError as e: |
| 81 | logging.warning('Failed to parse the buildbucket response, %s.' |
| 82 | 'Error: %s', str(resp), str(e)) |
| 83 | self.task.error.error_message = str(resp) |
| 84 | |
| 85 | |
| 86 | class ScheduleJobSection(AnalyticsBase): |
| 87 | """Record all the scheduling decisions in a job section.""" |
| 88 | |
| 89 | def __init__(self, task_info): |
| 90 | """Initialize a ScheduleJobSection |
| 91 | |
| 92 | Args: |
| 93 | task_info: a config_reader.TaskInfo object. |
| 94 | """ |
| 95 | self.job_section = _job_section(task_info) |
| 96 | self.bq_client = _gen_bq_client(constants.Metrics.TRIGGER_EVENT_TABLE) |
| 97 | super(ScheduleJobSection, self).__init__( |
| 98 | constants.Metrics.TRIGGER_EVENT_TABLE, |
| 99 | self.job_section) |
| 100 | |
| 101 | def add_board(self, board): |
| 102 | """Add a board to job_section. |
| 103 | |
| 104 | Boards added are the target, on which we schedule the configured |
| 105 | suite test. |
| 106 | |
| 107 | Args: |
| 108 | board: a string of board name. |
| 109 | """ |
| 110 | self.job_section.build_targets.add(name=board) |
| 111 | |
| 112 | def add_model(self, model): |
| 113 | """Add a model to job_section. |
| 114 | |
| 115 | Models added are the target, on which we schedule the configured |
| 116 | suite test. If not set in the config, all models under the boards in this |
| 117 | section will be appended. |
| 118 | |
| 119 | Args: |
| 120 | model: a string of model name. |
| 121 | """ |
| 122 | self.job_section.models.add(value=model) |
| 123 | |
| 124 | def add_matched_build(self, board, build_type, milestone, manifest): |
| 125 | """Add an eligible build for this job section. |
| 126 | |
| 127 | Args: |
| 128 | board: a string of board name. |
| 129 | build_type: a string of build type with the board, like 'release'. |
| 130 | milestone: a string milestone, like '80'. |
| 131 | manifest: a string of chromeos version, e.g. '12240.0.0'. |
| 132 | """ |
| 133 | self.job_section.matched_builds.add(release_build=analytics_pb2.BuildInfo( |
| 134 | build_target=common_pb2.BuildTarget(name=board), |
| 135 | milestone=int(milestone), |
| 136 | chrome_os_version=manifest, |
| 137 | type=_branch_type(build_type))) |
| 138 | |
| 139 | def add_matched_relax_build(self, board, build_type, milestone, manifest): |
| 140 | """Add an eligible relax build for this job section. |
| 141 | |
| 142 | Args: |
| 143 | board: a string of board name. |
| 144 | build_type: a string of build type with the board, like 'release'. |
| 145 | milestone: a string milestone, like '80'. |
| 146 | manifest: a string of chromeos version, e.g. '12240.0.0'. |
| 147 | """ |
| 148 | self.job_section.matched_builds.add(relax_build=analytics_pb2.BuildInfo( |
| 149 | build_target=common_pb2.BuildTarget(name=board), |
| 150 | milestone=int(milestone), |
| 151 | chrome_os_version=manifest, |
| 152 | type=_branch_type(build_type))) |
| 153 | |
| 154 | def add_matched_fw_build(self, board, build_type, artifact, read_only=True): |
| 155 | """Add an eligible firmware build for this job section. |
| 156 | |
| 157 | Args: |
| 158 | board: a string of board name. |
| 159 | build_type: a string of build type with the board, like 'release'. |
| 160 | artifact: relative path to the artifact file, e.g. |
| 161 | 'firmware-board-12345.67.B-firmwarebranch/RFoo-1.0.0-b1e234567/board'. |
| 162 | read_only: a boolean if true for RO firmware build and false for RW firmware. |
| 163 | """ |
| 164 | firmware_build = analytics_pb2.FirmwareBuildInfo( |
| 165 | build_target=common_pb2.BuildTarget(name=board), |
| 166 | artifact=artifacts_pb2.Artifact(path=artifact), |
| 167 | type=_branch_type(build_type)) |
| 168 | if read_only: |
| 169 | self.job_section.matched_builds.add( |
| 170 | firmware_ro_build=firmware_build) |
| 171 | else: |
| 172 | self.job_section.matched_builds.add( |
| 173 | firmware_rw_build=firmware_build) |
| 174 | |
| 175 | def add_schedule_job(self, board, model, msg=None, task_id=None): |
| 176 | """Add a schedule job. |
| 177 | |
| 178 | Args: |
| 179 | board: a string of board name. |
| 180 | model: a string of model name. |
| 181 | msg: a string of the reason we drop this build. |
| 182 | task_id: a string of uuid to track it in taskqueue. |
| 183 | |
| 184 | Raise: |
| 185 | AnalyticsError: if both msg/task_id are specified or both are None. |
| 186 | """ |
| 187 | if all([msg, task_id]): |
| 188 | raise AnalyticsError('msg and task_id should not be set both; got' |
| 189 | 'msg: %s, task_id: %s.' % (msg, task_id)) |
| 190 | if not any([msg, task_id]): |
| 191 | raise AnalyticsError('At least one of msg and task_id should ' |
| 192 | 'be set; got msg: %s, task_id: %s.' % (msg, task_id)) |
| 193 | new_job = self.job_section.schedule_jobs.add( |
| 194 | generated_time=timestamp_pb2.Timestamp()) |
| 195 | if model: |
| 196 | new_job.model.value = model |
| 197 | new_job.build_target.name = board |
| 198 | if msg: |
| 199 | new_job.justification = msg |
| 200 | if task_id: |
| 201 | new_job.queued_task_id = task_id |
| 202 | new_job.generated_time.GetCurrentTime() |
| 203 | |
| 204 | |
| 205 | def _gen_bq_client(table): |
| 206 | """A wrapper to generate Bigquery REST client. |
| 207 | |
| 208 | Args: |
| 209 | table: string of the table name. |
| 210 | |
| 211 | Returns: |
| 212 | REST client for Bigquery API. |
| 213 | """ |
| 214 | project = constants.Metrics.PROJECT_ID_STAGING |
| 215 | if (constants.environment() == constants.RunningEnv.ENV_PROD and |
| 216 | constants.application_id() == constants.AppID.PROD_APP): |
| 217 | project = constants.Metrics.PROJECT_ID |
| 218 | return rest_client.BigqueryRestClient( |
| 219 | rest_client.BaseRestClient( |
| 220 | constants.RestClient.BIGQUERY_CLIENT.scopes, |
| 221 | constants.RestClient.BIGQUERY_CLIENT.service_name, |
| 222 | constants.RestClient.BIGQUERY_CLIENT.service_version), |
| 223 | project=project, |
| 224 | dataset=constants.Metrics.DATASET, |
| 225 | table=table) |
| 226 | |
| 227 | |
| 228 | def _parse_branch_spec(spec): |
| 229 | """A wrapper to parse the branch spec into channcel and lag. |
| 230 | |
| 231 | Args: |
| 232 | spec: string of branch spec, e.g. '>=tot-2', '==tot' |
| 233 | |
| 234 | Returns: |
| 235 | channel: analytics_pb2.BranchFilter for the builder type. |
| 236 | lag: number of minor versions behind tip-of-tree. |
| 237 | |
| 238 | Raise: |
| 239 | AnalyticsError: if the spec are not supported. |
| 240 | """ |
| 241 | if not 'tot' in spec: |
| 242 | raise ValueError('Only support branch specs relative to tot, ' |
| 243 | 'e.g. >=tot-2, got %s' % spec) |
| 244 | if spec[:2] not in ["<=", ">=", "=="]: |
| 245 | raise ValueError('Only "<=", ">=", "==" are supported, ' |
| 246 | 'e.g. >=tot-2, got %s' % spec) |
| 247 | to_operator = { |
| 248 | "==": analytics_pb2.BranchFilter.EQ, |
| 249 | ">=": analytics_pb2.BranchFilter.GE, |
| 250 | "<=": analytics_pb2.BranchFilter.LE} |
| 251 | TOT_SPEC = r'.*tot-(?P<lag>.+)' |
| 252 | match = re.match(TOT_SPEC, spec) |
| 253 | if not match: |
| 254 | return analytics_pb2.BranchFilter.MASTER, to_operator[spec[:2]], 0 |
| 255 | lag = match.group('lag') |
| 256 | if lag.isdigit(): |
| 257 | return analytics_pb2.BranchFilter.MASTER, to_operator[spec[:2]], int(lag) |
| 258 | raise ValueError('Failed to get the lag number from spec, %s' % spec) |
| 259 | |
| 260 | |
| 261 | def _job_section(task_info): |
| 262 | """A wrapper to generate job_section from task_info. |
| 263 | |
| 264 | Args: |
| 265 | task_info: a config_reader.TaskInfo object. |
| 266 | |
| 267 | Returns: |
| 268 | job_section: a analytics_pb2.ScheduleJobSection object. |
| 269 | """ |
| 270 | job_section = analytics_pb2.ScheduleJobSection( |
Xinan Lin | 3ffd97c | 2020-03-31 10:34:43 -0700 | [diff] [blame] | 271 | job_name=task_info.name, pool=task_info.pool, suite=task_info.suite) |
Xinan Lin | c9f0115 | 2020-02-05 22:05:13 -0800 | [diff] [blame] | 272 | if task_info.hour: |
| 273 | job_section.schedule_job_trigger.nightly.hour = task_info.hour |
| 274 | elif task_info.day: |
| 275 | job_section.schedule_job_trigger.weekly.day = task_info.day |
| 276 | else: |
| 277 | job_section.schedule_job_trigger.interval.pause = 0 |
| 278 | build_filters = job_section.schedule_job_trigger.build_filters |
| 279 | build_filters.only_hwtest_sanity_required = ( |
| 280 | task_info.only_hwtest_sanity_required is not None) |
| 281 | if task_info.branch_specs: |
| 282 | for spec in task_info.branch_specs: |
| 283 | try: |
| 284 | channel, op, lag = _parse_branch_spec(spec) |
| 285 | build_filters.branch_filters.add( |
| 286 | channel=channel, operator=op, lag=lag) |
| 287 | except ValueError as e: |
| 288 | logging.warning('Failed to parse the spec, %s', str(e)) |
| 289 | if task_info.firmware_rw_build_spec: |
| 290 | build_filters.firmware_rw_build_spec = branch_pb2.Branch.FIRMWARE |
| 291 | if task_info.firmware_ro_build_spec: |
| 292 | build_filters.firmware_ro_build_spec = branch_pb2.Branch.FIRMWARE |
| 293 | return job_section |
| 294 | |
| 295 | |
| 296 | def _branch_type(build_type): |
| 297 | if build_type == "release": |
| 298 | return branch_pb2.Branch.RELEASE |
| 299 | if build_type == "firmware": |
| 300 | return branch_pb2.Branch.FIRMWARE |
| 301 | return branch_pb2.Branch.UNSPECIFIED |