1# Copyright 2015 The Chromium 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"""Model for a group of alerts."""
6
7import logging
8
9from google.appengine.ext import ndb
10
11from dashboard import quick_logger
12from dashboard import utils
13
14# Max number of AlertGroup entities to fetch.
15_MAX_GROUPS_TO_FETCH = 2000
16
17
18class AlertGroup(ndb.Model):
19  """Represents a group of alerts that are likely to have the same cause."""
20
21  # Issue tracker id.
22  bug_id = ndb.IntegerProperty(indexed=True)
23
24  # The minimum start of the revision range where the anomaly occurred.
25  start_revision = ndb.IntegerProperty(indexed=True)
26
27  # The minimum end of the revision range where the anomaly occurred.
28  end_revision = ndb.IntegerProperty(indexed=False)
29
30  # A list of test suites.
31  test_suites = ndb.StringProperty(repeated=True, indexed=False)
32
33  # The kind of the alerts in this group. Each group only has one kind.
34  alert_kind = ndb.StringProperty(indexed=False)
35
36  def UpdateRevisionRange(self, grouped_alerts):
37    """Sets this group's revision range the minimum of the given group.
38
39    Args:
40      grouped_alerts: Alert entities that belong to this group. These
41          are only given here so that they don't need to be fetched.
42    """
43    min_rev_range = utils.MinimumAlertRange(grouped_alerts)
44    start, end = min_rev_range if min_rev_range else (None, None)
45    if self.start_revision != start or self.end_revision != end:
46      self.start_revision = start
47      self.end_revision = end
48      self.put()
49
50
51def GroupAlerts(alerts, test_suite, kind):
52  """Groups alerts with matching criteria.
53
54  Assigns a bug_id or a group_id if there is a matching group,
55  otherwise creates a new group for that anomaly.
56
57  Args:
58    alerts: A list of Alerts.
59    test_suite: The test suite name for |alerts|.
60    kind: The kind string of the given alert entity.
61  """
62  if not alerts:
63    return
64  alerts = [a for a in alerts if not getattr(a, 'is_improvement', False)]
65  alerts = sorted(alerts, key=lambda a: a.end_revision)
66  if not alerts:
67    return
68  groups = _FetchAlertGroups(alerts[-1].end_revision)
69  for alert_entity in alerts:
70    if not _FindAlertGroup(alert_entity, groups, test_suite, kind):
71      _CreateGroupForAlert(alert_entity, test_suite, kind)
72
73
74def _FetchAlertGroups(max_start_revision):
75  """Fetches AlertGroup entities up to a given revision."""
76  query = AlertGroup.query(AlertGroup.start_revision <= max_start_revision)
77  query = query.order(-AlertGroup.start_revision)
78  groups = query.fetch(limit=_MAX_GROUPS_TO_FETCH)
79
80  return groups
81
82
83def _FindAlertGroup(alert_entity, groups, test_suite, kind):
84  """Finds and assigns a group for |alert_entity|.
85
86  An alert should only be assigned an existing group if the group if
87  the other alerts in the group are of the same kind, which should be
88  the case if the alert_kind property of the group matches the alert's
89  kind.
90
91  Args:
92    alert_entity: Alert to find group for.
93    groups: List of AlertGroup.
94    test_suite: The test suite of |alert_entity|.
95    kind: The kind string of the given alert entity.
96
97  Returns:
98    True if a group is found and assigned, False otherwise.
99  """
100  for group in groups:
101    if (_IsOverlapping(alert_entity, group.start_revision, group.end_revision)
102        and group.alert_kind == kind
103        and test_suite in group.test_suites):
104      _AddAlertToGroup(alert_entity, group)
105      return True
106  return False
107
108
109def _CreateGroupForAlert(alert_entity, test_suite, kind):
110  """Creates an AlertGroup for |alert_entity|."""
111  group = AlertGroup()
112  group.start_revision = alert_entity.start_revision
113  group.end_revision = alert_entity.end_revision
114  group.test_suites = [test_suite]
115  group.alert_kind = kind
116  group.put()
117  alert_entity.group = group.key
118  logging.debug('Auto triage: Created group %s.', group)
119
120
121def _AddAlertToGroup(alert_entity, group):
122  """Adds an anomaly to group and updates the group's properties."""
123  update_group = False
124  if alert_entity.start_revision > group.start_revision:
125    # TODO(qyearsley): Add test coverage. See catapult:#1346.
126    group.start_revision = alert_entity.start_revision
127    update_group = True
128  if alert_entity.end_revision < group.end_revision:
129    group.end_revision = alert_entity.end_revision
130    update_group = True
131  if update_group:
132    group.put()
133
134  if group.bug_id:
135    alert_entity.bug_id = group.bug_id
136    _AddLogForBugAssociate(alert_entity, group.bug_id)
137  alert_entity.group = group.key
138  logging.debug('Auto triage: Associated anomaly on %s with %s.',
139                utils.TestPath(alert_entity.GetTestMetadataKey()),
140                group.key.urlsafe())
141
142
143def _IsOverlapping(alert_entity, start, end):
144  """Whether |alert_entity| overlaps with |start| and |end| revision range."""
145  return (alert_entity.start_revision <= end and
146          alert_entity.end_revision >= start)
147
148
149def _AddLogForBugAssociate(anomaly_entity, bug_id):
150  """Adds a log for associating alert with a bug."""
151  sheriff = anomaly_entity.GetTestMetadataKey().get().sheriff
152  if not sheriff:
153    return
154  # TODO(qyearsley): Add test coverage. See catapult:#1346.
155  sheriff = sheriff.string_id()
156  bug_url = ('https://chromeperf.appspot.com/group_report?bug_id=' +
157             str(bug_id))
158  test_path = utils.TestPath(anomaly_entity.GetTestMetadataKey())
159  html_str = ('Associated alert on %s with bug <a href="%s">%s</a>.' %
160              (test_path, bug_url, bug_id))
161  formatter = quick_logger.Formatter()
162  logger = quick_logger.QuickLogger('auto_triage', sheriff, formatter)
163  logger.Log(html_str)
164  logger.Save()
165