Bug 1193535 - Store Heartbeat Scores in Unified Telemetry. r=MattN

This commit is contained in:
Vladan Djeric 2016-02-03 15:25:53 -08:00
parent 3d7905c366
commit f5b6b70bc8
6 changed files with 362 additions and 21 deletions

View File

@ -212,6 +212,8 @@ pref("browser.uitour.themeOrigin", "https://addons.mozilla.org/%LOCALE%/firefox/
pref("browser.uitour.url", "https://www.mozilla.org/%LOCALE%/firefox/%VERSION%/tour/");
// This is used as a regexp match against the page's URL.
pref("browser.uitour.readerViewTrigger", "^https:\\/\\/www\\.mozilla\\.org\\/[^\\/]+\\/firefox\\/reading\\/start");
// How long to show a Hearbeat survey (two hours, in seconds)
pref("browser.uitour.surveyDuration", 7200);
pref("browser.customizemode.tip0.shown", false);
pref("browser.customizemode.tip0.learnMoreUrl", "https://support.mozilla.org/1/firefox/%VERSION%/%OS%/%LOCALE%/customize");

View File

@ -15,6 +15,7 @@ Cu.import("resource://gre/modules/Promise.jsm");
Cu.import("resource:///modules/RecentWindow.jsm");
Cu.import("resource://gre/modules/Task.jsm");
Cu.import("resource://gre/modules/TelemetryController.jsm");
Cu.import("resource://gre/modules/Timer.jsm");
Cu.importGlobalProperties(["URL"]);
@ -41,6 +42,7 @@ XPCOMUtils.defineLazyModuleGetter(this, "ReaderParent",
const PREF_LOG_LEVEL = "browser.uitour.loglevel";
const PREF_SEENPAGEIDS = "browser.uitour.seenPageIDs";
const PREF_READERVIEW_TRIGGER = "browser.uitour.readerViewTrigger";
const PREF_SURVEY_DURATION = "browser.uitour.surveyDuration";
const BACKGROUND_PAGE_ACTIONS_ALLOWED = new Set([
"forceShowReaderIcon",
@ -1060,7 +1062,8 @@ this.UITour = {
* Show the Heartbeat UI to request user feedback. This function reports back to the
* caller using |notify|. The notification event name reflects the current status the UI
* is in (either "Heartbeat:NotificationOffered", "Heartbeat:NotificationClosed",
* "Heartbeat:LearnMore", "Heartbeat:Engaged" or "Heartbeat:Voted").
* "Heartbeat:LearnMore", "Heartbeat:Engaged", "Heartbeat:Voted",
* "Heartbeat:SurveyExpired" or "Heartbeat:WindowClosed").
* When a "Heartbeat:Voted" event is notified
* the data payload contains a |score| field which holds the rating picked by the user.
* Please note that input parameters are already validated by the caller.
@ -1086,16 +1089,115 @@ this.UITour = {
* @param {String} [aOptions.learnMoreURL=null]
* The learn more URL to open when clicking on the learn more link. No learn more
* will be shown if this is an invalid URL.
* @param {String} [aOptions.privateWindowsOnly=false]
* @param {boolean} [aOptions.privateWindowsOnly=false]
* Whether the heartbeat UI should only be targeted at a private window (if one exists).
* No notifications should be fired when this is true.
* @param {String} [aOptions.surveyId]
* An ID for the survey, reflected in the Telemetry ping.
* @param {Number} [aOptions.surveyVersion]
* Survey's version number, reflected in the Telemetry ping.
* @param {boolean} [aOptions.testing]
* Whether this is a test survey, reflected in the Telemetry ping.
*/
showHeartbeat(aChromeWindow, aOptions) {
let maybeNotifyHeartbeat = (...aParams) => {
// Initialize survey state
let pingSent = false;
let surveyResults = {};
let surveyEndTimer = null;
/**
* Accumulates survey events and submits to Telemetry after the survey ends.
*
* @param {String} aEventName
* Heartbeat event name
* @param {Object} aParams
* Additional parameters and their values
*/
let maybeNotifyHeartbeat = (aEventName, aParams = {}) => {
// Return if event occurred after the ping was sent
if (pingSent) {
log.warn("maybeNotifyHeartbeat: event occurred after ping sent:", aEventName, aParams);
return;
}
// No Telemetry from private-window-only Heartbeats
if (aOptions.privateWindowsOnly) {
return;
}
this.notify(...aParams);
let ts = Date.now();
let sendPing = false;
switch (aEventName) {
case "Heartbeat:NotificationOffered":
surveyResults.flowId = aOptions.flowId;
surveyResults.offeredTS = ts;
break;
case "Heartbeat:LearnMore":
// record only the first click
if (!surveyResults.learnMoreTS) {
surveyResults.learnMoreTS = ts;
}
break;
case "Heartbeat:Engaged":
surveyResults.engagedTS = ts;
break;
case "Heartbeat:Voted":
surveyResults.votedTS = ts;
surveyResults.score = aParams.score;
break;
case "Heartbeat:SurveyExpired":
surveyResults.expiredTS = ts;
break;
case "Heartbeat:NotificationClosed":
// this is the final event in most surveys
surveyResults.closedTS = ts;
sendPing = true;
break;
case "Heartbeat:WindowClosed":
surveyResults.windowClosedTS = ts;
sendPing = true;
break;
default:
log.error("maybeNotifyHeartbeat: unrecognized event:", aEventName);
break;
}
aParams.timestamp = ts;
aParams.flowId = aOptions.flowId;
this.notify(aEventName, aParams);
if (!sendPing) {
return;
}
// Send the ping to Telemetry
let payload = Object.assign({}, surveyResults);
payload.version = 1;
for (let meta of ["surveyId", "surveyVersion", "testing"]) {
if (aOptions.hasOwnProperty(meta)) {
payload[meta] = aOptions[meta];
}
}
log.debug("Sending payload to Telemetry: aEventName:", aEventName,
"payload:", payload);
TelemetryController.submitExternalPing("heartbeat", payload, {
addClientId: true,
addEnvironment: true,
});
// only for testing
this.notify("Heartbeat:TelemetrySent", payload);
// Survey is complete, clear out the expiry timer & survey configuration
if (surveyEndTimer) {
clearTimeout(surveyEndTimer);
surveyEndTimer = null;
}
pingSent = true;
surveyResults = {};
};
let nb = aChromeWindow.document.getElementById("high-priority-global-notificationbox");
@ -1106,7 +1208,7 @@ this.UITour = {
label: aOptions.engagementButtonLabel,
callback: () => {
// Let the consumer know user engaged.
maybeNotifyHeartbeat("Heartbeat:Engaged", { flowId: aOptions.flowId, timestamp: Date.now() });
maybeNotifyHeartbeat("Heartbeat:Engaged");
userEngaged(new Map([
["type", "button"],
@ -1121,11 +1223,16 @@ this.UITour = {
}
// Create the notification. Prefix its ID to decrease the chances of collisions.
let notice = nb.appendNotification(aOptions.message, "heartbeat-" + aOptions.flowId,
"chrome://browser/skin/heartbeat-icon.svg", nb.PRIORITY_INFO_HIGH, buttons, function() {
// Let the consumer know the notification bar was closed. This also happens
// after voting.
maybeNotifyHeartbeat("Heartbeat:NotificationClosed", { flowId: aOptions.flowId, timestamp: Date.now() });
}.bind(this));
"chrome://browser/skin/heartbeat-icon.svg",
nb.PRIORITY_INFO_HIGH, buttons,
(aEventType) => {
if (aEventType != "removed") {
return;
}
// Let the consumer know the notification bar was closed.
// This also happens after voting.
maybeNotifyHeartbeat("Heartbeat:NotificationClosed");
});
// Get the elements we need to style.
let messageImage =
@ -1196,11 +1303,7 @@ this.UITour = {
let rating = Number(evt.target.getAttribute("data-score"), 10);
// Let the consumer know user voted.
maybeNotifyHeartbeat("Heartbeat:Voted", {
flowId: aOptions.flowId,
score: rating,
timestamp: Date.now(),
});
maybeNotifyHeartbeat("Heartbeat:Voted", { score: rating });
// Append the score data to the engagement URL.
userEngaged(new Map([
@ -1239,8 +1342,7 @@ this.UITour = {
learnMore.className = "text-link";
learnMore.href = learnMoreURL.toString();
learnMore.setAttribute("value", aOptions.learnMoreLabel);
learnMore.addEventListener("click", () => maybeNotifyHeartbeat("Heartbeat:LearnMore",
{ flowId: aOptions.flowId, timestamp: Date.now() }));
learnMore.addEventListener("click", () => maybeNotifyHeartbeat("Heartbeat:LearnMore"));
frag.appendChild(learnMore);
}
@ -1251,10 +1353,23 @@ this.UITour = {
messageText.classList.add("heartbeat");
// Let the consumer know the notification was shown.
maybeNotifyHeartbeat("Heartbeat:NotificationOffered", {
flowId: aOptions.flowId,
timestamp: Date.now(),
});
maybeNotifyHeartbeat("Heartbeat:NotificationOffered");
// End the survey if the user quits, closes the window, or
// hasn't responded before expiration.
if (!aOptions.privateWindowsOnly) {
function handleWindowClosed(aTopic) {
maybeNotifyHeartbeat("Heartbeat:WindowClosed");
aChromeWindow.removeEventListener("SSWindowClosing", handleWindowClosed);
}
aChromeWindow.addEventListener("SSWindowClosing", handleWindowClosed);
let surveyDuration = Services.prefs.getIntPref(PREF_SURVEY_DURATION) * 1000;
surveyEndTimer = setTimeout(() => {
maybeNotifyHeartbeat("Heartbeat:SurveyExpired");
nb.removeNotification(notice);
}, surveyDuration);
}
},
/**

View File

@ -10,6 +10,9 @@ var gContentWindow;
function test() {
UITourTest();
requestLongerTimeout(2);
registerCleanupFunction(() => {
Services.prefs.clearUserPref("browser.uitour.surveyDuration");
});
}
function getHeartbeatNotification(aId, aChromeWindow = window) {
@ -66,6 +69,39 @@ function cleanUpNotification(aId, aChromeWindow = window) {
notification.close();
}
/**
* Check telemetry payload for proper format and expected content.
*
* @param aPayload
* The Telemetry payload to verify
* @param aFlowId
* Expected value of the flowId field.
* @param aExpectedFields
* Array of expected fields. No other fields are allowed.
*/
function checkTelemetry(aPayload, aFlowId, aExpectedFields) {
// Basic payload format
is(aPayload.version, 1, "Telemetry ping must have heartbeat version=1");
is(aPayload.flowId, aFlowId, "Flow ID in the Telemetry ping must match");
// Check for superfluous fields
let extraKeys = new Set(Object.keys(aPayload));
extraKeys.delete("version");
extraKeys.delete("flowId");
// Check for expected fields
for (let field of aExpectedFields) {
ok(field in aPayload, "The payload should have the field '" + field + "'");
if (field.endsWith("TS")) {
let ts = aPayload[field];
ok(Number.isInteger(ts) && ts > 0, "Timestamp '" + field + "' must be a natural number");
}
extraKeys.delete(field);
}
is(extraKeys.size, 0, "No unexpected fields in the Telemetry payload");
}
var tests = [
/**
* Check that the "stars" heartbeat UI correctly shows and closes.
@ -88,6 +124,11 @@ var tests = [
done();
break;
}
case "Heartbeat:TelemetrySent": {
info("'Heartbeat:TelemetrySent' notification received");
checkTelemetry(aData, flowId, ["offeredTS", "closedTS"]);
break;
}
default:
// We are not expecting other states for this test.
ok(false, "Unexpected notification received: " + aEventName);
@ -125,6 +166,12 @@ var tests = [
done();
break;
}
case "Heartbeat:TelemetrySent": {
info("'Heartbeat:TelemetrySent' notification received.");
checkTelemetry(aData, flowId, ["offeredTS", "votedTS", "closedTS", "score"]);
is(aData.score, 2, "Checking Telemetry payload.score");
break;
}
default:
// We are not expecting other states for this test.
ok(false, "Unexpected notification received: " + aEventName);
@ -163,6 +210,12 @@ var tests = [
done();
break;
}
case "Heartbeat:TelemetrySent": {
info("'Heartbeat:TelemetrySent' notification received.");
checkTelemetry(aData, flowId, ["offeredTS", "votedTS", "closedTS", "score"]);
is(aData.score, 2, "Checking Telemetry payload.score");
break;
}
default:
// We are not expecting other states for this test.
ok(false, "Unexpected notification received: " + aEventName);
@ -200,6 +253,12 @@ var tests = [
done();
break;
}
case "Heartbeat:TelemetrySent": {
info("'Heartbeat:TelemetrySent' notification received.");
checkTelemetry(aData, flowId, ["offeredTS", "votedTS", "closedTS", "score"]);
is(aData.score, expectedScore, "Checking Telemetry payload.score");
break;
}
default:
// We are not expecting other states for this test.
ok(false, "Unexpected notification received: " + aEventName);
@ -243,6 +302,12 @@ var tests = [
done();
break;
}
case "Heartbeat:TelemetrySent": {
info("'Heartbeat:TelemetrySent' notification received.");
checkTelemetry(aData, flowId, ["offeredTS", "votedTS", "closedTS", "score"]);
is(aData.score, 1, "Checking Telemetry payload.score");
break;
}
default:
// We are not expecting other states for this test.
ok(false, "Unexpected notification received: " + aEventName);
@ -290,6 +355,11 @@ var tests = [
executeSoon(done);
break;
}
case "Heartbeat:TelemetrySent": {
info("'Heartbeat:TelemetrySent' notification received.");
checkTelemetry(aData, flowId, ["offeredTS", "engagedTS", "closedTS"]);
break;
}
default: {
// We are not expecting other states for this test.
ok(false, "Unexpected notification received: " + aEventName);
@ -335,6 +405,11 @@ var tests = [
done();
break;
}
case "Heartbeat:TelemetrySent": {
info("'Heartbeat:TelemetrySent' notification received.");
checkTelemetry(aData, flowId, ["offeredTS", "learnMoreTS", "closedTS"]);
break;
}
default:
// We are not expecting other states for this test.
ok(false, "Unexpected notification received: " + aEventName);
@ -456,4 +531,90 @@ var tests = [
yield BrowserTestUtils.closeWindow(privateWin);
}),
/**
* Test that the survey closes itself after a while and submits Telemetry
*/
taskify(function* test_telemetry_surveyExpired() {
let flowId = "survey-expired-" + Math.random();
let engagementURL = "http://example.com";
let surveyDuration = 1; // 1 second (pref is in seconds)
Services.prefs.setIntPref("browser.uitour.surveyDuration", surveyDuration);
let telemetryPromise = new Promise((resolve, reject) => {
gContentAPI.observe(function (aEventName, aData) {
switch (aEventName) {
case "Heartbeat:NotificationOffered":
info("'Heartbeat:NotificationOffered' notification received");
break;
case "Heartbeat:SurveyExpired":
info("'Heartbeat:SurveyExpired' notification received");
ok(true, "Survey should end on its own after a time out");
case "Heartbeat:NotificationClosed":
info("'Heartbeat:NotificationClosed' notification received");
break;
case "Heartbeat:TelemetrySent": {
info("'Heartbeat:TelemetrySent' notification received");
checkTelemetry(aData, flowId, ["offeredTS", "expiredTS", "closedTS"]);
resolve();
break;
}
default:
// not expecting other states for this test
ok(false, "Unexpected notification received: " + aEventName);
reject();
}
});
});
gContentAPI.showHeartbeat("How would you rate Firefox?", "Thank you!", flowId, engagementURL);
yield telemetryPromise;
Services.prefs.clearUserPref("browser.uitour.surveyDuration");
}),
/**
* Check that certain whitelisted experiment parameters get reflected in the
* Telemetry ping
*/
function test_telemetry_params(done) {
let flowId = "telemetry-params-" + Math.random();
let engagementURL = "http://example.com";
let extraParams = {
"surveyId": "foo",
"surveyVersion": 1.5,
"testing": true,
"notWhitelisted": 123,
};
let expectedFields = ["surveyId", "surveyVersion", "testing"];
gContentAPI.observe(function (aEventName, aData) {
switch (aEventName) {
case "Heartbeat:NotificationOffered": {
info("'Heartbeat:Offered' notification received (timestamp " + aData.timestamp.toString() + ").");
cleanUpNotification(flowId);
break;
}
case "Heartbeat:NotificationClosed": {
info("'Heartbeat:NotificationClosed' notification received (timestamp " + aData.timestamp.toString() + ").");
break;
}
case "Heartbeat:TelemetrySent": {
info("'Heartbeat:TelemetrySent' notification received");
checkTelemetry(aData, flowId, ["offeredTS", "closedTS"].concat(expectedFields));
for (let param of expectedFields) {
is(aData[param], extraParams[param],
"Whitelisted experiment configs should be copied into Telemetry pings");
}
done();
break;
}
default:
// We are not expecting other states for this test.
ok(false, "Unexpected notification received: " + aEventName);
}
});
gContentAPI.showHeartbeat("How would you rate Firefox?", "Thank you!",
flowId, engagementURL, null, null, extraParams);
},
];

View File

@ -0,0 +1,61 @@
"heartbeat" ping
=================
This ping is submitted after a Firefox Heartbeat survey. Even if the user exits
the browser, closes the survey window, or ignores the survey, Heartbeat will
provide a ping to Telemetry for sending during the same session.
The payload contains the user's survey response (if any) as well as timestamps
of various Heartbeat events (survey shown, survey closed, link clicked, etc).
The ping will also report the "surveyId", "surveyVersion" and "testing"
Heartbeat survey parameters (if they are present in the survey config).
These "meta fields" will be repeated verbatim in the payload section.
The environment block and client ID are submitted with this ping.
Structure::
{
type: "heartbeat",
version: 4,
clientId: <UUID>,
environment: { ... }
... common ping data ...
payload: {
version: 1,
flowId: <string>,
... timestamps below ...
offeredTS: <integer epoch timestamp>,
learnMoreTS: <integer epoch timestamp>,
votedTS: <integer epoch timestamp>,
engagedTS: <integer epoch timestamp>,
closedTS: <integer epoch timestamp>,
expiredTS: <integer epoch timestamp>,
windowClosedTS: <integer epoch timestamp>,
... user's rating below ...
score: <integer>,
... survey meta fields below ...
surveyId: <string>,
surveyVersion: <integer>,
testing: <boolean>
}
}
Notes:
* Pings will **NOT** have all possible timestamps, timestamps are only reported for events that actually occurred.
* Timestamp meanings:
* offeredTS: when the survey was shown to the user
* learnMoreTS: when the user clicked on the "Learn More" link
* votedTS: when the user voted
* engagedTS: when the user clicked on the survey-provided button (alternative to voting feature)
* closedTS: when the Heartbeat notification bar was closed
* expiredTS: indicates that the survey expired after 2 hours of no interaction (threshold regulated by "browser.uitour.surveyDuration" pref)
* windowClosedTS: the user closed the entire Firefox window containing the survey, thus ending the survey. This timestamp will also be reported when the survey is ended by the browser being shut down.
* The surveyId/surveyVersion fields identify a specific survey (like a "1040EZ" tax paper form). The flowID is a UUID that uniquely identifies a single user's interaction with the survey. Think of it as a session token.
* The self-support page cannot include additional data in this payload. Only the the 4 flowId/surveyId/surveyVersion/testing fields are under the self-support page's control.
See also: :doc:`common ping fields <common-ping>`

View File

@ -23,5 +23,6 @@ Client-side, this consists of:
deletion-ping
crash-ping
uitour-ping
heartbeat-ping
preferences
crashes

View File

@ -48,6 +48,7 @@ Ping types
* :doc:`uitour-ping` - a ping submitted via the UITour API
* ``activation`` - *planned* - sent right after installation or profile creation
* ``upgrade`` - *planned* - sent right after an upgrade
* :doc:`heartbeat-ping` - contains information on Heartbeat surveys
* :doc:`deletion <deletion-ping>` - sent when FHR upload is disabled, requesting deletion of the data associated with this user
Archiving