diff --git a/lib/static_resources.js b/lib/static_resources.js index f877ca51a84f7d4b4c7e3b9ae0a94ddf9a07373a..8f4d85c9947b850a366f82f39d8dc1e0a64cd5ac 100644 --- a/lib/static_resources.js +++ b/lib/static_resources.js @@ -80,7 +80,8 @@ var dialog_js = und.flatten([ '/shared/command.js', '/shared/history.js', '/shared/state_machine.js', - '/shared/interaction_data.js', + + '/shared/modules/interaction_data.js', '/dialog/resources/internal_api.js', '/dialog/resources/helpers.js', diff --git a/resources/static/dialog/start.js b/resources/static/dialog/start.js index 45e04d4fb15c4d2f7c590eed8a4a213e9b78bfbe..1d0655877c632dfd28b18d2fe025877c464dca4f 100644 --- a/resources/static/dialog/start.js +++ b/resources/static/dialog/start.js @@ -14,6 +14,12 @@ xhr.init({ time_until_delay: 10 * 1000 }); network.init(); + var hash = window.location.hash || "", + continuation = hash.indexOf("#CREATE_EMAIL") > -1 || hash.indexOf("#ADD_EMAIL") > -1; + + moduleManager.register("interaction_data", modules.InteractionData); + moduleManager.start("interaction_data", { continuation: continuation }); + moduleManager.register("cookie_check", modules.CookieCheck); moduleManager.start("cookie_check", { ready: function(status) { diff --git a/resources/static/shared/interaction_data.js b/resources/static/shared/interaction_data.js deleted file mode 100644 index 82c131d366bb6d1c694d95a08a5ff17dd4dd42bf..0000000000000000000000000000000000000000 --- a/resources/static/shared/interaction_data.js +++ /dev/null @@ -1,132 +0,0 @@ -/* 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/. */ - -/** - * interaction_data is a module responsible for collecting and - * reporting anonymous interaction data that represents a user's - * interaction with the dialog. It aggregates information that is not - * user specific like the user's OS, Browser, and the interface - * elements they've clicked on. It stores this information in - * localstorage, and at initialization reports previous interaction - * data to the server. This data is then used to optimize the user - * experience of the Persona dialog. - * - * More information about interaction data and 'Key Performance Indicators' - * stats that are derived from it: - * - * https://wiki.mozilla.org/Privacy/Reviews/KPI_Backend - */ - -// TODO: -// * should code explicitly call .addEvent? or instead should this module -// listen for events via the mediator? -// * the primary flow will cause unload and reload. omg. How do we deal? - -(function() { - var bid = BrowserID, - mediator = bid.Mediator, - storage = bid.Storage.interactionData, - network = bid.Network; - - var startTime = new Date(); - - // sample rate is specified from the server. it's set at the - // first 'context_info' event, which corresponds to the first time - // we get 'session_context' from the server. When sampleRate is - // not undefined, then the interaction data is initialized. - // sample will be true or false - var sample = undefined; - - var currentData = { - event_stream: [ - ] - }; - - // whenever session_context is hit, let's hear about it so we can - // extract the information that's important to us (like, whether we - // should be running or not) - mediator.subscribe('context_info', onSessionContext); - - function onSessionContext(msg, result) { - // defend against onSessionContext being called multiple times - if (sample !== undefined) return; - - // set the sample rate as defined by the server. It's a value - // between 0..1, integer or float, and it specifies the percentage - // of the time that we should capture - var sampleRate = result.data_sample_rate || 0; - - currentData.sample_rate = sampleRate; - - // now that we've got sample rate, let's smash it into a boolean - // probalistically - sample = (Math.random() <= sampleRate); - - // if we're not going to sample, kick out early. - if (!sample) { - currentData = undefined; - return; - } - - // set current time - currentData.timestamp = result.server_time; - - // language - currentData.lang = $('html').attr('lang'); - - if (window.screen) { - currentData.screen_size = { - width: window.screen.width, - height: window.screen.height - }; - } - - // XXX: implement me! - currentData.user_agent = { - os: null, - browser: null, - version: null - }; - - // cool. now let's persist this data - storage.push(currentData); - currentData = undefined; - - // finally, let's try to publish any old data - setTimeout(publishOld, 10); - } - - // At every load, after session_context returns, we'll try to publish - // past interaction data to the server if it exists. The psuedo - // transactional model employed here is to attempt to post, and only - // once we receive a server response do we purge data. We don't - // care if the post is a success or failure as this data is not - // critical to the functioning of the system (and some failure scenarios - // simply won't resolve with retries - like corrupt data, or too much - // data) - function publishOld() { - var data = storage.get(); - if (data.length === 0) return; - network.sendInteractionData(data, complete, complete); - return; - - function complete() { - storage.clear(); - } - } - - // on all events, update event_stream - mediator.subscribeAll(function(msg, data) { - if (sample === false) return; - - if (currentData) { - currentData.event_stream.push([ msg, new Date() - startTime ]); - } else { - var d = storage.current(); - if (!d.event_stream) d.event_stream = []; - d.event_stream.push([ msg, new Date() - startTime ]); - storage.setCurrent(d); - } - }); -}()); diff --git a/resources/static/shared/modules/interaction_data.js b/resources/static/shared/modules/interaction_data.js new file mode 100644 index 0000000000000000000000000000000000000000..458478ed645672d646fc768a615114a80735b4dd --- /dev/null +++ b/resources/static/shared/modules/interaction_data.js @@ -0,0 +1,203 @@ +/* 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/. */ + +/** + * interaction_data is a module responsible for collecting and + * reporting anonymous interaction data that represents a user's + * interaction with the dialog. It aggregates information that is not + * user specific like the user's OS, Browser, and the interface + * elements they've clicked on. It stores this information in + * localstorage, and at initialization reports previous interaction + * data to the server. This data is then used to optimize the user + * experience of the Persona dialog. + * + * More information about interaction data and 'Key Performance Indicators' + * stats that are derived from it: + * + * https://wiki.mozilla.org/Privacy/Reviews/KPI_Backend + */ + +// TODO: +// * should code explicitly call .addEvent? or instead should this module +// listen for events via the mediator? + +BrowserID.Modules.InteractionData = (function() { + var bid = BrowserID, + storage = bid.Storage.interactionData, + network = bid.Network, + complete = bid.Helpers.complete, + dom = bid.DOM, + sc; + + function onSessionContext(msg, result) { + var self=this; + + // defend against onSessionContext being called multiple times + if (self.sessionContextHandled) return; + self.sessionContextHandled = true; + + // Publish any outstanding data. Unless this is a continuation, previous + // session data must be published independently of whether the current + // dialog session is allowed to sample data. This is because the original + // dialog session has already decided whether to collect data. + publishStored(); + + // set the sample rate as defined by the server. It's a value + // between 0..1, integer or float, and it specifies the percentage + // of the time that we should capture + var sampleRate = result.data_sample_rate || 0; + + if (typeof self.samplingEnabled === "undefined") { + // now that we've got sample rate, let's smash it into a boolean + // probalistically + self.samplingEnabled = Math.random() <= sampleRate; + } + + // if we're not going to sample, kick out early. + if (!self.samplingEnabled) { + return; + } + + var currentData = { + event_stream: self.initialEventStream, + sample_rate: sampleRate, + timestamp: result.server_time, + local_timestamp: self.startTime.toString(), + lang: dom.getAttr('html', 'lang') || null, + }; + + if (window.screen) { + currentData.screen_size = { + width: window.screen.width, + height: window.screen.height + }; + } + + // cool. now let's persist the initial data. This data will be published + // as soon as the first session_context completes for the next dialog + // session. Use a push because old data *may not* have been correctly + // published to a down server or erroring web service. + storage.push(currentData); + + self.initialEventStream = null; + + self.samplesBeingStored = true; + } + + // At every load, after session_context returns, we'll try to publish + // past interaction data to the server if it exists. The psuedo + // transactional model employed here is to attempt to post, and only + // once we receive a server response do we purge data. We don't + // care if the post is a success or failure as this data is not + // critical to the functioning of the system (and some failure scenarios + // simply won't resolve with retries - like corrupt data, or too much + // data) + function publishStored(oncomplete) { + var data = storage.get(); + + if (data && data.length !== 0) { + network.sendInteractionData(data, function() { + storage.clear(); + complete(oncomplete, true); + }); + } + else { + complete(oncomplete, false); + } + } + + + function addEvent(eventName) { + var self=this; + + if (self.samplingEnabled === false) return; + + var eventData = [ eventName, new Date() - self.startTime ]; + if (self.samplesBeingStored) { + var d = storage.current() || {}; + if (!d.event_stream) d.event_stream = []; + d.event_stream.push(eventData); + storage.setCurrent(d); + } else { + self.initialEventStream.push(eventData); + } + } + + var Module = bid.Modules.PageModule.extend({ + start: function(options) { + options = options || {}; + + var self = this; + + // options.samplingEnabled is used for testing purposes. + // + // If samplingEnabled is not specified in the options, and this is not + // a continuation, samplingEnabled will be decided on the first " + // context_info" event, which corresponds to the first time + // 'session_context' returns from the server. + self.samplingEnabled = options.samplingEnabled; + + // continuation means the users dialog session is continuing, probably + // due to a redirect to an IdP and then a return after authentication. + if (options.continuation) { + var previousData = storage.current(); + + var samplingEnabled = self.samplingEnabled = !!previousData.event_stream; + if (samplingEnabled) { + self.startTime = Date.parse(previousData.local_timestamp); + + if (typeof self.samplingEnabled === "undefined") { + self.samplingEnabled = samplingEnabled; + } + + // instead of waiting for session_context to start appending data to + // localStorage, start saving into localStorage now. + self.samplesBeingStored = true; + } + else { + // If there was no previous event stream, that means data collection + // was not allowed for the previous session. Return with no further + // action, data collection is not allowed for this session either. + return; + } + } + else { + self.startTime = new Date(); + + // The initialEventStream is used to store events until onSessionContext + // is called. Once onSessionContext is called and it is known whether + // the user's data will be saved, initialEventStream will either be + // discarded or added to the data set that is saved to localStorage. + self.initialEventStream = []; + self.samplesBeingStored = false; + + // whenever session_context is hit, let's hear about it so we can + // extract the information that's important to us (like, whether we + // should be running or not) + self.contextInfoHandle = this.subscribe('context_info', onSessionContext); + } + + // on all events, update event_stream + this.subscribeAll(addEvent); + }, + + addEvent: addEvent, + + getCurrentStoredData: function() { + var und; + return this.samplesBeingStored ? storage.current() : und; + }, + + getEventStream: function() { + return this.samplesBeingStored ? storage.current().event_stream : this.initialEventStream || []; + }, + + publishStored: publishStored + }); + + sc = Module.sc; + + return Module; + +}()); diff --git a/resources/static/shared/modules/page_module.js b/resources/static/shared/modules/page_module.js index b47ecf8ac7a2a496634f1b6c4ed87e437fb3e3d0..a86f40cf99d4dfafd53bcb26f1a74a257aa43841 100644 --- a/resources/static/shared/modules/page_module.js +++ b/resources/static/shared/modules/page_module.js @@ -176,7 +176,17 @@ BrowserID.Modules.PageModule = (function() { * @param {object} [context] - context, if not given, use this. */ subscribe: function(message, callback, context) { - mediator.subscribe(message, callback.bind(context || this)); + mediator.subscribe(message, callback, context || this); + }, + + /** + * Subscribe to all messages on the mediator. + * @method subscribeAll + * @param {function} callback + * @param {object} [context] - context, if not given, use this. + */ + subscribeAll: function(callback, context) { + mediator.subscribeAll(callback, context || this); }, /** diff --git a/resources/static/shared/network.js b/resources/static/shared/network.js index 60651aff69af869b2a98583e7e39253cb9404906..d1e0cfc1d4391ae2f1408be4985de6ada84683e2 100644 --- a/resources/static/shared/network.js +++ b/resources/static/shared/network.js @@ -157,6 +157,10 @@ BrowserID.Network = (function() { }, onFailure); }, + withContext: function(onComplete, onFailure) { + withContext(onComplete, onFailure) + }, + /** * clear local cache, including authentication status and * other session data. diff --git a/resources/static/shared/storage.js b/resources/static/shared/storage.js index f87cff94401569a58f25c75d168abbdd0583b5de..dd5c8ce0f17105f804b70af5a1a03c3d89e851a9 100644 --- a/resources/static/shared/storage.js +++ b/resources/static/shared/storage.js @@ -394,7 +394,7 @@ BrowserID.Storage = (function() { try { return JSON.parse(storage.interactionData)[0]; } catch(e) { - alert(e); + //alert(e); return {}; } } @@ -405,7 +405,7 @@ BrowserID.Storage = (function() { id = JSON.parse(storage.interactionData); id[0] = data; } catch(e) { - alert(e); + //alert(e); id = [ data ]; } storage.interactionData = JSON.stringify(id); @@ -413,21 +413,19 @@ BrowserID.Storage = (function() { function getAllInteractionData() { try { - var id = JSON.parse(storage.interactionData); - return id.slice(1); + return JSON.parse(storage.interactionData); } catch(e) { - alert(e); + //alert(e); return []; } } function clearInteractionData() { try { - var id = JSON.parse(storage.interactionData); - storage.interactionData = JSON.stringify(id.slice(0,1)); + storage.interactionData = JSON.stringify([]); } catch(e) { - alert(e); - delete storage.interactionData; + // alert(e); + // delete storage.interactionData; } } diff --git a/resources/static/test/cases/shared/modules/interaction_data.js b/resources/static/test/cases/shared/modules/interaction_data.js new file mode 100644 index 0000000000000000000000000000000000000000..951c506903538a898a6e0c80c8fa8913aeff3593 --- /dev/null +++ b/resources/static/test/cases/shared/modules/interaction_data.js @@ -0,0 +1,186 @@ +/*jshint browsers:true, forin: true, laxbreak: true */ +/*global test: true, start: true, stop: true, module: true, ok: true, equal: true, BrowserID:true */ +/* 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/. */ +(function() { + "use strict"; + + var bid = BrowserID, + testHelpers = bid.TestHelpers, + network = bid.Network, + storage = bid.Storage, + controller; + + module("shared/modules/interaction_data", { + setup: testHelpers.setup, + teardown: function() { + testHelpers.teardown(); + + controller.destroy(); + } + }); + + function createController(config) { + config = _.extend({ samplingEnabled: true }, config); + controller = BrowserID.Modules.InteractionData.create(); + controller.start(config); + } + + function indexOfEvent(eventStream, eventName) { + for(var event, i = 0; event = eventStream[i]; ++i) { + if(event[0] === eventName) return i; + } + + return -1; + } + + asyncTest("samplingEnabled - ensure data collection working as expected", function() { + createController(); + + controller.addEvent("before_session_context"); + + var events = controller.getEventStream(); + ok(indexOfEvent(events, "before_session_context") > -1, "before_session_context correctly saved to event stream"); + ok(indexOfEvent(events, "after_session_context") === -1, "after_session_context not yet added to current event stream"); + + // with context initializes the current stored data. + network.withContext(function() { + var data = controller.getCurrentStoredData(); + + // Make sure expected items are in the current stored data. + testHelpers.testKeysInObject(data, ["event_stream", "sample_rate", "timestamp", "lang"]); + + controller.addEvent("after_session_context"); + + var events = controller.getEventStream(); + + // Make sure both the before_session_context and after_session_context + // are both on the event stream. + ok(indexOfEvent(events, "before_session_context") > -1, "before_session_context correctly saved to current event stream"); + ok(indexOfEvent(events, "after_session_context") > -1, "after_session_context correctly saved to current event stream"); + + + // Ensure that the event name as well as relative time are saved for an + // event. + var index = indexOfEvent(events, "after_session_context"); + var event = events[index]; + + ok(index > -1, "after_session_context correctly saved to current event stream"); + equal(event[0], "after_session_context", "name stored"); + equal(typeof event[1], "number", "time stored"); + + start(); + }); + + }); + + asyncTest("publish data", function() { + createController(); + + // force saved data to be cleared. + storage.interactionData.clear(); + controller.publishStored(function(status) { + equal(status, false, "no data to publish"); + + // session context is required start saving events to localStorage. + network.withContext(function() { + + // Add an event which should allow us to publish + controller.addEvent("something_special"); + controller.publishStored(function(status) { + equal(status, true, "data correctly published"); + + start(); + }); + }); + }); + }); + + asyncTest("samplingEnabled set to false - no data collection occurs", function() { + createController({ samplingEnabled: false }); + + // the initial with_context will send off any stored data, there should be + // no stored data. + network.withContext(function() { + controller.addEvent("after_session_context"); + var events = controller.getEventStream(); + + var index = indexOfEvent(events, "after_session_context"); + equal(index, -1, "events not being stored"); + + equal(typeof controller.getCurrentStoredData(), "undefined", "no stored data"); + + controller.publishStored(function(status) { + equal(status, false, "there was no data to publish"); + start(); + }); + }); + }); + + asyncTest("continue: true, data collection permitted on previous session - continue appending data to previous session", function() { + createController(); + + controller.addEvent("session1_before_session_context"); + network.withContext(function() { + controller.addEvent("session1_after_session_context"); + + // simulate a restart of the dialog. Clear the session_context and then + // re-get session context. + controller = null; + network.clearContext(); + createController({ continuation: true }); + + controller.addEvent("session2_before_session_context"); + network.withContext(function() { + controller.addEvent("session2_after_session_context"); + + var events = controller.getEventStream(); + + ok(indexOfEvent(events, "session1_before_session_context") > -1, "session1_before_session_context correctly saved to current event stream"); + ok(indexOfEvent(events, "session1_after_session_context") > -1, "session1_after_session_context correctly saved to current event stream"); + ok(indexOfEvent(events, "session2_before_session_context") > -1, "session2_before_session_context correctly saved to current event stream"); + ok(indexOfEvent(events, "session2_after_session_context") > -1, "session2_after_session_context correctly saved to current event stream"); + + }); + + start(); + }); + + }); + + asyncTest("continue: true, data collection not permitted in previous session - no data collected", function() { + createController({ samplingEnabled: false }); + + controller.addEvent("session1_before_session_context"); + network.withContext(function() { + controller.addEvent("session1_after_session_context"); + + // simulate a restart of the dialog. Clear the session_context and then + // re-get session context. + controller = null; + network.clearContext(); + createController({ continuation: true }); + + controller.addEvent("session2_before_session_context"); + network.withContext(function() { + controller.addEvent("session2_after_session_context"); + + var events = controller.getEventStream(); + + ok(indexOfEvent(events, "session1_before_session_context") === -1, "no data collected"); + ok(indexOfEvent(events, "session1_after_session_context") === -1, "no data collected"); + ok(indexOfEvent(events, "session2_before_session_context") === -1, "no data collected"); + ok(indexOfEvent(events, "session2_after_session_context") === -1, "no data collected"); + + controller.publishStored(function(status) { + equal(status, false, "there was no data to publish"); + start(); + }); + }); + }); + + }); + + +}()); diff --git a/resources/static/test/cases/shared/network.js b/resources/static/test/cases/shared/network.js index 7d50cfaff4865df05562ee6e20236ec4079b8cc4..1fc02f083dcd60aa6bec431d9312f8faed97066e 100644 --- a/resources/static/test/cases/shared/network.js +++ b/resources/static/test/cases/shared/network.js @@ -614,4 +614,18 @@ network.prolongSession(testHelpers.unexpectedSuccess, testHelpers.expectedXHRFailure); }); + asyncTest("sendInteractionData success - call success", function() { + var data = {}; + network.sendInteractionData(data, function(status) { + equal(status, true, "complete with correct status"); + start(); + }, testHelpers.unexpectedXHRFailure); + }); + + asyncTest("sendInteractionData with XHR failure - call failure", function() { + var data = {}; + transport.useResult("ajaxError"); + network.sendInteractionData(data, testHelpers.unexpectedSuccess, testHelpers.expectedXHRFailure); + }); + }()); diff --git a/resources/static/test/cases/shared/storage.js b/resources/static/test/cases/shared/storage.js index 5bab4b78ed7d0164c80d29957d29a63da4da76ba..c6f459e6264d103e8fc589b9479047dc287e0fb6 100644 --- a/resources/static/test/cases/shared/storage.js +++ b/resources/static/test/cases/shared/storage.js @@ -189,15 +189,13 @@ "after clearing, interaction data is zero length"); }); - test("get interaction data returns everything except current (in-progress) data", function() { + test("get interaction data returns all data", function() { storage.interactionData.push({ foo: "old2" }); storage.interactionData.clear(); storage.interactionData.push({ foo: "old1" }); - storage.interactionData.push({ foo: "current" }); var d = storage.interactionData.get(); - equal(d.length, 2, "get() returns complete unpublished data blobs"); + equal(d.length, 1, "get() returns complete unpublished data blobs"); equal(d[0].foo, 'old1', "get() returns complete unpublished data blobs"); - equal(d[1].foo, 'old2', "get() returns complete unpublished data blobs"); }); }()); diff --git a/resources/static/test/mocks/xhr.js b/resources/static/test/mocks/xhr.js index 17aa6c9851933562e057afbd92c26cb68fa1d4af..d9048e7bfd80a82e99b6b6f8525c8c0e1c2f7630 100644 --- a/resources/static/test/mocks/xhr.js +++ b/resources/static/test/mocks/xhr.js @@ -13,7 +13,8 @@ BrowserID.Mocks.xhr = (function() { authenticated: false, auth_level: undefined, code_version: "ABC123", - random_seed: "H+ZgKuhjVckv/H4i0Qvj/JGJEGDVOXSIS5RCOjY9/Bo=" + random_seed: "H+ZgKuhjVckv/H4i0Qvj/JGJEGDVOXSIS5RCOjY9/Bo=", + data_sample_rate: 1 }; // this cert is meaningless, but it has the right format @@ -118,6 +119,8 @@ BrowserID.Mocks.xhr = (function() { "post /wsapi/prolong_session valid": { success: true }, "post /wsapi/prolong_session unauthenticated": 400, "post /wsapi/prolong_session ajaxError": undefined, + "post /wsapi/interaction_data valid": { success: true }, + "post /wsapi/interaction_data ajaxError": undefined }, setContextInfo: function(field, value) { diff --git a/resources/static/test/testHelpers/helpers.js b/resources/static/test/testHelpers/helpers.js index 31ac2d086649287fd4b4d68810fed152a9cdeab7..910db36bd27f94bb2265896c3c2e103077c7a510 100644 --- a/resources/static/test/testHelpers/helpers.js +++ b/resources/static/test/testHelpers/helpers.js @@ -197,7 +197,14 @@ BrowserID.TestHelpers = (function() { str += (i % 10); } return str; + }, + + testKeysInObject: function(objToTest, expected, msg) { + for(var i=0, key; key=expected[i]; ++i) { + ok(key in objToTest, msg || ("object contains " + key)); + } } + }; return TestHelpers; diff --git a/resources/views/test.ejs b/resources/views/test.ejs index a1fb2cd58698ae93864018bbfaeee0935584d76c..3d55b817e19316d0226c033ec9dbec85ddc13020 100644 --- a/resources/views/test.ejs +++ b/resources/views/test.ejs @@ -108,6 +108,7 @@ <script src="/shared/modules/xhr_delay.js"></script> <script src="/shared/modules/xhr_disable_form.js"></script> <script src="/shared/modules/cookie_check.js"></script> + <script src="/shared/modules/interaction_data.js"></script> <script src="/dialog/resources/internal_api.js"></script> <script src="/dialog/resources/helpers.js"></script> @@ -159,6 +160,7 @@ <script src="cases/shared/modules/xhr_delay.js"></script> <script src="cases/shared/modules/xhr_disable_form.js"></script> <script src="cases/shared/modules/cookie_check.js"></script> + <script src="cases/shared/modules/interaction_data.js"></script> <script src="cases/pages/browserid.js"></script> <script src="cases/pages/page_helpers.js"></script>