merge autoland to mozilla-central a=merge
authorCarsten "Tomcat" Book <cbook@mozilla.com>
Mon, 09 Jan 2017 10:32:06 +0100
changeset 328490 97896f92f196462e0072f8304b97c29217da1327
parent 328469 d192a99be4b436f2dc839435319f7630d5d8f4b0 (current diff)
parent 328489 b1576b5adad1ca5a1e3ad8c6c8cfb951de19087e (diff)
child 328517 701868bfddcba5bdec516be33a86dcd525dc74cf
push id31174
push usercbook@mozilla.com
push dateMon, 09 Jan 2017 09:32:17 +0000
treeherdermozilla-central@97896f92f196 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmerge
milestone53.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
merge autoland to mozilla-central a=merge
--- a/devtools/client/netmonitor/components/request-list-item.js
+++ b/devtools/client/netmonitor/components/request-list-item.js
@@ -1,21 +1,59 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 "use strict";
 
-const { createClass, PropTypes, DOM } = require("devtools/client/shared/vendor/react");
+const { createClass, createFactory, PropTypes, DOM } = require("devtools/client/shared/vendor/react");
 const { div, span, img } = DOM;
 const { L10N } = require("../l10n");
 const { getFormattedSize } = require("../utils/format-utils");
 const { getAbbreviatedMimeType } = require("../request-utils");
 
 /**
+ * Compare two objects on a subset of their properties
+ */
+function propertiesEqual(props, item1, item2) {
+  return item1 === item2 || props.every(p => item1[p] === item2[p]);
+}
+
+/**
+ * Used by shouldComponentUpdate: compare two items, and compare only properties
+ * relevant for rendering the RequestListItem. Other properties (like request and
+ * response headers, cookies, bodies) are ignored. These are very useful for the
+ * sidebar details, but not here.
+ */
+const UPDATED_REQ_ITEM_PROPS = [
+  "mimeType",
+  "eventTimings",
+  "securityState",
+  "responseContentDataUri",
+  "status",
+  "statusText",
+  "fromCache",
+  "fromServiceWorker",
+  "method",
+  "url",
+  "remoteAddress",
+  "cause",
+  "contentSize",
+  "transferredSize",
+  "startedMillis",
+  "totalTime",
+];
+
+const UPDATED_REQ_PROPS = [
+  "index",
+  "isSelected",
+  "firstRequestStartedMillis"
+];
+
+/**
  * Render one row in the request list.
  */
 const RequestListItem = createClass({
   displayName: "RequestListItem",
 
   propTypes: {
     item: PropTypes.object.isRequired,
     index: PropTypes.number.isRequired,
@@ -28,20 +66,18 @@ const RequestListItem = createClass({
 
   componentDidMount() {
     if (this.props.isSelected) {
       this.refs.el.focus();
     }
   },
 
   shouldComponentUpdate(nextProps) {
-    return !relevantPropsEqual(this.props.item, nextProps.item)
-      || this.props.index !== nextProps.index
-      || this.props.isSelected !== nextProps.isSelected
-      || this.props.firstRequestStartedMillis !== nextProps.firstRequestStartedMillis;
+    return !propertiesEqual(UPDATED_REQ_ITEM_PROPS, this.props.item, nextProps.item) ||
+           !propertiesEqual(UPDATED_REQ_PROPS, this.props, nextProps);
   },
 
   componentDidUpdate(prevProps) {
     if (!prevProps.isSelected && this.props.isSelected) {
       this.refs.el.focus();
       if (this.props.onFocusedNodeChange) {
         this.props.onFocusedNodeChange();
       }
@@ -83,218 +119,292 @@ const RequestListItem = createClass({
       {
         ref: "el",
         className: classList.join(" "),
         "data-id": item.id,
         tabIndex: 0,
         onContextMenu,
         onMouseDown,
       },
-      StatusColumn(item),
-      MethodColumn(item),
-      FileColumn(item),
-      DomainColumn(item, onSecurityIconClick),
-      CauseColumn(item),
-      TypeColumn(item),
-      TransferredSizeColumn(item),
-      ContentSizeColumn(item),
-      WaterfallColumn(item, firstRequestStartedMillis)
+      StatusColumn({ item }),
+      MethodColumn({ item }),
+      FileColumn({ item }),
+      DomainColumn({ item, onSecurityIconClick }),
+      CauseColumn({ item }),
+      TypeColumn({ item }),
+      TransferredSizeColumn({ item }),
+      ContentSizeColumn({ item }),
+      WaterfallColumn({ item, firstRequestStartedMillis })
     );
   }
 });
 
-/**
- * Used by shouldComponentUpdate: compare two items, and compare only properties
- * relevant for rendering the RequestListItem. Other properties (like request and
- * response headers, cookies, bodies) are ignored. These are very useful for the
- * sidebar details, but not here.
- */
-const RELEVANT_ITEM_PROPS = [
+const UPDATED_STATUS_PROPS = [
   "status",
   "statusText",
   "fromCache",
   "fromServiceWorker",
-  "method",
-  "url",
-  "responseContentDataUri",
-  "remoteAddress",
-  "securityState",
-  "cause",
-  "mimeType",
-  "contentSize",
-  "transferredSize",
-  "startedMillis",
-  "totalTime",
-  "eventTimings",
 ];
 
-function relevantPropsEqual(item1, item2) {
-  return item1 === item2 || RELEVANT_ITEM_PROPS.every(p => item1[p] === item2[p]);
-}
+const StatusColumn = createFactory(createClass({
+  shouldComponentUpdate(nextProps) {
+    return !propertiesEqual(UPDATED_STATUS_PROPS, this.props.item, nextProps.item);
+  },
 
-function StatusColumn(item) {
-  const { status, statusText, fromCache, fromServiceWorker } = item;
+  render() {
+    const { status, statusText, fromCache, fromServiceWorker } = this.props.item;
 
-  let code, title;
+    let code, title;
 
-  if (status) {
-    if (fromCache) {
-      code = "cached";
-    } else if (fromServiceWorker) {
-      code = "service worker";
-    } else {
-      code = status;
-    }
+    if (status) {
+      if (fromCache) {
+        code = "cached";
+      } else if (fromServiceWorker) {
+        code = "service worker";
+      } else {
+        code = status;
+      }
 
-    if (statusText) {
-      title = `${status} ${statusText}`;
-      if (fromCache) {
-        title += " (cached)";
-      }
-      if (fromServiceWorker) {
-        title += " (service worker)";
+      if (statusText) {
+        title = `${status} ${statusText}`;
+        if (fromCache) {
+          title += " (cached)";
+        }
+        if (fromServiceWorker) {
+          title += " (service worker)";
+        }
       }
     }
-  }
 
-  return div({ className: "requests-menu-subitem requests-menu-status", title },
-    div({ className: "requests-menu-status-icon", "data-code": code }),
-    span({ className: "subitem-label requests-menu-status-code" }, status)
-  );
-}
+    return div({ className: "requests-menu-subitem requests-menu-status", title },
+      div({ className: "requests-menu-status-icon", "data-code": code }),
+      span({ className: "subitem-label requests-menu-status-code" }, status)
+    );
+  }
+}));
+
+const MethodColumn = createFactory(createClass({
+  shouldComponentUpdate(nextProps) {
+    return this.props.item.method !== nextProps.item.method;
+  },
 
-function MethodColumn(item) {
-  const { method } = item;
-  return div({ className: "requests-menu-subitem requests-menu-method-box" },
-    span({ className: "subitem-label requests-menu-method" }, method)
-  );
-}
+  render() {
+    const { method } = this.props.item;
+    return div({ className: "requests-menu-subitem requests-menu-method-box" },
+      span({ className: "subitem-label requests-menu-method" }, method)
+    );
+  }
+}));
 
-function FileColumn(item) {
-  const { urlDetails, responseContentDataUri } = item;
+const UPDATED_FILE_PROPS = [
+  "urlDetails",
+  "responseContentDataUri",
+];
 
-  return div({ className: "requests-menu-subitem requests-menu-icon-and-file" },
-    img({
-      className: "requests-menu-icon",
-      src: responseContentDataUri,
-      hidden: !responseContentDataUri,
-      "data-type": responseContentDataUri ? "thumbnail" : undefined
-    }),
-    div(
-      {
-        className: "subitem-label requests-menu-file",
-        title: urlDetails.unicodeUrl
-      },
-      urlDetails.baseNameWithQuery
-    )
-  );
-}
+const FileColumn = createFactory(createClass({
+  shouldComponentUpdate(nextProps) {
+    return !propertiesEqual(UPDATED_FILE_PROPS, this.props.item, nextProps.item);
+  },
+
+  render() {
+    const { urlDetails, responseContentDataUri } = this.props.item;
 
-function DomainColumn(item, onSecurityIconClick) {
-  const { urlDetails, remoteAddress, securityState } = item;
+    return div({ className: "requests-menu-subitem requests-menu-icon-and-file" },
+      img({
+        className: "requests-menu-icon",
+        src: responseContentDataUri,
+        hidden: !responseContentDataUri,
+        "data-type": responseContentDataUri ? "thumbnail" : undefined
+      }),
+      div(
+        {
+          className: "subitem-label requests-menu-file",
+          title: urlDetails.unicodeUrl
+        },
+        urlDetails.baseNameWithQuery
+      )
+    );
+  }
+}));
+
+const UPDATED_DOMAIN_PROPS = [
+  "urlDetails",
+  "remoteAddress",
+  "securityState",
+];
 
-  let iconClassList = [ "requests-security-state-icon" ];
-  let iconTitle;
-  if (urlDetails.isLocal) {
-    iconClassList.push("security-state-local");
-    iconTitle = L10N.getStr("netmonitor.security.state.secure");
-  } else if (securityState) {
-    iconClassList.push(`security-state-${securityState}`);
-    iconTitle = L10N.getStr(`netmonitor.security.state.${securityState}`);
-  }
+const DomainColumn = createFactory(createClass({
+  shouldComponentUpdate(nextProps) {
+    return !propertiesEqual(UPDATED_DOMAIN_PROPS, this.props.item, nextProps.item);
+  },
+
+  render() {
+    const { item, onSecurityIconClick } = this.props;
+    const { urlDetails, remoteAddress, securityState } = item;
 
-  let title = urlDetails.host + (remoteAddress ? ` (${remoteAddress})` : "");
+    let iconClassList = [ "requests-security-state-icon" ];
+    let iconTitle;
+    if (urlDetails.isLocal) {
+      iconClassList.push("security-state-local");
+      iconTitle = L10N.getStr("netmonitor.security.state.secure");
+    } else if (securityState) {
+      iconClassList.push(`security-state-${securityState}`);
+      iconTitle = L10N.getStr(`netmonitor.security.state.${securityState}`);
+    }
+
+    let title = urlDetails.host + (remoteAddress ? ` (${remoteAddress})` : "");
 
-  return div(
-    { className: "requests-menu-subitem requests-menu-security-and-domain" },
-    div({
-      className: iconClassList.join(" "),
-      title: iconTitle,
-      onClick: onSecurityIconClick,
-    }),
-    span({ className: "subitem-label requests-menu-domain", title }, urlDetails.host)
-  );
-}
+    return div(
+      { className: "requests-menu-subitem requests-menu-security-and-domain" },
+      div({
+        className: iconClassList.join(" "),
+        title: iconTitle,
+        onClick: onSecurityIconClick,
+      }),
+      span({ className: "subitem-label requests-menu-domain", title }, urlDetails.host)
+    );
+  }
+}));
 
-function CauseColumn(item) {
-  const { cause } = item;
+const CauseColumn = createFactory(createClass({
+  shouldComponentUpdate(nextProps) {
+    return this.props.item.cause !== nextProps.item.cause;
+  },
 
-  let causeType = "";
-  let causeUri = undefined;
-  let causeHasStack = false;
+  render() {
+    const { cause } = this.props.item;
+
+    let causeType = "";
+    let causeUri = undefined;
+    let causeHasStack = false;
 
-  if (cause) {
-    causeType = cause.type;
-    causeUri = cause.loadingDocumentUri;
-    causeHasStack = cause.stacktrace && cause.stacktrace.length > 0;
-  }
+    if (cause) {
+      causeType = cause.type;
+      causeUri = cause.loadingDocumentUri;
+      causeHasStack = cause.stacktrace && cause.stacktrace.length > 0;
+    }
 
-  return div(
-    { className: "requests-menu-subitem requests-menu-cause", title: causeUri },
-    span({ className: "requests-menu-cause-stack", hidden: !causeHasStack }, "JS"),
-    span({ className: "subitem-label" }, causeType)
-  );
-}
+    return div(
+      { className: "requests-menu-subitem requests-menu-cause", title: causeUri },
+      span({ className: "requests-menu-cause-stack", hidden: !causeHasStack }, "JS"),
+      span({ className: "subitem-label" }, causeType)
+    );
+  }
+}));
 
 const CONTENT_MIME_TYPE_ABBREVIATIONS = {
   "ecmascript": "js",
   "javascript": "js",
   "x-javascript": "js"
 };
 
-function TypeColumn(item) {
-  const { mimeType } = item;
-  let abbrevType;
-  if (mimeType) {
-    abbrevType = getAbbreviatedMimeType(mimeType);
-    abbrevType = CONTENT_MIME_TYPE_ABBREVIATIONS[abbrevType] || abbrevType;
+const TypeColumn = createFactory(createClass({
+  shouldComponentUpdate(nextProps) {
+    return this.props.item.mimeType !== nextProps.item.mimeType;
+  },
+
+  render() {
+    const { mimeType } = this.props.item;
+    let abbrevType;
+    if (mimeType) {
+      abbrevType = getAbbreviatedMimeType(mimeType);
+      abbrevType = CONTENT_MIME_TYPE_ABBREVIATIONS[abbrevType] || abbrevType;
+    }
+
+    return div(
+      { className: "requests-menu-subitem requests-menu-type", title: mimeType },
+      span({ className: "subitem-label" }, abbrevType)
+    );
   }
+}));
 
-  return div(
-    { className: "requests-menu-subitem requests-menu-type", title: mimeType },
-    span({ className: "subitem-label" }, abbrevType)
-  );
-}
+const UPDATED_TRANSFERRED_PROPS = [
+  "transferredSize",
+  "fromCache",
+  "fromServiceWorker",
+];
+
+const TransferredSizeColumn = createFactory(createClass({
+  shouldComponentUpdate(nextProps) {
+    return !propertiesEqual(UPDATED_TRANSFERRED_PROPS, this.props.item, nextProps.item);
+  },
+
+  render() {
+    const { transferredSize, fromCache, fromServiceWorker } = this.props.item;
 
-function TransferredSizeColumn(item) {
-  const { transferredSize, fromCache, fromServiceWorker } = item;
+    let text;
+    let className = "subitem-label";
+    if (fromCache) {
+      text = L10N.getStr("networkMenu.sizeCached");
+      className += " theme-comment";
+    } else if (fromServiceWorker) {
+      text = L10N.getStr("networkMenu.sizeServiceWorker");
+      className += " theme-comment";
+    } else if (typeof transferredSize == "number") {
+      text = getFormattedSize(transferredSize);
+    } else if (transferredSize === null) {
+      text = L10N.getStr("networkMenu.sizeUnavailable");
+    }
 
-  let text;
-  let className = "subitem-label";
-  if (fromCache) {
-    text = L10N.getStr("networkMenu.sizeCached");
-    className += " theme-comment";
-  } else if (fromServiceWorker) {
-    text = L10N.getStr("networkMenu.sizeServiceWorker");
-    className += " theme-comment";
-  } else if (typeof transferredSize == "number") {
-    text = getFormattedSize(transferredSize);
-  } else if (transferredSize === null) {
-    text = L10N.getStr("networkMenu.sizeUnavailable");
+    return div(
+      { className: "requests-menu-subitem requests-menu-transferred", title: text },
+      span({ className }, text)
+    );
   }
+}));
+
+const ContentSizeColumn = createFactory(createClass({
+  shouldComponentUpdate(nextProps) {
+    return this.props.item.contentSize !== nextProps.item.contentSize;
+  },
+
+  render() {
+    const { contentSize } = this.props.item;
+
+    let text;
+    if (typeof contentSize == "number") {
+      text = getFormattedSize(contentSize);
+    }
 
-  return div(
-    { className: "requests-menu-subitem requests-menu-transferred", title: text },
-    span({ className }, text)
-  );
-}
+    return div(
+      {
+        className: "requests-menu-subitem subitem-label requests-menu-size",
+        title: text
+      },
+      span({ className: "subitem-label" }, text)
+    );
+  }
+}));
 
-function ContentSizeColumn(item) {
-  const { contentSize } = item;
+const UPDATED_WATERFALL_PROPS = [
+  "eventTimings",
+  "totalTime",
+  "fromCache",
+  "fromServiceWorker",
+];
 
-  let text;
-  if (typeof contentSize == "number") {
-    text = getFormattedSize(contentSize);
-  }
+const WaterfallColumn = createFactory(createClass({
+  shouldComponentUpdate(nextProps) {
+    return this.props.firstRequestStartedMillis !== nextProps.firstRequestStartedMillis ||
+           !propertiesEqual(UPDATED_WATERFALL_PROPS, this.props.item, nextProps.item);
+  },
 
-  return div(
-    { className: "requests-menu-subitem subitem-label requests-menu-size", title: text },
-    span({ className: "subitem-label" }, text)
-  );
-}
+  render() {
+    const { item, firstRequestStartedMillis } = this.props;
+    const startedDeltaMillis = item.startedMillis - firstRequestStartedMillis;
+    const paddingInlineStart = `${startedDeltaMillis}px`;
+
+    return div({ className: "requests-menu-subitem requests-menu-waterfall" },
+      div(
+        { className: "requests-menu-timings", style: { paddingInlineStart } },
+        timingBoxes(item)
+      )
+    );
+  }
+}));
 
 // List of properties of the timing info we want to create boxes for
 const TIMING_KEYS = ["blocked", "dns", "connect", "send", "wait", "receive"];
 
 function timingBoxes(item) {
   const { eventTimings, totalTime, fromCache, fromServiceWorker } = item;
   let boxes = [];
 
@@ -326,21 +436,9 @@ function timingBoxes(item) {
       className: "requests-menu-timings-total",
       title: text
     }, text));
   }
 
   return boxes;
 }
 
-function WaterfallColumn(item, firstRequestStartedMillis) {
-  const startedDeltaMillis = item.startedMillis - firstRequestStartedMillis;
-  const paddingInlineStart = `${startedDeltaMillis}px`;
-
-  return div({ className: "requests-menu-subitem requests-menu-waterfall" },
-    div(
-      { className: "requests-menu-timings", style: { paddingInlineStart } },
-      timingBoxes(item)
-    )
-  );
-}
-
 module.exports = RequestListItem;
new file mode 100644
--- /dev/null
+++ b/devtools/client/netmonitor/shared/components/editor.js
@@ -0,0 +1,92 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const { createClass, DOM, PropTypes } = require("devtools/client/shared/vendor/react");
+const SourceEditor = require("devtools/client/sourceeditor/editor");
+
+const { div } = DOM;
+
+/**
+ * CodeMirror editor as a React component
+ */
+const Editor = createClass({
+  displayName: "Editor",
+
+  propTypes: {
+    open: PropTypes.bool,
+    text: PropTypes.string,
+  },
+
+  getDefaultProps() {
+    return {
+      open: true,
+      text: "",
+    };
+  },
+
+  componentDidMount() {
+    const { text } = this.props;
+
+    this.editor = new SourceEditor({
+      lineNumbers: true,
+      readOnly: true,
+      value: text,
+    });
+
+    this.deferEditor = this.editor.appendTo(this.refs.editorElement);
+  },
+
+  componentDidUpdate(prevProps) {
+    const { mode, open, text } = this.props;
+
+    if (!open) {
+      return;
+    }
+
+    if (prevProps.mode !== mode) {
+      this.deferEditor.then(() => {
+        this.editor.setMode(mode);
+      });
+    }
+
+    if (prevProps.text !== text) {
+      this.deferEditor.then(() => {
+        // FIXME: Workaround for browser_net_accessibility test to
+        // make sure editor node exists while setting editor text.
+        // deferEditor workround should be removed in bug 1308442
+        if (this.refs.editor) {
+          this.editor.setText(text);
+        }
+      });
+    }
+  },
+
+  componentWillUnmount() {
+    this.deferEditor.then(() => {
+      this.editor.destroy();
+      this.editor = null;
+    });
+    this.deferEditor = null;
+  },
+
+  render() {
+    const { open } = this.props;
+
+    return (
+      div({ className: "editor-container devtools-monospace" },
+        div({
+          ref: "editorElement",
+          className: "editor-mount devtools-monospace",
+          // Using visibility instead of display property to avoid breaking
+          // CodeMirror indentation
+          style: { visibility: open ? "visible" : "hidden" },
+        }),
+      )
+    );
+  }
+});
+
+module.exports = Editor;
--- a/devtools/client/netmonitor/shared/components/moz.build
+++ b/devtools/client/netmonitor/shared/components/moz.build
@@ -1,9 +1,11 @@
 # This Source Code Form is subject to the terms of the Mozilla Public
 # License, v. 2.0. If a copy of the MPL was not distributed with this
 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
 
 DevToolsModules(
+    'editor.js',
     'preview-panel.js',
+    'properties-view.js',
     'security-panel.js',
     'timings-panel.js',
 )
new file mode 100644
--- /dev/null
+++ b/devtools/client/netmonitor/shared/components/properties-view.js
@@ -0,0 +1,162 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const {
+  createClass,
+  createFactory,
+  DOM,
+  PropTypes,
+} = require("devtools/client/shared/vendor/react");
+const { createFactories } = require("devtools/client/shared/components/reps/rep-utils");
+const { MODE } = require("devtools/client/shared/components/reps/constants");
+const { FILTER_SEARCH_DELAY } = require("../../constants");
+
+// Components
+const Editor = createFactory(require("devtools/client/netmonitor/shared/components/editor"));
+const SearchBox = createFactory(require("devtools/client/shared/components/search-box"));
+const TreeView = createFactory(require("devtools/client/shared/components/tree/tree-view"));
+const TreeRow = createFactory(require("devtools/client/shared/components/tree/tree-row"));
+const { Rep } = createFactories(require("devtools/client/shared/components/reps/rep"));
+
+const { div, tr, td } = DOM;
+
+/*
+ * Properties View component
+ * A scrollable tree view component which provides some useful features for
+ * representing object properties.
+ *
+ * Search filter - Set enableFilter to enable / disable SearchBox feature.
+ * Tree view - Default enabled.
+ * Source editor - Enable by specifying object level 1 property name to "editorText".
+ * Rep - Default enabled.
+ */
+const PropertiesView = createClass({
+  displayName: "PropertiesView",
+
+  propTypes: {
+    object: PropTypes.object,
+    enableInput: PropTypes.bool,
+    expandableStrings: PropTypes.bool,
+    filterPlaceHolder: PropTypes.string,
+    sectionNames: PropTypes.array,
+  },
+
+  getDefaultProps() {
+    return {
+      enableInput: true,
+      enableFilter: true,
+      expandableStrings: false,
+      filterPlaceHolder: "",
+    };
+  },
+
+  getInitialState() {
+    return {
+      filterText: "",
+    };
+  },
+
+  getRowClass(object, sectionNames) {
+    return sectionNames.includes(object.name) ? "tree-section" : "";
+  },
+
+  onFilter(object, whiteList) {
+    let { name, value } = object;
+    let filterText = this.state.filterText;
+
+    if (!filterText || whiteList.includes(name)) {
+      return true;
+    }
+
+    let jsonString = JSON.stringify({ [name]: value }).toLowerCase();
+    return jsonString.includes(filterText.toLowerCase());
+  },
+
+  renderRowWithEditor(props) {
+    const { level, name, value } = props.member;
+    // Display source editor when prop name specify to editorText
+    if (level === 1 && name === "editorText") {
+      return (
+        tr({},
+          td({ colSpan: 2 },
+            Editor({ text: value })
+          )
+        )
+      );
+    }
+
+    return TreeRow(props);
+  },
+
+  renderValueWithRep(props) {
+    // Hide rep summary for sections
+    if (props.member.level === 0) {
+      return null;
+    }
+
+    return Rep(Object.assign(props, {
+      // FIXME: A workaround for the issue in StringRep
+      // Force StringRep to crop the text everytime
+      member: Object.assign({}, props.member, { open: false }),
+      mode: MODE.TINY,
+      cropLimit: 60,
+    }));
+  },
+
+  updateFilterText(filterText) {
+    this.setState({
+      filterText,
+    });
+  },
+
+  render() {
+    const {
+      object,
+      decorator,
+      enableInput,
+      enableFilter,
+      expandableStrings,
+      filterPlaceHolder,
+      renderRow,
+      renderValue,
+      sectionNames,
+    } = this.props;
+
+    return (
+      div({ className: "properties-view" },
+        enableFilter && div({ className: "searchbox-section" },
+          SearchBox({
+            delay: FILTER_SEARCH_DELAY,
+            type: "filter",
+            onChange: this.updateFilterText,
+            placeholder: filterPlaceHolder,
+          }),
+        ),
+        div({ className: "tree-container" },
+          TreeView({
+            object,
+            columns: [{
+              id: "value",
+              width: "100%",
+            }],
+            decorator: decorator || {
+              getRowClass: (rowObject) => this.getRowClass(rowObject, sectionNames),
+            },
+            enableInput,
+            expandableStrings,
+            expandedNodes: new Set(sectionNames.map((sec) => "/" + sec)),
+            onFilter: (props) => this.onFilter(props, sectionNames),
+            renderRow: renderRow || this.renderRowWithEditor,
+            renderValue: renderValue || this.renderValueWithRep,
+          }),
+        ),
+      )
+    );
+  }
+
+});
+
+module.exports = PropertiesView;
--- a/devtools/client/themes/netmonitor.css
+++ b/devtools/client/themes/netmonitor.css
@@ -1115,34 +1115,83 @@
   outline: 0;
   box-shadow: var(--theme-focus-box-shadow-textbox);
 }
 
 .treeTable .treeLabel {
   font-weight: 600;
 }
 
-/* Customize default tree table style to align with devtools theme  */
-.theme-light .treeTable .treeLabel,
-.theme-light .treeTable .treeRow.hasChildren > .treeLabelCell > .treeLabel:hover {
-  color: var(--theme-highlight-red);
+.properties-view {
+  /* FIXME: Minus 24px * 2 for toolbox height + panel height
+   * Give a fixed panel container height in order to force tree view scrollable */
+  height: calc(100vh - 48px);
+  display: flex;
+  flex-direction: column;
+}
+
+.properties-view .searchbox-section {
+  flex: 0 1 auto;
+}
+
+.properties-view .devtools-searchbox {
+  padding: 0;
+}
+
+.properties-view .devtools-searchbox input {
+  margin: 1px 3px;
+}
+
+.tree-container {
+  position: relative;
+  height: 100%;
 }
 
-.theme-dark .treeTable .treeLabel,
-.theme-dark .treeTable .treeRow.hasChildren > .treeLabelCell > .treeLabel:hover {
-  color: var(--theme-highlight-purple);
+/* Make treeTable fill parent element and scrollable */
+.tree-container .treeTable {
+  position: absolute;
+  display: block;
+  overflow-y: auto;
+  top: 0;
+  right: 0;
+  bottom: 0;
+  left: 0;
+}
+
+.properties-view .devtools-searchbox,
+.tree-container .treeTable .tree-section {
+  width: 100%;
+  background-color: var(--theme-toolbar-background);
+}
+
+.tree-container .treeTable .tree-section > * {
+  vertical-align: middle;
 }
 
-.theme-firebug .treeTable .treeLabel {
-  color: var(--theme-body-color);
+.tree-container .treeTable .treeRow.tree-section > .treeLabelCell > .treeLabel,
+.tree-container .treeTable .treeRow.tree-section > .treeLabelCell > .treeLabel:hover {
+  color: var(--theme-body-color-alt);
+}
+
+.tree-container .treeTable .treeValueCell {
+  /* FIXME: Make value cell can be reduced to shorter width */
+  max-width: 0;
+  padding-inline-end: 5px;
 }
 
-.treeTable .treeRow.hasChildren > .treeLabelCell > .treeLabel:hover {
-  cursor: default;
-  text-decoration: none;
+.tree-container .objectBox {
+  white-space: nowrap;
+}
+
+.editor-container,
+.editor-mount,
+.editor-mount iframe {
+  border: none;
+  width: 100%;
+  height: 100%;
 }
 
 /*
  * FIXME: normal html block element cannot fill outer XUL element
  * This workaround should be removed after netmonitor is migrated to react
  */
 #react-preview-tabpanel-hook,
 #react-security-tabpanel-hook,
--- a/layout/style/test/browser.ini
+++ b/layout/style/test/browser.ini
@@ -1,8 +1,9 @@
 [DEFAULT]
 support-files =
   bug453896_iframe.html
   media_queries_iframe.html
   newtab_share_rule_processors.html
 
 [browser_bug453896.js]
 [browser_newtab_share_rule_processors.js]
+skip-if = stylo # bug 1290224
--- a/layout/style/test/chrome/chrome.ini
+++ b/layout/style/test/chrome/chrome.ini
@@ -10,12 +10,14 @@ support-files =
   mismatch.png
 
 [test_author_specified_style.html]
 [test_bug418986-2.xul]
 [test_bug1157097.html]
 [test_bug1160724.xul]
 [test_bug535806.xul]
 [test_display_mode.html]
+tags = fullscreen
 [test_display_mode_reflow.html]
 tags = fullscreen
 [test_hover.html]
 [test_moz_document_rules.html]
+skip-if = stylo # bug 1290224
--- a/layout/style/test/mochitest.ini
+++ b/layout/style/test/mochitest.ini
@@ -31,19 +31,23 @@ support-files =
   visited_image_loading_frame.html
   visited_image_loading.sjs
   visited-lying-inner.html
   visited-pref-iframe.html
   xbl_bindings.xml
 
 [test_acid3_test46.html]
 [test_addSheet.html]
+skip-if = stylo # bug 1290224
 support-files = additional_sheets_helper.html
 [test_additional_sheets.html]
+skip-if = stylo # bug 1290224
 support-files = additional_sheets_helper.html
+[test_align_justify_computed_values.html]
+[test_align_shorthand_serialization.html]
 [test_all_shorthand.html]
 [test_animations.html]
 skip-if = toolkit == 'android'
 [test_animations_async_tests.html]
 support-files = ../../reftests/fonts/Ahem.ttf file_animations_async_tests.html
 [test_animations_dynamic_changes.html]
 [test_animations_effect_timing_duration.html]
 support-files = file_animations_effect_timing_duration.html
@@ -57,20 +61,23 @@ support-files = file_animations_effect_t
 support-files = file_animations_iterationstart.html
 [test_animations_omta.html]
 [test_animations_omta_start.html]
 [test_animations_pausing.html]
 support-files = file_animations_pausing.html
 [test_animations_playbackrate.html]
 support-files = file_animations_playbackrate.html
 [test_animations_styles_on_event.html]
+skip-if = stylo # timeout bug 1328505
 support-files = file_animations_styles_on_event.html
 [test_animations_with_disabled_properties.html]
+skip-if = stylo # timeout bug 1328503
 support-files = file_animations_with_disabled_properties.html
 [test_any_dynamic.html]
+[test_asyncopen2.html]
 [test_at_rule_parse_serialize.html]
 [test_attribute_selector_eof_behavior.html]
 [test_background_blend_mode.html]
 [test_box_size_keywords.html]
 [test_bug73586.html]
 [test_bug74880.html]
 [test_bug98997.html]
 [test_bug160403.html]
@@ -137,16 +144,17 @@ support-files = bug732209-css.sjs
 [test_bug829816.html]
 [test_bug874919.html]
 support-files = file_bug829816.css
 [test_bug887741_at-rules_in_declaration_lists.html]
 [test_bug892929.html]
 [test_bug1055933.html]
 support-files = file_bug1055933_circle-xxl.png
 [test_bug1089417.html]
+skip-if = stylo # bug 1323665
 support-files = file_bug1089417_iframe.html
 [test_bug1112014.html]
 [test_bug1203766.html]
 [test_bug1232829.html]
 [test_bug1292447.html]
 [test_cascade.html]
 [test_ch_ex_no_infloops.html]
 [test_change_hint_optimizations.html]
@@ -164,26 +172,27 @@ skip-if = toolkit == 'android'
 [test_counter_style.html]
 [test_css_cross_domain.html]
 skip-if = toolkit == 'android' #bug 536603
 [test_css_eof_handling.html]
 [test_css_escape_api.html]
 [test_css_function_mismatched_parenthesis.html]
 [test_css_loader_crossorigin_data_url.html]
 [test_css_supports.html]
+skip-if = stylo # bug 1323715
 [test_css_supports_variables.html]
+skip-if = stylo # bug 1323715
 [test_default_bidi_css.html]
 [test_default_computed_style.html]
 [test_descriptor_storage.html]
 [test_descriptor_syntax_errors.html]
 [test_dont_use_document_colors.html]
 [test_dynamic_change_causing_reflow.html]
 [test_exposed_prop_accessors.html]
 [test_extra_inherit_initial.html]
-[test_align_justify_computed_values.html]
 [test_flexbox_child_display_values.xhtml]
 [test_flexbox_flex_grow_and_shrink.html]
 [test_flexbox_flex_shorthand.html]
 [test_flexbox_layout.html]
 support-files = flexbox_layout_testcases.js
 [test_flexbox_order.html]
 [test_flexbox_order_table.html]
 [test_flexbox_reflow_counts.html]
@@ -201,21 +210,19 @@ support-files =
 [test_grid_computed_values.html]
 [test_group_insertRule.html]
 [test_hover_quirk.html]
 [test_html_attribute_computed_values.html]
 [test_ident_escaping.html]
 [test_inherit_computation.html]
 skip-if = toolkit == 'android'
 [test_inherit_storage.html]
-tags = stylo
 [test_initial_computation.html]
 skip-if = toolkit == 'android'
 [test_initial_storage.html]
-tags = stylo
 [test_keyframes_rules.html]
 [test_load_events_on_stylesheets.html]
 [test_logical_properties.html]
 [test_media_queries.html]
 skip-if = android_version == '18' #debug-only failure; timed out #Android 4.3 aws only; bug 1030419
 [test_media_queries_dynamic.html]
 [test_media_queries_dynamic_xbl.html]
 [test_media_query_list.html]
@@ -233,16 +240,17 @@ skip-if = android_version == '18' #debug
 [test_position_float_display.html]
 [test_position_sticky.html]
 [test_priority_preservation.html]
 [test_property_database.html]
 [test_property_syntax_errors.html]
 [test_pseudoelement_state.html]
 [test_pseudoelement_parsing.html]
 [test_redundant_font_download.html]
+skip-if = stylo # bug 1323665
 support-files = redundant_font_download.sjs
 [test_rem_unit.html]
 [test_restyles_in_smil_animation.html]
 [test_root_node_display.html]
 [test_rule_insertion.html]
 [test_rule_serialization.html]
 [test_rules_out_of_sheets.html]
 [test_selectors.html]
@@ -255,60 +263,64 @@ skip-if = toolkit == 'android' #bug 7752
 [test_style_attribute_standards.html]
 [test_style_struct_copy_constructors.html]
 [test_supports_rules.html]
 [test_system_font_serialization.html]
 [test_text_decoration_shorthands.html]
 [test_transitions_and_reframes.html]
 [test_transitions_and_restyles.html]
 [test_transitions_and_zoom.html]
+skip-if = stylo # timeout bug 1328499
 [test_transitions_cancel_near_end.html]
+skip-if = stylo # timeout bug 1328499
 [test_transitions_computed_values.html]
 [test_transitions_computed_value_combinations.html]
 [test_transitions_events.html]
+skip-if = stylo # timeout bug 1328499
 [test_transitions.html]
 skip-if = (android_version == '18' && debug) # bug 1159532
 [test_transitions_bug537151.html]
+skip-if = stylo # timeout bug 1328499
 [test_transitions_dynamic_changes.html]
 [test_transitions_per_property.html]
-skip-if = toolkit == 'android' #bug 775227
+skip-if = (toolkit == 'android' || stylo) # bug 775227 for android, bug 1292283 for stylo
 [test_transitions_replacement_on_busy_frame.html]
+skip-if = stylo # timeout bug 1328503
 support-files = file_transitions_replacement_on_busy_frame.html
 [test_transitions_step_functions.html]
 [test_transitions_with_disabled_properties.html]
 support-files = file_transitions_with_disabled_properties.html
 [test_unclosed_parentheses.html]
 [test_unicode_range_loading.html]
+skip-if = stylo # timeout bug 1328507
 support-files = ../../reftests/fonts/markA.woff ../../reftests/fonts/markB.woff ../../reftests/fonts/markC.woff ../../reftests/fonts/markD.woff
 [test_units_angle.html]
 [test_units_frequency.html]
 [test_units_length.html]
 [test_units_time.html]
 [test_unprefixing_service.html]
 support-files = unprefixing_service_iframe.html unprefixing_service_utils.js
 [test_unprefixing_service_prefs.html]
 support-files = unprefixing_service_iframe.html unprefixing_service_utils.js
 [test_value_cloning.html]
-tags = stylo
 skip-if = toolkit == 'android' #bug 775227
 [test_value_computation.html]
 skip-if = toolkit == 'android'
 [test_value_storage.html]
+skip-if = stylo # bug 1329533
 [test_variable_serialization_computed.html]
 [test_variable_serialization_specified.html]
 [test_variables.html]
 support-files = support/external-variable-url.css
 [test_video_object_fit.html]
 [test_viewport_units.html]
 [test_visited_image_loading.html]
-skip-if = toolkit == 'android' #TIMED_OUT
+skip-if = (toolkit == 'android' || stylo) # TIMED_OUT for android, timeout bug 1328511 for stylo
 [test_visited_image_loading_empty.html]
-skip-if = toolkit == 'android' #TIMED_OUT
+skip-if = (toolkit == 'android' || stylo) # TIMED_OUT for android, timeout bug 1328511 for stylo
 [test_visited_lying.html]
-skip-if = toolkit == 'android' #TIMED_OUT
+skip-if = (toolkit == 'android' || stylo) # TIMED_OUT for android, timeout bug 1328511 for stylo
 [test_visited_pref.html]
-skip-if = toolkit == 'android' #TIMED_OUT
+skip-if = (toolkit == 'android' || stylo) # TIMED_OUT for android, timeout bug 1328511 for stylo
 [test_visited_reftests.html]
-skip-if = toolkit == 'android' #TIMED_OUT
+skip-if = (toolkit == 'android' || stylo) # TIMED_OUT for android, timeout bug 1328511 for stylo
 [test_webkit_device_pixel_ratio.html]
 [test_webkit_flex_display.html]
-[test_asyncopen2.html]
-[test_align_shorthand_serialization.html]
--- a/services/sync/modules/engines/clients.js
+++ b/services/sync/modules/engines/clients.js
@@ -246,20 +246,21 @@ ClientEngine.prototype = {
       })
     );
   },
 
   _addClientCommand(clientId, command) {
     const allCommands = this._readCommands();
     const clientCommands = allCommands[clientId] || [];
     if (hasDupeCommand(clientCommands, command)) {
-      return;
+      return false;
     }
     allCommands[clientId] = clientCommands.concat(command);
     this._saveCommands(allCommands);
+    return true;
   },
 
   _syncStartup: function _syncStartup() {
     // Reupload new client record periodically.
     if (Date.now() / 1000 - this.lastRecordUpload > CLIENTS_TTL_REFRESH) {
       this._tracker.addChangedID(this.localID);
       this.lastRecordUpload = Date.now() / 1000;
     }
@@ -472,35 +473,45 @@ ClientEngine.prototype = {
 
   /**
    * Sends a command+args pair to a specific client.
    *
    * @param command Command string
    * @param args Array of arguments/data for command
    * @param clientId Client to send command to
    */
-  _sendCommandToClient: function sendCommandToClient(command, args, clientId) {
+  _sendCommandToClient: function sendCommandToClient(command, args, clientId, flowID = null) {
     this._log.trace("Sending " + command + " to " + clientId);
 
     let client = this._store._remoteClients[clientId];
     if (!client) {
       throw new Error("Unknown remote client ID: '" + clientId + "'.");
     }
     if (client.stale) {
       throw new Error("Stale remote client ID: '" + clientId + "'.");
     }
 
     let action = {
       command: command,
       args: args,
+      flowID: flowID || Utils.makeGUID(), // used for telemetry.
     };
 
-    this._log.trace("Client " + clientId + " got a new action: " + [command, args]);
-    this._addClientCommand(clientId, action);
-    this._tracker.addChangedID(clientId);
+    if (this._addClientCommand(clientId, action)) {
+      this._log.trace(`Client ${clientId} got a new action`, [command, args]);
+      this._tracker.addChangedID(clientId);
+      let deviceID;
+      try {
+        deviceID = this.service.identity.hashedDeviceID(clientId);
+      } catch (_) {}
+      this.service.recordTelemetryEvent("sendcommand", command, undefined,
+                                        { flowID: action.flowID, deviceID });
+    } else {
+      this._log.trace(`Client ${clientId} got a duplicate action`, [command, args]);
+    }
   },
 
   /**
    * Check if the local client has any remote commands and perform them.
    *
    * @return false to abort sync
    */
   processIncomingCommands: function processIncomingCommands() {
@@ -510,19 +521,22 @@ ClientEngine.prototype = {
       }
 
       const clearedCommands = this._readCommands()[this.localID];
       const commands = this.localCommands.filter(command => !hasDupeCommand(clearedCommands, command));
 
       let URIsToDisplay = [];
       // Process each command in order.
       for (let rawCommand of commands) {
-        let {command, args} = rawCommand;
+        let {command, args, flowID} = rawCommand;
         this._log.debug("Processing command: " + command + "(" + args + ")");
 
+        this.service.recordTelemetryEvent("processcommand", command, undefined,
+                                          { flowID });
+
         let engines = [args[0]];
         switch (command) {
           case "resetAll":
             engines = null;
             // Fallthrough
           case "resetEngine":
             this.service.resetClient(engines);
             break;
@@ -565,37 +579,40 @@ ClientEngine.prototype = {
    *
    * @param command
    *        Command to invoke on remote clients
    * @param args
    *        Array of arguments to give to the command
    * @param clientId
    *        Client ID to send command to. If undefined, send to all remote
    *        clients.
+   * @param flowID
+   *        A unique identifier used to track success for this operation across
+   *        devices.
    */
-  sendCommand: function sendCommand(command, args, clientId) {
+  sendCommand: function sendCommand(command, args, clientId, flowID = null) {
     let commandData = this._commands[command];
     // Don't send commands that we don't know about.
     if (!commandData) {
       this._log.error("Unknown command to send: " + command);
       return;
     }
     // Don't send a command with the wrong number of arguments.
     else if (!args || args.length != commandData.args) {
       this._log.error("Expected " + commandData.args + " args for '" +
                       command + "', but got " + args);
       return;
     }
 
     if (clientId) {
-      this._sendCommandToClient(command, args, clientId);
+      this._sendCommandToClient(command, args, clientId, flowID);
     } else {
       for (let [id, record] of Object.entries(this._store._remoteClients)) {
         if (!record.stale) {
-          this._sendCommandToClient(command, args, id);
+          this._sendCommandToClient(command, args, id, flowID);
         }
       }
     }
   },
 
   /**
    * Send a URI to another client for display.
    *
--- a/services/sync/modules/service.js
+++ b/services/sync/modules/service.js
@@ -1745,12 +1745,16 @@ Sync11Service.prototype = {
         this._log.debug("Server returned invalid JSON for '" + info_type +
                         "': " + this.response.body);
         return callback(ex);
       }
       this._log.trace("Successfully retrieved '" + info_type + "'.");
       return callback(null, result);
     });
   },
+
+  recordTelemetryEvent(object, method, value, extra = undefined) {
+    Svc.Obs.notify("weave:telemetry:event", { object, method, value, extra });
+  },
 };
 
 this.Service = new Sync11Service();
 Service.onStartup();
--- a/services/sync/modules/telemetry.js
+++ b/services/sync/modules/telemetry.js
@@ -40,16 +40,18 @@ const TOPICS = [
 
   "weave:engine:sync:start",
   "weave:engine:sync:finish",
   "weave:engine:sync:error",
   "weave:engine:sync:applied",
   "weave:engine:sync:uploaded",
   "weave:engine:validate:finish",
   "weave:engine:validate:error",
+
+  "weave:telemetry:event",
 ];
 
 const PING_FORMAT_VERSION = 1;
 
 const EMPTY_UID = "0".repeat(32);
 
 // The set of engines we record telemetry for - any other engines are ignored.
 const ENGINES = new Set(["addons", "bookmarks", "clients", "forms", "history",
@@ -122,16 +124,54 @@ function tryGetMonotonicTimestamp() {
 function timeDeltaFrom(monotonicStartTime) {
   let now = tryGetMonotonicTimestamp();
   if (monotonicStartTime !== -1 && now !== -1) {
     return Math.round(now - monotonicStartTime);
   }
   return -1;
 }
 
+// This function validates the payload of a telemetry "event" - this can be
+// removed once there are APIs available for the telemetry modules to collect
+// these events (bug 1329530) - but for now we simulate that planned API as
+// best we can.
+function validateTelemetryEvent(eventDetails) {
+  let { object, method, value, extra } = eventDetails;
+  // Do do basic validation of the params - everything except "extra" must
+  // be a string. method and object are required.
+  if (typeof method != "string" || typeof object != "string" ||
+      (value && typeof value != "string") ||
+      (extra && typeof extra != "object")) {
+    log.warn("Invalid event parameters - wrong types", eventDetails);
+    return false;
+  }
+  // length checks.
+  if (method.length > 20 || object.length > 20 ||
+      (value && value.length > 80)) {
+    log.warn("Invalid event parameters - wrong lengths", eventDetails);
+    return false;
+  }
+
+  // extra can be falsey, or an object with string names and values.
+  if (extra) {
+    if (Object.keys(extra).length > 10) {
+      log.warn("Invalid event parameters - too many extra keys", eventDetails);
+      return false;
+    }
+    for (let [ename, evalue] of Object.entries(extra)) {
+      if (typeof ename != "string" || ename.length > 15 ||
+          typeof evalue != "string" || evalue.length > 85) {
+        log.warn(`Invalid event parameters: extra item "${ename} is invalid`, eventDetails);
+        return false;
+      }
+    }
+  }
+  return true;
+}
+
 class EngineRecord {
   constructor(name) {
     // startTime is in ms from process start, but is monotonic (unlike Date.now())
     // so we need to keep both it and when.
     this.startTime = tryGetMonotonicTimestamp();
     this.name = name;
   }
 
@@ -411,40 +451,44 @@ class SyncTelemetryImpl {
     log.level = Log.Level[Svc.Prefs.get("log.logger.telemetry", "Trace")];
     // This is accessible so we can enable custom engines during tests.
     this.allowedEngines = allowedEngines;
     this.current = null;
     this.setupObservers();
 
     this.payloads = [];
     this.discarded = 0;
+    this.events = [];
+    this.maxEventsCount = Svc.Prefs.get("telemetry.maxEventsCount", 1000);
     this.maxPayloadCount = Svc.Prefs.get("telemetry.maxPayloadCount");
     this.submissionInterval = Svc.Prefs.get("telemetry.submissionInterval") * 1000;
     this.lastSubmissionTime = Telemetry.msSinceProcessStart();
     this.lastUID = EMPTY_UID;
     this.lastDeviceID = undefined;
   }
 
   getPingJSON(reason) {
     return {
       why: reason,
       discarded: this.discarded || undefined,
       version: PING_FORMAT_VERSION,
       syncs: this.payloads.slice(),
       uid: this.lastUID,
       deviceID: this.lastDeviceID,
+      events: this.events.length == 0 ? undefined : this.events,
     };
   }
 
   finish(reason) {
     // Note that we might be in the middle of a sync right now, and so we don't
     // want to touch this.current.
     let result = this.getPingJSON(reason);
     this.payloads = [];
     this.discarded = 0;
+    this.events = [];
     this.submit(result);
   }
 
   setupObservers() {
     for (let topic of TOPICS) {
       Observers.add(topic, this, this);
     }
   }
@@ -525,16 +569,49 @@ class SyncTelemetryImpl {
     }
     this.current = null;
     if ((Telemetry.msSinceProcessStart() - this.lastSubmissionTime) > this.submissionInterval) {
       this.finish("schedule");
       this.lastSubmissionTime = Telemetry.msSinceProcessStart();
     }
   }
 
+  _recordEvent(eventDetails) {
+    if (this.events.length >= this.maxEventsCount) {
+      log.warn("discarding event - already queued our maximum", eventDetails);
+      return;
+    }
+
+    if (!validateTelemetryEvent(eventDetails)) {
+      // we've already logged what the problem is...
+      return;
+    }
+    log.debug("recording event", eventDetails);
+
+    let { object, method, value, extra } = eventDetails;
+    let category = "sync";
+    let ts = Math.floor(tryGetMonotonicTimestamp());
+
+    // An event record is a simple array with at least 4 items.
+    let event = [ts, category, method, object];
+    // It may have up to 6 elements if |extra| is defined
+    if (value) {
+      event.push(value);
+      if (extra) {
+        event.push(extra);
+      }
+    } else {
+      if (extra) {
+        event.push(null); // a null for the empty value.
+        event.push(extra);
+      }
+    }
+    this.events.push(event);
+  }
+
   observe(subject, topic, data) {
     log.trace(`observed ${topic} ${data}`);
 
     switch (topic) {
       case "profile-before-change":
         this.shutdown();
         break;
 
@@ -593,16 +670,20 @@ class SyncTelemetryImpl {
         break;
 
       case "weave:engine:validate:error":
         if (this._checkCurrent(topic)) {
           this.current.onEngineValidateError(data, subject || "Unknown");
         }
         break;
 
+      case "weave:telemetry:event":
+        this._recordEvent(subject);
+        break;
+
       default:
         log.warn(`unexpected observer topic ${topic}`);
         break;
     }
   }
 }
 
 this.SyncTelemetry = new SyncTelemetryImpl(ENGINES);
--- a/services/sync/tests/unit/head_helpers.js
+++ b/services/sync/tests/unit/head_helpers.js
@@ -271,17 +271,25 @@ function get_sync_test_telemetry() {
 
 function assert_valid_ping(record) {
   // This is called as the test harness tears down due to shutdown. This
   // will typically have no recorded syncs, and the validator complains about
   // it. So ignore such records (but only ignore when *both* shutdown and
   // no Syncs - either of them not being true might be an actual problem)
   if (record && (record.why != "shutdown" || record.syncs.length != 0)) {
     if (!SyncPingValidator(record)) {
-      deepEqual([], SyncPingValidator.errors, "Sync telemetry ping validation failed");
+      if (SyncPingValidator.errors.length) {
+        // validation failed - using a simple |deepEqual([], errors)| tends to
+        // truncate the validation errors in the output and doesn't show that
+        // the ping actually was - so be helpful.
+        do_print("telemetry ping validation failed");
+        do_print("the ping data is: " + JSON.stringify(record, undefined, 2));
+        do_print("the validation failures: " + JSON.stringify(SyncPingValidator.errors, undefined, 2));
+        ok(false, "Sync telemetry ping validation failed - see output above for details");
+      }
     }
     equal(record.version, 1);
     record.syncs.forEach(p => {
       lessOrEqual(p.when, Date.now());
       if (p.devices) {
         ok(!p.devices.some(device => device.id == record.deviceID));
         equal(new Set(p.devices.map(device => device.id)).size,
               p.devices.length, "Duplicate device ids in ping devices list");
--- a/services/sync/tests/unit/sync_ping_schema.json
+++ b/services/sync/tests/unit/sync_ping_schema.json
@@ -15,16 +15,21 @@
     "deviceID": {
       "type": "string",
       "pattern": "^[0-9a-f]{64}$"
     },
     "syncs": {
       "type": "array",
       "minItems": 1,
       "items": { "$ref": "#/definitions/payload" }
+    },
+    "events": {
+      "type": "array",
+      "minItems": 1,
+      "items": { "$ref": "#/definitions/event" }
     }
   },
   "definitions": {
     "payload": {
       "type": "object",
       "additionalProperties": false,
       "required": ["when", "took"],
       "properties": {
@@ -123,16 +128,21 @@
         {"required": ["sent"]},
         {"required": ["failed"]}
       ],
       "properties": {
         "sent": { "type": "integer", "minimum": 1 },
         "failed": { "type": "integer", "minimum": 1 }
       }
     },
+    "event": {
+      "type": "array",
+      "minItems": 4,
+      "maxItems": 6
+    },
     "error": {
       "oneOf": [
         { "$ref": "#/definitions/httpError" },
         { "$ref": "#/definitions/nsError" },
         { "$ref": "#/definitions/shutdownError" },
         { "$ref": "#/definitions/authError" },
         { "$ref": "#/definitions/otherError" },
         { "$ref": "#/definitions/unexpectedError" },
--- a/services/sync/tests/unit/test_clients_engine.js
+++ b/services/sync/tests/unit/test_clients_engine.js
@@ -384,16 +384,17 @@ add_test(function test_send_command() {
   let clientCommands = engine._readCommands()[remoteId];
   notEqual(newRecord, undefined);
   equal(clientCommands.length, 1);
 
   let command = clientCommands[0];
   equal(command.command, action);
   equal(command.args.length, 2);
   deepEqual(command.args, args);
+  ok(command.flowID);
 
   notEqual(tracker.changedIDs[remoteId], undefined);
 
   engine._tracker.clearChangedIDs();
   run_next_test();
 });
 
 add_test(function test_command_validation() {
@@ -627,22 +628,22 @@ add_task(async function test_filter_dupl
     equal(counts.newFailed, 0);
 
     _("Broadcast logout to all clients");
     engine.sendCommand("logout", []);
     engine._sync();
 
     let collection = server.getCollection("foo", "clients");
     let recentPayload = JSON.parse(JSON.parse(collection.payload(recentID)).ciphertext);
-    deepEqual(recentPayload.commands, [{ command: "logout", args: [] }],
-              "Should send commands to the recent client");
+    compareCommands(recentPayload.commands, [{ command: "logout", args: [] }],
+                    "Should send commands to the recent client");
 
     let oldPayload = JSON.parse(JSON.parse(collection.payload(oldID)).ciphertext);
-    deepEqual(oldPayload.commands, [{ command: "logout", args: [] }],
-              "Should send commands to the week-old client");
+    compareCommands(oldPayload.commands, [{ command: "logout", args: [] }],
+                    "Should send commands to the week-old client");
 
     let dupePayload = JSON.parse(JSON.parse(collection.payload(dupeID)).ciphertext);
     deepEqual(dupePayload.commands, [],
               "Should not send commands to the dupe client");
 
     _("Update the dupe client's modified time");
     server.insertWBO("foo", "clients", new ServerWBO(dupeID, encryptPayload({
       id: dupeID,
@@ -909,29 +910,31 @@ add_task(async function test_merge_comma
   let desktopID = Utils.makeGUID();
   server.insertWBO("foo", "clients", new ServerWBO(desktopID, encryptPayload({
     id: desktopID,
     name: "Desktop client",
     type: "desktop",
     commands: [{
       command: "displayURI",
       args: ["https://example.com", engine.localID, "Yak Herders Anonymous"],
+      flowID: Utils.makeGUID(),
     }],
     version: "48",
     protocols: ["1.5"],
   }), now - 10));
 
   let mobileID = Utils.makeGUID();
   server.insertWBO("foo", "clients", new ServerWBO(mobileID, encryptPayload({
     id: mobileID,
     name: "Mobile client",
     type: "mobile",
     commands: [{
       command: "logout",
       args: [],
+      flowID: Utils.makeGUID(),
     }],
     version: "48",
     protocols: ["1.5"],
   }), now - 10));
 
   try {
     let store = engine._store;
 
@@ -940,27 +943,27 @@ add_task(async function test_merge_comma
     engine._sync();
 
     _("Broadcast logout to all clients");
     engine.sendCommand("logout", []);
     engine._sync();
 
     let collection = server.getCollection("foo", "clients");
     let desktopPayload = JSON.parse(JSON.parse(collection.payload(desktopID)).ciphertext);
-    deepEqual(desktopPayload.commands, [{
+    compareCommands(desktopPayload.commands, [{
       command: "displayURI",
       args: ["https://example.com", engine.localID, "Yak Herders Anonymous"],
     }, {
       command: "logout",
       args: [],
     }], "Should send the logout command to the desktop client");
 
     let mobilePayload = JSON.parse(JSON.parse(collection.payload(mobileID)).ciphertext);
-    deepEqual(mobilePayload.commands, [{ command: "logout", args: [] }],
-      "Should not send a duplicate logout to the mobile client");
+    compareCommands(mobilePayload.commands, [{ command: "logout", args: [] }],
+                    "Should not send a duplicate logout to the mobile client");
   } finally {
     Svc.Prefs.resetBranch("");
     Service.recordManager.clearCache();
     engine._resetClient();
 
     try {
       server.deleteCollections("foo");
     } finally {
@@ -1017,17 +1020,17 @@ add_task(async function test_duplicate_r
     }), now - 10));
 
     _("Send another tab to the desktop client");
     engine.sendCommand("displayURI", ["https://foobar.com", engine.localID, "Foo bar!"], desktopID);
     engine._sync();
 
     let collection = server.getCollection("foo", "clients");
     let desktopPayload = JSON.parse(JSON.parse(collection.payload(desktopID)).ciphertext);
-    deepEqual(desktopPayload.commands, [{
+    compareCommands(desktopPayload.commands, [{
       command: "displayURI",
       args: ["https://foobar.com", engine.localID, "Foo bar!"],
     }], "Should only send the second command to the desktop client");
   } finally {
     Svc.Prefs.resetBranch("");
     Service.recordManager.clearCache();
     engine._resetClient();
 
@@ -1057,17 +1060,19 @@ add_task(async function test_upload_afte
 
   let deviceBID = Utils.makeGUID();
   let deviceCID = Utils.makeGUID();
   server.insertWBO("foo", "clients", new ServerWBO(deviceBID, encryptPayload({
     id: deviceBID,
     name: "Device B",
     type: "desktop",
     commands: [{
-      command: "displayURI", args: ["https://deviceclink.com", deviceCID, "Device C link"]
+      command: "displayURI",
+      args: ["https://deviceclink.com", deviceCID, "Device C link"],
+      flowID: Utils.makeGUID(),
     }],
     version: "48",
     protocols: ["1.5"],
   }), now - 10));
   server.insertWBO("foo", "clients", new ServerWBO(deviceCID, encryptPayload({
     id: deviceCID,
     name: "Device C",
     type: "desktop",
@@ -1087,17 +1092,17 @@ add_task(async function test_upload_afte
     engine.sendCommand("displayURI", ["https://example.com", engine.localID, "Yak Herders Anonymous"], deviceBID);
 
     const oldUploadOutgoing = SyncEngine.prototype._uploadOutgoing;
     SyncEngine.prototype._uploadOutgoing = () => engine._onRecordsWritten.call(engine, [], [deviceBID]);
     engine._sync();
 
     let collection = server.getCollection("foo", "clients");
     let deviceBPayload = JSON.parse(JSON.parse(collection.payload(deviceBID)).ciphertext);
-    deepEqual(deviceBPayload.commands, [{
+    compareCommands(deviceBPayload.commands, [{
       command: "displayURI", args: ["https://deviceclink.com", deviceCID, "Device C link"]
     }], "Should be the same because the upload failed");
 
     _("Simulate the client B consuming the command and syncing to the server");
     server.insertWBO("foo", "clients", new ServerWBO(deviceBID, encryptPayload({
       id: deviceBID,
       name: "Device B",
       type: "desktop",
@@ -1108,17 +1113,17 @@ add_task(async function test_upload_afte
 
     // Simulate reboot
     SyncEngine.prototype._uploadOutgoing = oldUploadOutgoing;
     engine = Service.clientsEngine = new ClientEngine(Service);
 
     engine._sync();
 
     deviceBPayload = JSON.parse(JSON.parse(collection.payload(deviceBID)).ciphertext);
-    deepEqual(deviceBPayload.commands, [{
+    compareCommands(deviceBPayload.commands, [{
       command: "displayURI",
       args: ["https://example.com", engine.localID, "Yak Herders Anonymous"],
     }], "Should only had written our outgoing command");
   } finally {
     Svc.Prefs.resetBranch("");
     Service.recordManager.clearCache();
     engine._resetClient();
 
@@ -1148,20 +1153,24 @@ add_task(async function test_keep_cleare
 
   let deviceBID = Utils.makeGUID();
   let deviceCID = Utils.makeGUID();
   server.insertWBO("foo", "clients", new ServerWBO(engine.localID, encryptPayload({
     id: engine.localID,
     name: "Device A",
     type: "desktop",
     commands: [{
-      command: "displayURI", args: ["https://deviceblink.com", deviceBID, "Device B link"]
+      command: "displayURI",
+      args: ["https://deviceblink.com", deviceBID, "Device B link"],
+      flowID: Utils.makeGUID(),
     },
     {
-      command: "displayURI", args: ["https://deviceclink.com", deviceCID, "Device C link"]
+      command: "displayURI",
+      args: ["https://deviceclink.com", deviceCID, "Device C link"],
+      flowID: Utils.makeGUID(),
     }],
     version: "48",
     protocols: ["1.5"],
   }), now - 10));
   server.insertWBO("foo", "clients", new ServerWBO(deviceBID, encryptPayload({
     id: deviceBID,
     name: "Device B",
     type: "desktop",
@@ -1190,36 +1199,42 @@ add_task(async function test_keep_cleare
     let commandsProcessed = 0;
     engine._handleDisplayURIs = (uris) => { commandsProcessed = uris.length };
 
     engine._sync();
     engine.processIncomingCommands(); // Not called by the engine.sync(), gotta call it ourselves
     equal(commandsProcessed, 2, "We processed 2 commands");
 
     let localRemoteRecord = JSON.parse(JSON.parse(collection.payload(engine.localID)).ciphertext);
-    deepEqual(localRemoteRecord.commands, [{
+    compareCommands(localRemoteRecord.commands, [{
       command: "displayURI", args: ["https://deviceblink.com", deviceBID, "Device B link"]
     },
     {
       command: "displayURI", args: ["https://deviceclink.com", deviceCID, "Device C link"]
     }], "Should be the same because the upload failed");
 
     // Another client sends another link
     server.insertWBO("foo", "clients", new ServerWBO(engine.localID, encryptPayload({
       id: engine.localID,
       name: "Device A",
       type: "desktop",
       commands: [{
-        command: "displayURI", args: ["https://deviceblink.com", deviceBID, "Device B link"]
+        command: "displayURI",
+        args: ["https://deviceblink.com", deviceBID, "Device B link"],
+        flowID: Utils.makeGUID(),
       },
       {
-        command: "displayURI", args: ["https://deviceclink.com", deviceCID, "Device C link"]
+        command: "displayURI",
+        args: ["https://deviceclink.com", deviceCID, "Device C link"],
+        flowID: Utils.makeGUID(),
       },
       {
-        command: "displayURI", args: ["https://deviceclink2.com", deviceCID, "Device C link 2"]
+        command: "displayURI",
+        args: ["https://deviceclink2.com", deviceCID, "Device C link 2"],
+        flowID: Utils.makeGUID(),
       }],
       version: "48",
       protocols: ["1.5"],
     }), now - 10));
 
     // Simulate reboot
     SyncEngine.prototype._uploadOutgoing = oldUploadOutgoing;
     engine = Service.clientsEngine = new ClientEngine(Service);
@@ -1297,17 +1312,17 @@ add_task(async function test_deleted_com
     _("Broadcast a command to all clients");
     engine.sendCommand("logout", []);
     engine._sync();
 
     deepEqual(collection.keys().sort(), [activeID, engine.localID].sort(),
       "Should not reupload deleted clients");
 
     let activePayload = JSON.parse(JSON.parse(collection.payload(activeID)).ciphertext);
-    deepEqual(activePayload.commands, [{ command: "logout", args: [] }],
+    compareCommands(activePayload.commands, [{ command: "logout", args: [] }],
       "Should send the command to the active client");
   } finally {
     Svc.Prefs.resetBranch("");
     Service.recordManager.clearCache();
     engine._resetClient();
 
     try {
       server.deleteCollections("foo");
@@ -1341,28 +1356,29 @@ add_task(async function test_send_uri_ac
     let collection = server.getCollection("foo", "clients");
     let ourPayload = JSON.parse(JSON.parse(collection.payload(engine.localID)).ciphertext);
     ok(ourPayload, "Should upload our client record");
 
     _("Send a URL to the device on the server");
     ourPayload.commands = [{
       command: "displayURI",
       args: ["https://example.com", fakeSenderID, "Yak Herders Anonymous"],
+      flowID: Utils.makeGUID(),
     }];
     server.insertWBO("foo", "clients", new ServerWBO(engine.localID, encryptPayload(ourPayload), now));
 
     _("Sync again");
     engine._sync();
-    deepEqual(engine.localCommands, [{
+    compareCommands(engine.localCommands, [{
       command: "displayURI",
       args: ["https://example.com", fakeSenderID, "Yak Herders Anonymous"],
     }], "Should receive incoming URI");
     ok(engine.processIncomingCommands(), "Should process incoming commands");
     const clearedCommands = engine._readCommands()[engine.localID];
-    deepEqual(clearedCommands, [{
+    compareCommands(clearedCommands, [{
       command: "displayURI",
       args: ["https://example.com", fakeSenderID, "Yak Herders Anonymous"],
     }], "Should mark the commands as cleared after processing");
 
     _("Check that the command was removed on the server");
     engine._sync();
     ourPayload = JSON.parse(JSON.parse(collection.payload(engine.localID)).ciphertext);
     ok(ourPayload, "Should upload the synced client record");
--- a/services/sync/tests/unit/test_telemetry.js
+++ b/services/sync/tests/unit/test_telemetry.js
@@ -560,9 +560,97 @@ add_task(async function test_no_foreign_
   await SyncTestingInfrastructure(server);
   try {
     let ping = await sync_and_validate_telem();
     ok(ping.engines.every(e => e.name !== "bogus"));
   } finally {
     Service.engineManager.unregister(engine);
     await cleanAndGo(engine, server);
   }
-});
\ No newline at end of file
+});
+
+add_task(async function test_events() {
+  Service.engineManager.register(BogusEngine);
+  let engine = Service.engineManager.get("bogus");
+  engine.enabled = true;
+  let server = serverForUsers({"foo": "password"}, {
+    meta: {global: {engines: {bogus: {version: engine.version, syncID: engine.syncID}}}},
+    steam: {}
+  });
+
+  await SyncTestingInfrastructure(server);
+  try {
+    Service.recordTelemetryEvent("object", "method", "value", { foo: "bar" });
+    let ping = await wait_for_ping(() => Service.sync(), true, true);
+    equal(ping.events.length, 1);
+    let [timestamp, category, method, object, value, extra] = ping.events[0];
+    ok((typeof timestamp == "number") && timestamp > 0); // timestamp.
+    equal(category, "sync");
+    equal(method, "method");
+    equal(object, "object");
+    equal(value, "value");
+    deepEqual(extra, { foo: "bar" });
+    // Test with optional values.
+    Service.recordTelemetryEvent("object", "method");
+    ping = await wait_for_ping(() => Service.sync(), false, true);
+    equal(ping.events.length, 1);
+    equal(ping.events[0].length, 4);
+
+    Service.recordTelemetryEvent("object", "method", "extra");
+    ping = await wait_for_ping(() => Service.sync(), false, true);
+    equal(ping.events.length, 1);
+    equal(ping.events[0].length, 5);
+
+    Service.recordTelemetryEvent("object", "method", undefined, { foo: "bar" });
+    ping = await wait_for_ping(() => Service.sync(), false, true);
+    equal(ping.events.length, 1);
+    equal(ping.events[0].length, 6);
+    [timestamp, category, method, object, value, extra] = ping.events[0];
+    equal(value, null);
+  } finally {
+    Service.engineManager.unregister(engine);
+    await cleanAndGo(engine, server);
+  }
+});
+
+add_task(async function test_invalid_events() {
+  Service.engineManager.register(BogusEngine);
+  let engine = Service.engineManager.get("bogus");
+  engine.enabled = true;
+  let server = serverForUsers({"foo": "password"}, {
+    meta: {global: {engines: {bogus: {version: engine.version, syncID: engine.syncID}}}},
+    steam: {}
+  });
+
+  async function checkNotRecorded(...args) {
+    Service.recordTelemetryEvent.call(args);
+    let ping = await wait_for_ping(() => Service.sync(), false, true);
+    equal(ping.events, undefined);
+  }
+
+  await SyncTestingInfrastructure(server);
+  try {
+    let long21 = "l".repeat(21);
+    let long81 = "l".repeat(81);
+    let long86 = "l".repeat(86);
+    await checkNotRecorded("object");
+    await checkNotRecorded("object", 2);
+    await checkNotRecorded(2, "method");
+    await checkNotRecorded("object", "method", 2);
+    await checkNotRecorded("object", "method", "value", 2);
+    await checkNotRecorded("object", "method", "value", { foo: 2 });
+    await checkNotRecorded(long21, "method", "value");
+    await checkNotRecorded("object", long21, "value");
+    await checkNotRecorded("object", "method", long81);
+    let badextra = {};
+    badextra[long21] = "x";
+    await checkNotRecorded("object", "method", "value", badextra);
+    badextra = { "x": long86 };
+    await checkNotRecorded("object", "method", "value", badextra);
+    for (let i = 0; i < 10; i++) {
+      badextra["name" + i] = "x";
+    }
+    await checkNotRecorded("object", "method", "value", badextra);
+  } finally {
+    Service.engineManager.unregister(engine);
+    await cleanAndGo(engine, server);
+  }
+});
--- a/taskcluster/ci/test/test-sets.yml
+++ b/taskcluster/ci/test/test-sets.yml
@@ -62,17 +62,17 @@ talos:
 
 ##
 # Limited test sets for specific platforms
 
 stylo-tests:
     - cppunit
     - crashtest
     - reftest-stylo
-    - mochitest-stylo
+    - mochitest-style
 
 ccov-code-coverage-tests:
     - mochitest
     - mochitest-browser-chrome
     - mochitest-devtools-chrome
     - xpcshell
 
 jsdcov-code-coverage-tests:
--- a/taskcluster/ci/test/tests.yml
+++ b/taskcluster/ci/test/tests.yml
@@ -765,32 +765,32 @@ mochitest-webgl:
                             - remove_executables.py
                             - unittests/mac_unittest.py
                         linux.*:
                             - unittests/linux_unittest.py
                             - remove_executables.py
                 extra-options:
                     - --mochitest-suite=mochitest-gl
 
-mochitest-stylo:
-    description: "Mochitest run for Stylo"
-    suite: mochitest/mochitest-stylo
+mochitest-style:
+    description: "Mochitest plain run for style system"
+    suite: mochitest/mochitest-style
     treeherder-symbol: tc-M(s)
     loopback-video: true
     e10s: false
     mozharness:
         script: desktop_unittest.py
         no-read-buildbot-config: true
         config:
             by-test-platform:
                 default:
                     - unittests/linux_unittest.py
                     - remove_executables.py
         extra-options:
-            - --mochitest-suite=mochitest-stylo
+            - --mochitest-suite=mochitest-style
 
 reftest:
     description: "Reftest run"
     suite: reftest/reftest
     treeherder-symbol: tc-R(R)
     docker-image:
         by-test-platform:
             android.*: {'in-tree': 'desktop-test'}
--- a/testing/mozharness/configs/unittests/linux_unittest.py
+++ b/testing/mozharness/configs/unittests/linux_unittest.py
@@ -198,17 +198,17 @@ config = {
         "mochitest-gl": ["--subsuite=webgl"],
         "mochitest-devtools-chrome": ["--flavor=browser", "--subsuite=devtools"],
         "mochitest-devtools-chrome-chunked": ["--flavor=browser", "--subsuite=devtools", "--chunk-by-runtime"],
         "mochitest-devtools-chrome-coverage": ["--flavor=browser", "--subsuite=devtools", "--chunk-by-runtime", "--timeout=1200"],
         "jetpack-package": ["--flavor=jetpack-package"],
         "jetpack-package-clipboard": ["--flavor=jetpack-package", "--subsuite=clipboard"],
         "jetpack-addon": ["--flavor=jetpack-addon"],
         "a11y": ["--flavor=a11y"],
-        "mochitest-stylo": ["--disable-e10s", "--tag=stylo"],
+        "mochitest-style": ["--disable-e10s", "layout/style/test"],
     },
     # local reftest suites
     "all_reftest_suites": {
         "crashtest": {
             "options": ["--suite=crashtest"],
             "tests": ["tests/reftest/tests/testing/crashtest/crashtests.list"]
         },
         "jsreftest": {
--- a/toolkit/components/telemetry/docs/data/sync-ping.rst
+++ b/toolkit/components/telemetry/docs/data/sync-ping.rst
@@ -92,17 +92,20 @@ Structure:
                   }
                 ],
                 // Format is same as above, this is only included if we tried and failed
                 // to run validation, and if it's present, all other fields in this object are optional.
                 failureReason: { ... },
               }
             }
           ]
-        }]
+        }],
+        events: [
+          event_array // See events below,
+        ]
       }
     }
 
 info
 ----
 
 discarded
 ~~~~~~~~~
@@ -175,8 +178,47 @@ syncs.engine.validation.problems
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
 For engines that can run validation on themselves, an array of objects describing validation errors that have occurred. Items that would have a count of 0 are excluded. Each engine will have its own set of items that it might put in the ``name`` field, but there are a finite number. See ``BookmarkProblemData.getSummary`` in `services/sync/modules/bookmark\_validator.js <https://dxr.mozilla.org/mozilla-central/source/services/sync/modules/bookmark_validator.js>`_ for an example.
 
 syncs.devices
 ~~~~~~~~~~~~~
 
 The list of remote devices associated with this account, as reported by the clients collection. The ID of each device is hashed using the same algorithm as the local id.
+
+
+Events in the "sync" ping
+=========================
+
+The sync ping includes events in the same format as they are included in the
+main ping. The documentation for these events will land in bug 1302666.
+
+Every event recorded in this ping will have a category of ``sync``. The following
+events are defined, categorized by the event method.
+
+sendcommand
+-----------
+
+Records that Sync wrote a remote "command" to another client. These commands
+cause that other client to take some action, such as resetting Sync on that
+client, or opening a new URL.
+
+- object: The specific command being written.
+- value: Not used (ie, ``undefined``)
+- extra: An object with the following attributes:
+
+  - deviceID: A GUID which identifies the device the command is being sent to.
+  - flowID: A GUID which uniquely identifies this command invocation.
+
+processcommand
+--------------
+
+Records that Sync processed a remote "command" previously sent by another
+client. This is logically the "other end" of ``sendcommand``.
+
+- object: The specific command being processed.
+- value: Not used (ie, ``undefined``)
+- extra: An object with the following attributes:
+
+  - deviceID: A GUID which identifies the device the command is being sent to.
+  - flowID: A GUID which uniquely identifies this command invocation. The value
+            for this GUID will be the same as the flowID sent to the client via
+            ``sendcommand``.
--- a/tools/profiler/public/GeckoProfiler.h
+++ b/tools/profiler/public/GeckoProfiler.h
@@ -241,24 +241,29 @@ static inline void profiler_js_operation
 static inline double profiler_time() { return 0; }
 static inline double profiler_time(const mozilla::TimeStamp& aTime) { return 0; }
 
 static inline bool profiler_in_privacy_mode() { return false; }
 
 static inline void profiler_log(const char *str) {}
 static inline void profiler_log(const char *fmt, va_list args) {}
 
+namespace mozilla {
+
 class AutoProfilerRegister final MOZ_STACK_CLASS
 {
-  AutoProfilerRegister(const char* aName) {}
+public:
+  explicit AutoProfilerRegister(const char* aName) {}
 private:
   AutoProfilerRegister(const AutoProfilerRegister&) = delete;
   AutoProfilerRegister& operator=(const AutoProfilerRegister&) = delete;
 };
 
+} // namespace mozilla
+
 #else
 
 #include "GeckoProfilerImpl.h"
 
 #endif
 
 class MOZ_RAII GeckoProfilerInitRAII {
 public:
--- a/widget/gtk/gtk3drawing.cpp
+++ b/widget/gtk/gtk3drawing.cpp
@@ -1808,22 +1808,28 @@ moz_gtk_menu_separator_paint(cairo_t *cr
 }
 
 // See gtk_menu_item_draw() for reference.
 static gint
 moz_gtk_menu_item_paint(WidgetNodeType widget, cairo_t *cr, GdkRectangle* rect,
                         GtkWidgetState* state, GtkTextDirection direction)
 {
     gint x, y, w, h;
+    guint minorVersion = gtk_get_minor_version();
+    GtkStateFlags state_flags = GetStateFlagsFromGtkWidgetState(state);
 
-    GtkStateFlags state_flags = GetStateFlagsFromGtkWidgetState(state);
+    // GTK versions prior to 3.8 render the background and frame only when not
+    // a separator and in hover prelight.
+    if (minorVersion < 8 && (widget == MOZ_GTK_MENUSEPARATOR ||
+                             !(state_flags & GTK_STATE_FLAG_PRELIGHT)))
+        return MOZ_GTK_SUCCESS;
+
     GtkStyleContext* style = ClaimStyleContext(widget, direction, state_flags);
 
-    bool pre_3_6 = gtk_check_version(3, 6, 0) != nullptr;
-    if (pre_3_6) {
+    if (minorVersion < 6) {
         // GTK+ 3.4 saves the style context and adds the menubar class to
         // menubar children, but does each of these only when drawing, not
         // during layout.
         gtk_style_context_save(style);
         if (widget == MOZ_GTK_MENUBARITEM) {
             gtk_style_context_add_class(style, GTK_STYLE_CLASS_MENUBAR);
         }
     }
@@ -1831,17 +1837,17 @@ moz_gtk_menu_item_paint(WidgetNodeType w
     x = rect->x;
     y = rect->y;
     w = rect->width;
     h = rect->height;
 
     gtk_render_background(style, cr, x, y, w, h);
     gtk_render_frame(style, cr, x, y, w, h);
 
-    if (pre_3_6) {
+    if (minorVersion < 6) {
         gtk_style_context_restore(style);
     }
     ReleaseStyleContext(style);
 
     return MOZ_GTK_SUCCESS;
 }
 
 static gint