Bug 1885580 - Add a MenuGroup component for the menu redesign r=android-reviewers,007
[gecko.git] / browser / modules / BrowserUsageTelemetry.sys.mjs
blob955a3338ec135977d0d021fed60f91eca0c56898
1 /* -*- js-indent-level: 2; indent-tabs-mode: nil -*- */
2 /* This Source Code Form is subject to the terms of the Mozilla Public
3  * License, v. 2.0. If a copy of the MPL was not distributed with this
4  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
6 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
7 import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
9 const lazy = {};
11 ChromeUtils.defineESModuleGetters(lazy, {
12   ClientID: "resource://gre/modules/ClientID.sys.mjs",
13   CustomizableUI: "resource:///modules/CustomizableUI.sys.mjs",
14   DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs",
15   PageActions: "resource:///modules/PageActions.sys.mjs",
16   PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
17   SearchSERPTelemetry: "resource:///modules/SearchSERPTelemetry.sys.mjs",
18   SearchSERPTelemetryUtils: "resource:///modules/SearchSERPTelemetry.sys.mjs",
20   WindowsInstallsInfo:
21     "resource://gre/modules/components-utils/WindowsInstallsInfo.sys.mjs",
23   clearTimeout: "resource://gre/modules/Timer.sys.mjs",
24   setTimeout: "resource://gre/modules/Timer.sys.mjs",
25 });
27 // This pref is in seconds!
28 XPCOMUtils.defineLazyPreferenceGetter(
29   lazy,
30   "gRecentVisitedOriginsExpiry",
31   "browser.engagement.recent_visited_origins.expiry"
34 // The upper bound for the count of the visited unique domain names.
35 const MAX_UNIQUE_VISITED_DOMAINS = 100;
37 // Observed topic names.
38 const TAB_RESTORING_TOPIC = "SSTabRestoring";
39 const TELEMETRY_SUBSESSIONSPLIT_TOPIC =
40   "internal-telemetry-after-subsession-split";
41 const DOMWINDOW_OPENED_TOPIC = "domwindowopened";
43 // Probe names.
44 const MAX_TAB_COUNT_SCALAR_NAME = "browser.engagement.max_concurrent_tab_count";
45 const MAX_WINDOW_COUNT_SCALAR_NAME =
46   "browser.engagement.max_concurrent_window_count";
47 const TAB_OPEN_EVENT_COUNT_SCALAR_NAME =
48   "browser.engagement.tab_open_event_count";
49 const MAX_TAB_PINNED_COUNT_SCALAR_NAME =
50   "browser.engagement.max_concurrent_tab_pinned_count";
51 const TAB_PINNED_EVENT_COUNT_SCALAR_NAME =
52   "browser.engagement.tab_pinned_event_count";
53 const WINDOW_OPEN_EVENT_COUNT_SCALAR_NAME =
54   "browser.engagement.window_open_event_count";
55 const UNIQUE_DOMAINS_COUNT_SCALAR_NAME =
56   "browser.engagement.unique_domains_count";
57 const TOTAL_URI_COUNT_SCALAR_NAME = "browser.engagement.total_uri_count";
58 const UNFILTERED_URI_COUNT_SCALAR_NAME =
59   "browser.engagement.unfiltered_uri_count";
60 const TOTAL_URI_COUNT_NORMAL_AND_PRIVATE_MODE_SCALAR_NAME =
61   "browser.engagement.total_uri_count_normal_and_private_mode";
63 export const MINIMUM_TAB_COUNT_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes, in ms
65 // The elements we consider to be interactive.
66 const UI_TARGET_ELEMENTS = [
67   "menuitem",
68   "toolbarbutton",
69   "key",
70   "command",
71   "checkbox",
72   "input",
73   "button",
74   "image",
75   "radio",
76   "richlistitem",
79 // The containers of interactive elements that we care about and their pretty
80 // names. These should be listed in order of most-specific to least-specific,
81 // when iterating JavaScript will guarantee that ordering and so we will find
82 // the most specific area first.
83 const BROWSER_UI_CONTAINER_IDS = {
84   "toolbar-menubar": "menu-bar",
85   TabsToolbar: "tabs-bar",
86   PersonalToolbar: "bookmarks-bar",
87   "appMenu-popup": "app-menu",
88   tabContextMenu: "tabs-context",
89   contentAreaContextMenu: "content-context",
90   "widget-overflow-list": "overflow-menu",
91   "widget-overflow-fixed-list": "pinned-overflow-menu",
92   "page-action-buttons": "pageaction-urlbar",
93   pageActionPanel: "pageaction-panel",
94   "unified-extensions-area": "unified-extensions-area",
95   "allTabsMenu-allTabsView": "alltabs-menu",
97   // This should appear last as some of the above are inside the nav bar.
98   "nav-bar": "nav-bar",
101 const ENTRYPOINT_TRACKED_CONTEXT_MENU_IDS = {
102   [BROWSER_UI_CONTAINER_IDS.tabContextMenu]: "tabs-context-entrypoint",
105 // A list of the expected panes in about:preferences
106 const PREFERENCES_PANES = [
107   "paneHome",
108   "paneGeneral",
109   "panePrivacy",
110   "paneSearch",
111   "paneSearchResults",
112   "paneSync",
113   "paneContainers",
114   "paneExperimental",
115   "paneMoreFromMozilla",
118 const IGNORABLE_EVENTS = new WeakMap();
120 const KNOWN_ADDONS = [];
122 // Buttons that, when clicked, set a preference to true. The convention
123 // is that the preference is named:
125 // browser.engagement.<button id>.has-used
127 // and is defaulted to false.
128 const SET_USAGE_PREF_BUTTONS = [
129   "downloads-button",
130   "fxa-toolbar-menu-button",
131   "home-button",
132   "sidebar-button",
133   "library-button",
136 // Buttons that, when clicked, increase a counter. The convention
137 // is that the preference is named:
139 // browser.engagement.<button id>.used-count
141 // and doesn't have a default value.
142 const SET_USAGECOUNT_PREF_BUTTONS = [
143   "pageAction-panel-copyURL",
144   "pageAction-panel-emailLink",
145   "pageAction-panel-pinTab",
146   "pageAction-panel-screenshots_mozilla_org",
147   "pageAction-panel-shareURL",
150 // Places context menu IDs.
151 const PLACES_CONTEXT_MENU_ID = "placesContext";
152 const PLACES_OPEN_IN_CONTAINER_TAB_MENU_ID =
153   "placesContext_open:newcontainertab";
155 // Commands used to open history or bookmark links from places context menu.
156 const PLACES_OPEN_COMMANDS = [
157   "placesCmd_open",
158   "placesCmd_open:window",
159   "placesCmd_open:privatewindow",
160   "placesCmd_open:tab",
163 function telemetryId(widgetId, obscureAddons = true) {
164   // Add-on IDs need to be obscured.
165   function addonId(id) {
166     if (!obscureAddons) {
167       return id;
168     }
170     let pos = KNOWN_ADDONS.indexOf(id);
171     if (pos < 0) {
172       pos = KNOWN_ADDONS.length;
173       KNOWN_ADDONS.push(id);
174     }
175     return `addon${pos}`;
176   }
178   if (widgetId.endsWith("-browser-action")) {
179     widgetId = addonId(
180       widgetId.substring(0, widgetId.length - "-browser-action".length)
181     );
182   } else if (widgetId.startsWith("pageAction-")) {
183     let actionId;
184     if (widgetId.startsWith("pageAction-urlbar-")) {
185       actionId = widgetId.substring("pageAction-urlbar-".length);
186     } else if (widgetId.startsWith("pageAction-panel-")) {
187       actionId = widgetId.substring("pageAction-panel-".length);
188     }
190     if (actionId) {
191       let action = lazy.PageActions.actionForID(actionId);
192       widgetId = action?._isMozillaAction ? actionId : addonId(actionId);
193     }
194   } else if (widgetId.startsWith("ext-keyset-id-")) {
195     // Webextension command shortcuts don't have an id on their key element so
196     // we see the id from the keyset that contains them.
197     widgetId = addonId(widgetId.substring("ext-keyset-id-".length));
198   } else if (widgetId.startsWith("ext-key-id-")) {
199     // The command for a webextension sidebar action is an exception to the above rule.
200     widgetId = widgetId.substring("ext-key-id-".length);
201     if (widgetId.endsWith("-sidebar-action")) {
202       widgetId = addonId(
203         widgetId.substring(0, widgetId.length - "-sidebar-action".length)
204       );
205     }
206   }
208   return widgetId.replace(/_/g, "-");
211 function getOpenTabsAndWinsCounts() {
212   let loadedTabCount = 0;
213   let tabCount = 0;
214   let winCount = 0;
216   for (let win of Services.wm.getEnumerator("navigator:browser")) {
217     winCount++;
218     tabCount += win.gBrowser.tabs.length;
219     for (const tab of win.gBrowser.tabs) {
220       if (tab.getAttribute("pending") !== "true") {
221         loadedTabCount += 1;
222       }
223     }
224   }
226   return { loadedTabCount, tabCount, winCount };
229 function getPinnedTabsCount() {
230   let pinnedTabs = 0;
232   for (let win of Services.wm.getEnumerator("navigator:browser")) {
233     pinnedTabs += [...win.ownerGlobal.gBrowser.tabs].filter(
234       t => t.pinned
235     ).length;
236   }
238   return pinnedTabs;
241 export let URICountListener = {
242   // A set containing the visited domains, see bug 1271310.
243   _domainSet: new Set(),
244   // A set containing the visited origins during the last 24 hours (similar to domains, but not quite the same)
245   _domain24hrSet: new Set(),
246   // A map to keep track of the URIs loaded from the restored tabs.
247   _restoredURIsMap: new WeakMap(),
248   // Ongoing expiration timeouts.
249   _timeouts: new Set(),
251   isHttpURI(uri) {
252     // Only consider http(s) schemas.
253     return uri.schemeIs("http") || uri.schemeIs("https");
254   },
256   addRestoredURI(browser, uri) {
257     if (!this.isHttpURI(uri)) {
258       return;
259     }
261     this._restoredURIsMap.set(browser, uri.spec);
262   },
264   onLocationChange(browser, webProgress, request, uri, flags) {
265     if (
266       !(flags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT) &&
267       webProgress.isTopLevel
268     ) {
269       // By default, assume we no longer need to track this tab.
270       lazy.SearchSERPTelemetry.stopTrackingBrowser(
271         browser,
272         lazy.SearchSERPTelemetryUtils.ABANDONMENTS.NAVIGATION
273       );
274     }
276     // Don't count this URI if it's an error page.
277     if (flags & Ci.nsIWebProgressListener.LOCATION_CHANGE_ERROR_PAGE) {
278       return;
279     }
281     // We only care about top level loads.
282     if (!webProgress.isTopLevel) {
283       return;
284     }
286     // The SessionStore sets the URI of a tab first, firing onLocationChange the
287     // first time, then manages content loading using its scheduler. Once content
288     // loads, we will hit onLocationChange again.
289     // We can catch the first case by checking for null requests: be advised that
290     // this can also happen when navigating page fragments, so account for it.
291     if (
292       !request &&
293       !(flags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT)
294     ) {
295       return;
296     }
298     // Don't include URI and domain counts when in private mode.
299     let shouldCountURI =
300       !lazy.PrivateBrowsingUtils.isWindowPrivate(browser.ownerGlobal) ||
301       Services.prefs.getBoolPref(
302         "browser.engagement.total_uri_count.pbm",
303         false
304       );
306     // Track URI loads, even if they're not http(s).
307     let uriSpec = null;
308     try {
309       uriSpec = uri.spec;
310     } catch (e) {
311       // If we have troubles parsing the spec, still count this as
312       // an unfiltered URI.
313       if (shouldCountURI) {
314         Services.telemetry.scalarAdd(UNFILTERED_URI_COUNT_SCALAR_NAME, 1);
315       }
316       return;
317     }
319     // Don't count about:blank and similar pages, as they would artificially
320     // inflate the counts.
321     if (browser.ownerGlobal.gInitialPages.includes(uriSpec)) {
322       return;
323     }
325     // If the URI we're loading is in the _restoredURIsMap, then it comes from a
326     // restored tab. If so, let's skip it and remove it from the map as we want to
327     // count page refreshes.
328     if (this._restoredURIsMap.get(browser) === uriSpec) {
329       this._restoredURIsMap.delete(browser);
330       return;
331     }
333     // The URI wasn't from a restored tab. Count it among the unfiltered URIs.
334     // If this is an http(s) URI, this also gets counted by the "total_uri_count"
335     // probe.
336     if (shouldCountURI) {
337       Services.telemetry.scalarAdd(UNFILTERED_URI_COUNT_SCALAR_NAME, 1);
338     }
340     if (!this.isHttpURI(uri)) {
341       return;
342     }
344     if (!(flags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT)) {
345       lazy.SearchSERPTelemetry.updateTrackingStatus(
346         browser,
347         uriSpec,
348         webProgress.loadType
349       );
350     } else {
351       lazy.SearchSERPTelemetry.updateTrackingSinglePageApp(
352         browser,
353         uriSpec,
354         webProgress.loadType,
355         flags
356       );
357     }
359     // Update total URI count, including when in private mode.
360     Services.telemetry.scalarAdd(
361       TOTAL_URI_COUNT_NORMAL_AND_PRIVATE_MODE_SCALAR_NAME,
362       1
363     );
364     Glean.browserEngagement.uriCount.add(1);
366     if (!shouldCountURI) {
367       return;
368     }
370     // Update the URI counts.
371     Services.telemetry.scalarAdd(TOTAL_URI_COUNT_SCALAR_NAME, 1);
373     // Update tab count
374     BrowserUsageTelemetry._recordTabCounts(getOpenTabsAndWinsCounts());
376     // Unique domains should be aggregated by (eTLD + 1): x.test.com and y.test.com
377     // are counted once as test.com.
378     let baseDomain;
379     try {
380       // Even if only considering http(s) URIs, |getBaseDomain| could still throw
381       // due to the URI containing invalid characters or the domain actually being
382       // an ipv4 or ipv6 address.
383       baseDomain = Services.eTLD.getBaseDomain(uri);
384     } catch (e) {
385       return;
386     }
388     // We only want to count the unique domains up to MAX_UNIQUE_VISITED_DOMAINS.
389     if (this._domainSet.size < MAX_UNIQUE_VISITED_DOMAINS) {
390       this._domainSet.add(baseDomain);
391       Services.telemetry.scalarSet(
392         UNIQUE_DOMAINS_COUNT_SCALAR_NAME,
393         this._domainSet.size
394       );
395     }
397     this._domain24hrSet.add(baseDomain);
398     if (lazy.gRecentVisitedOriginsExpiry) {
399       let timeoutId = lazy.setTimeout(() => {
400         this._domain24hrSet.delete(baseDomain);
401         this._timeouts.delete(timeoutId);
402       }, lazy.gRecentVisitedOriginsExpiry * 1000);
403       this._timeouts.add(timeoutId);
404     }
405   },
407   /**
408    * Reset the counts. This should be called when breaking a session in Telemetry.
409    */
410   reset() {
411     this._domainSet.clear();
412   },
414   /**
415    * Returns the number of unique domains visited in this session during the
416    * last 24 hours.
417    */
418   get uniqueDomainsVisitedInPast24Hours() {
419     return this._domain24hrSet.size;
420   },
422   /**
423    * Resets the number of unique domains visited in this session.
424    */
425   resetUniqueDomainsVisitedInPast24Hours() {
426     this._timeouts.forEach(timeoutId => lazy.clearTimeout(timeoutId));
427     this._timeouts.clear();
428     this._domain24hrSet.clear();
429   },
431   QueryInterface: ChromeUtils.generateQI([
432     "nsIWebProgressListener",
433     "nsISupportsWeakReference",
434   ]),
437 export let BrowserUsageTelemetry = {
438   /**
439    * This is a policy object used to override behavior for testing.
440    */
441   Policy: {
442     getTelemetryClientId: async () => lazy.ClientID.getClientID(),
443     getUpdateDirectory: () => Services.dirsvc.get("UpdRootD", Ci.nsIFile),
444     readProfileCountFile: async path => IOUtils.readUTF8(path),
445     writeProfileCountFile: async (path, data) => IOUtils.writeUTF8(path, data),
446   },
448   _inited: false,
450   init() {
451     this._lastRecordTabCount = 0;
452     this._lastRecordLoadedTabCount = 0;
453     this._setupAfterRestore();
454     this._inited = true;
456     Services.prefs.addObserver("browser.tabs.inTitlebar", this);
458     this._recordUITelemetry();
460     this._onTabsOpenedTask = new lazy.DeferredTask(
461       () => this._onTabsOpened(),
462       0
463     );
464   },
466   /**
467    * Resets the masked add-on identifiers. Only for use in tests.
468    */
469   _resetAddonIds() {
470     KNOWN_ADDONS.length = 0;
471   },
473   /**
474    * Handle subsession splits in the parent process.
475    */
476   afterSubsessionSplit() {
477     // Scalars just got cleared due to a subsession split. We need to set the maximum
478     // concurrent tab and window counts so that they reflect the correct value for the
479     // new subsession.
480     const counts = getOpenTabsAndWinsCounts();
481     Services.telemetry.scalarSetMaximum(
482       MAX_TAB_COUNT_SCALAR_NAME,
483       counts.tabCount
484     );
485     Services.telemetry.scalarSetMaximum(
486       MAX_WINDOW_COUNT_SCALAR_NAME,
487       counts.winCount
488     );
490     // Reset the URI counter.
491     URICountListener.reset();
492   },
494   QueryInterface: ChromeUtils.generateQI([
495     "nsIObserver",
496     "nsISupportsWeakReference",
497   ]),
499   uninit() {
500     if (!this._inited) {
501       return;
502     }
503     Services.obs.removeObserver(this, DOMWINDOW_OPENED_TOPIC);
504     Services.obs.removeObserver(this, TELEMETRY_SUBSESSIONSPLIT_TOPIC);
505   },
507   observe(subject, topic, data) {
508     switch (topic) {
509       case DOMWINDOW_OPENED_TOPIC:
510         this._onWindowOpen(subject);
511         break;
512       case TELEMETRY_SUBSESSIONSPLIT_TOPIC:
513         this.afterSubsessionSplit();
514         break;
515       case "nsPref:changed":
516         switch (data) {
517           case "browser.tabs.inTitlebar":
518             this._recordWidgetChange(
519               "titlebar",
520               Services.appinfo.drawInTitlebar ? "off" : "on",
521               "pref"
522             );
523             break;
524         }
525         break;
526     }
527   },
529   handleEvent(event) {
530     switch (event.type) {
531       case "TabOpen":
532         this._onTabOpen();
533         break;
534       case "TabPinned":
535         this._onTabPinned();
536         break;
537       case "unload":
538         this._unregisterWindow(event.target);
539         break;
540       case TAB_RESTORING_TOPIC:
541         // We're restoring a new tab from a previous or crashed session.
542         // We don't want to track the URIs from these tabs, so let
543         // |URICountListener| know about them.
544         let browser = event.target.linkedBrowser;
545         URICountListener.addRestoredURI(browser, browser.currentURI);
547         const { loadedTabCount } = getOpenTabsAndWinsCounts();
548         this._recordTabCounts({ loadedTabCount });
549         break;
550     }
551   },
553   /**
554    * This gets called shortly after the SessionStore has finished restoring
555    * windows and tabs. It counts the open tabs and adds listeners to all the
556    * windows.
557    */
558   _setupAfterRestore() {
559     // Make sure to catch new chrome windows and subsession splits.
560     Services.obs.addObserver(this, DOMWINDOW_OPENED_TOPIC, true);
561     Services.obs.addObserver(this, TELEMETRY_SUBSESSIONSPLIT_TOPIC, true);
563     // Attach the tabopen handlers to the existing Windows.
564     for (let win of Services.wm.getEnumerator("navigator:browser")) {
565       this._registerWindow(win);
566     }
568     // Get the initial tab and windows max counts.
569     const counts = getOpenTabsAndWinsCounts();
570     Services.telemetry.scalarSetMaximum(
571       MAX_TAB_COUNT_SCALAR_NAME,
572       counts.tabCount
573     );
574     Services.telemetry.scalarSetMaximum(
575       MAX_WINDOW_COUNT_SCALAR_NAME,
576       counts.winCount
577     );
578   },
580   _buildWidgetPositions() {
581     let widgetMap = new Map();
583     const toolbarState = nodeId => {
584       let value;
585       if (nodeId == "PersonalToolbar") {
586         value = Services.prefs.getCharPref(
587           "browser.toolbars.bookmarks.visibility",
588           "newtab"
589         );
590         if (value != "newtab") {
591           return value == "never" ? "off" : "on";
592         }
593         return value;
594       }
595       value = Services.xulStore.getValue(
596         AppConstants.BROWSER_CHROME_URL,
597         nodeId,
598         "collapsed"
599       );
601       if (value) {
602         return value == "true" ? "off" : "on";
603       }
604       return "off";
605     };
607     widgetMap.set(
608       BROWSER_UI_CONTAINER_IDS.PersonalToolbar,
609       toolbarState("PersonalToolbar")
610     );
612     let menuBarHidden =
613       Services.xulStore.getValue(
614         AppConstants.BROWSER_CHROME_URL,
615         "toolbar-menubar",
616         "autohide"
617       ) != "false";
619     widgetMap.set("menu-toolbar", menuBarHidden ? "off" : "on");
621     // Drawing in the titlebar means not showing the titlebar, hence the negation.
622     widgetMap.set("titlebar", Services.appinfo.drawInTitlebar ? "off" : "on");
624     for (let area of lazy.CustomizableUI.areas) {
625       if (!(area in BROWSER_UI_CONTAINER_IDS)) {
626         continue;
627       }
629       let position = BROWSER_UI_CONTAINER_IDS[area];
630       if (area == "nav-bar") {
631         position = `${BROWSER_UI_CONTAINER_IDS[area]}-start`;
632       }
634       let widgets = lazy.CustomizableUI.getWidgetsInArea(area);
636       for (let widget of widgets) {
637         if (!widget) {
638           continue;
639         }
641         if (widget.id.startsWith("customizableui-special-")) {
642           continue;
643         }
645         if (area == "nav-bar" && widget.id == "urlbar-container") {
646           position = `${BROWSER_UI_CONTAINER_IDS[area]}-end`;
647           continue;
648         }
650         widgetMap.set(widget.id, position);
651       }
652     }
654     let actions = lazy.PageActions.actions;
655     for (let action of actions) {
656       if (action.pinnedToUrlbar) {
657         widgetMap.set(action.id, "pageaction-urlbar");
658       }
659     }
661     return widgetMap;
662   },
664   _getWidgetID(node) {
665     // We want to find a sensible ID for this element.
666     if (!node) {
667       return null;
668     }
670     // See if this is a customizable widget.
671     if (node.ownerDocument.URL == AppConstants.BROWSER_CHROME_URL) {
672       // First find if it is inside one of the customizable areas.
673       for (let area of lazy.CustomizableUI.areas) {
674         if (node.closest(`#${CSS.escape(area)}`)) {
675           for (let widget of lazy.CustomizableUI.getWidgetIdsInArea(area)) {
676             if (
677               // We care about the buttons on the tabs themselves.
678               widget == "tabbrowser-tabs" ||
679               // We care about the page action and other buttons in here.
680               widget == "urlbar-container" ||
681               // We care about the actual menu items.
682               widget == "menubar-items" ||
683               // We care about individual bookmarks here.
684               widget == "personal-bookmarks"
685             ) {
686               continue;
687             }
689             if (node.closest(`#${CSS.escape(widget)}`)) {
690               return widget;
691             }
692           }
693           break;
694         }
695       }
696     }
698     if (node.id) {
699       return node.id;
700     }
702     // A couple of special cases in the tabs.
703     for (let cls of ["bookmark-item", "tab-icon-sound", "tab-close-button"]) {
704       if (!node.classList.contains(cls)) {
705         continue;
706       }
707       if (cls == "bookmark-item" && node.parentElement.id.includes("history")) {
708         return "history-item";
709       }
710       return cls;
711     }
713     // One of these will at least let us know what the widget is for.
714     let possibleAttributes = [
715       "preference",
716       "command",
717       "observes",
718       "data-l10n-id",
719     ];
721     // The key attribute on key elements is the actual key to listen for.
722     if (node.localName != "key") {
723       possibleAttributes.unshift("key");
724     }
726     for (let idAttribute of possibleAttributes) {
727       if (node.hasAttribute(idAttribute)) {
728         return node.getAttribute(idAttribute);
729       }
730     }
732     return this._getWidgetID(node.parentElement);
733   },
735   _getBrowserWidgetContainer(node) {
736     // Find the container holding this element.
737     for (let containerId of Object.keys(BROWSER_UI_CONTAINER_IDS)) {
738       let container = node.ownerDocument.getElementById(containerId);
739       if (container && container.contains(node)) {
740         return BROWSER_UI_CONTAINER_IDS[containerId];
741       }
742     }
743     // Treat toolbar context menu items that relate to tabs as the tab menu:
744     if (
745       node.closest("#toolbar-context-menu") &&
746       node.getAttribute("contexttype") == "tabbar"
747     ) {
748       return BROWSER_UI_CONTAINER_IDS.tabContextMenu;
749     }
750     return null;
751   },
753   _getWidgetContainer(node) {
754     if (node.localName == "key") {
755       return "keyboard";
756     }
758     const { URL } = node.ownerDocument;
759     if (URL == AppConstants.BROWSER_CHROME_URL) {
760       return this._getBrowserWidgetContainer(node);
761     }
762     if (
763       URL.startsWith("about:preferences") ||
764       URL.startsWith("about:settings")
765     ) {
766       // Find the element's category.
767       let container = node.closest("[data-category]");
768       if (!container) {
769         return null;
770       }
772       let pane = container.getAttribute("data-category");
774       if (!PREFERENCES_PANES.includes(pane)) {
775         pane = "paneUnknown";
776       }
778       return `preferences_${pane}`;
779     }
781     return null;
782   },
784   lastClickTarget: null,
786   ignoreEvent(event) {
787     IGNORABLE_EVENTS.set(event, true);
788   },
790   _recordCommand(event) {
791     if (IGNORABLE_EVENTS.get(event)) {
792       return;
793     }
795     let sourceEvent = event;
796     while (sourceEvent.sourceEvent) {
797       sourceEvent = sourceEvent.sourceEvent;
798     }
800     let lastTarget = this.lastClickTarget?.get();
801     if (
802       lastTarget &&
803       sourceEvent.type == "command" &&
804       sourceEvent.target.contains(lastTarget)
805     ) {
806       // Ignore a command event triggered by a click.
807       this.lastClickTarget = null;
808       return;
809     }
811     this.lastClickTarget = null;
813     if (sourceEvent.type == "click") {
814       // Only care about main button clicks.
815       if (sourceEvent.button != 0) {
816         return;
817       }
819       // This click may trigger a command event so retain the target to be able
820       // to dedupe that event.
821       this.lastClickTarget = Cu.getWeakReference(sourceEvent.target);
822     }
824     // We should never see events from web content as they are fired in a
825     // content process, but let's be safe.
826     let url = sourceEvent.target.ownerDocument.documentURIObject;
827     if (!url.schemeIs("chrome") && !url.schemeIs("about")) {
828       return;
829     }
831     // This is what events targetted  at content will actually look like.
832     if (sourceEvent.target.localName == "browser") {
833       return;
834     }
836     // Find the actual element we're interested in.
837     let node = sourceEvent.target;
838     const isAboutPreferences =
839       node.ownerDocument.URL.startsWith("about:preferences") ||
840       node.ownerDocument.URL.startsWith("about:settings");
841     while (
842       !UI_TARGET_ELEMENTS.includes(node.localName) &&
843       !node.classList?.contains("wants-telemetry") &&
844       // We are interested in links on about:preferences as well.
845       !(
846         isAboutPreferences &&
847         (node.getAttribute("is") === "text-link" || node.localName === "a")
848       )
849     ) {
850       node = node.parentNode;
851       if (!node?.parentNode) {
852         // A click on a space or label or top-level document or something we're
853         // not interested in.
854         return;
855       }
856     }
858     if (sourceEvent.type === "command") {
859       const { command, ownerDocument, parentNode } = node;
860       // Check if this command is for a history or bookmark link being opened
861       // from the context menu. In this case, we are interested in the DOM node
862       // for the link, not the menu item itself.
863       if (
864         PLACES_OPEN_COMMANDS.includes(command) ||
865         parentNode?.parentNode?.id === PLACES_OPEN_IN_CONTAINER_TAB_MENU_ID
866       ) {
867         node = ownerDocument.getElementById(PLACES_CONTEXT_MENU_ID).triggerNode;
868       }
869     }
871     let item = this._getWidgetID(node);
872     let source = this._getWidgetContainer(node);
874     if (item && source) {
875       let scalar = `browser.ui.interaction.${source.replace(/-/g, "_")}`;
876       Services.telemetry.keyedScalarAdd(scalar, telemetryId(item), 1);
877       if (SET_USAGECOUNT_PREF_BUTTONS.includes(item)) {
878         let pref = `browser.engagement.${item}.used-count`;
879         Services.prefs.setIntPref(pref, Services.prefs.getIntPref(pref, 0) + 1);
880       }
881       if (SET_USAGE_PREF_BUTTONS.includes(item)) {
882         Services.prefs.setBoolPref(`browser.engagement.${item}.has-used`, true);
883       }
884     }
886     if (ENTRYPOINT_TRACKED_CONTEXT_MENU_IDS[source]) {
887       let contextMenu = ENTRYPOINT_TRACKED_CONTEXT_MENU_IDS[source];
888       let triggerContainer = this._getWidgetContainer(
889         node.closest("menupopup")?.triggerNode
890       );
891       if (triggerContainer) {
892         let scalar = `browser.ui.interaction.${contextMenu.replace(/-/g, "_")}`;
893         Services.telemetry.keyedScalarAdd(
894           scalar,
895           telemetryId(triggerContainer),
896           1
897         );
898       }
899     }
900   },
902   /**
903    * Listens for UI interactions in the window.
904    */
905   _addUsageListeners(win) {
906     // Listen for command events from the UI.
907     win.addEventListener("command", event => this._recordCommand(event), true);
908     win.addEventListener("click", event => this._recordCommand(event), true);
909   },
911   /**
912    * A public version of the private method to take care of the `nav-bar-start`,
913    * `nav-bar-end` thing that callers shouldn't have to care about. It also
914    * accepts the DOM ids for the areas rather than the cleaner ones we report
915    * to telemetry.
916    */
917   recordWidgetChange(widgetId, newPos, reason) {
918     try {
919       if (newPos) {
920         newPos = BROWSER_UI_CONTAINER_IDS[newPos];
921       }
923       if (newPos == "nav-bar") {
924         let { position } = lazy.CustomizableUI.getPlacementOfWidget(widgetId);
925         let { position: urlPosition } =
926           lazy.CustomizableUI.getPlacementOfWidget("urlbar-container");
927         newPos = newPos + (urlPosition > position ? "-start" : "-end");
928       }
930       this._recordWidgetChange(widgetId, newPos, reason);
931     } catch (e) {
932       console.error(e);
933     }
934   },
936   recordToolbarVisibility(toolbarId, newState, reason) {
937     if (typeof newState != "string") {
938       newState = newState ? "on" : "off";
939     }
940     this._recordWidgetChange(
941       BROWSER_UI_CONTAINER_IDS[toolbarId],
942       newState,
943       reason
944     );
945   },
947   _recordWidgetChange(widgetId, newPos, reason) {
948     // In some cases (like when add-ons are detected during startup) this gets
949     // called before we've reported the initial positions. Ignore such cases.
950     if (!this.widgetMap) {
951       return;
952     }
954     if (widgetId == "urlbar-container") {
955       // We don't report the position of the url bar, it is after nav-bar-start
956       // and before nav-bar-end. But moving it means the widgets around it have
957       // effectively moved so update those.
958       let position = "nav-bar-start";
959       let widgets = lazy.CustomizableUI.getWidgetsInArea("nav-bar");
961       for (let widget of widgets) {
962         if (!widget) {
963           continue;
964         }
966         if (widget.id.startsWith("customizableui-special-")) {
967           continue;
968         }
970         if (widget.id == "urlbar-container") {
971           position = "nav-bar-end";
972           continue;
973         }
975         // This will do nothing if the position hasn't changed.
976         this._recordWidgetChange(widget.id, position, reason);
977       }
979       return;
980     }
982     let oldPos = this.widgetMap.get(widgetId);
983     if (oldPos == newPos) {
984       return;
985     }
987     let action = "move";
989     if (!oldPos) {
990       action = "add";
991     } else if (!newPos) {
992       action = "remove";
993     }
995     let key = `${telemetryId(widgetId, false)}_${action}_${oldPos ?? "na"}_${
996       newPos ?? "na"
997     }_${reason}`;
998     Services.telemetry.keyedScalarAdd("browser.ui.customized_widgets", key, 1);
1000     if (newPos) {
1001       this.widgetMap.set(widgetId, newPos);
1002     } else {
1003       this.widgetMap.delete(widgetId);
1004     }
1005   },
1007   _recordUITelemetry() {
1008     this.widgetMap = this._buildWidgetPositions();
1010     for (let [widgetId, position] of this.widgetMap.entries()) {
1011       let key = `${telemetryId(widgetId, false)}_pinned_${position}`;
1012       Services.telemetry.keyedScalarSet(
1013         "browser.ui.toolbar_widgets",
1014         key,
1015         true
1016       );
1017     }
1018   },
1020   /**
1021    * Adds listeners to a single chrome window.
1022    */
1023   _registerWindow(win) {
1024     this._addUsageListeners(win);
1026     win.addEventListener("unload", this);
1027     win.addEventListener("TabOpen", this, true);
1028     win.addEventListener("TabPinned", this, true);
1030     win.gBrowser.tabContainer.addEventListener(TAB_RESTORING_TOPIC, this);
1031     win.gBrowser.addTabsProgressListener(URICountListener);
1032   },
1034   /**
1035    * Removes listeners from a single chrome window.
1036    */
1037   _unregisterWindow(win) {
1038     win.removeEventListener("unload", this);
1039     win.removeEventListener("TabOpen", this, true);
1040     win.removeEventListener("TabPinned", this, true);
1042     win.defaultView.gBrowser.tabContainer.removeEventListener(
1043       TAB_RESTORING_TOPIC,
1044       this
1045     );
1046     win.defaultView.gBrowser.removeTabsProgressListener(URICountListener);
1047   },
1049   /**
1050    * Updates the tab counts.
1051    */
1052   _onTabOpen() {
1053     // Update the "tab opened" count and its maximum.
1054     Services.telemetry.scalarAdd(TAB_OPEN_EVENT_COUNT_SCALAR_NAME, 1);
1056     // In the case of opening multiple tabs at once, avoid enumerating all open
1057     // tabs and windows each time a tab opens.
1058     this._onTabsOpenedTask.disarm();
1059     this._onTabsOpenedTask.arm();
1060   },
1062   /**
1063    * Update tab counts after opening multiple tabs.
1064    */
1065   _onTabsOpened() {
1066     const { tabCount, loadedTabCount } = getOpenTabsAndWinsCounts();
1067     Services.telemetry.scalarSetMaximum(MAX_TAB_COUNT_SCALAR_NAME, tabCount);
1069     this._recordTabCounts({ tabCount, loadedTabCount });
1070   },
1072   _onTabPinned() {
1073     const pinnedTabs = getPinnedTabsCount();
1075     // Update the "tab pinned" count and its maximum.
1076     Services.telemetry.scalarAdd(TAB_PINNED_EVENT_COUNT_SCALAR_NAME, 1);
1077     Services.telemetry.scalarSetMaximum(
1078       MAX_TAB_PINNED_COUNT_SCALAR_NAME,
1079       pinnedTabs
1080     );
1081   },
1083   /**
1084    * Tracks the window count and registers the listeners for the tab count.
1085    * @param{Object} win The window object.
1086    */
1087   _onWindowOpen(win) {
1088     // Make sure to have a |nsIDOMWindow|.
1089     if (!(win instanceof Ci.nsIDOMWindow)) {
1090       return;
1091     }
1093     let onLoad = () => {
1094       win.removeEventListener("load", onLoad);
1096       // Ignore non browser windows.
1097       if (
1098         win.document.documentElement.getAttribute("windowtype") !=
1099         "navigator:browser"
1100       ) {
1101         return;
1102       }
1104       this._registerWindow(win);
1105       // Track the window open event and check the maximum.
1106       const counts = getOpenTabsAndWinsCounts();
1107       Services.telemetry.scalarAdd(WINDOW_OPEN_EVENT_COUNT_SCALAR_NAME, 1);
1108       Services.telemetry.scalarSetMaximum(
1109         MAX_WINDOW_COUNT_SCALAR_NAME,
1110         counts.winCount
1111       );
1113       // We won't receive the "TabOpen" event for the first tab within a new window.
1114       // Account for that.
1115       this._onTabOpen(counts);
1116     };
1117     win.addEventListener("load", onLoad);
1118   },
1120   /**
1121    * Record telemetry about the given tab counts.
1122    *
1123    * Telemetry for each count will only be recorded if the value isn't
1124    * `undefined`.
1125    *
1126    * @param {object} [counts] The tab counts to register with telemetry.
1127    * @param {number} [counts.tabCount] The number of tabs in all browsers.
1128    * @param {number} [counts.loadedTabCount] The number of loaded (i.e., not
1129    *                                         pending) tabs in all browsers.
1130    */
1131   _recordTabCounts({ tabCount, loadedTabCount }) {
1132     let currentTime = Date.now();
1133     if (
1134       tabCount !== undefined &&
1135       currentTime > this._lastRecordTabCount + MINIMUM_TAB_COUNT_INTERVAL_MS
1136     ) {
1137       Services.telemetry.getHistogramById("TAB_COUNT").add(tabCount);
1138       this._lastRecordTabCount = currentTime;
1139     }
1141     if (
1142       loadedTabCount !== undefined &&
1143       currentTime >
1144         this._lastRecordLoadedTabCount + MINIMUM_TAB_COUNT_INTERVAL_MS
1145     ) {
1146       Services.telemetry
1147         .getHistogramById("LOADED_TAB_COUNT")
1148         .add(loadedTabCount);
1149       this._lastRecordLoadedTabCount = currentTime;
1150     }
1151   },
1153   _checkProfileCountFileSchema(fileData) {
1154     // Verifies that the schema of the file is the expected schema
1155     if (typeof fileData.version != "string") {
1156       throw new Error("Schema Mismatch Error: Bad type for 'version' field");
1157     }
1158     if (!Array.isArray(fileData.profileTelemetryIds)) {
1159       throw new Error(
1160         "Schema Mismatch Error: Bad type for 'profileTelemetryIds' field"
1161       );
1162     }
1163     for (let profileTelemetryId of fileData.profileTelemetryIds) {
1164       if (typeof profileTelemetryId != "string") {
1165         throw new Error(
1166           "Schema Mismatch Error: Bad type for an element of 'profileTelemetryIds'"
1167         );
1168       }
1169     }
1170   },
1172   // Reports the number of Firefox profiles on this machine to telemetry.
1173   async reportProfileCount() {
1174     if (
1175       AppConstants.platform != "win" ||
1176       !AppConstants.MOZ_TELEMETRY_REPORTING
1177     ) {
1178       // This is currently a windows-only feature.
1179       // Also, this function writes directly to disk, without using the usual
1180       // telemetry recording functions. So we excplicitly check if telemetry
1181       // reporting was disabled at compile time, and we do not do anything in
1182       // case.
1183       return;
1184     }
1186     // To report only as much data as we need, we will bucket our values.
1187     // Rather than the raw value, we will report the greatest value in the list
1188     // below that is no larger than the raw value.
1189     const buckets = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 100, 1000, 10000];
1191     // We need both the C:\ProgramData\Mozilla directory and the install
1192     // directory hash to create the profile count file path. We can easily
1193     // reassemble this from the update directory, which looks like:
1194     // C:\ProgramData\Mozilla\updates\hash
1195     // Retrieving the directory this way also ensures that the "Mozilla"
1196     // directory is created with the correct permissions.
1197     // The ProgramData directory, by default, grants write permissions only to
1198     // file creators. The directory service calls GetCommonUpdateDirectory,
1199     // which makes sure the the directory is created with user-writable
1200     // permissions.
1201     const updateDirectory = BrowserUsageTelemetry.Policy.getUpdateDirectory();
1202     const hash = updateDirectory.leafName;
1203     const profileCountFilename = "profile_count_" + hash + ".json";
1204     let profileCountFile = updateDirectory.parent.parent;
1205     profileCountFile.append(profileCountFilename);
1207     let readError = false;
1208     let fileData;
1209     try {
1210       let json = await BrowserUsageTelemetry.Policy.readProfileCountFile(
1211         profileCountFile.path
1212       );
1213       fileData = JSON.parse(json);
1214       BrowserUsageTelemetry._checkProfileCountFileSchema(fileData);
1215     } catch (ex) {
1216       // Note that since this also catches the "no such file" error, this is
1217       // always the template that we use when writing to the file for the first
1218       // time.
1219       fileData = { version: "1", profileTelemetryIds: [] };
1220       if (!(ex.name == "NotFoundError")) {
1221         console.error(ex);
1222         // Don't just return here on a read error. We need to send the error
1223         // value to telemetry and we want to attempt to fix the file.
1224         // However, we will still report an error for this ping, even if we
1225         // fix the file. This is to prevent always sending a profile count of 1
1226         // if, for some reason, we always get a read error but never a write
1227         // error.
1228         readError = true;
1229       }
1230     }
1232     let writeError = false;
1233     let currentTelemetryId =
1234       await BrowserUsageTelemetry.Policy.getTelemetryClientId();
1235     // Don't add our telemetry ID to the file if we've already reached the
1236     // largest bucket. This prevents the file size from growing forever.
1237     if (
1238       !fileData.profileTelemetryIds.includes(currentTelemetryId) &&
1239       fileData.profileTelemetryIds.length < Math.max(...buckets)
1240     ) {
1241       fileData.profileTelemetryIds.push(currentTelemetryId);
1242       try {
1243         await BrowserUsageTelemetry.Policy.writeProfileCountFile(
1244           profileCountFile.path,
1245           JSON.stringify(fileData)
1246         );
1247       } catch (ex) {
1248         console.error(ex);
1249         writeError = true;
1250       }
1251     }
1253     // Determine the bucketed value to report
1254     let rawProfileCount = fileData.profileTelemetryIds.length;
1255     let valueToReport = 0;
1256     for (let bucket of buckets) {
1257       if (bucket <= rawProfileCount && bucket > valueToReport) {
1258         valueToReport = bucket;
1259       }
1260     }
1262     if (readError || writeError) {
1263       // We convey errors via a profile count of 0.
1264       valueToReport = 0;
1265     }
1267     Services.telemetry.scalarSet(
1268       "browser.engagement.profile_count",
1269       valueToReport
1270     );
1271     // Manually mirror to Glean
1272     Glean.browserEngagement.profileCount.set(valueToReport);
1273   },
1275   /**
1276    * Check if this is the first run of this profile since installation,
1277    * if so then send installation telemetry.
1278    *
1279    * @param {nsIFile} [dataPathOverride] Optional, full data file path, for tests.
1280    * @param {Array<string>} [msixPackagePrefixes] Optional, list of prefixes to
1281             consider "existing" installs when looking at installed MSIX packages.
1282             Defaults to prefixes for builds produced in Firefox automation.
1283    * @return {Promise}
1284    * @resolves When the event has been recorded, or if the data file was not found.
1285    * @rejects JavaScript exception on any failure.
1286    */
1287   async reportInstallationTelemetry(
1288     dataPathOverride,
1289     msixPackagePrefixes = ["Mozilla.Firefox", "Mozilla.MozillaFirefox"]
1290   ) {
1291     if (AppConstants.platform != "win") {
1292       // This is a windows-only feature.
1293       return;
1294     }
1296     const TIMESTAMP_PREF = "app.installation.timestamp";
1297     const lastInstallTime = Services.prefs.getStringPref(TIMESTAMP_PREF, null);
1298     const wpm = Cc["@mozilla.org/windows-package-manager;1"].createInstance(
1299       Ci.nsIWindowsPackageManager
1300     );
1301     let installer_type = "";
1302     let pfn;
1303     try {
1304       pfn = Services.sysinfo.getProperty("winPackageFamilyName");
1305     } catch (e) {}
1307     function getInstallData() {
1308       // We only care about where _any_ other install existed - no
1309       // need to count more than 1.
1310       const installPaths = lazy.WindowsInstallsInfo.getInstallPaths(
1311         1,
1312         new Set([Services.dirsvc.get("GreBinD", Ci.nsIFile).path])
1313       );
1314       const msixInstalls = new Set();
1315       // We're just going to eat all errors here -- we don't want the event
1316       // to go unsent if we were unable to look for MSIX installs.
1317       try {
1318         wpm
1319           .findUserInstalledPackages(msixPackagePrefixes)
1320           .forEach(i => msixInstalls.add(i));
1321         if (pfn) {
1322           msixInstalls.delete(pfn);
1323         }
1324       } catch (ex) {}
1325       return {
1326         installPaths,
1327         msixInstalls,
1328       };
1329     }
1331     let extra = {};
1333     if (pfn) {
1334       if (lastInstallTime != null) {
1335         // We've already seen this install
1336         return;
1337       }
1339       // First time seeing this install, record the timestamp.
1340       Services.prefs.setStringPref(TIMESTAMP_PREF, wpm.getInstalledDate());
1341       let install_data = getInstallData();
1343       installer_type = "msix";
1345       // Build the extra event data
1346       extra.version = AppConstants.MOZ_APP_VERSION;
1347       extra.build_id = AppConstants.MOZ_BUILDID;
1348       // The next few keys are static for the reasons described
1349       // No way to detect whether or not we were installed by an admin
1350       extra.admin_user = "false";
1351       // Always false at the moment, because we create a new profile
1352       // on first launch
1353       extra.profdir_existed = "false";
1354       // Obviously false for MSIX installs
1355       extra.from_msi = "false";
1356       // We have no way of knowing whether we were installed via the GUI,
1357       // through the command line, or some Enterprise management tool.
1358       extra.silent = "false";
1359       // There's no way to change the install path for an MSIX package
1360       extra.default_path = "true";
1361       extra.install_existed = install_data.msixInstalls.has(pfn).toString();
1362       install_data.msixInstalls.delete(pfn);
1363       extra.other_inst = (!!install_data.installPaths.size).toString();
1364       extra.other_msix_inst = (!!install_data.msixInstalls.size).toString();
1365     } else {
1366       let dataPath = dataPathOverride;
1367       if (!dataPath) {
1368         dataPath = Services.dirsvc.get("GreD", Ci.nsIFile);
1369         dataPath.append("installation_telemetry.json");
1370       }
1372       let dataBytes;
1373       try {
1374         dataBytes = await IOUtils.read(dataPath.path);
1375       } catch (ex) {
1376         if (ex.name == "NotFoundError") {
1377           // Many systems will not have the data file, return silently if not found as
1378           // there is nothing to record.
1379           return;
1380         }
1381         throw ex;
1382       }
1383       const dataString = new TextDecoder("utf-16").decode(dataBytes);
1384       const data = JSON.parse(dataString);
1386       if (lastInstallTime && data.install_timestamp == lastInstallTime) {
1387         // We've already seen this install
1388         return;
1389       }
1391       // First time seeing this install, record the timestamp.
1392       Services.prefs.setStringPref(TIMESTAMP_PREF, data.install_timestamp);
1393       let install_data = getInstallData();
1395       installer_type = data.installer_type;
1397       // Installation timestamp is not intended to be sent with telemetry,
1398       // remove it to emphasize this point.
1399       delete data.install_timestamp;
1401       // Build the extra event data
1402       extra.version = data.version;
1403       extra.build_id = data.build_id;
1404       extra.admin_user = data.admin_user.toString();
1405       extra.install_existed = data.install_existed.toString();
1406       extra.profdir_existed = data.profdir_existed.toString();
1407       extra.other_inst = (!!install_data.installPaths.size).toString();
1408       extra.other_msix_inst = (!!install_data.msixInstalls.size).toString();
1410       if (data.installer_type == "full") {
1411         extra.silent = data.silent.toString();
1412         extra.from_msi = data.from_msi.toString();
1413         extra.default_path = data.default_path.toString();
1414       }
1415     }
1416     // Record the event
1417     Services.telemetry.setEventRecordingEnabled("installation", true);
1418     Services.telemetry.recordEvent(
1419       "installation",
1420       "first_seen",
1421       installer_type,
1422       null,
1423       extra
1424     );
1425   },
1428 // Used by nsIBrowserUsage
1429 export function getUniqueDomainsVisitedInPast24Hours() {
1430   return URICountListener.uniqueDomainsVisitedInPast24Hours;