Source code
Revision control
Copy as Markdown
Other Tools
/* 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
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
UrlbarTelemetryUtils:
"chrome://browser/content/urlbar/UrlbarTelemetryUtils.mjs",
});
/**
* @import {UrlbarChildController} from "chrome://browser/content/urlbar/UrlbarChildController.mjs"
*/
/**
* Content-side engagement-telemetry collector for the urlbar actor's message
* path. It owns the synchronous session state (`start()` info)
* and, on `record()`, assembles the engagement snapshot plus the input/view
* content the recording needs and ships it to the parent recorder as a
* `RecordEngagement` message. The parent does the actual `Glean`/`Interactions`
* recording, so this collector pulls in neither.
*
* On the direct path this collector isn't used: `UrlbarChildController` hands
* back the real parent-side `TelemetryEvent` and recording happens in-process.
* It mirrors that class's `start()`/`record()`/`discard()` surface, sharing the
* pure parts through `UrlbarTelemetryUtils`.
*/
export class UrlbarChildTelemetry {
/** @type {UrlbarChildController} */
#controller;
// Guards against re-entering record(); see the parent recorder's record().
#handlingRecord = false;
/**
* The in-progress session's start info (`{timeStamp, interactionType,
* searchString}`), or null when no session is being measured.
*
* @type {?object}
*/
#startEventInfo = null;
// The previous recorded session's search words, for the "refined" interaction
// check. Owned here since the child builds the event (the parent recorder
// keeps its own copy for the direct path).
#previousSearchWords = null;
// The session's exposures, mirroring the parent recorder's queues: results
// exposed during the session, the deferred (hidden) ones, and a set to record
// at most one exposure per result. Resolved and shipped at session end.
#exposures = [];
#tentativeExposures = [];
#exposureResults = new WeakSet();
/**
* @param {UrlbarChildController} controller
* The paired child controller, used to reach the live input/view and to
* ship the engagement to the parent.
*/
constructor(controller) {
this.#controller = controller;
}
/**
* Begins measuring a session from a user-generated event. See the parent
* recorder's `start()`; this keeps the same once-per-session semantics.
*
* @param {Event} event
* The event that started the session.
* @param {object} queryContext
* The query context (unused here, kept for signature parity).
* @param {string} [searchString]
* The search string related to the event, if any.
* @param {string} [interactionType]
* An explicit interaction type, used in preference to the derived one.
*/
start(event, queryContext, searchString = null, interactionType = null) {
if (this.#startEventInfo) {
if (this.#startEventInfo.interactionType == "topsites") {
this.#startEventInfo.interactionType =
interactionType ||
lazy.UrlbarTelemetryUtils.startInteractionType(event, searchString);
this.#startEventInfo.searchString = searchString;
} else if (
this.#startEventInfo.interactionType == "returned" &&
(!searchString ||
this.#startEventInfo.searchString[0] != searchString[0])
) {
this.#startEventInfo.interactionType = "restarted";
}
return;
}
if (!event) {
console.error("Must always provide an event");
return;
}
const validEvents = [
"click",
"command",
"drop",
"input",
"keydown",
"mousedown",
"paste",
"tabswitch",
"focus",
];
if (!validEvents.includes(event.type)) {
console.error("Can't start recording from event type: ", event.type);
return;
}
this.#startEventInfo = {
timeStamp: event.timeStamp || ChromeUtils.now(),
interactionType:
interactionType ||
lazy.UrlbarTelemetryUtils.startInteractionType(event, searchString),
searchString,
};
}
/**
* Builds the engagement/abandonment Glean event from the live input and view
* and ships it to the parent recorder, which fills the parent-only fields and
* makes the `Glean` call. The picked result rides along in wire form for the
* provider notifications the parent runs; the disable-tracking candidate and
* the exposures are built and resolved here and shipped in recordable form.
*
* @param {?Event} event
* The DOM event behind the engagement, or null for paste&go / drop&go.
* @param {object} details
* The interaction details.
*/
record(event, details) {
if (this.#handlingRecord) {
return;
}
// This should never throw, or it may break the urlbar.
try {
this.#handlingRecord = true;
let snapshot = lazy.UrlbarTelemetryUtils.collectSnapshot(
event,
details,
this.#startEventInfo
);
if (snapshot) {
let { input, view } = this.#controller;
let engagementData = lazy.UrlbarTelemetryUtils.engagementData(
input,
view
);
let smartbarData = lazy.UrlbarTelemetryUtils.smartbarData(input);
let { built, previousSearchWords } =
lazy.UrlbarTelemetryUtils.buildRecordedEngagement(
snapshot,
engagementData,
smartbarData,
this.#previousSearchWords
);
this.#previousSearchWords = previousSearchWords;
// If a Suggest result was shown, build the disable-tracking candidate
// now too, so the parent can record it should the user turn Suggest off
// shortly after, without needing the live input at that point.
let disableBuilt = engagementData.visibleResults.some(
r => r.providerName == "UrlbarProviderQuickSuggest"
)
? lazy.UrlbarTelemetryUtils.buildRecordedDisableCandidate(
snapshot,
engagementData,
smartbarData,
this.#previousSearchWords
)
: null;
// Exposures are recorded only when the session ends, alongside the
// engagement, so resolve and ship them with it.
let exposures = details.isSessionOngoing
? null
: this.#resolveExposures(
view.queryContext,
engagementData.visibleResults
);
this.#controller.recordEngagement(
lazy.UrlbarTelemetryUtils.recordedEngagementToWire({
built,
disableBuilt,
method: snapshot.method,
searchSource: snapshot.internalDetails.searchSource,
internalDetails: snapshot.internalDetails,
exposures,
})
);
}
} catch (ex) {
console.error("Could not record event: ", ex);
} finally {
this.#handlingRecord = false;
if (!details.isSessionOngoing) {
this.#startEventInfo = null;
}
}
}
/**
* Drops the in-progress session so it won't be recorded.
*/
discard() {
this.#startEventInfo = null;
}
/**
* Resets cross-session recording state: the local "refined"-check words, and
* (over the wire) the parent recorder's own copy.
*/
reset() {
this.#previousSearchWords = null;
this.#controller.resetEngagement();
}
/**
* Queues an exposure for a result, if it records exposure telemetry.
*
* @param {object} result The exposed result.
* @param {object} queryContext The query the result belongs to.
*/
addExposure(result, queryContext) {
if (result.exposureTelemetry) {
this.#addExposureInternal(result, queryContext);
}
}
/**
* Queues a tentative (hidden) exposure, to be confirmed or discarded later.
*
* @param {object} result The exposed result.
* @param {object} queryContext The query the result belongs to.
*/
addTentativeExposure(result, queryContext) {
if (result.exposureTelemetry) {
this.#tentativeExposures.push({ result, queryContext });
}
}
/**
* Promotes the queued tentative exposures to real exposures.
*/
acceptTentativeExposures() {
for (let { result, queryContext } of this.#tentativeExposures) {
this.#addExposureInternal(result, queryContext);
}
this.#tentativeExposures = [];
}
/**
* Drops the queued tentative exposures.
*/
discardTentativeExposures() {
this.#tentativeExposures = [];
}
#addExposureInternal(result, queryContext) {
// Record at most one exposure per result, like the parent recorder.
if (!this.#exposureResults.has(result)) {
this.#exposureResults.add(result);
let { resultType, keyword } = lazy.UrlbarTelemetryUtils.exposureEntry(
result,
queryContext
);
this.#exposures.push({ result, resultType, keyword });
}
}
/**
* Resolves the queued exposures to their recordable form (computing the
* terminal flag against the live results) and clears the queues.
*
* @param {?UrlbarQueryContext} queryContext
* The terminal query's context, for hidden exposures' terminal flag.
* @param {object[]} visibleResults
* The results shown at session end.
* @returns {Array<{resultType: string, keyword: ?string, terminal: boolean}>}
*/
#resolveExposures(queryContext, visibleResults) {
let exposures = this.#exposures;
this.#exposures = [];
this.#tentativeExposures = [];
return exposures.map(({ result, resultType, keyword }) => {
this.#exposureResults.delete(result);
return {
resultType,
keyword,
terminal: lazy.UrlbarTelemetryUtils.exposureTerminal(
result,
queryContext,
visibleResults
),
};
});
}
/**
* Starts tracking a potential bounce after an engagement, resolving the
* bounce snapshot content-side (the recording itself runs parent-side).
*
* Bounce tracking keys on the chrome address bar's selected-tab browser and
* its tab-close/navigation triggers, so -- unlike the collector's engagement
* recording -- it doesn't run for a content-process urlbar, which has no such
* browser.
*
* @param {MozBrowser} browser
* The chrome <browser> for the tab the engagement happened in.
* @param {Event} event The DOM event behind the engagement.
* @param {object} details The interaction details.
*/
async startTrackingBounceEvent(browser, event, details) {
let state = this.#controller.input.getBrowserState(browser);
// Another engagement while already tracking could itself be a bounce.
if (state.bounceEventTracking) {
await this.handleBounceEventTrigger(browser);
}
let { input, view } = this.#controller;
let engagementData = lazy.UrlbarTelemetryUtils.engagementData(input, view);
let snapshot = lazy.UrlbarTelemetryUtils.collectBounceSnapshot(
event,
details,
this.#startEventInfo,
engagementData.visibleResults
);
// Build the Glean event now, while the input and view are live; `view_time`
// is filled parent-side once `Interactions` reports it at trigger time.
let built = null;
let searchSource = null;
if (snapshot) {
searchSource = snapshot.searchSource;
let searchMode = snapshot.searchMode ?? engagementData.searchMode;
let { interaction } = lazy.UrlbarTelemetryUtils.getInteractionType(
"bounce",
snapshot.startEventInfo,
searchSource,
snapshot.searchWords,
searchMode,
this.#previousSearchWords
);
let smartbarData = lazy.UrlbarTelemetryUtils.smartbarData(input);
built = lazy.UrlbarTelemetryUtils.buildEventInfo({
method: "bounce",
action: snapshot.action,
interaction,
numChars: snapshot.numChars,
numWords: snapshot.numWords,
provider: snapshot.provider,
searchSource,
searchMode,
selIndex: snapshot.selIndex,
visibleResults: snapshot.visibleResults,
viewIsOpen: engagementData.viewIsOpen,
selType: snapshot.selType,
location: snapshot.location,
chatId: smartbarData.chatId,
intent: smartbarData.intent,
model: smartbarData.model,
windowMode: snapshot.windowMode,
});
}
state.bounceEventTracking = { startTime: Date.now(), built, searchSource };
// The bounce records parent-side at trigger time, by which point a closing
// tab's browser is gone. Hand the parent the live browser now so it can
// still resolve it then.
this.#controller.trackBounceBrowser(browser.browsingContext?.browserId);
}
/**
* Handles a bounce trigger (tab close, navigating away, re-engaging the
* urlbar): ships the tracked snapshot, start time, browser id, and the
* content the recording reads to the parent, which queries `Interactions`
* and records the bounce if warranted.
*
* @param {MozBrowser} browser
* The chrome <browser> for the tab the trigger happened in.
*/
handleBounceEventTrigger(browser) {
let state = this.#controller.input.getBrowserState(browser);
if (!state.bounceEventTracking) {
return;
}
let { built, searchSource, startTime } = state.bounceEventTracking;
state.bounceEventTracking = null;
this.#controller.handleBounceTrigger({
built,
searchSource,
startTime,
browserId: browser.browsingContext?.browserId,
});
}
}