/* 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/. */ const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { 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; ChromeUtils.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 []; } let url; try { url = new URL(endpointString); } catch (error) { this.logger.error("Error creating endpoint URL: " + error); return []; } 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 = Promise.withResolvers(); } 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 = Promise.withResolvers(); } 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; }