Bug 1938424 - metrics event when tabs added to tab group r=dao,jswinarton,tabbrowser-reviewers,extension-reviewers,robwu

When using the tab context menu or drag-dropping tabs to put them into a group, record a metric for the number of tabs added to the group.

Data Science asked us to launch with this metric disabled so that they could control it using server knobs. They expect a high volume of events, so they expect to only enable this metric for some proportion of users.

I converted the existing `TabMove` event derived from `UIEvent` being fired when tabs change their tab index in the tab strip. `UIEvent` doesn't allow for attaching additional context/detail to the event. `TabMove` is now a `CustomEvent` that provides more context about the moved tab and it fires in more cases -- it's possible for the tab index not to change despite the tab having "moved" into/out of a tab group.

This approach would not capture tab movements that occur across multiple frames/event loop iterations.

Differential Revision: https://phabricator.services.mozilla.com/D244616
This commit is contained in:
Stephen Thompson
2025-04-11 13:52:22 +00:00
parent cd6729c28d
commit f2e07044ae
17 changed files with 488 additions and 158 deletions

View File

@@ -10,8 +10,7 @@ document.addEventListener(
() => { () => {
const lazy = {}; const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, { ChromeUtils.defineESModuleGetters(lazy, {
TabGroupMetrics: TabMetrics: "moz-src:///browser/components/tabbrowser/TabMetrics.sys.mjs",
"moz-src:///browser/components/tabbrowser/TabGroupMetrics.sys.mjs",
}); });
let mainPopupSet = document.getElementById("mainPopupSet"); let mainPopupSet = document.getElementById("mainPopupSet");
// eslint-disable-next-line complexity // eslint-disable-next-line complexity
@@ -135,11 +134,12 @@ document.addEventListener(
let tabGroup = gBrowser.getTabGroupById(tabGroupId); let tabGroup = gBrowser.getTabGroupById(tabGroupId);
// Tabs need to be removed by their owning `Tabbrowser` or else // Tabs need to be removed by their owning `Tabbrowser` or else
// there are errors. // there are errors.
tabGroup.ownerGlobal.gBrowser.removeTabGroup(tabGroup, { tabGroup.ownerGlobal.gBrowser.removeTabGroup(
isUserTriggered: true, tabGroup,
telemetrySource: lazy.TabMetrics.userTriggeredContext(
lazy.TabGroupMetrics.METRIC_SOURCE.TAB_OVERFLOW_MENU, lazy.TabMetrics.METRIC_SOURCE.TAB_OVERFLOW_MENU
}); )
);
} }
break; break;
@@ -148,7 +148,7 @@ document.addEventListener(
{ {
let { tabGroupId } = event.target.parentElement.triggerNode.dataset; let { tabGroupId } = event.target.parentElement.triggerNode.dataset;
SessionStore.openSavedTabGroup(tabGroupId, window, { SessionStore.openSavedTabGroup(tabGroupId, window, {
source: lazy.TabGroupMetrics.METRIC_SOURCE.RECENT_TABS, source: lazy.TabMetrics.METRIC_SOURCE.RECENT_TABS,
}); });
} }
break; break;
@@ -157,7 +157,7 @@ document.addEventListener(
// TODO Bug 1940112: "Open Group in New Window" should directly restore saved tab groups into a new window // TODO Bug 1940112: "Open Group in New Window" should directly restore saved tab groups into a new window
let { tabGroupId } = event.target.parentElement.triggerNode.dataset; let { tabGroupId } = event.target.parentElement.triggerNode.dataset;
let tabGroup = SessionStore.openSavedTabGroup(tabGroupId, window, { let tabGroup = SessionStore.openSavedTabGroup(tabGroupId, window, {
source: lazy.TabGroupMetrics.METRIC_SOURCE.RECENT_TABS, source: lazy.TabMetrics.METRIC_SOURCE.RECENT_TABS,
}); });
gBrowser.replaceGroupWithWindow(tabGroup); gBrowser.replaceGroupWithWindow(tabGroup);
} }

View File

@@ -260,13 +260,17 @@ this.tabs = class extends ExtensionAPIPersistent {
}), }),
onMoved({ fire }) { onMoved({ fire }) {
let { tabManager } = this.extension; let { tabManager } = this.extension;
/**
* @param {CustomEvent} event
*/
let moveListener = event => { let moveListener = event => {
let nativeTab = event.originalTarget; let nativeTab = event.originalTarget;
let { previousTabState, currentTabState } = event.detail;
if (tabManager.canAccessTab(nativeTab)) { if (tabManager.canAccessTab(nativeTab)) {
fire.async(tabTracker.getId(nativeTab), { fire.async(tabTracker.getId(nativeTab), {
windowId: windowTracker.getId(nativeTab.ownerGlobal), windowId: windowTracker.getId(nativeTab.ownerGlobal),
fromIndex: event.detail, fromIndex: previousTabState.tabIndex,
toIndex: nativeTab._tPos, toIndex: currentTabState.tabIndex,
}); });
} }
}; };

View File

@@ -158,7 +158,7 @@ const kLastIndex = Number.MAX_SAFE_INTEGER - 1;
import { PrivateBrowsingUtils } from "resource://gre/modules/PrivateBrowsingUtils.sys.mjs"; import { PrivateBrowsingUtils } from "resource://gre/modules/PrivateBrowsingUtils.sys.mjs";
import { TabGroupMetrics } from "moz-src:///browser/components/tabbrowser/TabGroupMetrics.sys.mjs"; import { TabMetrics } from "moz-src:///browser/components/tabbrowser/TabMetrics.sys.mjs";
import { TelemetryTimestamps } from "resource://gre/modules/TelemetryTimestamps.sys.mjs"; import { TelemetryTimestamps } from "resource://gre/modules/TelemetryTimestamps.sys.mjs";
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
@@ -899,16 +899,16 @@ export var SessionStore = {
openSavedTabGroup( openSavedTabGroup(
tabGroupId, tabGroupId,
targetWindow, targetWindow,
{ source = TabGroupMetrics.METRIC_SOURCE.UNKNOWN } = {} { source = TabMetrics.METRIC_SOURCE.UNKNOWN } = {}
) { ) {
let isVerticalMode = targetWindow.gBrowser.tabContainer.verticalMode; let isVerticalMode = targetWindow.gBrowser.tabContainer.verticalMode;
Glean.tabgroup.reopen.record({ Glean.tabgroup.reopen.record({
id: tabGroupId, id: tabGroupId,
source, source,
layout: isVerticalMode layout: isVerticalMode
? TabGroupMetrics.METRIC_TABS_LAYOUT.VERTICAL ? TabMetrics.METRIC_TABS_LAYOUT.VERTICAL
: TabGroupMetrics.METRIC_TABS_LAYOUT.HORIZONTAL, : TabMetrics.METRIC_TABS_LAYOUT.HORIZONTAL,
type: TabGroupMetrics.METRIC_REOPEN_TYPE.SAVED, type: TabMetrics.METRIC_REOPEN_TYPE.SAVED,
}); });
return SessionStoreInternal.openSavedTabGroup(tabGroupId, targetWindow); return SessionStoreInternal.openSavedTabGroup(tabGroupId, targetWindow);

View File

@@ -3,7 +3,7 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { PrivateBrowsingUtils } from "resource://gre/modules/PrivateBrowsingUtils.sys.mjs"; import { PrivateBrowsingUtils } from "resource://gre/modules/PrivateBrowsingUtils.sys.mjs";
import { TabGroupMetrics } from "moz-src:///browser/components/tabbrowser/TabGroupMetrics.sys.mjs"; import { TabMetrics } from "moz-src:///browser/components/tabbrowser/TabMetrics.sys.mjs";
const MAX_INITIAL_ITEMS = 5; const MAX_INITIAL_ITEMS = 5;
@@ -79,7 +79,7 @@ export class GroupsPanel {
case "allTabsGroupView_restoreGroup": case "allTabsGroupView_restoreGroup":
this.win.SessionStore.openSavedTabGroup(tabGroupId, this.win, { this.win.SessionStore.openSavedTabGroup(tabGroupId, this.win, {
source: TabGroupMetrics.METRIC_SOURCE.TAB_OVERFLOW_MENU, source: TabMetrics.METRIC_SOURCE.TAB_OVERFLOW_MENU,
}); });
break; break;
} }

View File

@@ -1,32 +0,0 @@
/* 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/. */
/**
* A common list of systems, surfaces, controls, etc. from which user
* interactions with tab groups could originate. These "source" values
* should be sent as extra data with tab group-related metrics events.
*/
const METRIC_SOURCE = Object.freeze({
TAB_OVERFLOW_MENU: "tab_overflow",
TAB_GROUP_MENU: "tab_group",
SUGGEST: "suggest",
RECENT_TABS: "recent",
UNKNOWN: "unknown",
});
const METRIC_TABS_LAYOUT = Object.freeze({
HORIZONTAL: "horizontal",
VERTICAL: "vertical",
});
const METRIC_REOPEN_TYPE = Object.freeze({
SAVED: "saved",
DELETED: "deleted",
});
export const TabGroupMetrics = {
METRIC_SOURCE,
METRIC_TABS_LAYOUT,
METRIC_REOPEN_TYPE,
};

View File

@@ -0,0 +1,62 @@
/* 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/. */
/**
* A common list of systems, surfaces, controls, etc. from which user
* interactions with tabs could originate. These "source" values
* should be sent as extra data with tab-related metrics events.
*/
const METRIC_SOURCE = Object.freeze({
TAB_OVERFLOW_MENU: "tab_overflow",
TAB_GROUP_MENU: "tab_group",
TAB_MENU: "tab_menu",
DRAG_AND_DROP: "drag",
SUGGEST: "suggest",
RECENT_TABS: "recent",
UNKNOWN: "unknown",
});
const METRIC_TABS_LAYOUT = Object.freeze({
HORIZONTAL: "horizontal",
VERTICAL: "vertical",
});
const METRIC_REOPEN_TYPE = Object.freeze({
SAVED: "saved",
DELETED: "deleted",
});
/**
* @typedef {object} TabMetricsContext
* @property {boolean} [isUserTriggered=false]
* Should be true if there was an explicit action/request from the user
* (as opposed to some action being taken internally or for technical
* bookkeeping reasons alone). This causes telemetry events to fire.
* @property {string} [telemetrySource="unknown"]
* The system, surface, or control the user used to take this action.
* @see TabMetrics.METRIC_SOURCE for possible values.
* Defaults to "unknown".
*/
/**
* Creates a `TabMetricsContext` object for a user event originating from
* the specified source.
*
* @param {string} telemetrySource
* @see TabMetrics.METRIC_SOURCE
* @returns {TabMetricsContext}
*/
function userTriggeredContext(telemetrySource) {
return {
isUserTriggered: true,
telemetrySource,
};
}
export const TabMetrics = {
METRIC_SOURCE,
METRIC_TABS_LAYOUT,
METRIC_REOPEN_TYPE,
userTriggeredContext,
};

View File

@@ -235,6 +235,9 @@ class TabsListBase {
} }
} }
/**
* @param {MozTabbrowserTab} tab
*/
_moveTab(tab) { _moveTab(tab) {
let item = this.tabToElement.get(tab); let item = this.tabToElement.get(tab);
if (item) { if (item) {

View File

@@ -110,8 +110,8 @@
PictureInPicture: "resource://gre/modules/PictureInPicture.sys.mjs", PictureInPicture: "resource://gre/modules/PictureInPicture.sys.mjs",
SmartTabGroupingManager: SmartTabGroupingManager:
"moz-src:///browser/components/tabbrowser/SmartTabGrouping.sys.mjs", "moz-src:///browser/components/tabbrowser/SmartTabGrouping.sys.mjs",
TabGroupMetrics: TabMetrics:
"moz-src:///browser/components/tabbrowser/TabGroupMetrics.sys.mjs", "moz-src:///browser/components/tabbrowser/TabMetrics.sys.mjs",
TabStateFlusher: TabStateFlusher:
"resource:///modules/sessionstore/TabStateFlusher.sys.mjs", "resource:///modules/sessionstore/TabStateFlusher.sys.mjs",
UrlbarProviderOpenTabs: UrlbarProviderOpenTabs:
@@ -2958,7 +2958,7 @@
* Causes the group create UI to be displayed and telemetry events to be fired. * Causes the group create UI to be displayed and telemetry events to be fired.
* @param {string} [options.telemetryUserCreateSource] * @param {string} [options.telemetryUserCreateSource]
* The means by which the tab group was created. * The means by which the tab group was created.
* @see TabGroupMetrics.METRIC_SOURCE for possible values. * @see TabMetrics.METRIC_SOURCE for possible values.
* Defaults to "unknown". * Defaults to "unknown".
*/ */
addTabGroup( addTabGroup(
@@ -3035,14 +3035,14 @@
* switches windows). This causes telemetry events to fire. * switches windows). This causes telemetry events to fire.
* @param {string} [options.telemetrySource="unknown"] * @param {string} [options.telemetrySource="unknown"]
* The means by which the tab group was removed. * The means by which the tab group was removed.
* @see TabGroupMetrics.METRIC_SOURCE for possible values. * @see TabMetrics.METRIC_SOURCE for possible values.
* Defaults to "unknown". * Defaults to "unknown".
*/ */
async removeTabGroup( async removeTabGroup(
group, group,
options = { options = {
isUserTriggered: false, isUserTriggered: false,
telemetrySource: this.TabGroupMetrics.METRIC_SOURCE.UNKNOWN, telemetrySource: this.TabMetrics.METRIC_SOURCE.UNKNOWN,
} }
) { ) {
if (this.tabGroupMenu.panel.state != "closed") { if (this.tabGroupMenu.panel.state != "closed") {
@@ -3943,7 +3943,7 @@
// Place tab at the end of the contextual tab group because one of: // Place tab at the end of the contextual tab group because one of:
// 1) no `itemAfter` so `tab` should be the last tab in the tab strip // 1) no `itemAfter` so `tab` should be the last tab in the tab strip
// 2) `itemAfter` is in a different tab group // 2) `itemAfter` is in a different tab group
this.moveTabToGroup(tab, tabGroup); tabGroup.appendChild(tab);
} }
} else if ( } else if (
(this.isTab(itemAfter) && itemAfter.group?.tabs[0] == itemAfter) || (this.isTab(itemAfter) && itemAfter.group?.tabs[0] == itemAfter) ||
@@ -5925,8 +5925,26 @@
* any possibility of entering a tab group. For example, setting `true` * any possibility of entering a tab group. For example, setting `true`
* ensures that a pinned tab will not accidentally be placed inside of * ensures that a pinned tab will not accidentally be placed inside of
* a tab group, since pinned tabs are presently not allowed in tab groups. * a tab group, since pinned tabs are presently not allowed in tab groups.
* @property {boolean} [options.isUserTriggered=false]
* Should be true if there was an explicit action/request from the user
* (as opposed to some action being taken internally or for technical
* bookkeeping reasons alone) to move the tab. This causes telemetry
* events to fire.
* @property {string} [options.telemetrySource="unknown"]
* The system, surface, or control the user used to move the tab.
* @see TabMetrics.METRIC_SOURCE for possible values.
* Defaults to "unknown".
*/ */
moveTabTo(aTab, { elementIndex, tabIndex, forceUngrouped = false } = {}) { moveTabTo(
aTab,
{
elementIndex,
tabIndex,
forceUngrouped = false,
isUserTriggered = false,
telemetrySource = this.TabMetrics.METRIC_SOURCE.UNKNOWN,
} = {}
) {
if (typeof elementIndex == "number") { if (typeof elementIndex == "number") {
tabIndex = this.#elementIndexToTabIndex(elementIndex); tabIndex = this.#elementIndexToTabIndex(elementIndex);
} }
@@ -5954,49 +5972,57 @@
aTab = aTab.group; aTab = aTab.group;
} }
this.#handleTabMove(aTab, () => { this.#handleTabMove(
let neighbor = this.tabs[tabIndex]; aTab,
if (forceUngrouped && neighbor.group) { () => {
neighbor = neighbor.group; let neighbor = this.tabs[tabIndex];
} if (forceUngrouped && neighbor.group) {
if (neighbor && tabIndex > aTab._tPos) { neighbor = neighbor.group;
neighbor.after(aTab); }
} else { if (neighbor && tabIndex > aTab._tPos) {
this.tabContainer.insertBefore(aTab, neighbor); neighbor.after(aTab);
} } else {
}); this.tabContainer.insertBefore(aTab, neighbor);
}
},
{ isUserTriggered, telemetrySource }
);
} }
/** /**
* @param {MozTabbrowserTab|MozTabbrowserTabGroup} tab * @param {MozTabbrowserTab|MozTabbrowserTabGroup} tab
* @param {MozTabbrowserTab|MozTabbrowserTabGroup} targetElement * @param {MozTabbrowserTab|MozTabbrowserTabGroup} targetElement
* @param {TabMetricsContext} [metricsContext]
*/ */
moveTabBefore(tab, targetElement) { moveTabBefore(tab, targetElement, metricsContext) {
this.#moveTabNextTo(tab, targetElement, true); this.#moveTabNextTo(tab, targetElement, true, metricsContext);
} }
/** /**
* @param {MozTabbrowserTab|MozTabbrowserTabGroup[]} tabs * @param {MozTabbrowserTab|MozTabbrowserTabGroup[]} tabs
* @param {MozTabbrowserTab|MozTabbrowserTabGroup} targetElement * @param {MozTabbrowserTab|MozTabbrowserTabGroup} targetElement
* @param {TabMetricsContext} [metricsContext]
*/ */
moveTabsBefore(tabs, targetElement) { moveTabsBefore(tabs, targetElement, metricsContext) {
this.#moveTabsNextTo(tabs, targetElement, true); this.#moveTabsNextTo(tabs, targetElement, true, metricsContext);
} }
/** /**
* @param {MozTabbrowserTab|MozTabbrowserTabGroup} tab * @param {MozTabbrowserTab|MozTabbrowserTabGroup} tab
* @param {MozTabbrowserTab|MozTabbrowserTabGroup} targetElement * @param {MozTabbrowserTab|MozTabbrowserTabGroup} targetElement
* @param {TabMetricsContext} [metricsContext]
*/ */
moveTabAfter(tab, targetElement) { moveTabAfter(tab, targetElement, metricsContext) {
this.#moveTabNextTo(tab, targetElement, false); this.#moveTabNextTo(tab, targetElement, false, metricsContext);
} }
/** /**
* @param {MozTabbrowserTab|MozTabbrowserTabGroup[]} tabs * @param {MozTabbrowserTab|MozTabbrowserTabGroup[]} tabs
* @param {MozTabbrowserTab|MozTabbrowserTabGroup} targetElement * @param {MozTabbrowserTab|MozTabbrowserTabGroup} targetElement
* @param {TabMetricsContext} [metricsContext]
*/ */
moveTabsAfter(tabs, targetElement) { moveTabsAfter(tabs, targetElement, metricsContext) {
this.#moveTabsNextTo(tabs, targetElement, false); this.#moveTabsNextTo(tabs, targetElement, false, metricsContext);
} }
/** /**
@@ -6004,9 +6030,10 @@
* The tab or tab group to move. Also accepts a tab group label as a * The tab or tab group to move. Also accepts a tab group label as a
* stand-in for its group. * stand-in for its group.
* @param {MozTabbrowserTab|MozTabbrowserTabGroup} targetElement * @param {MozTabbrowserTab|MozTabbrowserTabGroup} targetElement
* @param {boolean} moveBefore * @param {boolean} [moveBefore=false]
* @param {TabMetricsContext} [metricsContext]
*/ */
#moveTabNextTo(tab, targetElement, moveBefore = false) { #moveTabNextTo(tab, targetElement, moveBefore = false, metricsContext) {
if (this.isTabGroupLabel(targetElement)) { if (this.isTabGroupLabel(targetElement)) {
targetElement = targetElement.group; targetElement = targetElement.group;
if (!moveBefore) { if (!moveBefore) {
@@ -6037,30 +6064,41 @@
return this.tabContainer; return this.tabContainer;
}; };
this.#handleTabMove(tab, () => { this.#handleTabMove(
if (moveBefore) { tab,
getContainer().insertBefore(tab, targetElement); () => {
} else if (targetElement) { if (moveBefore) {
targetElement.after(tab); getContainer().insertBefore(tab, targetElement);
} else { } else if (targetElement) {
getContainer().appendChild(tab); targetElement.after(tab);
} } else {
}); getContainer().appendChild(tab);
}
},
metricsContext
);
} }
/** /**
* @param {MozTabbrowserTab[]} tabs * @param {MozTabbrowserTab[]} tabs
* @param {MozTabbrowserTab|MozTabbrowserTabGroup} targetElement * @param {MozTabbrowserTab|MozTabbrowserTabGroup} targetElement
* @param {boolean} moveBefore * @param {boolean} [moveBefore=false]
* @param {TabMetricsContext} [metricsContext]
*/ */
#moveTabsNextTo(tabs, targetElement, moveBefore = false) { #moveTabsNextTo(tabs, targetElement, moveBefore = false, metricsContext) {
this.#moveTabNextTo(tabs[0], targetElement, moveBefore); this.#moveTabNextTo(tabs[0], targetElement, moveBefore, metricsContext);
for (let i = 1; i < tabs.length; i++) { for (let i = 1; i < tabs.length; i++) {
this.#moveTabNextTo(tabs[i], tabs[i - 1]); this.#moveTabNextTo(tabs[i], tabs[i - 1], false, metricsContext);
} }
} }
moveTabToGroup(aTab, aGroup) { /**
*
* @param {MozTabbrowserTab} aTab
* @param {MozTabbrowserTabGroup} aGroup
* @param {TabMetricsContext} [metricsContext]
*/
moveTabToGroup(aTab, aGroup, metricsContext) {
if (aTab.pinned) { if (aTab.pinned) {
return; return;
} }
@@ -6069,19 +6107,80 @@
} }
aGroup.collapsed = false; aGroup.collapsed = false;
this.#handleTabMove(aTab, () => aGroup.appendChild(aTab)); this.#handleTabMove(aTab, () => aGroup.appendChild(aTab), metricsContext);
this.removeFromMultiSelectedTabs(aTab); this.removeFromMultiSelectedTabs(aTab);
this.tabContainer._notifyBackgroundTab(aTab); this.tabContainer._notifyBackgroundTab(aTab);
} }
/**
* @typedef {object} TabMoveState
* @property {number} tabIndex
* @property {number} [elementIndex]
* @property {string} [tabGroupId]
*/
/**
* @param {MozTabbrowserTab} tab
* @returns {TabMoveState|undefined}
*/
#getTabMoveState(tab) {
if (!this.isTab(tab)) {
return undefined;
}
let state = {
tabIndex: tab._tPos,
};
if (tab.visible) {
state.elementIndex = tab.elementIndex;
}
if (tab.group) {
state.tabGroupId = tab.group.id;
}
return state;
}
/**
* @param {MozTabbrowserTab} tab
* @param {TabMoveState} [previousTabState]
* @param {TabMoveState} [currentTabState]
* @param {TabMetricsContext} [metricsContext]
*/
#notifyOnTabMove(tab, previousTabState, currentTabState, metricsContext) {
if (!this.isTab(tab) || !previousTabState || !currentTabState) {
return;
}
let changedPosition =
previousTabState.tabIndex != currentTabState.tabIndex;
let changedTabGroup =
previousTabState.tabGroupId != currentTabState.tabGroupId;
if (changedPosition || changedTabGroup) {
tab.dispatchEvent(
new CustomEvent("TabMove", {
bubbles: true,
detail: {
previousTabState,
currentTabState,
isUserTriggered: metricsContext?.isUserTriggered ?? false,
telemetrySource:
metricsContext?.telemetrySource ??
this.TabMetrics.METRIC_SOURCE.UNKNOWN,
},
})
);
}
}
/** /**
* @param {MozTabbrowserTab} aTab * @param {MozTabbrowserTab} aTab
* @param {function():void} moveActionCallback * @param {function():void} moveActionCallback
* @returns * @param {TabMetricsContext} [metricsContext]
*/ */
#handleTabMove(aTab, moveActionCallback) { #handleTabMove(aTab, moveActionCallback, metricsContext) {
let wasFocused = document.activeElement == this.selectedTab; let wasFocused = document.activeElement == this.selectedTab;
let oldPosition = this.isTab(aTab) && aTab._tPos; let previousTabState = this.#getTabMoveState(aTab);
moveActionCallback(); moveActionCallback();
@@ -6104,13 +6203,15 @@
if (aTab.pinned) { if (aTab.pinned) {
this.tabContainer._positionPinnedTabs(); this.tabContainer._positionPinnedTabs();
} }
// Pinning/unpinning vertical tabs, and moving tabs into tab groups, both bypass moveTabTo.
// We still want to check whether its worth dispatching an event. let currentTabState = this.#getTabMoveState(aTab);
if (this.isTab(aTab) && oldPosition != aTab._tPos) {
let evt = document.createEvent("UIEvents"); this.#notifyOnTabMove(
evt.initUIEvent("TabMove", true, false, window, oldPosition); aTab,
aTab.dispatchEvent(evt); previousTabState,
} currentTabState,
metricsContext
);
} }
/** /**
@@ -9056,8 +9157,16 @@ var TabContextMenu = {
gTabsPanel.hideAllTabsPanel(); gTabsPanel.hideAllTabsPanel();
}, },
/**
* @param {MozTabbrowserTabGroup} group
*/
moveTabsToGroup(group) { moveTabsToGroup(group) {
group.addTabs(this.contextTabs); group.addTabs(
this.contextTabs,
gBrowser.TabMetrics.userTriggeredContext(
gBrowser.TabMetrics.METRIC_SOURCE.TAB_MENU
)
);
group.ownerGlobal.focus(); group.ownerGlobal.focus();
}, },

View File

@@ -7,8 +7,8 @@
// This is loaded into chrome windows with the subscript loader. Wrap in // This is loaded into chrome windows with the subscript loader. Wrap in
// a block to prevent accidentally leaking globals onto `window`. // a block to prevent accidentally leaking globals onto `window`.
{ {
const { TabGroupMetrics } = ChromeUtils.importESModule( const { TabMetrics } = ChromeUtils.importESModule(
"moz-src:///browser/components/tabbrowser/TabGroupMetrics.sys.mjs" "moz-src:///browser/components/tabbrowser/TabMetrics.sys.mjs"
); );
const { TabStateFlusher } = ChromeUtils.importESModule( const { TabStateFlusher } = ChromeUtils.importESModule(
"resource:///modules/sessionstore/TabStateFlusher.sys.mjs" "resource:///modules/sessionstore/TabStateFlusher.sys.mjs"
@@ -448,10 +448,12 @@
document document
.getElementById("tabGroupEditor_deleteGroup") .getElementById("tabGroupEditor_deleteGroup")
.addEventListener("command", () => { .addEventListener("command", () => {
gBrowser.removeTabGroup(this.activeGroup, { gBrowser.removeTabGroup(
isUserTriggered: true, this.activeGroup,
telemetrySource: TabGroupMetrics.METRIC_SOURCE.TAB_GROUP_MENU, TabMetrics.userTriggeredContext(
}); TabMetrics.METRIC_SOURCE.TAB_GROUP_MENU
)
);
}); });
this.panel.addEventListener("popupshown", this); this.panel.addEventListener("popupshown", this);

View File

@@ -234,9 +234,11 @@
/** /**
* add tabs to the group * add tabs to the group
* *
* @param tabs array of tabs to add * @param {MozTabbrowserTab[]} tabs
* @param {TabMetricsContext} [metricsContext]
* Optional context to record for metrics purposes.
*/ */
addTabs(tabs) { addTabs(tabs, metricsContext) {
for (let tab of tabs) { for (let tab of tabs) {
let tabToMove = let tabToMove =
this.ownerGlobal === tab.ownerGlobal this.ownerGlobal === tab.ownerGlobal
@@ -245,7 +247,7 @@
tabIndex: gBrowser.tabs.at(-1)._tPos + 1, tabIndex: gBrowser.tabs.at(-1)._tPos + 1,
selectTab: tab.selected, selectTab: tab.selected,
}); });
gBrowser.moveTabToGroup(tabToMove, this); gBrowser.moveTabToGroup(tabToMove, this, metricsContext);
} }
this.#lastAddedTo = Date.now(); this.#lastAddedTo = Date.now();
} }

View File

@@ -9,6 +9,11 @@
// This is loaded into all browser windows. Wrap in a block to prevent // This is loaded into all browser windows. Wrap in a block to prevent
// leaking to window scope. // leaking to window scope.
{ {
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
TabMetrics: "moz-src:///browser/components/tabbrowser/TabMetrics.sys.mjs",
});
const TAB_PREVIEW_PREF = "browser.tabs.hoverPreview.enabled"; const TAB_PREVIEW_PREF = "browser.tabs.hoverPreview.enabled";
const DIRECTION_BACKWARD = -1; const DIRECTION_BACKWARD = -1;
@@ -1060,6 +1065,10 @@
var dropEffect = dt.dropEffect; var dropEffect = dt.dropEffect;
var draggedTab; var draggedTab;
let movingTabs; let movingTabs;
/** @type {TabMetricsContext} */
const dropMetricsContext = lazy.TabMetrics.userTriggeredContext(
lazy.TabMetrics.METRIC_SOURCE.DRAG_AND_DROP
);
if (dt.mozTypesAt(0)[0] == TAB_DROP_TYPE) { if (dt.mozTypesAt(0)[0] == TAB_DROP_TYPE) {
// tab copy or move // tab copy or move
draggedTab = dt.mozGetDataAt(TAB_DROP_TYPE, 0); draggedTab = dt.mozGetDataAt(TAB_DROP_TYPE, 0);
@@ -1084,7 +1093,7 @@
duplicatedDraggedTab = duplicatedTab; duplicatedDraggedTab = duplicatedTab;
} }
} }
gBrowser.moveTabsBefore(duplicatedTabs, dropTarget); gBrowser.moveTabsBefore(duplicatedTabs, dropTarget, dropMetricsContext);
if (draggedTab.container != this || event.shiftKey) { if (draggedTab.container != this || event.shiftKey) {
this.selectedItem = duplicatedDraggedTab; this.selectedItem = duplicatedDraggedTab;
} }
@@ -1172,15 +1181,23 @@
let moveTabs = () => { let moveTabs = () => {
if (dropIndex !== undefined) { if (dropIndex !== undefined) {
for (let tab of movingTabs) { for (let tab of movingTabs) {
gBrowser.moveTabTo(tab, { elementIndex: dropIndex }); gBrowser.moveTabTo(
tab,
{ elementIndex: dropIndex },
dropMetricsContext
);
if (!directionForward) { if (!directionForward) {
dropIndex++; dropIndex++;
} }
} }
} else if (dropBefore) { } else if (dropBefore) {
gBrowser.moveTabsBefore(movingTabs, dropElement); gBrowser.moveTabsBefore(
movingTabs,
dropElement,
dropMetricsContext
);
} else { } else {
gBrowser.moveTabsAfter(movingTabs, dropElement); gBrowser.moveTabsAfter(movingTabs, dropElement, dropMetricsContext);
} }
this.#expandGroupOnDrop(draggedTab); this.#expandGroupOnDrop(draggedTab);
}; };

View File

@@ -196,6 +196,33 @@ tabgroup:
type: string type: string
expires: never expires: never
add_tab:
type: event
disabled: true # To be controlled by server knobs during Firefox 138 launch due to expected high volume
description: >
Recorded when the user adds one or more ungrouped tabs to an existing tab group
notification_emails:
- dao@mozilla.com
- jswinarton@mozilla.com
- sthompson@mozilla.com
bugs:
- https://bugzil.la/1938424
data_reviews:
- https://bugzil.la/1938424
data_sensitivity:
- interaction
extra_keys:
source:
description: The system, surface, or control the user used to add the tab(s) to the tab group
type: string
tabs:
description: The number of tabs added to the tab group
type: quantity
layout:
description: The layout of the tab strip when the tabs were added (either "horizontal" or "vertical")
type: string
expires: never
active_groups: active_groups:
type: labeled_quantity type: labeled_quantity
description: > description: >

View File

@@ -13,7 +13,7 @@ MOZ_SRC_FILES += [
"NewTabPagePreloading.sys.mjs", "NewTabPagePreloading.sys.mjs",
"OpenInTabsUtils.sys.mjs", "OpenInTabsUtils.sys.mjs",
"SmartTabGrouping.sys.mjs", "SmartTabGrouping.sys.mjs",
"TabGroupMetrics.sys.mjs", "TabMetrics.sys.mjs",
"TabsList.sys.mjs", "TabsList.sys.mjs",
"TabUnloader.sys.mjs", "TabUnloader.sys.mjs",
] ]

View File

@@ -353,39 +353,6 @@ async function closeTabsMenu() {
await hidden; await hidden;
} }
/**
* @param {XULToolbarButton} triggerNode
* @param {string} contextMenuId
* @returns {Promise<XULMenuElement|XULPopupElement>}
*/
async function getContextMenu(triggerNode, contextMenuId) {
let nodeWindow = triggerNode.ownerGlobal;
triggerNode.scrollIntoView();
const contextMenu = nodeWindow.document.getElementById(contextMenuId);
const contextMenuShown = BrowserTestUtils.waitForPopupEvent(
contextMenu,
"shown"
);
EventUtils.synthesizeMouseAtCenter(
triggerNode,
{ type: "contextmenu", button: 2 },
nodeWindow
);
await contextMenuShown;
return contextMenu;
}
/**
* @param {XULMenuElement|XULPopupElement} contextMenu
* @returns {Promise<void>}
*/
async function closeContextMenu(contextMenu) {
let menuHidden = BrowserTestUtils.waitForPopupEvent(contextMenu, "hidden");
contextMenu.hidePopup();
await menuHidden;
}
/** /**
* Returns a new basic, unnamed tab group that is fully loaded in the browser * Returns a new basic, unnamed tab group that is fully loaded in the browser
* and in session state. * and in session state.
@@ -593,3 +560,66 @@ add_task(async function test_reopenSavedGroupTelemetry() {
info("Perform reopen tests in vertical tabs mode"); info("Perform reopen tests in vertical tabs mode");
await doReopenTests(true); await doReopenTests(true);
}); });
add_task(async function test_tabContextMenu_addTabsToGroup() {
await resetTelemetry();
// `tabgroup.add_tab` is disabled by default and enabled by server knobs,
// so this test needs to enable it manually in order to test it.
Services.fog.applyServerKnobsConfig(
JSON.stringify({
metrics_enabled: {
"tabgroup.add_tab": true,
},
})
);
info("set up a tab group to test with");
let group = await makeTabGroup();
let groupId = group.id;
info("create 8 ungrouped tabs to test with");
let moreTabs = Array.from({ length: 8 }).map(() =>
BrowserTestUtils.addTab(win.gBrowser, "https://example.com")
);
info("select first ungrouped tab and multi-select three more tabs");
win.gBrowser.selectedTab = moreTabs[0];
moreTabs.slice(1, 4).forEach(tab => win.gBrowser.addToMultiSelectedTabs(tab));
await BrowserTestUtils.waitForCondition(() => {
return win.gBrowser.multiSelectedTabsCount == 4;
}, "Wait for Tabbrowser to update the multiselected tab state");
let menu = await getContextMenu(win.gBrowser.selectedTab, "tabContextMenu");
let moveTabToGroupItem = win.document.getElementById(
"context_moveTabToGroup"
);
let tabGroupButton = moveTabToGroupItem.querySelector(
`[tab-group-id="${groupId}"]`
);
tabGroupButton.click();
await closeContextMenu(menu);
await BrowserTestUtils.waitForCondition(() => {
return Glean.tabgroup.addTab.testGetValue() !== null;
}, "Wait for a Glean event to be recorded");
let [addTabEvent] = Glean.tabgroup.addTab.testGetValue();
Assert.deepEqual(
addTabEvent.extra,
{
source: "tab_menu",
tabs: "4",
layout: "horizontal",
},
"should have recorded the correct event metadata"
);
for (let tab of moreTabs) {
BrowserTestUtils.removeTab(tab);
}
await removeTabGroup(group);
await resetTelemetry();
});

View File

@@ -601,3 +601,36 @@ async function removeTabGroup(group) {
await group.ownerGlobal.gBrowser.removeTabGroup(group, { animate: false }); await group.ownerGlobal.gBrowser.removeTabGroup(group, { animate: false });
await removePromise; await removePromise;
} }
/**
* @param {Node} triggerNode
* @param {string} contextMenuId
* @returns {Promise<XULMenuElement|XULPopupElement>}
*/
async function getContextMenu(triggerNode, contextMenuId) {
let win = triggerNode.ownerGlobal;
triggerNode.scrollIntoView({ behavior: "instant" });
const contextMenu = win.document.getElementById(contextMenuId);
const contextMenuShown = BrowserTestUtils.waitForPopupEvent(
contextMenu,
"shown"
);
EventUtils.synthesizeMouseAtCenter(
triggerNode,
{ type: "contextmenu", button: 2 },
win
);
await contextMenuShown;
return contextMenu;
}
/**
* @param {XULMenuElement|XULPopupElement} contextMenu
* @returns {Promise<void>}
*/
async function closeContextMenu(contextMenu) {
let menuHidden = BrowserTestUtils.waitForPopupEvent(contextMenu, "hidden");
contextMenu.hidePopup();
await menuHidden;
}

View File

@@ -12,8 +12,7 @@ ChromeUtils.defineESModuleGetters(lazy, {
BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.sys.mjs", BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.sys.mjs",
UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs", UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs",
SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs", SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs",
TabGroupMetrics: TabMetrics: "moz-src:///browser/components/tabbrowser/TabMetrics.sys.mjs",
"moz-src:///browser/components/tabbrowser/TabGroupMetrics.sys.mjs",
}); });
const MIN_SEARCH_PREF = "tabGroups.minSearchLength"; const MIN_SEARCH_PREF = "tabGroups.minSearchLength";
@@ -77,7 +76,7 @@ class ProviderTabGroups extends ActionsProvider {
savedGroup.id, savedGroup.id,
window, window,
{ {
source: lazy.TabGroupMetrics.METRIC_SOURCE.SUGGEST, source: lazy.TabMetrics.METRIC_SOURCE.SUGGEST,
} }
); );
this.#switchToGroup(group); this.#switchToGroup(group);

View File

@@ -18,8 +18,7 @@ ChromeUtils.defineESModuleGetters(lazy, {
"moz-src:///browser/components/search/SearchSERPTelemetry.sys.mjs", "moz-src:///browser/components/search/SearchSERPTelemetry.sys.mjs",
SearchSERPTelemetryUtils: SearchSERPTelemetryUtils:
"moz-src:///browser/components/search/SearchSERPTelemetry.sys.mjs", "moz-src:///browser/components/search/SearchSERPTelemetry.sys.mjs",
TabGroupMetrics: TabMetrics: "moz-src:///browser/components/tabbrowser/TabMetrics.sys.mjs",
"moz-src:///browser/components/tabbrowser/TabGroupMetrics.sys.mjs",
WindowsInstallsInfo: WindowsInstallsInfo:
"resource://gre/modules/components-utils/WindowsInstallsInfo.sys.mjs", "resource://gre/modules/components-utils/WindowsInstallsInfo.sys.mjs",
@@ -467,6 +466,20 @@ export let BrowserUsageTelemetry = {
_inited: false, _inited: false,
/**
* @typedef {object} TabMovementsRecord
* @property {DeferredTask} deferredTask
* The `DeferredTask` that will report this record's metrics once all
* tab movement events with the same `telemetrySource` have been received
* in the current event loop.
* @property {number} numberAddedToTabGroup
* The number of tabs from `tabs` which started out as ungrouped tabs but
* moved into a tab group during the tab movement operation.
*/
/** @type {Map<string, TabMovementsRecord>} */
_tabMovementsBySource: new Map(),
init() { init() {
this._lastRecordTabCount = 0; this._lastRecordTabCount = 0;
this._lastRecordLoadedTabCount = 0; this._lastRecordLoadedTabCount = 0;
@@ -598,6 +611,9 @@ export let BrowserUsageTelemetry = {
case "TabGroupExpand": case "TabGroupExpand":
this._onTabGroupExpandOrCollapse(); this._onTabGroupExpandOrCollapse();
break; break;
case "TabMove":
this._onTabMove(event);
break;
case "TabGroupRemoveRequested": case "TabGroupRemoveRequested":
this._onTabGroupRemoveRequested(event); this._onTabGroupRemoveRequested(event);
break; break;
@@ -1143,11 +1159,13 @@ export let BrowserUsageTelemetry = {
/** /**
* Adds listeners to a single chrome window. * Adds listeners to a single chrome window.
* @param {Window} win
*/ */
_registerWindow(win) { _registerWindow(win) {
this._addUsageListeners(win); this._addUsageListeners(win);
win.addEventListener("unload", this); win.addEventListener("unload", this);
win.addEventListener("TabMove", this);
win.addEventListener("TabOpen", this, true); win.addEventListener("TabOpen", this, true);
win.addEventListener("TabPinned", this, true); win.addEventListener("TabPinned", this, true);
win.addEventListener("TabGroupCreate", this); win.addEventListener("TabGroupCreate", this);
@@ -1167,6 +1185,7 @@ export let BrowserUsageTelemetry = {
*/ */
_unregisterWindow(win) { _unregisterWindow(win) {
win.removeEventListener("unload", this); win.removeEventListener("unload", this);
win.removeEventListener("TabMove", this);
win.removeEventListener("TabOpen", this, true); win.removeEventListener("TabOpen", this, true);
win.removeEventListener("TabPinned", this, true); win.removeEventListener("TabPinned", this, true);
win.removeEventListener("TabGroupCreate", this); win.removeEventListener("TabGroupCreate", this);
@@ -1230,7 +1249,9 @@ export let BrowserUsageTelemetry = {
if (event.detail.isUserTriggered) { if (event.detail.isUserTriggered) {
Glean.tabgroup.createGroup.record({ Glean.tabgroup.createGroup.record({
id: event.target.id, id: event.target.id,
layout: lazy.sidebarVerticalTabs ? "vertical" : "horizontal", layout: lazy.sidebarVerticalTabs
? lazy.TabMetrics.METRIC_TABS_LAYOUT.VERTICAL
: lazy.TabMetrics.METRIC_TABS_LAYOUT.HORIZONTAL,
source: event.detail.telemetryUserCreateSource, source: event.detail.telemetryUserCreateSource,
tabs: event.target.tabs.length, tabs: event.target.tabs.length,
}); });
@@ -1325,7 +1346,7 @@ export let BrowserUsageTelemetry = {
_onTabGroupRemoveRequested(event) { _onTabGroupRemoveRequested(event) {
let { let {
isUserTriggered = false, isUserTriggered = false,
telemetrySource = lazy.TabGroupMetrics.METRIC_SOURCE.UNKNOWN, telemetrySource = lazy.TabMetrics.METRIC_SOURCE.UNKNOWN,
} = event.detail; } = event.detail;
if (isUserTriggered) { if (isUserTriggered) {
@@ -1336,6 +1357,59 @@ export let BrowserUsageTelemetry = {
} }
}, },
/**
* Accumulates `TabMove` events in order to record 1 metrics event per frame
* per telemetry source.
*
* For example, dragging and dropping 4 tabs should listen for 4 `TabMove`
* events but result in 1 metrics event being recorded with a source of
* `drag` and a tab count of 4.
*
* @param {CustomEvent} event
*/
_onTabMove(event) {
let { isUserTriggered, telemetrySource } = event.detail;
if (!isUserTriggered) {
return;
}
let tabMovementsRecord = this._tabMovementsBySource.get(telemetrySource);
if (!tabMovementsRecord) {
let deferredTask = new lazy.DeferredTask(() => {
Glean.tabgroup.addTab.record({
source: telemetrySource,
tabs: tabMovementsRecord.numberAddedToTabGroup,
layout: lazy.sidebarVerticalTabs ? "vertical" : "horizontal",
});
this._tabMovementsBySource.delete(telemetrySource);
}, 0);
tabMovementsRecord = {
deferredTask,
numberAddedToTabGroup: 0,
};
this._tabMovementsBySource.set(telemetrySource, tabMovementsRecord);
this._updateTabMovementsRecord(tabMovementsRecord, event);
deferredTask.arm();
} else {
tabMovementsRecord.deferredTask.disarm();
this._updateTabMovementsRecord(tabMovementsRecord, event);
tabMovementsRecord.deferredTask.arm();
}
},
/**
* @param {TabMovementsRecord} record
* @param {CustomEvent} event
*/
_updateTabMovementsRecord(record, event) {
let { previousTabState, currentTabState } = event.detail;
if (!previousTabState.tabGroupId && currentTabState.tabGroupId) {
record.numberAddedToTabGroup += 1;
}
},
/** /**
* Tracks the window count and registers the listeners for the tab count. * Tracks the window count and registers the listeners for the tab count.
* @param{Object} win The window object. * @param{Object} win The window object.