forked from mirrors/gecko-dev
This adds a new `name` property to MerinoClient that's included in log messages. That makes it easier to understand logs when there's more than one client, as there will be since the new weather feature will use its own. This also adds more logging. It also adds a timeout param to `fetch()`. The param overrides the timeout pref. The weather MerinoClient will use a custom timeout. Differential Revision: https://phabricator.services.mozilla.com/D161369
373 lines
11 KiB
JavaScript
373 lines
11 KiB
JavaScript
/* 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.
|
|
* @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"),
|
|
}) {
|
|
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);
|
|
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);
|
|
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);
|
|
this.logger.debug(
|
|
"Got response: " +
|
|
JSON.stringify({ "response.status": response.status, ...details })
|
|
);
|
|
recordResponse?.(response.ok ? "success" : "http_error");
|
|
} catch (error) {
|
|
TelemetryStopwatch.cancel(HISTOGRAM_LATENCY, 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) {
|
|
return [];
|
|
}
|
|
|
|
let { suggestions, request_id } = body;
|
|
if (!Array.isArray(suggestions)) {
|
|
this.logger.error("Unexpected response: " + JSON.stringify(body));
|
|
return [];
|
|
}
|
|
|
|
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;
|
|
}
|