diff options
Diffstat (limited to 'modules/BrowserUITelemetry.jsm')
-rw-r--r-- | modules/BrowserUITelemetry.jsm | 896 |
1 files changed, 896 insertions, 0 deletions
diff --git a/modules/BrowserUITelemetry.jsm b/modules/BrowserUITelemetry.jsm new file mode 100644 index 0000000..392462b --- /dev/null +++ b/modules/BrowserUITelemetry.jsm @@ -0,0 +1,896 @@ +// 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/. + +"use strict"; + +this.EXPORTED_SYMBOLS = ["BrowserUITelemetry"]; + +const {interfaces: Ci, utils: Cu} = Components; + +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "UITelemetry", + "resource://gre/modules/UITelemetry.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "RecentWindow", + "resource:///modules/RecentWindow.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "CustomizableUI", + "resource:///modules/CustomizableUI.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "UITour", + "resource:///modules/UITour.jsm"); +XPCOMUtils.defineLazyGetter(this, "Timer", function() { + let timer = {}; + Cu.import("resource://gre/modules/Timer.jsm", timer); + return timer; +}); + +const MS_SECOND = 1000; +const MS_MINUTE = MS_SECOND * 60; +const MS_HOUR = MS_MINUTE * 60; + +XPCOMUtils.defineLazyGetter(this, "DEFAULT_AREA_PLACEMENTS", function() { + let result = { + "PanelUI-contents": [ + "edit-controls", + "zoom-controls", + "new-window-button", + "privatebrowsing-button", + "save-page-button", + "print-button", + "history-panelmenu", + "fullscreen-button", + "find-button", + "preferences-button", + "add-ons-button", + "sync-button", + "developer-button", + ], + "nav-bar": [ + "urlbar-container", + "search-container", + "bookmarks-menu-button", + "pocket-button", + "downloads-button", + "home-button", + "social-share-button", + ], + // It's true that toolbar-menubar is not visible + // on OS X, but the XUL node is definitely present + // in the document. + "toolbar-menubar": [ + "menubar-items", + ], + "TabsToolbar": [ + "tabbrowser-tabs", + "new-tab-button", + "alltabs-button", + ], + "PersonalToolbar": [ + "personal-bookmarks", + ], + }; + + let showCharacterEncoding = Services.prefs.getComplexValue( + "browser.menu.showCharacterEncoding", + Ci.nsIPrefLocalizedString + ).data; + if (showCharacterEncoding == "true") { + result["PanelUI-contents"].push("characterencoding-button"); + } + + return result; +}); + +XPCOMUtils.defineLazyGetter(this, "DEFAULT_AREAS", function() { + return Object.keys(DEFAULT_AREA_PLACEMENTS); +}); + +XPCOMUtils.defineLazyGetter(this, "PALETTE_ITEMS", function() { + let result = [ + "open-file-button", + "developer-button", + "feed-button", + "email-link-button", + "containers-panelmenu", + ]; + + let panelPlacements = DEFAULT_AREA_PLACEMENTS["PanelUI-contents"]; + if (panelPlacements.indexOf("characterencoding-button") == -1) { + result.push("characterencoding-button"); + } + + if (Services.prefs.getBoolPref("privacy.panicButton.enabled")) { + result.push("panic-button"); + } + + return result; +}); + +XPCOMUtils.defineLazyGetter(this, "DEFAULT_ITEMS", function() { + let result = []; + for (let [, buttons] of Object.entries(DEFAULT_AREA_PLACEMENTS)) { + result = result.concat(buttons); + } + return result; +}); + +XPCOMUtils.defineLazyGetter(this, "ALL_BUILTIN_ITEMS", function() { + // These special cases are for click events on built-in items that are + // contained within customizable items (like the navigation widget). + const SPECIAL_CASES = [ + "back-button", + "forward-button", + "urlbar-stop-button", + "urlbar-go-button", + "urlbar-reload-button", + "searchbar", + "cut-button", + "copy-button", + "paste-button", + "zoom-out-button", + "zoom-reset-button", + "zoom-in-button", + "BMB_bookmarksPopup", + "BMB_unsortedBookmarksPopup", + "BMB_bookmarksToolbarPopup", + "search-go-button", + "soundplaying-icon", + ] + return DEFAULT_ITEMS.concat(PALETTE_ITEMS) + .concat(SPECIAL_CASES); +}); + +const OTHER_MOUSEUP_MONITORED_ITEMS = [ + "PlacesChevron", + "PlacesToolbarItems", + "menubar-items", +]; + +// Items that open arrow panels will often be overlapped by +// the panel that they're opening by the time the mouseup +// event is fired, so for these items, we monitor mousedown. +const MOUSEDOWN_MONITORED_ITEMS = [ + "PanelUI-menu-button", +]; + +// Weakly maps browser windows to objects whose keys are relative +// timestamps for when some kind of session started. For example, +// when a customization session started. That way, when the window +// exits customization mode, we can determine how long the session +// lasted. +const WINDOW_DURATION_MAP = new WeakMap(); + +// Default bucket name, when no other bucket is active. +const BUCKET_DEFAULT = "__DEFAULT__"; +// Bucket prefix, for named buckets. +const BUCKET_PREFIX = "bucket_"; +// Standard separator to use between different parts of a bucket name, such +// as primary name and the time step string. +const BUCKET_SEPARATOR = "|"; + +this.BrowserUITelemetry = { + init: function() { + UITelemetry.addSimpleMeasureFunction("toolbars", + this.getToolbarMeasures.bind(this)); + UITelemetry.addSimpleMeasureFunction("contextmenu", + this.getContextMenuInfo.bind(this)); + // Ensure that UITour.jsm remains lazy-loaded, yet always registers its + // simple measure function with UITelemetry. + UITelemetry.addSimpleMeasureFunction("UITour", + () => UITour.getTelemetry()); + + UITelemetry.addSimpleMeasureFunction("syncstate", + this.getSyncState.bind(this)); + + Services.obs.addObserver(this, "sessionstore-windows-restored", false); + Services.obs.addObserver(this, "browser-delayed-startup-finished", false); + Services.obs.addObserver(this, "autocomplete-did-enter-text", false); + CustomizableUI.addListener(this); + }, + + observe: function(aSubject, aTopic, aData) { + switch (aTopic) { + case "sessionstore-windows-restored": + this._gatherFirstWindowMeasurements(); + break; + case "browser-delayed-startup-finished": + this._registerWindow(aSubject); + break; + case "autocomplete-did-enter-text": + let input = aSubject.QueryInterface(Ci.nsIAutoCompleteInput); + if (input && input.id == "urlbar" && !input.inPrivateContext && + input.popup.selectedIndex != -1) { + this._logAwesomeBarSearchResult(input.textValue); + } + break; + } + }, + + /** + * For the _countableEvents object, constructs a chain of + * Javascript Objects with the keys in aKeys, with the final + * key getting the value in aEndWith. If the final key already + * exists in the final object, its value is not set. In either + * case, a reference to the second last object in the chain is + * returned. + * + * Example - suppose I want to store: + * _countableEvents: { + * a: { + * b: { + * c: 0 + * } + * } + * } + * + * And then increment the "c" value by 1, you could call this + * function like this: + * + * let example = this._ensureObjectChain([a, b, c], 0); + * example["c"]++; + * + * Subsequent repetitions of these last two lines would + * simply result in the c value being incremented again + * and again. + * + * @param aKeys the Array of keys to chain Objects together with. + * @param aEndWith the value to assign to the last key. + * @param aRoot the root object onto which we create/get the object chain + * designated by aKeys. + * @returns a reference to the second last object in the chain - + * so in our example, that'd be "b". + */ + _ensureObjectChain: function(aKeys, aEndWith, aRoot) { + let current = aRoot; + let parent = null; + aKeys.unshift(this._bucket); + for (let [i, key] of aKeys.entries()) { + if (!(key in current)) { + if (i == aKeys.length - 1) { + current[key] = aEndWith; + } else { + current[key] = {}; + } + } + parent = current; + current = current[key]; + } + return parent; + }, + + _countableEvents: {}, + _countEvent: function(aKeyArray, root=this._countableEvents) { + let countObject = this._ensureObjectChain(aKeyArray, 0, root); + let lastItemKey = aKeyArray[aKeyArray.length - 1]; + countObject[lastItemKey]++; + }, + + _countMouseUpEvent: function(aCategory, aAction, aButton) { + const BUTTONS = ["left", "middle", "right"]; + let buttonKey = BUTTONS[aButton]; + if (buttonKey) { + this._countEvent([aCategory, aAction, buttonKey]); + } + }, + + _firstWindowMeasurements: null, + _gatherFirstWindowMeasurements: function() { + // We'll gather measurements as soon as the session has restored. + // We do this here instead of waiting for UITelemetry to ask for + // our measurements because at that point all browser windows have + // probably been closed, since the vast majority of saved-session + // pings are gathered during shutdown. + let win = RecentWindow.getMostRecentBrowserWindow({ + private: false, + allowPopups: false, + }); + + Services.search.init(rv => { + // If there are no such windows (or we've just about found one + // but it's closed already), we're out of luck. :( + let hasWindow = win && !win.closed; + this._firstWindowMeasurements = hasWindow ? this._getWindowMeasurements(win, rv) + : {}; + }); + }, + + _registerWindow: function(aWindow) { + aWindow.addEventListener("unload", this); + let document = aWindow.document; + + for (let areaID of CustomizableUI.areas) { + let areaNode = document.getElementById(areaID); + if (areaNode) { + (areaNode.customizationTarget || areaNode).addEventListener("mouseup", this); + } + } + + for (let itemID of OTHER_MOUSEUP_MONITORED_ITEMS) { + let item = document.getElementById(itemID); + if (item) { + item.addEventListener("mouseup", this); + } + } + + for (let itemID of MOUSEDOWN_MONITORED_ITEMS) { + let item = document.getElementById(itemID); + if (item) { + item.addEventListener("mousedown", this); + } + } + + WINDOW_DURATION_MAP.set(aWindow, {}); + }, + + _unregisterWindow: function(aWindow) { + aWindow.removeEventListener("unload", this); + let document = aWindow.document; + + for (let areaID of CustomizableUI.areas) { + let areaNode = document.getElementById(areaID); + if (areaNode) { + (areaNode.customizationTarget || areaNode).removeEventListener("mouseup", this); + } + } + + for (let itemID of OTHER_MOUSEUP_MONITORED_ITEMS) { + let item = document.getElementById(itemID); + if (item) { + item.removeEventListener("mouseup", this); + } + } + + for (let itemID of MOUSEDOWN_MONITORED_ITEMS) { + let item = document.getElementById(itemID); + if (item) { + item.removeEventListener("mousedown", this); + } + } + }, + + handleEvent: function(aEvent) { + switch (aEvent.type) { + case "unload": + this._unregisterWindow(aEvent.currentTarget); + break; + case "mouseup": + this._handleMouseUp(aEvent); + break; + case "mousedown": + this._handleMouseDown(aEvent); + break; + } + }, + + _handleMouseUp: function(aEvent) { + let targetID = aEvent.currentTarget.id; + + switch (targetID) { + case "PlacesToolbarItems": + this._PlacesToolbarItemsMouseUp(aEvent); + break; + case "PlacesChevron": + this._PlacesChevronMouseUp(aEvent); + break; + case "menubar-items": + this._menubarMouseUp(aEvent); + break; + default: + this._checkForBuiltinItem(aEvent); + } + }, + + _handleMouseDown: function(aEvent) { + if (aEvent.currentTarget.id == "PanelUI-menu-button") { + // _countMouseUpEvent expects a detail for the second argument, + // but we don't really have any details to give. Just passing in + // "button" is probably simpler than trying to modify + // _countMouseUpEvent for this particular case. + this._countMouseUpEvent("click-menu-button", "button", aEvent.button); + } + }, + + _PlacesChevronMouseUp: function(aEvent) { + let target = aEvent.originalTarget; + let result = target.id == "PlacesChevron" ? "chevron" : "overflowed-item"; + this._countMouseUpEvent("click-bookmarks-bar", result, aEvent.button); + }, + + _PlacesToolbarItemsMouseUp: function(aEvent) { + let target = aEvent.originalTarget; + // If this isn't a bookmark-item, we don't care about it. + if (!target.classList.contains("bookmark-item")) { + return; + } + + let result = target.hasAttribute("container") ? "container" : "item"; + this._countMouseUpEvent("click-bookmarks-bar", result, aEvent.button); + }, + + _menubarMouseUp: function(aEvent) { + let target = aEvent.originalTarget; + let tag = target.localName + let result = (tag == "menu" || tag == "menuitem") ? tag : "other"; + this._countMouseUpEvent("click-menubar", result, aEvent.button); + }, + + _bookmarksMenuButtonMouseUp: function(aEvent) { + let bookmarksWidget = CustomizableUI.getWidget("bookmarks-menu-button"); + if (bookmarksWidget.areaType == CustomizableUI.TYPE_MENU_PANEL) { + // In the menu panel, only the star is visible, and that opens up the + // bookmarks subview. + this._countMouseUpEvent("click-bookmarks-menu-button", "in-panel", + aEvent.button); + } else { + let clickedItem = aEvent.originalTarget; + // Did we click on the star, or the dropmarker? The star + // has an anonid of "button". If we don't find that, we'll + // assume we clicked on the dropmarker. + let action = "menu"; + if (clickedItem.getAttribute("anonid") == "button") { + // We clicked on the star - now we just need to record + // whether or not we're adding a bookmark or editing an + // existing one. + let bookmarksMenuNode = + bookmarksWidget.forWindow(aEvent.target.ownerGlobal).node; + action = bookmarksMenuNode.hasAttribute("starred") ? "edit" : "add"; + } + this._countMouseUpEvent("click-bookmarks-menu-button", action, + aEvent.button); + } + }, + + _checkForBuiltinItem: function(aEvent) { + let item = aEvent.originalTarget; + + // We don't want to count clicks on the private browsing + // button for privacy reasons. See bug 1176391. + if (item.id == "privatebrowsing-button") { + return; + } + + // We special-case the bookmarks-menu-button, since we want to + // monitor more than just clicks on it. + if (item.id == "bookmarks-menu-button" || + getIDBasedOnFirstIDedAncestor(item) == "bookmarks-menu-button") { + this._bookmarksMenuButtonMouseUp(aEvent); + return; + } + + // Perhaps we're seeing one of the default toolbar items + // being clicked. + if (ALL_BUILTIN_ITEMS.indexOf(item.id) != -1) { + // Base case - we clicked directly on one of our built-in items, + // and we can go ahead and register that click. + this._countMouseUpEvent("click-builtin-item", item.id, aEvent.button); + return; + } + + // If not, we need to check if the item's anonid is in our list + // of built-in items to check. + if (ALL_BUILTIN_ITEMS.indexOf(item.getAttribute("anonid")) != -1) { + this._countMouseUpEvent("click-builtin-item", item.getAttribute("anonid"), aEvent.button); + return; + } + + // If not, we need to check if one of the ancestors of the clicked + // item is in our list of built-in items to check. + let candidate = getIDBasedOnFirstIDedAncestor(item); + if (ALL_BUILTIN_ITEMS.indexOf(candidate) != -1) { + this._countMouseUpEvent("click-builtin-item", candidate, aEvent.button); + } + }, + + _getWindowMeasurements: function(aWindow, searchResult) { + let document = aWindow.document; + let result = {}; + + // Determine if the window is in the maximized, normal or + // fullscreen state. + result.sizemode = document.documentElement.getAttribute("sizemode"); + + // Determine if the Bookmarks bar is currently visible + let bookmarksBar = document.getElementById("PersonalToolbar"); + result.bookmarksBarEnabled = bookmarksBar && !bookmarksBar.collapsed; + + // Determine if the menubar is currently visible. On OS X, the menubar + // is never shown, despite not having the collapsed attribute set. + let menuBar = document.getElementById("toolbar-menubar"); + result.menuBarEnabled = + menuBar && Services.appinfo.OS != "Darwin" + && menuBar.getAttribute("autohide") != "true"; + + // Determine if the titlebar is currently visible. + result.titleBarEnabled = !Services.prefs.getBoolPref("browser.tabs.drawInTitlebar"); + + // Examine all customizable areas and see what default items + // are present and missing. + let defaultKept = []; + let defaultMoved = []; + let nondefaultAdded = []; + + for (let areaID of CustomizableUI.areas) { + let items = CustomizableUI.getWidgetIdsInArea(areaID); + for (let item of items) { + // Is this a default item? + if (DEFAULT_ITEMS.indexOf(item) != -1) { + // Ok, it's a default item - but is it in its default + // toolbar? We use Array.isArray instead of checking for + // toolbarID in DEFAULT_AREA_PLACEMENTS because an add-on might + // be clever and give itself the id of "toString" or something. + if (Array.isArray(DEFAULT_AREA_PLACEMENTS[areaID]) && + DEFAULT_AREA_PLACEMENTS[areaID].indexOf(item) != -1) { + // The item is in its default toolbar + defaultKept.push(item); + } else { + defaultMoved.push(item); + } + } else if (PALETTE_ITEMS.indexOf(item) != -1) { + // It's a palette item that's been moved into a toolbar + nondefaultAdded.push(item); + } + // else, it's provided by an add-on, and we won't record it. + } + } + + // Now go through the items in the palette to see what default + // items are in there. + let paletteItems = + CustomizableUI.getUnusedWidgets(aWindow.gNavToolbox.palette); + let defaultRemoved = []; + for (let item of paletteItems) { + if (DEFAULT_ITEMS.indexOf(item.id) != -1) { + defaultRemoved.push(item.id); + } + } + + result.defaultKept = defaultKept; + result.defaultMoved = defaultMoved; + result.nondefaultAdded = nondefaultAdded; + result.defaultRemoved = defaultRemoved; + + // Next, determine how many add-on provided toolbars exist. + let addonToolbars = 0; + let toolbars = document.querySelectorAll("toolbar[customizable=true]"); + for (let toolbar of toolbars) { + if (DEFAULT_AREAS.indexOf(toolbar.id) == -1) { + addonToolbars++; + } + } + result.addonToolbars = addonToolbars; + + // Find out how many open tabs we have in each window + let winEnumerator = Services.wm.getEnumerator("navigator:browser"); + let visibleTabs = []; + let hiddenTabs = []; + while (winEnumerator.hasMoreElements()) { + let someWin = winEnumerator.getNext(); + if (someWin.gBrowser) { + let visibleTabsNum = someWin.gBrowser.visibleTabs.length; + visibleTabs.push(visibleTabsNum); + hiddenTabs.push(someWin.gBrowser.tabs.length - visibleTabsNum); + } + } + result.visibleTabs = visibleTabs; + result.hiddenTabs = hiddenTabs; + + if (Components.isSuccessCode(searchResult)) { + result.currentSearchEngine = Services.search.currentEngine.name; + } + + return result; + }, + + getToolbarMeasures: function() { + let result = this._firstWindowMeasurements || {}; + result.countableEvents = this._countableEvents; + result.durations = this._durations; + return result; + }, + + getSyncState: function() { + let result = {}; + for (let sub of ["desktop", "mobile"]) { + let count = 0; + try { + count = Services.prefs.getIntPref("services.sync.clients.devices." + sub); + } catch (ex) {} + result[sub] = count; + } + return result; + }, + + countCustomizationEvent: function(aEventType) { + this._countEvent(["customize", aEventType]); + }, + + countSearchEvent: function(source, query, selection) { + this._countEvent(["search", source]); + if ((/^[a-zA-Z]+:[^\/\\]/).test(query)) { + this._countEvent(["search", "urlbar-keyword"]); + } + if (selection) { + this._countEvent(["search", "selection", source, selection.index, selection.kind]); + } + }, + + countOneoffSearchEvent: function(id, type, where) { + this._countEvent(["search-oneoff", id, type, where]); + }, + + countSearchSettingsEvent: function(source) { + this._countEvent(["click-builtin-item", source, "search-settings"]); + }, + + countPanicEvent: function(timeId) { + this._countEvent(["forget-button", timeId]); + }, + + countTabMutingEvent: function(action, reason) { + this._countEvent(["tab-audio-control", action, reason || "no reason given"]); + }, + + countSyncedTabEvent: function(what, where) { + // "what" will be, eg, "open" + // "where" will be "toolbarbutton-subview" or "sidebar" + this._countEvent(["synced-tabs", what, where]); + }, + + countSidebarEvent: function(sidebarID, action) { + // sidebarID is the ID of the sidebar (duh!) + // action will be "hide" or "show" + this._countEvent(["sidebar", sidebarID, action]); + }, + + _logAwesomeBarSearchResult: function (url) { + let spec = Services.search.parseSubmissionURL(url); + if (spec.engine) { + let matchedEngine = "default"; + if (spec.engine.name !== Services.search.currentEngine.name) { + matchedEngine = "other"; + } + this.countSearchEvent("autocomplete-" + matchedEngine); + } + }, + + _durations: { + customization: [], + }, + + onCustomizeStart: function(aWindow) { + this._countEvent(["customize", "start"]); + let durationMap = WINDOW_DURATION_MAP.get(aWindow); + if (!durationMap) { + durationMap = {}; + WINDOW_DURATION_MAP.set(aWindow, durationMap); + } + + durationMap.customization = { + start: aWindow.performance.now(), + bucket: this._bucket, + }; + }, + + onCustomizeEnd: function(aWindow) { + let durationMap = WINDOW_DURATION_MAP.get(aWindow); + if (durationMap && "customization" in durationMap) { + let duration = aWindow.performance.now() - durationMap.customization.start; + this._durations.customization.push({ + duration: duration, + bucket: durationMap.customization.bucket, + }); + delete durationMap.customization; + } + }, + + _contextMenuItemWhitelist: new Set([ + "close-without-interaction", // for closing the menu without clicking it. + "custom-page-item", // The ID we use for page-provided items + "unknown", // The bucket for stuff with no id. + // Everything we know of so far (which will exclude add-on items): + "navigation", "back", "forward", "reload", "stop", "bookmarkpage", + "spell-no-suggestions", "spell-add-to-dictionary", + "spell-undo-add-to-dictionary", "openlinkincurrent", "openlinkintab", + "openlink", + // "openlinkprivate" intentionally omitted for privacy reasons. See bug 1176391. + "bookmarklink", "sharelink", "savelink", + "marklinkMenu", "copyemail", "copylink", "media-play", "media-pause", + "media-mute", "media-unmute", "media-playbackrate", + "media-playbackrate-050x", "media-playbackrate-100x", + "media-playbackrate-125x", "media-playbackrate-150x", "media-playbackrate-200x", + "media-showcontrols", "media-hidecontrols", + "video-fullscreen", "leave-dom-fullscreen", + "reloadimage", "viewimage", "viewvideo", "copyimage-contents", "copyimage", + "copyvideourl", "copyaudiourl", "saveimage", "shareimage", "sendimage", + "setDesktopBackground", "viewimageinfo", "viewimagedesc", "savevideo", + "sharevideo", "saveaudio", "video-saveimage", "sendvideo", "sendaudio", + "ctp-play", "ctp-hide", "sharepage", "savepage", "pocket", "markpageMenu", + "viewbgimage", "undo", "cut", "copy", "paste", "delete", "selectall", + "keywordfield", "searchselect", "shareselect", "frame", "showonlythisframe", + "openframeintab", "openframe", "reloadframe", "bookmarkframe", "saveframe", + "printframe", "viewframesource", "viewframeinfo", + "viewpartialsource-selection", "viewpartialsource-mathml", + "viewsource", "viewinfo", "spell-check-enabled", + "spell-add-dictionaries-main", "spell-dictionaries", + "spell-dictionaries-menu", "spell-add-dictionaries", + "bidi-text-direction-toggle", "bidi-page-direction-toggle", "inspect", + "media-eme-learn-more" + ]), + + _contextMenuInteractions: {}, + + registerContextMenuInteraction: function(keys, itemID) { + if (itemID) { + if (itemID == "openlinkprivate") { + // Don't record anything, not even an other-item count + // if the user chose to open in a private window. See + // bug 1176391. + return; + } + + if (!this._contextMenuItemWhitelist.has(itemID)) { + itemID = "other-item"; + } + keys.push(itemID); + } + + this._countEvent(keys, this._contextMenuInteractions); + }, + + getContextMenuInfo: function() { + return this._contextMenuInteractions; + }, + + _bucket: BUCKET_DEFAULT, + _bucketTimer: null, + + /** + * Default bucket name, when no other bucket is active. + */ + get BUCKET_DEFAULT() { + return BUCKET_DEFAULT; + }, + + /** + * Bucket prefix, for named buckets. + */ + get BUCKET_PREFIX() { + return BUCKET_PREFIX; + }, + + /** + * Standard separator to use between different parts of a bucket name, such + * as primary name and the time step string. + */ + get BUCKET_SEPARATOR() { + return BUCKET_SEPARATOR; + }, + + get currentBucket() { + return this._bucket; + }, + + /** + * Sets a named bucket for all countable events and select durections to be + * put into. + * + * @param aName Name of bucket, or null for default bucket name (__DEFAULT__) + */ + setBucket: function(aName) { + if (this._bucketTimer) { + Timer.clearTimeout(this._bucketTimer); + this._bucketTimer = null; + } + + if (aName) + this._bucket = BUCKET_PREFIX + aName; + else + this._bucket = BUCKET_DEFAULT; + }, + + /** + * Sets a bucket that expires at the rate of a given series of time steps. + * Once the bucket expires, the current bucket will automatically revert to + * the default bucket. While the bucket is expiring, it's name is postfixed + * by '|' followed by a short string representation of the time step it's + * currently in. + * If any other bucket (expiring or normal) is set while an expiring bucket is + * still expiring, the old expiring bucket stops expiring and the new bucket + * immediately takes over. + * + * @param aName Name of bucket. + * @param aTimeSteps An array of times in milliseconds to count up to before + * reverting back to the default bucket. The array of times + * is expected to be pre-sorted in ascending order. + * For example, given a bucket name of 'bucket', the times: + * [60000, 300000, 600000] + * will result in the following buckets: + * * bucket|1m - for the first 1 minute + * * bucket|5m - for the following 4 minutes + * (until 5 minutes after the start) + * * bucket|10m - for the following 5 minutes + * (until 10 minutes after the start) + * * __DEFAULT__ - until a new bucket is set + * @param aTimeOffset Time offset, in milliseconds, from which to start + * counting. For example, if the first time step is 1000ms, + * and the time offset is 300ms, then the next time step + * will become active after 700ms. This affects all + * following time steps also, meaning they will also all be + * timed as though they started expiring 300ms before + * setExpiringBucket was called. + */ + setExpiringBucket: function(aName, aTimeSteps, aTimeOffset = 0) { + if (aTimeSteps.length === 0) { + this.setBucket(null); + return; + } + + if (this._bucketTimer) { + Timer.clearTimeout(this._bucketTimer); + this._bucketTimer = null; + } + + // Make a copy of the time steps array, so we can safely modify it without + // modifying the original array that external code has passed to us. + let steps = [...aTimeSteps]; + let msec = steps.shift(); + let postfix = this._toTimeStr(msec); + this.setBucket(aName + BUCKET_SEPARATOR + postfix); + + this._bucketTimer = Timer.setTimeout(() => { + this._bucketTimer = null; + this.setExpiringBucket(aName, steps, aTimeOffset + msec); + }, msec - aTimeOffset); + }, + + /** + * Formats a time interval, in milliseconds, to a minimal non-localized string + * representation. Format is: 'h' for hours, 'm' for minutes, 's' for seconds, + * 'ms' for milliseconds. + * Examples: + * 65 => 65ms + * 1000 => 1s + * 60000 => 1m + * 61000 => 1m01s + * + * @param aTimeMS Time in milliseconds + * + * @return Minimal string representation. + */ + _toTimeStr: function(aTimeMS) { + let timeStr = ""; + + function reduce(aUnitLength, aSymbol) { + if (aTimeMS >= aUnitLength) { + let units = Math.floor(aTimeMS / aUnitLength); + aTimeMS = aTimeMS - (units * aUnitLength) + timeStr += units + aSymbol; + } + } + + reduce(MS_HOUR, "h"); + reduce(MS_MINUTE, "m"); + reduce(MS_SECOND, "s"); + reduce(1, "ms"); + + return timeStr; + }, +}; + +/** + * Returns the id of the first ancestor of aNode that has an id. If aNode + * has no parent, or no ancestor has an id, returns null. + * + * @param aNode the node to find the first ID'd ancestor of + */ +function getIDBasedOnFirstIDedAncestor(aNode) { + while (!aNode.id) { + aNode = aNode.parentNode; + if (!aNode) { + return null; + } + } + + return aNode.id; +} |