Bug 1171962: introduce telemetry histogram that counts the amount of sessions that exchanged one or more chat messages. r=vladan,dmose
authorMike de Boer <mdeboer@mozilla.com>
Tue, 29 Sep 2015 11:50:21 +0200
changeset 300169 5e518c0314a6896b08bd5e299e07ba06cfa5c786
parent 300168 ed8ce0300161aef4d0b061c5fddb69b2afced8bc
child 300170 ebbf62bde078a1c781ae9f66c34e5887bf446f58
push id1001
push userraliiev@mozilla.com
push dateMon, 18 Jan 2016 19:06:03 +0000
treeherdermozilla-release@8b89261f3ac4 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersvladan, dmose
bugs1171962
milestone44.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
Bug 1171962: introduce telemetry histogram that counts the amount of sessions that exchanged one or more chat messages. r=vladan,dmose
browser/components/loop/content/shared/js/activeRoomStore.js
browser/components/loop/content/shared/js/dispatcher.js
browser/components/loop/content/shared/js/textChatStore.js
browser/components/loop/content/shared/js/textChatView.js
browser/components/loop/content/shared/js/textChatView.jsx
browser/components/loop/content/shared/js/utils.js
browser/components/loop/test/mochitest/browser_mozLoop_telemetry.js
browser/components/loop/test/shared/activeRoomStore_test.js
browser/components/loop/test/shared/dispatcher_test.js
browser/components/loop/test/shared/otSdkDriver_test.js
browser/components/loop/test/shared/textChatStore_test.js
browser/components/loop/test/shared/textChatView_test.js
browser/components/loop/ui/ui-showcase.js
browser/components/loop/ui/ui-showcase.jsx
toolkit/components/telemetry/Histograms.json
--- a/browser/components/loop/content/shared/js/activeRoomStore.js
+++ b/browser/components/loop/content/shared/js/activeRoomStore.js
@@ -32,16 +32,17 @@ loop.store.ROOM_STATES = {
     CLOSING: "room-closing"
 };
 
 loop.store.ActiveRoomStore = (function() {
   "use strict";
 
   var sharedActions = loop.shared.actions;
   var crypto = loop.crypto;
+  var CHAT_CONTENT_TYPES = loop.shared.utils.CHAT_CONTENT_TYPES;
   var FAILURE_DETAILS = loop.shared.utils.FAILURE_DETAILS;
   var SCREEN_SHARE_STATES = loop.shared.utils.SCREEN_SHARE_STATES;
 
   // Error numbers taken from
   // https://github.com/mozilla-services/loop-server/blob/master/loop/errno.json
   var REST_ERRNOS = loop.shared.utils.REST_ERRNOS;
 
   var ROOM_STATES = loop.store.ROOM_STATES;
@@ -102,16 +103,17 @@ loop.store.ActiveRoomStore = (function()
      * due to user choice, failure or other reason. It is a subset of
      * getInitialStoreState as some items (e.g. roomState, failureReason,
      * context information) can persist across room exit & re-entry.
      *
      * @type {Array}
      */
     _statesToResetOnLeave: [
       "audioMuted",
+      "chatMessageExchanged",
       "localSrcMediaElement",
       "localVideoDimensions",
       "mediaConnected",
       "receivingScreenShare",
       "remoteSrcMediaElement",
       "remoteVideoDimensions",
       "remoteVideoEnabled",
       "screenSharingState",
@@ -150,17 +152,20 @@ loop.store.ActiveRoomStore = (function()
         roomDescription: null,
         // Room information failed to be obtained for a reason. See ROOM_INFO_FAILURES.
         roomInfoFailure: null,
         // The name of the room.
         roomName: null,
         // Social API state.
         socialShareProviders: null,
         // True if media has been connected both-ways.
-        mediaConnected: false
+        mediaConnected: false,
+        // True if a chat message was sent or received during a session.
+        // Read more at https://wiki.mozilla.org/Loop/Session.
+        chatMessageExchanged: false
       };
     },
 
     /**
      * Handles a room failure.
      *
      * @param {sharedActions.RoomFailure} actionData
      */
@@ -231,17 +236,17 @@ loop.store.ActiveRoomStore = (function()
       // before we know what type we are, but in some cases we need to re-do
       // an action (e.g. FetchServerData).
       if (this._registeredActions) {
         return;
       }
 
       this._registeredActions = true;
 
-      this.dispatcher.register(this, [
+      var actions = [
         "roomFailure",
         "retryAfterRoomFailure",
         "updateRoomInfo",
         "userAgentHandlesRoom",
         "gotMediaPermission",
         "joinRoom",
         "joinedRoom",
         "connectedToSdkServers",
@@ -258,17 +263,24 @@ loop.store.ActiveRoomStore = (function()
         "mediaStreamDestroyed",
         "remoteVideoStatus",
         "videoDimensionsChanged",
         "startScreenShare",
         "endScreenShare",
         "updateSocialShareInfo",
         "connectionStatus",
         "mediaConnected"
-      ]);
+      ];
+      // Register actions that are only used on Desktop.
+      if (this._isDesktop) {
+        // 'receivedTextChatMessage' and  'sendTextChatMessage' actions are only
+        // registered for Telemetry. Once measured, they're unregistered.
+        actions.push("receivedTextChatMessage", "sendTextChatMessage");
+      }
+      this.dispatcher.register(this, actions);
 
       this._onUpdateListener = this._handleRoomUpdate.bind(this);
       this._onDeleteListener = this._handleRoomDelete.bind(this);
       this._onSocialShareUpdate = this._handleSocialShareUpdate.bind(this);
 
       this._mozLoop.rooms.on("update:" + this._storeState.roomToken, this._onUpdateListener);
       this._mozLoop.rooms.on("delete:" + this._storeState.roomToken, this._onDeleteListener);
       window.addEventListener("LoopShareWidgetChanged", this._onSocialShareUpdate);
@@ -1118,13 +1130,57 @@ loop.store.ActiveRoomStore = (function()
       // NOTE: in the future, when multiple remote video streams are supported,
       //       we'll need to make this support multiple remotes as well. Good
       //       starting point for video tiling.
       var storeProp = (actionData.isLocal ? "local" : "remote") + "VideoDimensions";
       var nextState = {};
       nextState[storeProp] = this.getStoreState()[storeProp];
       nextState[storeProp][actionData.videoType] = actionData.dimensions;
       this.setStoreState(nextState);
+    },
+
+    /**
+     * Handles chat messages received and/ or about to send. If this is the first
+     * chat message for the current session, register a count with telemetry.
+     * It will unhook the listeners when the telemetry criteria have been
+     * fulfilled to make sure we remain lean.
+     * Note: the 'receivedTextChatMessage' and 'sendTextChatMessage' actions are
+     *       only registered on Desktop.
+     *
+     * @param  {sharedActions.ReceivedTextChatMessage|SendTextChatMessage} actionData
+     */
+    _handleTextChatMessage: function(actionData) {
+      if (!this._isDesktop || this.getStoreState().chatMessageExchanged ||
+          actionData.contentType !== CHAT_CONTENT_TYPES.TEXT) {
+        return;
+      }
+
+      this.setStoreState({ chatMessageExchanged: true });
+      // There's no need to listen to these actions anymore.
+      this.dispatcher.unregister(this, [
+        "receivedTextChatMessage",
+        "sendTextChatMessage"
+      ]);
+      // Ping telemetry of this session with successful message(s) exchange.
+      this._mozLoop.telemetryAddValue("LOOP_ROOM_SESSION_WITHCHAT", 1);
+    },
+
+    /**
+     * Handles received text chat messages. For telemetry purposes only.
+     *
+     * @param {sharedActions.ReceivedTextChatMessage} actionData
+     */
+    receivedTextChatMessage: function(actionData) {
+      this._handleTextChatMessage(actionData);
+    },
+
+    /**
+     * Handles sending of a chat message. For telemetry purposes only.
+     *
+     * @param {sharedActions.SendTextChatMessage} actionData
+     */
+    sendTextChatMessage: function(actionData) {
+      this._handleTextChatMessage(actionData);
     }
   });
 
   return ActiveRoomStore;
 })();
--- a/browser/components/loop/content/shared/js/dispatcher.js
+++ b/browser/components/loop/content/shared/js/dispatcher.js
@@ -34,16 +34,38 @@ loop.Dispatcher = (function() {
           this._eventData[type].push(store);
         } else {
           this._eventData[type] = [store];
         }
       }.bind(this));
     },
 
     /**
+     * Unregister a store from receiving notifications of specific actions.
+     *
+     * @param {Object} store The store object to unregister
+     * @param {Array} eventTypes An array of action names
+     */
+    unregister: function(store, eventTypes) {
+      eventTypes.forEach(function(type) {
+        if (!this._eventData.hasOwnProperty(type)) {
+          return;
+        }
+        var idx = this._eventData[type].indexOf(store);
+        if (idx === -1) {
+          return;
+        }
+        this._eventData[type].splice(idx, 1);
+        if (!this._eventData[type].length) {
+          delete this._eventData[type];
+        }
+      }.bind(this));
+    },
+
+    /**
      * Dispatches an action to all registered stores.
      */
     dispatch: function(action) {
       // Always put it on the queue, to make it simpler.
       this._actionQueue.push(action);
       this._dispatchNextAction();
     },
 
--- a/browser/components/loop/content/shared/js/textChatStore.js
+++ b/browser/components/loop/content/shared/js/textChatStore.js
@@ -11,21 +11,17 @@ loop.store.TextChatStore = (function() {
   var sharedActions = loop.shared.actions;
 
   var CHAT_MESSAGE_TYPES = loop.store.CHAT_MESSAGE_TYPES = {
     RECEIVED: "recv",
     SENT: "sent",
     SPECIAL: "special"
   };
 
-  var CHAT_CONTENT_TYPES = loop.store.CHAT_CONTENT_TYPES = {
-    CONTEXT: "chat-context",
-    TEXT: "chat-text",
-    ROOM_NAME: "room-name"
-  };
+  var CHAT_CONTENT_TYPES = loop.shared.utils.CHAT_CONTENT_TYPES;
 
   /**
    * A store to handle text chats. The store has a message list that may
    * contain different types of messages and data.
    */
   var TextChatStore = loop.store.createStore({
     actions: [
       "dataChannelsAvailable",
--- a/browser/components/loop/content/shared/js/textChatView.js
+++ b/browser/components/loop/content/shared/js/textChatView.js
@@ -7,17 +7,17 @@ loop.shared = loop.shared || {};
 loop.shared.views = loop.shared.views || {};
 loop.shared.views.chat = (function(mozL10n) {
   "use strict";
 
   var sharedActions = loop.shared.actions;
   var sharedMixins = loop.shared.mixins;
   var sharedViews = loop.shared.views;
   var CHAT_MESSAGE_TYPES = loop.store.CHAT_MESSAGE_TYPES;
-  var CHAT_CONTENT_TYPES = loop.store.CHAT_CONTENT_TYPES;
+  var CHAT_CONTENT_TYPES = loop.shared.utils.CHAT_CONTENT_TYPES;
 
   /**
    * Renders an individual entry for the text chat entries view.
    */
   var TextChatEntry = React.createClass({displayName: "TextChatEntry",
     mixins: [React.addons.PureRenderMixin],
 
     propTypes: {
--- a/browser/components/loop/content/shared/js/textChatView.jsx
+++ b/browser/components/loop/content/shared/js/textChatView.jsx
@@ -7,17 +7,17 @@ loop.shared = loop.shared || {};
 loop.shared.views = loop.shared.views || {};
 loop.shared.views.chat = (function(mozL10n) {
   "use strict";
 
   var sharedActions = loop.shared.actions;
   var sharedMixins = loop.shared.mixins;
   var sharedViews = loop.shared.views;
   var CHAT_MESSAGE_TYPES = loop.store.CHAT_MESSAGE_TYPES;
-  var CHAT_CONTENT_TYPES = loop.store.CHAT_CONTENT_TYPES;
+  var CHAT_CONTENT_TYPES = loop.shared.utils.CHAT_CONTENT_TYPES;
 
   /**
    * Renders an individual entry for the text chat entries view.
    */
   var TextChatEntry = React.createClass({
     mixins: [React.addons.PureRenderMixin],
 
     propTypes: {
--- a/browser/components/loop/content/shared/js/utils.js
+++ b/browser/components/loop/content/shared/js/utils.js
@@ -105,16 +105,22 @@ var inChrome = typeof Components != "und
 
   var SCREEN_SHARE_STATES = {
     INACTIVE: "ss-inactive",
     // Pending is when the user is being prompted, aka gUM in progress.
     PENDING: "ss-pending",
     ACTIVE: "ss-active"
   };
 
+  var CHAT_CONTENT_TYPES = {
+    CONTEXT: "chat-context",
+    TEXT: "chat-text",
+    ROOM_NAME: "room-name"
+  };
+
   /**
    * Format a given date into an l10n-friendly string.
    *
    * @param {Integer} The timestamp in seconds to format.
    * @return {String} The formatted string.
    */
   function formatDate(timestamp) {
     var date = (new Date(timestamp * 1000));
@@ -770,16 +776,17 @@ var inChrome = typeof Components != "und
       parentNode = parentNode.parentNode;
     }
 
     return node;
   }
 
   this.utils = {
     CALL_TYPES: CALL_TYPES,
+    CHAT_CONTENT_TYPES: CHAT_CONTENT_TYPES,
     FAILURE_DETAILS: FAILURE_DETAILS,
     REST_ERRNOS: REST_ERRNOS,
     WEBSOCKET_REASONS: WEBSOCKET_REASONS,
     STREAM_PROPERTIES: STREAM_PROPERTIES,
     SCREEN_SHARE_STATES: SCREEN_SHARE_STATES,
     ROOM_INFO_FAILURES: ROOM_INFO_FAILURES,
     setRootObjects: setRootObjects,
     composeCallUrlEmail: composeCallUrlEmail,
--- a/browser/components/loop/test/mochitest/browser_mozLoop_telemetry.js
+++ b/browser/components/loop/test/mochitest/browser_mozLoop_telemetry.js
@@ -167,13 +167,27 @@ add_task(function* test_mozLoop_telemetr
 add_task(function* test_mozLoop_telemetryAdd_roomContextClick() {
   let histogramId = "LOOP_ROOM_CONTEXT_CLICK";
   let histogram = Services.telemetry.getHistogramById(histogramId);
 
   histogram.clear();
 
   let snapshot;
   for (let i = 1; i < 4; ++i) {
-    gMozLoopAPI.telemetryAddValue("LOOP_ROOM_CONTEXT_CLICK", 1);
+    gMozLoopAPI.telemetryAddValue(histogramId, 1);
     snapshot = histogram.snapshot();
     Assert.strictEqual(snapshot.counts[0], i);
   }
 });
+
+add_task(function* test_mozLoop_telemetryAdd_roomSessionWithChat() {
+  let histogramId = "LOOP_ROOM_SESSION_WITHCHAT";
+  let histogram = Services.telemetry.getHistogramById(histogramId);
+
+  histogram.clear();
+
+  let snapshot;
+  for (let i = 1; i < 4; ++i) {
+    gMozLoopAPI.telemetryAddValue(histogramId, 1);
+    snapshot = histogram.snapshot();
+    Assert.strictEqual(snapshot.counts[0], i);
+  }
+});
--- a/browser/components/loop/test/shared/activeRoomStore_test.js
+++ b/browser/components/loop/test/shared/activeRoomStore_test.js
@@ -4,16 +4,17 @@
 describe("loop.store.ActiveRoomStore", function () {
   "use strict";
 
   var expect = chai.expect;
   var sharedActions = loop.shared.actions;
   var sharedUtils = loop.shared.utils;
   var REST_ERRNOS = loop.shared.utils.REST_ERRNOS;
   var ROOM_STATES = loop.store.ROOM_STATES;
+  var CHAT_CONTENT_TYPES = loop.shared.utils.CHAT_CONTENT_TYPES;
   var FAILURE_DETAILS = loop.shared.utils.FAILURE_DETAILS;
   var SCREEN_SHARE_STATES = loop.shared.utils.SCREEN_SHARE_STATES;
   var ROOM_INFO_FAILURES = loop.shared.utils.ROOM_INFO_FAILURES;
   var sandbox, dispatcher, store, fakeMozLoop, fakeSdkDriver, fakeMultiplexGum;
   var standaloneMediaRestore;
 
   beforeEach(function() {
     sandbox = sinon.sandbox.create();
@@ -34,17 +35,18 @@ describe("loop.store.ActiveRoomStore", f
         refreshMembership: sinon.stub(),
         leave: sinon.stub(),
         on: sinon.stub(),
         off: sinon.stub(),
         sendConnectionStatus: sinon.stub()
       },
       setScreenShareState: sinon.stub(),
       getActiveTabWindowId: sandbox.stub().callsArgWith(0, null, 42),
-      getSocialShareProviders: sinon.stub().returns([])
+      getSocialShareProviders: sinon.stub().returns([]),
+      telemetryAddValue: sinon.stub()
     };
 
     fakeSdkDriver = {
       connectSession: sinon.stub(),
       disconnectSession: sinon.stub(),
       forceDisconnectAll: sinon.stub().callsArg(0),
       retryPublishWithoutVideo: sinon.stub(),
       startScreenShare: sinon.stub(),
@@ -1837,27 +1839,29 @@ describe("loop.store.ActiveRoomStore", f
 
     it("should reset various store states", function() {
       store.setStoreState({
         audioMuted: true,
         localVideoDimensions: { x: 10 },
         receivingScreenShare: true,
         remoteVideoDimensions: { y: 10 },
         screenSharingState: true,
-        videoMuted: true
+        videoMuted: true,
+        chatMessageExchanged: false
       });
 
       store.leaveRoom();
 
       expect(store._storeState.audioMuted).eql(false);
       expect(store._storeState.localVideoDimensions).eql({});
       expect(store._storeState.receivingScreenShare).eql(false);
       expect(store._storeState.remoteVideoDimensions).eql({});
       expect(store._storeState.screenSharingState).eql(SCREEN_SHARE_STATES.INACTIVE);
       expect(store._storeState.videoMuted).eql(false);
+      expect(store._storeState.chatMessageExchanged).eql(false);
     });
 
     it("should not reset the room context", function() {
       store.setStoreState({
         roomContextUrls: [{ fake: 1 }],
         roomName: "fred"
       });
 
@@ -1881,16 +1885,83 @@ describe("loop.store.ActiveRoomStore", f
 
     it("should call respective mozLoop methods", function() {
       store._handleSocialShareUpdate();
 
       sinon.assert.calledOnce(fakeMozLoop.getSocialShareProviders);
     });
   });
 
+  describe("#_handleTextChatMessage", function() {
+    beforeEach(function() {
+      store._isDesktop = true;
+      store.setupWindowData(new sharedActions.SetupWindowData({
+        windowId: "42",
+        type: "room",
+        roomToken: "fakeToken"
+      }));
+    });
+
+    function assertWeDidNothing() {
+      expect(dispatcher._eventData.receivedTextChatMessage.length).eql(1);
+      expect(dispatcher._eventData.sendTextChatMessage.length).eql(1);
+      expect(store.getStoreState().chatMessageExchanged).eql(false);
+      sinon.assert.notCalled(fakeMozLoop.telemetryAddValue);
+    }
+
+    it("should not do anything for the link clicker side", function() {
+      store._isDesktop = false;
+
+      store._handleTextChatMessage(new sharedActions.SendTextChatMessage({
+        contentType: CHAT_CONTENT_TYPES.TEXT,
+        message: "Hello!",
+        sentTimestamp: "1970-01-01T00:00:00.000Z"
+      }));
+
+      assertWeDidNothing();
+    });
+
+    it("should not do anything when a chat message has arrived before", function() {
+      store.setStoreState({ chatMessageExchanged: true });
+
+      store._handleTextChatMessage(new sharedActions.ReceivedTextChatMessage({
+        contentType: CHAT_CONTENT_TYPES.TEXT,
+        message: "Hello!",
+        receivedTimestamp: "1970-01-01T00:00:00.000Z"
+      }));
+
+      sinon.assert.notCalled(fakeMozLoop.telemetryAddValue);
+    });
+
+    it("should not do anything for non-chat messages", function() {
+      store._handleTextChatMessage(new sharedActions.SendTextChatMessage({
+        contentType: CHAT_CONTENT_TYPES.CONTEXT,
+        message: "Hello!",
+        sentTimestamp: "1970-01-01T00:00:00.000Z"
+      }));
+
+      assertWeDidNothing();
+    });
+
+    it("should ping telemetry when a chat message arrived or is to be sent", function() {
+      store._handleTextChatMessage(new sharedActions.ReceivedTextChatMessage({
+        contentType: CHAT_CONTENT_TYPES.TEXT,
+        message: "Hello!",
+        receivedTimestamp: "1970-01-01T00:00:00.000Z"
+      }));
+
+      sinon.assert.calledOnce(fakeMozLoop.telemetryAddValue);
+      sinon.assert.calledWithExactly(fakeMozLoop.telemetryAddValue,
+        "LOOP_ROOM_SESSION_WITHCHAT", 1);
+      expect(store.getStoreState().chatMessageExchanged).eql(true);
+      expect(dispatcher._eventData.hasOwnProperty("receivedTextChatMessage")).eql(false);
+      expect(dispatcher._eventData.hasOwnProperty("sendTextChatMessage")).eql(false);
+    });
+  });
+
   describe("Events", function() {
     describe("update:{roomToken}", function() {
       beforeEach(function() {
         store.setupWindowData(new sharedActions.SetupWindowData({
           roomToken: "fakeToken",
           type: "room",
           windowId: "42"
         }));
--- a/browser/components/loop/test/shared/dispatcher_test.js
+++ b/browser/components/loop/test/shared/dispatcher_test.js
@@ -18,31 +18,58 @@ describe("loop.Dispatcher", function () 
   });
 
   describe("#register", function() {
     it("should register a store against an action name", function() {
       var object = { fake: true };
 
       dispatcher.register(object, ["getWindowData"]);
 
+      // XXXmikedeboer: Consider changing these tests to not access private
+      //                properties anymore (`_eventData`).
       expect(dispatcher._eventData.getWindowData[0]).eql(object);
     });
 
     it("should register multiple store against an action name", function() {
       var object1 = { fake: true };
       var object2 = { fake2: true };
 
       dispatcher.register(object1, ["getWindowData"]);
       dispatcher.register(object2, ["getWindowData"]);
 
       expect(dispatcher._eventData.getWindowData[0]).eql(object1);
       expect(dispatcher._eventData.getWindowData[1]).eql(object2);
     });
   });
 
+  describe("#unregister", function() {
+    it("should unregister a store against an action name", function() {
+      var object = { fake: true };
+
+      dispatcher.register(object, ["getWindowData"]);
+      dispatcher.unregister(object, ["getWindowData"]);
+
+      expect(dispatcher._eventData.hasOwnProperty("getWindowData")).eql(false);
+    });
+
+    it("should unregister multiple stores against an action name", function() {
+      var object1 = { fake: true };
+      var object2 = { fake2: true };
+
+      dispatcher.register(object1, ["getWindowData"]);
+      dispatcher.register(object2, ["getWindowData"]);
+
+      dispatcher.unregister(object1, ["getWindowData"]);
+      expect(dispatcher._eventData.getWindowData.length).eql(1);
+
+      dispatcher.unregister(object2, ["getWindowData"]);
+      expect(dispatcher._eventData.hasOwnProperty("getWindowData")).eql(false);
+    });
+  });
+
   describe("#dispatch", function() {
     var getDataStore1, getDataStore2, cancelStore1, connectStore1;
     var getDataAction, cancelAction, connectAction, resolveCancelStore1;
 
     beforeEach(function() {
       getDataAction = new sharedActions.GetWindowData({
         windowId: "42"
       });
--- a/browser/components/loop/test/shared/otSdkDriver_test.js
+++ b/browser/components/loop/test/shared/otSdkDriver_test.js
@@ -4,17 +4,17 @@
 describe("loop.OTSdkDriver", function () {
   "use strict";
 
   var expect = chai.expect;
   var sharedActions = loop.shared.actions;
   var FAILURE_DETAILS = loop.shared.utils.FAILURE_DETAILS;
   var STREAM_PROPERTIES = loop.shared.utils.STREAM_PROPERTIES;
   var SCREEN_SHARE_STATES = loop.shared.utils.SCREEN_SHARE_STATES;
-  var CHAT_CONTENT_TYPES = loop.store.CHAT_CONTENT_TYPES;
+  var CHAT_CONTENT_TYPES = loop.shared.utils.CHAT_CONTENT_TYPES;
 
   var sandbox;
   var dispatcher, driver, mozLoop, publisher, sdk, session, sessionData, subscriber;
   var publisherConfig, fakeEvent;
 
   beforeEach(function() {
     sandbox = sinon.sandbox.create();
 
--- a/browser/components/loop/test/shared/textChatStore_test.js
+++ b/browser/components/loop/test/shared/textChatStore_test.js
@@ -2,17 +2,17 @@
  * http://creativecommons.org/publicdomain/zero/1.0/ */
 
 describe("loop.store.TextChatStore", function () {
   "use strict";
 
   var expect = chai.expect;
   var sharedActions = loop.shared.actions;
   var CHAT_MESSAGE_TYPES = loop.store.CHAT_MESSAGE_TYPES;
-  var CHAT_CONTENT_TYPES = loop.store.CHAT_CONTENT_TYPES;
+  var CHAT_CONTENT_TYPES = loop.shared.utils.CHAT_CONTENT_TYPES;
 
   var dispatcher, fakeSdkDriver, sandbox, store;
 
   beforeEach(function() {
     sandbox = sinon.sandbox.create();
     sandbox.useFakeTimers();
 
     dispatcher = new loop.Dispatcher();
--- a/browser/components/loop/test/shared/textChatView_test.js
+++ b/browser/components/loop/test/shared/textChatView_test.js
@@ -4,17 +4,17 @@
 describe("loop.shared.views.TextChatView", function () {
   "use strict";
 
   var expect = chai.expect;
   var sharedActions = loop.shared.actions;
   var sharedViews = loop.shared.views;
   var TestUtils = React.addons.TestUtils;
   var CHAT_MESSAGE_TYPES = loop.store.CHAT_MESSAGE_TYPES;
-  var CHAT_CONTENT_TYPES = loop.store.CHAT_CONTENT_TYPES;
+  var CHAT_CONTENT_TYPES = loop.shared.utils.CHAT_CONTENT_TYPES;
   var fixtures = document.querySelector("#fixtures");
 
   var dispatcher, fakeSdkDriver, sandbox, store, fakeClock;
 
   beforeEach(function() {
     sandbox = sinon.sandbox.create();
     fakeClock = sandbox.useFakeTimers();
 
--- a/browser/components/loop/ui/ui-showcase.js
+++ b/browser/components/loop/ui/ui-showcase.js
@@ -93,17 +93,17 @@
 
   MockSDK.prototype = {
     setupStreamElements: function() {
       // Dummy function to stop warnings.
     },
 
     sendTextChatMessage: function(actionData) {
       dispatcher.dispatch(new loop.shared.actions.ReceivedTextChatMessage({
-        contentType: loop.store.CHAT_CONTENT_TYPES.TEXT,
+        contentType: loop.shared.utils.CHAT_CONTENT_TYPES.TEXT,
         message: actionData.message,
         receivedTimestamp: actionData.sentTimestamp
       }));
     }
   };
 
   var mockSDK = new MockSDK();
 
@@ -404,50 +404,50 @@
       location: "http://wonderful.invalid"
       // use the fallback thumbnail
     }]
   }));
 
   textChatStore.setStoreState({textChatEnabled: true});
 
   dispatcher.dispatch(new sharedActions.SendTextChatMessage({
-    contentType: loop.store.CHAT_CONTENT_TYPES.TEXT,
+    contentType: loop.shared.utils.CHAT_CONTENT_TYPES.TEXT,
     message: "Rheet!",
     sentTimestamp: "2015-06-23T22:21:45.590Z"
   }));
   dispatcher.dispatch(new sharedActions.ReceivedTextChatMessage({
-    contentType: loop.store.CHAT_CONTENT_TYPES.TEXT,
+    contentType: loop.shared.utils.CHAT_CONTENT_TYPES.TEXT,
     message: "Hello",
     receivedTimestamp: "2015-06-23T23:24:45.590Z"
   }));
   dispatcher.dispatch(new sharedActions.SendTextChatMessage({
-    contentType: loop.store.CHAT_CONTENT_TYPES.TEXT,
+    contentType: loop.shared.utils.CHAT_CONTENT_TYPES.TEXT,
     message: "Nowforareallylongwordwithoutspacesorpunctuationwhichshouldcause" +
     "linewrappingissuesifthecssiswrong",
     sentTimestamp: "2015-06-23T22:23:45.590Z"
   }));
   dispatcher.dispatch(new sharedActions.SendTextChatMessage({
-    contentType: loop.store.CHAT_CONTENT_TYPES.TEXT,
+    contentType: loop.shared.utils.CHAT_CONTENT_TYPES.TEXT,
     message: "Check out this menu from DNA Pizza:" +
     " http://example.com/DNA/pizza/menu/lots-of-different-kinds-of-pizza/" +
     "%8D%E0%B8%88%E0%B8%A1%E0%B8%A3%E0%8D%E0%B8%88%E0%B8%A1%E0%B8%A3%E0%",
     sentTimestamp: "2015-06-23T22:23:45.590Z"
   }));
   dispatcher.dispatch(new sharedActions.ReceivedTextChatMessage({
-    contentType: loop.store.CHAT_CONTENT_TYPES.TEXT,
+    contentType: loop.shared.utils.CHAT_CONTENT_TYPES.TEXT,
     message: "That avocado monkey-brains pie sounds tasty!",
     receivedTimestamp: "2015-06-23T22:25:45.590Z"
   }));
   dispatcher.dispatch(new sharedActions.SendTextChatMessage({
-    contentType: loop.store.CHAT_CONTENT_TYPES.TEXT,
+    contentType: loop.shared.utils.CHAT_CONTENT_TYPES.TEXT,
     message: "What time should we meet?",
     sentTimestamp: "2015-06-23T22:27:45.590Z"
   }));
   dispatcher.dispatch(new sharedActions.ReceivedTextChatMessage({
-    contentType: loop.store.CHAT_CONTENT_TYPES.TEXT,
+    contentType: loop.shared.utils.CHAT_CONTENT_TYPES.TEXT,
     message: "8:00 PM",
     receivedTimestamp: "2015-06-23T22:27:45.590Z"
   }));
 
   loop.store.StoreMixin.register({
     activeRoomStore: activeRoomStore,
     conversationStore: conversationStores[0],
     textChatStore: textChatStore
--- a/browser/components/loop/ui/ui-showcase.jsx
+++ b/browser/components/loop/ui/ui-showcase.jsx
@@ -93,17 +93,17 @@
 
   MockSDK.prototype = {
     setupStreamElements: function() {
       // Dummy function to stop warnings.
     },
 
     sendTextChatMessage: function(actionData) {
       dispatcher.dispatch(new loop.shared.actions.ReceivedTextChatMessage({
-        contentType: loop.store.CHAT_CONTENT_TYPES.TEXT,
+        contentType: loop.shared.utils.CHAT_CONTENT_TYPES.TEXT,
         message: actionData.message,
         receivedTimestamp: actionData.sentTimestamp
       }));
     }
   };
 
   var mockSDK = new MockSDK();
 
@@ -404,50 +404,50 @@
       location: "http://wonderful.invalid"
       // use the fallback thumbnail
     }]
   }));
 
   textChatStore.setStoreState({textChatEnabled: true});
 
   dispatcher.dispatch(new sharedActions.SendTextChatMessage({
-    contentType: loop.store.CHAT_CONTENT_TYPES.TEXT,
+    contentType: loop.shared.utils.CHAT_CONTENT_TYPES.TEXT,
     message: "Rheet!",
     sentTimestamp: "2015-06-23T22:21:45.590Z"
   }));
   dispatcher.dispatch(new sharedActions.ReceivedTextChatMessage({
-    contentType: loop.store.CHAT_CONTENT_TYPES.TEXT,
+    contentType: loop.shared.utils.CHAT_CONTENT_TYPES.TEXT,
     message: "Hello",
     receivedTimestamp: "2015-06-23T23:24:45.590Z"
   }));
   dispatcher.dispatch(new sharedActions.SendTextChatMessage({
-    contentType: loop.store.CHAT_CONTENT_TYPES.TEXT,
+    contentType: loop.shared.utils.CHAT_CONTENT_TYPES.TEXT,
     message: "Nowforareallylongwordwithoutspacesorpunctuationwhichshouldcause" +
     "linewrappingissuesifthecssiswrong",
     sentTimestamp: "2015-06-23T22:23:45.590Z"
   }));
   dispatcher.dispatch(new sharedActions.SendTextChatMessage({
-    contentType: loop.store.CHAT_CONTENT_TYPES.TEXT,
+    contentType: loop.shared.utils.CHAT_CONTENT_TYPES.TEXT,
     message: "Check out this menu from DNA Pizza:" +
     " http://example.com/DNA/pizza/menu/lots-of-different-kinds-of-pizza/" +
     "%8D%E0%B8%88%E0%B8%A1%E0%B8%A3%E0%8D%E0%B8%88%E0%B8%A1%E0%B8%A3%E0%",
     sentTimestamp: "2015-06-23T22:23:45.590Z"
   }));
   dispatcher.dispatch(new sharedActions.ReceivedTextChatMessage({
-    contentType: loop.store.CHAT_CONTENT_TYPES.TEXT,
+    contentType: loop.shared.utils.CHAT_CONTENT_TYPES.TEXT,
     message: "That avocado monkey-brains pie sounds tasty!",
     receivedTimestamp: "2015-06-23T22:25:45.590Z"
   }));
   dispatcher.dispatch(new sharedActions.SendTextChatMessage({
-    contentType: loop.store.CHAT_CONTENT_TYPES.TEXT,
+    contentType: loop.shared.utils.CHAT_CONTENT_TYPES.TEXT,
     message: "What time should we meet?",
     sentTimestamp: "2015-06-23T22:27:45.590Z"
   }));
   dispatcher.dispatch(new sharedActions.ReceivedTextChatMessage({
-    contentType: loop.store.CHAT_CONTENT_TYPES.TEXT,
+    contentType: loop.shared.utils.CHAT_CONTENT_TYPES.TEXT,
     message: "8:00 PM",
     receivedTimestamp: "2015-06-23T22:27:45.590Z"
   }));
 
   loop.store.StoreMixin.register({
     activeRoomStore: activeRoomStore,
     conversationStore: conversationStores[0],
     textChatStore: textChatStore
--- a/toolkit/components/telemetry/Histograms.json
+++ b/toolkit/components/telemetry/Histograms.json
@@ -8306,16 +8306,23 @@
   },
   "LOOP_ROOM_CONTEXT_CLICK": {
     "alert_emails": ["firefox-dev@mozilla.org", "mdeboer@mozilla.com"],
     "expires_in_version": "45",
     "kind": "count",
     "releaseChannelCollection": "opt-out",
     "description": "Number times room context is clicked to visit the attached URL"
   },
+  "LOOP_ROOM_SESSION_WITHCHAT": {
+    "alert_emails": ["firefox-dev@mozilla.org", "mdeboer@mozilla.com"],
+    "expires_in_version": "45",
+    "kind": "count",
+    "releaseChannelCollection": "opt-out",
+    "description": "Number of sessions where at least one chat message was exchanged"
+  },
   "E10S_AUTOSTART": {
     "expires_in_version": "never",
     "kind": "boolean",
     "description": "Whether a session is set to autostart e10s windows"
   },
   "E10S_AUTOSTART_STATUS": {
     "expires_in_version": "never",
     "kind": "enumerated",