forked from mirrors/gecko-dev
Differential Revision: https://phabricator.services.mozilla.com/D37217 --HG-- extra : moz-landing-system : lando
367 lines
12 KiB
JavaScript
367 lines
12 KiB
JavaScript
/* 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/. */
|
|
|
|
/* globals main, auth, browser, catcher, deviceInfo, communication, log */
|
|
|
|
"use strict";
|
|
|
|
this.analytics = (function() {
|
|
const exports = {};
|
|
|
|
const GA_PORTION = 0.1; // 10% of users will send to the server/GA
|
|
// This is set from storage, or randomly; if it is less that GA_PORTION then we send analytics:
|
|
let myGaSegment = 1;
|
|
let telemetryPrefKnown = false;
|
|
let telemetryEnabled;
|
|
// If we ever get a 410 Gone response (or 404) from the server, we'll stop trying to send events for the rest
|
|
// of the session
|
|
let hasReturnedGone = false;
|
|
// If there's this many entirely failed responses (e.g., server can't be contacted), then stop sending events
|
|
// for the rest of the session:
|
|
let serverFailedResponses = 3;
|
|
|
|
const EVENT_BATCH_DURATION = 1000; // ms for setTimeout
|
|
let pendingEvents = [];
|
|
let pendingTimings = [];
|
|
let eventsTimeoutHandle, timingsTimeoutHandle;
|
|
const fetchOptions = {
|
|
method: "POST",
|
|
mode: "cors",
|
|
headers: { "content-type": "application/json" },
|
|
credentials: "include",
|
|
};
|
|
|
|
function shouldSendEvents() {
|
|
return !hasReturnedGone && serverFailedResponses > 0 && myGaSegment < GA_PORTION;
|
|
}
|
|
|
|
function flushEvents() {
|
|
if (pendingEvents.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const eventsUrl = `${main.getBackend()}/event`;
|
|
const deviceId = auth.getDeviceId();
|
|
const sendTime = Date.now();
|
|
|
|
pendingEvents.forEach(event => {
|
|
event.queueTime = sendTime - event.eventTime;
|
|
log.info(`sendEvent ${event.event}/${event.action}/${event.label || "none"} ${JSON.stringify(event.options)}`);
|
|
});
|
|
|
|
const body = JSON.stringify({deviceId, events: pendingEvents});
|
|
const fetchRequest = fetch(eventsUrl, Object.assign({body}, fetchOptions));
|
|
fetchWatcher(fetchRequest);
|
|
pendingEvents = [];
|
|
}
|
|
|
|
function flushTimings() {
|
|
if (pendingTimings.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const timingsUrl = `${main.getBackend()}/timing`;
|
|
const deviceId = auth.getDeviceId();
|
|
const body = JSON.stringify({deviceId, timings: pendingTimings});
|
|
const fetchRequest = fetch(timingsUrl, Object.assign({body}, fetchOptions));
|
|
fetchWatcher(fetchRequest);
|
|
pendingTimings.forEach(t => {
|
|
log.info(`sendTiming ${t.timingCategory}/${t.timingLabel}/${t.timingVar}: ${t.timingValue}`);
|
|
});
|
|
pendingTimings = [];
|
|
}
|
|
|
|
function sendTiming(timingLabel, timingVar, timingValue) {
|
|
// sendTiming is only called in response to sendEvent, so no need to check
|
|
// the telemetry pref again here.
|
|
if (!shouldSendEvents()) {
|
|
return;
|
|
}
|
|
const timingCategory = "addon";
|
|
pendingTimings.push({
|
|
timingCategory,
|
|
timingLabel,
|
|
timingVar,
|
|
timingValue,
|
|
});
|
|
if (!timingsTimeoutHandle) {
|
|
timingsTimeoutHandle = setTimeout(() => {
|
|
timingsTimeoutHandle = null;
|
|
flushTimings();
|
|
}, EVENT_BATCH_DURATION);
|
|
}
|
|
}
|
|
|
|
exports.sendEvent = function(action, label, options) {
|
|
const eventCategory = "addon";
|
|
if (!telemetryPrefKnown) {
|
|
log.warn("sendEvent called before we were able to refresh");
|
|
return Promise.resolve();
|
|
}
|
|
if (!telemetryEnabled) {
|
|
log.info(`Cancelled sendEvent ${eventCategory}/${action}/${label || "none"} ${JSON.stringify(options)}`);
|
|
return Promise.resolve();
|
|
}
|
|
measureTiming(action, label);
|
|
// Internal-only events are used for measuring time between events,
|
|
// but aren't submitted to GA.
|
|
if (action === "internal") {
|
|
return Promise.resolve();
|
|
}
|
|
if (typeof label === "object" && (!options)) {
|
|
options = label;
|
|
label = undefined;
|
|
}
|
|
options = options || {};
|
|
|
|
// Don't send events if in private browsing.
|
|
if (options.incognito) {
|
|
return Promise.resolve();
|
|
}
|
|
|
|
// Don't include in event data.
|
|
delete options.incognito;
|
|
|
|
const di = deviceInfo();
|
|
options.applicationName = di.appName;
|
|
options.applicationVersion = di.addonVersion;
|
|
const abTests = auth.getAbTests();
|
|
for (const [gaField, value] of Object.entries(abTests)) {
|
|
options[gaField] = value;
|
|
}
|
|
if (!shouldSendEvents()) {
|
|
// We don't want to save or send the events anymore
|
|
return Promise.resolve();
|
|
}
|
|
pendingEvents.push({
|
|
eventTime: Date.now(),
|
|
event: eventCategory,
|
|
action,
|
|
label,
|
|
options,
|
|
});
|
|
if (!eventsTimeoutHandle) {
|
|
eventsTimeoutHandle = setTimeout(() => {
|
|
eventsTimeoutHandle = null;
|
|
flushEvents();
|
|
}, EVENT_BATCH_DURATION);
|
|
}
|
|
// This function used to return a Promise that was not used at any of the
|
|
// call sites; doing this simply maintains that interface.
|
|
return Promise.resolve();
|
|
};
|
|
|
|
exports.incrementCount = function(scalar) {
|
|
const allowedScalars = ["download", "upload", "copy"];
|
|
if (!allowedScalars.includes(scalar)) {
|
|
const err = `incrementCount passed an unrecognized scalar ${scalar}`;
|
|
log.warn(err);
|
|
return Promise.resolve();
|
|
}
|
|
return browser.telemetry.scalarAdd(`screenshots.${scalar}`, 1).catch(err => {
|
|
log.warn(`incrementCount failed with error: ${err}`);
|
|
});
|
|
};
|
|
|
|
exports.refreshTelemetryPref = function() {
|
|
return browser.telemetry.canUpload().then((result) => {
|
|
telemetryPrefKnown = true;
|
|
telemetryEnabled = result;
|
|
}, (error) => {
|
|
// If there's an error reading the pref, we should assume that we shouldn't send data
|
|
telemetryPrefKnown = true;
|
|
telemetryEnabled = false;
|
|
throw error;
|
|
});
|
|
};
|
|
|
|
exports.isTelemetryEnabled = function() {
|
|
catcher.watchPromise(exports.refreshTelemetryPref());
|
|
return telemetryEnabled;
|
|
};
|
|
|
|
const timingData = new Map();
|
|
|
|
// Configuration for filtering the sendEvent stream on start/end events.
|
|
// When start or end events occur, the time is recorded.
|
|
// When end events occur, the elapsed time is calculated and submitted
|
|
// via `sendEvent`, where action = "perf-response-time", label = name of rule,
|
|
// and cd1 value is the elapsed time in milliseconds.
|
|
// If a cancel event happens between the start and end events, the start time
|
|
// is deleted.
|
|
const rules = [{
|
|
name: "page-action",
|
|
start: { action: "start-shot", label: "toolbar-button" },
|
|
end: { action: "internal", label: "unhide-preselection-frame" },
|
|
cancel: [
|
|
{ action: "cancel-shot" },
|
|
{ action: "internal", label: "document-hidden" },
|
|
{ action: "internal", label: "unhide-onboarding-frame" },
|
|
],
|
|
}, {
|
|
name: "context-menu",
|
|
start: { action: "start-shot", label: "context-menu" },
|
|
end: { action: "internal", label: "unhide-preselection-frame" },
|
|
cancel: [
|
|
{ action: "cancel-shot" },
|
|
{ action: "internal", label: "document-hidden" },
|
|
{ action: "internal", label: "unhide-onboarding-frame" },
|
|
],
|
|
}, {
|
|
name: "page-action-onboarding",
|
|
start: { action: "start-shot", label: "toolbar-button" },
|
|
end: { action: "internal", label: "unhide-onboarding-frame" },
|
|
cancel: [
|
|
{ action: "cancel-shot" },
|
|
{ action: "internal", label: "document-hidden" },
|
|
{ action: "internal", label: "unhide-preselection-frame" },
|
|
],
|
|
}, {
|
|
name: "context-menu-onboarding",
|
|
start: { action: "start-shot", label: "context-menu" },
|
|
end: { action: "internal", label: "unhide-onboarding-frame" },
|
|
cancel: [
|
|
{ action: "cancel-shot" },
|
|
{ action: "internal", label: "document-hidden" },
|
|
{ action: "internal", label: "unhide-preselection-frame" },
|
|
],
|
|
}, {
|
|
name: "capture-full-page",
|
|
start: { action: "capture-full-page" },
|
|
end: { action: "internal", label: "unhide-preview-frame" },
|
|
cancel: [
|
|
{ action: "cancel-shot" },
|
|
{ action: "internal", label: "document-hidden" },
|
|
],
|
|
}, {
|
|
name: "capture-visible",
|
|
start: { action: "capture-visible" },
|
|
end: { action: "internal", label: "unhide-preview-frame" },
|
|
cancel: [
|
|
{ action: "cancel-shot" },
|
|
{ action: "internal", label: "document-hidden" },
|
|
],
|
|
}, {
|
|
name: "make-selection",
|
|
start: { action: "make-selection" },
|
|
end: { action: "internal", label: "unhide-selection-frame" },
|
|
cancel: [
|
|
{ action: "cancel-shot" },
|
|
{ action: "internal", label: "document-hidden" },
|
|
],
|
|
}, {
|
|
name: "save-shot",
|
|
start: { action: "save-shot" },
|
|
end: { action: "internal", label: "open-shot-tab" },
|
|
cancel: [{ action: "cancel-shot" }, { action: "upload-failed" }],
|
|
}, {
|
|
name: "save-visible",
|
|
start: { action: "save-visible" },
|
|
end: { action: "internal", label: "open-shot-tab" },
|
|
cancel: [{ action: "cancel-shot" }, { action: "upload-failed" }],
|
|
}, {
|
|
name: "save-full-page",
|
|
start: { action: "save-full-page" },
|
|
end: { action: "internal", label: "open-shot-tab" },
|
|
cancel: [{ action: "cancel-shot" }, { action: "upload-failed" }],
|
|
}, {
|
|
name: "save-full-page-truncated",
|
|
start: { action: "save-full-page-truncated" },
|
|
end: { action: "internal", label: "open-shot-tab" },
|
|
cancel: [{ action: "cancel-shot" }, { action: "upload-failed" }],
|
|
}, {
|
|
name: "download-shot",
|
|
start: { action: "download-shot" },
|
|
end: { action: "internal", label: "deactivate" },
|
|
cancel: [
|
|
{ action: "cancel-shot" },
|
|
{ action: "internal", label: "document-hidden" },
|
|
],
|
|
}, {
|
|
name: "download-full-page",
|
|
start: { action: "download-full-page" },
|
|
end: { action: "internal", label: "deactivate" },
|
|
cancel: [
|
|
{ action: "cancel-shot" },
|
|
{ action: "internal", label: "document-hidden" },
|
|
],
|
|
}, {
|
|
name: "download-full-page-truncated",
|
|
start: { action: "download-full-page-truncated" },
|
|
end: { action: "internal", label: "deactivate" },
|
|
cancel: [
|
|
{ action: "cancel-shot" },
|
|
{ action: "internal", label: "document-hidden" },
|
|
],
|
|
}, {
|
|
name: "download-visible",
|
|
start: { action: "download-visible" },
|
|
end: { action: "internal", label: "deactivate" },
|
|
cancel: [
|
|
{ action: "cancel-shot" },
|
|
{ action: "internal", label: "document-hidden" },
|
|
],
|
|
}];
|
|
|
|
// Match a filter (action and optional label) against an action and label.
|
|
function match(filter, action, label) {
|
|
return filter.label ?
|
|
filter.action === action && filter.label === label :
|
|
filter.action === action;
|
|
}
|
|
|
|
function anyMatches(filters, action, label) {
|
|
return filters.some(filter => match(filter, action, label));
|
|
}
|
|
|
|
function measureTiming(action, label) {
|
|
rules.forEach(r => {
|
|
if (anyMatches(r.cancel, action, label)) {
|
|
delete timingData[r.name];
|
|
} else if (match(r.start, action, label)) {
|
|
timingData[r.name] = Math.round(performance.now());
|
|
} else if (timingData[r.name] && match(r.end, action, label)) {
|
|
const endTime = Math.round(performance.now());
|
|
const elapsed = endTime - timingData[r.name];
|
|
sendTiming("perf-response-time", r.name, elapsed);
|
|
delete timingData[r.name];
|
|
}
|
|
});
|
|
}
|
|
|
|
function fetchWatcher(request) {
|
|
request.then(response => {
|
|
if (response.status === 410 || response.status === 404) { // Gone
|
|
hasReturnedGone = true;
|
|
pendingEvents = [];
|
|
pendingTimings = [];
|
|
}
|
|
if (!response.ok) {
|
|
log.debug(`Error code in event response: ${response.status} ${response.statusText}`);
|
|
}
|
|
}).catch(error => {
|
|
serverFailedResponses--;
|
|
if (serverFailedResponses <= 0) {
|
|
log.info(`Server is not responding, no more events will be sent`);
|
|
pendingEvents = [];
|
|
pendingTimings = [];
|
|
}
|
|
log.debug(`Error event in response: ${error}`);
|
|
});
|
|
}
|
|
|
|
async function init() {
|
|
const result = await browser.storage.local.get(["myGaSegment"]);
|
|
if (!result.myGaSegment) {
|
|
myGaSegment = Math.random();
|
|
await browser.storage.local.set({myGaSegment});
|
|
} else {
|
|
myGaSegment = result.myGaSegment;
|
|
}
|
|
}
|
|
|
|
init();
|
|
|
|
return exports;
|
|
})();
|