diff options
Diffstat (limited to 'browser/extensions/screenshots/background/analytics.js')
-rw-r--r-- | browser/extensions/screenshots/background/analytics.js | 367 |
1 files changed, 367 insertions, 0 deletions
diff --git a/browser/extensions/screenshots/background/analytics.js b/browser/extensions/screenshots/background/analytics.js new file mode 100644 index 0000000000..37049e3b77 --- /dev/null +++ b/browser/extensions/screenshots/background/analytics.js @@ -0,0 +1,367 @@ +/* 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; +})(); |