/* 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; })();