diff options
Diffstat (limited to '')
-rw-r--r-- | browser/components/urlbar/MerinoClient.sys.mjs | 400 |
1 files changed, 400 insertions, 0 deletions
diff --git a/browser/components/urlbar/MerinoClient.sys.mjs b/browser/components/urlbar/MerinoClient.sys.mjs new file mode 100644 index 0000000000..3476122386 --- /dev/null +++ b/browser/components/urlbar/MerinoClient.sys.mjs @@ -0,0 +1,400 @@ +/* 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/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + PromiseUtils: "resource://gre/modules/PromiseUtils.sys.mjs", + SkippableTimer: "resource:///modules/UrlbarUtils.sys.mjs", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs", + UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs", +}); + +const SEARCH_PARAMS = { + CLIENT_VARIANTS: "client_variants", + PROVIDERS: "providers", + QUERY: "q", + SEQUENCE_NUMBER: "seq", + SESSION_ID: "sid", +}; + +const SESSION_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes + +const HISTOGRAM_LATENCY = "FX_URLBAR_MERINO_LATENCY_MS"; +const HISTOGRAM_RESPONSE = "FX_URLBAR_MERINO_RESPONSE"; + +/** + * Client class for querying the Merino server. Each instance maintains its own + * session state including a session ID and sequence number that is included in + * its requests to Merino. + */ +export class MerinoClient { + /** + * @returns {object} + * The names of URL search params. + */ + static get SEARCH_PARAMS() { + return { ...SEARCH_PARAMS }; + } + + /** + * @param {string} name + * An optional name for the client. It will be included in log messages. + */ + constructor(name = "anonymous") { + this.#name = name; + XPCOMUtils.defineLazyGetter(this, "logger", () => + lazy.UrlbarUtils.getLogger({ prefix: `MerinoClient [${name}]` }) + ); + } + + /** + * @returns {string} + * The name of the client. + */ + get name() { + return this.#name; + } + + /** + * @returns {number} + * If `resetSession()` is not called within this timeout period after a + * session starts, the session will time out and the next fetch will begin a + * new session. + */ + get sessionTimeoutMs() { + return this.#sessionTimeoutMs; + } + set sessionTimeoutMs(value) { + this.#sessionTimeoutMs = value; + } + + /** + * @returns {number} + * The current session ID. Null when there is no active session. + */ + get sessionID() { + return this.#sessionID; + } + + /** + * @returns {number} + * The current sequence number in the current session. Zero when there is no + * active session. + */ + get sequenceNumber() { + return this.#sequenceNumber; + } + + /** + * @returns {string} + * A string that indicates the status of the last fetch. The values are the + * same as the labels used in the `FX_URLBAR_MERINO_RESPONSE` histogram: + * success, timeout, network_error, http_error + */ + get lastFetchStatus() { + return this.#lastFetchStatus; + } + + /** + * Fetches Merino suggestions. + * + * @param {object} options + * Options object + * @param {string} options.query + * The search string. + * @param {Array} options.providers + * Array of provider names to request from Merino. If this is given it will + * override the `merinoProviders` Nimbus variable and its fallback pref + * `browser.urlbar.merino.providers`. + * @param {number} options.timeoutMs + * Timeout in milliseconds. This method will return once the timeout + * elapses, a response is received, or an error occurs, whichever happens + * first. + * @param {string} options.extraLatencyHistogram + * If specified, the fetch's latency will be recorded in this histogram in + * addition to the usual Merino latency histogram. + * @param {string} options.extraResponseHistogram + * If specified, the fetch's response will be recorded in this histogram in + * addition to the usual Merino response histogram. + * @returns {Array} + * The Merino suggestions or null if there's an error or unexpected + * response. + */ + async fetch({ + query, + providers = null, + timeoutMs = lazy.UrlbarPrefs.get("merinoTimeoutMs"), + extraLatencyHistogram = null, + extraResponseHistogram = null, + }) { + this.logger.info(`Fetch starting with query: "${query}"`); + + // Set up the Merino session ID and related state. The session ID is a UUID + // without leading and trailing braces. + if (!this.#sessionID) { + let uuid = Services.uuid.generateUUID().toString(); + this.#sessionID = uuid.substring(1, uuid.length - 1); + this.#sequenceNumber = 0; + this.#sessionTimer?.cancel(); + + // Per spec, for the user's privacy, the session should time out and a new + // session ID should be used if the engagement does not end soon. + this.#sessionTimer = new lazy.SkippableTimer({ + name: "Merino session timeout", + time: this.#sessionTimeoutMs, + logger: this.logger, + callback: () => this.resetSession(), + }); + } + + // Get the endpoint URL. It's empty by default when running tests so they + // don't hit the network. + let endpointString = lazy.UrlbarPrefs.get("merinoEndpointURL"); + if (!endpointString) { + return null; + } + let url; + try { + url = new URL(endpointString); + } catch (error) { + this.logger.error("Error creating endpoint URL: " + error); + return null; + } + url.searchParams.set(SEARCH_PARAMS.QUERY, query); + url.searchParams.set(SEARCH_PARAMS.SESSION_ID, this.#sessionID); + url.searchParams.set(SEARCH_PARAMS.SEQUENCE_NUMBER, this.#sequenceNumber); + this.#sequenceNumber++; + + let clientVariants = lazy.UrlbarPrefs.get("merinoClientVariants"); + if (clientVariants) { + url.searchParams.set(SEARCH_PARAMS.CLIENT_VARIANTS, clientVariants); + } + + let providersString; + if (providers != null) { + if (!Array.isArray(providers)) { + throw new Error("providers must be an array if given"); + } + providersString = providers.join(","); + } else { + let value = lazy.UrlbarPrefs.get("merinoProviders"); + if (value) { + // The Nimbus variable/pref is used only if it's a non-empty string. + providersString = value; + } + } + + // An empty providers string is a valid value and means Merino should + // receive the request but not return any suggestions, so do not do a simple + // `if (providersString)` here. + if (typeof providersString == "string") { + url.searchParams.set(SEARCH_PARAMS.PROVIDERS, providersString); + } + + let details = { query, providers, timeoutMs, url }; + this.logger.debug("Fetch details: " + JSON.stringify(details)); + + let recordResponse = category => { + this.logger.info("Fetch done with status: " + category); + Services.telemetry.getHistogramById(HISTOGRAM_RESPONSE).add(category); + if (extraResponseHistogram) { + Services.telemetry + .getHistogramById(extraResponseHistogram) + .add(category); + } + this.#lastFetchStatus = category; + recordResponse = null; + }; + + // Set up the timeout timer. + let timer = (this.#timeoutTimer = new lazy.SkippableTimer({ + name: "Merino timeout", + time: timeoutMs, + logger: this.logger, + callback: () => { + // The fetch timed out. + this.logger.info(`Fetch timed out (timeout = ${timeoutMs}ms)`); + recordResponse?.("timeout"); + }, + })); + + // If there's an ongoing fetch, abort it so there's only one at a time. By + // design we do not abort fetches on timeout or when the query is canceled + // so we can record their latency. + try { + this.#fetchController?.abort(); + } catch (error) { + this.logger.error("Error aborting previous fetch: " + error); + } + + // Do the fetch. + let response; + let controller = (this.#fetchController = new AbortController()); + let stopwatchInstance = (this.#latencyStopwatchInstance = {}); + TelemetryStopwatch.start(HISTOGRAM_LATENCY, stopwatchInstance); + if (extraLatencyHistogram) { + TelemetryStopwatch.start(extraLatencyHistogram, stopwatchInstance); + } + await Promise.race([ + timer.promise, + (async () => { + try { + // Canceling the timer below resolves its promise, which can resolve + // the outer promise created by `Promise.race`. This inner async + // function happens not to await anything after canceling the timer, + // but if it did, `timer.promise` could win the race and resolve the + // outer promise without a value. For that reason, we declare + // `response` in the outer scope and set it here instead of returning + // the response from this inner function and assuming it will also be + // returned by `Promise.race`. + response = await fetch(url, { signal: controller.signal }); + TelemetryStopwatch.finish(HISTOGRAM_LATENCY, stopwatchInstance); + if (extraLatencyHistogram) { + TelemetryStopwatch.finish(extraLatencyHistogram, stopwatchInstance); + } + this.logger.debug( + "Got response: " + + JSON.stringify({ "response.status": response.status, ...details }) + ); + if (!response.ok) { + recordResponse?.("http_error"); + } + } catch (error) { + TelemetryStopwatch.cancel(HISTOGRAM_LATENCY, stopwatchInstance); + if (extraLatencyHistogram) { + TelemetryStopwatch.cancel(extraLatencyHistogram, stopwatchInstance); + } + if (error.name != "AbortError") { + this.logger.error("Fetch error: " + error); + recordResponse?.("network_error"); + } + } finally { + // Now that the fetch is done, cancel the timeout timer so it doesn't + // fire and record a timeout. If it already fired, which it would have + // on timeout, or was already canceled, this is a no-op. + timer.cancel(); + if (controller == this.#fetchController) { + this.#fetchController = null; + } + this.#nextResponseDeferred?.resolve(response); + this.#nextResponseDeferred = null; + } + })(), + ]); + if (timer == this.#timeoutTimer) { + this.#timeoutTimer = null; + } + + // Get the response body as an object. + let body; + try { + body = await response?.json(); + } catch (error) { + this.logger.error("Error getting response as JSON: " + error); + } + + if (body) { + this.logger.debug("Response body: " + JSON.stringify(body)); + } + + if (!body?.suggestions?.length) { + recordResponse?.("no_suggestion"); + return []; + } + + let { suggestions, request_id } = body; + if (!Array.isArray(suggestions)) { + this.logger.error("Unexpected response: " + JSON.stringify(body)); + recordResponse?.("no_suggestion"); + return []; + } + + recordResponse?.("success"); + return suggestions.map(suggestion => ({ + ...suggestion, + request_id, + source: "merino", + })); + } + + /** + * Resets the Merino session ID and related state. + */ + resetSession() { + this.#sessionID = null; + this.#sequenceNumber = 0; + this.#sessionTimer?.cancel(); + this.#sessionTimer = null; + this.#nextSessionResetDeferred?.resolve(); + this.#nextSessionResetDeferred = null; + } + + /** + * Cancels the timeout timer. + */ + cancelTimeoutTimer() { + this.#timeoutTimer?.cancel(); + } + + /** + * Returns a promise that's resolved when the next response is received or a + * network error occurs. + * + * @returns {Promise} + * The promise is resolved with the `Response` object or undefined if a + * network error occurred. + */ + waitForNextResponse() { + if (!this.#nextResponseDeferred) { + this.#nextResponseDeferred = lazy.PromiseUtils.defer(); + } + return this.#nextResponseDeferred.promise; + } + + /** + * Returns a promise that's resolved when the session is next reset, including + * on session timeout. + * + * @returns {Promise} + */ + waitForNextSessionReset() { + if (!this.#nextSessionResetDeferred) { + this.#nextSessionResetDeferred = lazy.PromiseUtils.defer(); + } + return this.#nextSessionResetDeferred.promise; + } + + get _test_sessionTimer() { + return this.#sessionTimer; + } + + get _test_timeoutTimer() { + return this.#timeoutTimer; + } + + get _test_fetchController() { + return this.#fetchController; + } + + get _test_latencyStopwatchInstance() { + return this.#latencyStopwatchInstance; + } + + // State related to the current session. + #sessionID = null; + #sequenceNumber = 0; + #sessionTimer = null; + #sessionTimeoutMs = SESSION_TIMEOUT_MS; + + #name; + #timeoutTimer = null; + #fetchController = null; + #latencyStopwatchInstance = null; + #lastFetchStatus = null; + #nextResponseDeferred = null; + #nextSessionResetDeferred = null; +} |