Make devserver record and present a detailed log of client events.

The devserver now stores a complete list of timestamped attributes, as
they are extracted from client messages. A log is indexed by client IP
addresses.  Each of the events in a client's log is a set of attributes
and values, including the type of the event, a status code, the reported
board and OS version, and a timestamp.  A dedicated HTTP API allows to
read client logs in JSON encoding.  Previous client tracking
functionality is preserved for backward compatibility.

TEST=Unittests; complete update cycle of a chromebook client over
a network connection.
BUG=chromium-os:25028

Change-Id: I579d2daf5bf925bd1a75e1a27585f62a59442967
Reviewed-on: https://gerrit.chromium.org/gerrit/14090
Commit-Ready: Gilad Arnold <garnold@chromium.org>
Reviewed-by: Gilad Arnold <garnold@chromium.org>
Tested-by: Gilad Arnold <garnold@chromium.org>
diff --git a/autoupdate.py b/autoupdate.py
index 8e61dab..cd52442 100644
--- a/autoupdate.py
+++ b/autoupdate.py
@@ -39,6 +39,71 @@
   return urlparse.urlunsplit((scheme, netloc, path, query, fragment))
 
 
+class HostInfo:
+  """Records information about an individual host.
+
+  Members:
+    attrs: Static attributes (legacy)
+    log: Complete log of recorded client entries
+  """
+
+  def __init__(self):
+    # A dictionary of current attributes pertaining to the host.
+    self.attrs = {}
+
+    # A list of pairs consisting of a timestamp and a dictionary of recorded
+    # attributes.
+    self.log = []
+
+  def __repr__(self):
+    return 'attrs=%s, log=%s' % (self.attrs, self.log)
+
+  def AddLogEntry(self, entry):
+    """Append a new log entry."""
+    # Append a timestamp.
+    assert not 'timestamp' in entry, 'Oops, timestamp field already in use'
+    entry['timestamp'] = time.strftime('%Y-%m-%d %H:%M:%S')
+    # Add entry to hosts' message log.
+    self.log.append(entry)
+
+  def SetAttr(self, attr, value):
+    """Set an attribute value."""
+    self.attrs[attr] = value
+
+  def GetAttr(self, attr):
+    """Returns the value of an attribute."""
+    if attr in self.attrs:
+      return self.attrs[attr]
+
+  def PopAttr(self, attr, default):
+    """Returns and deletes a particular attribute."""
+    return self.attrs.pop(attr, default)
+
+
+class HostInfoTable:
+  """Records information about a set of hosts who engage in update activity.
+
+  Members:
+    table: Table of information on hosts.
+  """
+
+  def __init__(self):
+    # A dictionary of host information. Keys are normally IP addresses.
+    self.table = {}
+
+  def __repr__(self):
+    return '%s' % self.table
+
+  def GetInitHostInfo(self, host_id):
+    """Return a host's info object, or create a new one if none exists."""
+    return self.table.setdefault(host_id, HostInfo())
+
+  def GetHostInfo(self, host_id):
+    """Return an info object for given host, if such exists."""
+    if host_id in self.table:
+      return self.table[host_id]
+
+
 class Autoupdate(BuildObject):
   """Class that contains functionality that handles Chrome OS update pings.
 
@@ -90,8 +155,10 @@
     self.pregenerated_path = None
 
     # Initialize empty host info cache. Used to keep track of various bits of
-    # information about a given host.
-    self.host_info = {}
+    # information about a given host.  A host is identified by its IP address.
+    # The info stored for each host includes a complete log of events for this
+    # host, as well as a dictionary of current attributes derived from events.
+    self.host_infos = HostInfoTable()
 
   def _GetSecondsSinceMidnight(self):
     """Returns the seconds since midnight as a decimal value."""
@@ -658,16 +725,38 @@
     # Determine request IP, strip any IPv6 data for simplicity.
     client_ip = cherrypy.request.remote.ip.split(':')[-1]
 
-    # Initialize host info dictionary for this client if it doesn't exist.
-    self.host_info.setdefault(client_ip, {})
+    # Obtain (or init) info object for this client.
+    curr_host_info = self.host_infos.GetInitHostInfo(client_ip)
+
+    # Initialize an empty dictionary for event attributes.
+    log_message = {}
 
     # Store event details in the host info dictionary for API usage.
     event = root.getElementsByTagName('o:event')
     if event:
-      self.host_info[client_ip]['last_event_status'] = (
-          int(event[0].getAttribute('eventresult')))
-      self.host_info[client_ip]['last_event_type'] = (
-          int(event[0].getAttribute('eventtype')))
+      event_result = int(event[0].getAttribute('eventresult'))
+      event_type = int(event[0].getAttribute('eventtype'))
+      # Store attributes to legacy host info structure
+      curr_host_info.attrs['last_event_status'] = event_result
+      curr_host_info.attrs['last_event_type'] = event_type
+      # Add attributes to log message
+      log_message['event_result'] = event_result
+      log_message['event_type'] = event_type
+
+    # Get information about the requester.
+    query = root.getElementsByTagName('o:app')[0]
+    if query:
+      client_version = query.getAttribute('version')
+      channel = query.getAttribute('track')
+      board_id = (query.hasAttribute('board') and query.getAttribute('board')
+          or self._GetDefaultBoardID())
+      # Add attributes to log message
+      log_message['version'] = client_version
+      log_message['track'] = channel
+      log_message['board'] = board_id
+
+    # Log client's message
+    curr_host_info.AddLogEntry(log_message)
 
     # We only generate update payloads for updatecheck requests.
     update_check = root.getElementsByTagName('o:updatecheck')
@@ -677,18 +766,11 @@
       # update clients.
       return self.GetNoUpdatePayload()
 
-    # Since this is an updatecheck, get information about the requester.
-    query = root.getElementsByTagName('o:app')[0]
-    client_version = query.getAttribute('version')
-    channel = query.getAttribute('track')
-    board_id = (query.hasAttribute('board') and query.getAttribute('board')
-                or self._GetDefaultBoardID())
-
     # Store version for this host in the cache.
-    self.host_info[client_ip]['last_known_version'] = client_version
+    curr_host_info.attrs['last_known_version'] = client_version
 
     # Check if an update has been forced for this client.
-    forced_update = self.host_info[client_ip].pop('forced_update_label', None)
+    forced_update = curr_host_info.PopAttr('forced_update_label', None)
     if forced_update:
       label = forced_update
 
@@ -723,11 +805,20 @@
   def HandleHostInfoPing(self, ip):
     """Returns host info dictionary for the given IP in JSON format."""
     assert ip, 'No ip provided.'
-    if ip in self.host_info:
-      return json.dumps(self.host_info[ip])
+    if ip in self.host_infos.table:
+      return json.dumps(self.host_infos.GetHostInfo(ip).attrs)
+
+  def HandleHostLogPing(self, ip):
+    """Returns a complete log of events for host in JSON format."""
+    if ip == 'all':
+      return json.dumps(
+          dict([(key, self.host_infos.table[key].log)
+                for key in self.host_infos.table]))
+    if ip in self.host_infos.table:
+      return json.dumps(self.host_infos.GetHostInfo(ip).log)
 
   def HandleSetUpdatePing(self, ip, label):
     """Sets forced_update_label for a given host."""
     assert ip, 'No ip provided.'
     assert label, 'No label provided.'
-    self.host_info.setdefault(ip, {})['forced_update_label'] = label
+    self.host_infos.GetInitHostInfo(ip).attrs['forced_update_label'] = label