Bug 1944416: Restore individual tabs from closed groups in closed windows r=dao,sessi...
[gecko.git] / browser / components / urlbar / MerinoClient.sys.mjs
blobe3d21e19b92bd73469937d4df062857d797d2bdb
1 /* This Source Code Form is subject to the terms of the Mozilla Public
2  * License, v. 2.0. If a copy of the MPL was not distributed with this
3  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
5 const lazy = {};
7 ChromeUtils.defineESModuleGetters(lazy, {
8   SkippableTimer: "resource:///modules/UrlbarUtils.sys.mjs",
9   UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs",
10   UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs",
11 });
13 const SEARCH_PARAMS = {
14   CLIENT_VARIANTS: "client_variants",
15   PROVIDERS: "providers",
16   QUERY: "q",
17   SEQUENCE_NUMBER: "seq",
18   SESSION_ID: "sid",
21 const SESSION_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
23 const HISTOGRAM_LATENCY = "FX_URLBAR_MERINO_LATENCY_MS";
24 const HISTOGRAM_RESPONSE = "FX_URLBAR_MERINO_RESPONSE";
26 /**
27  * Client class for querying the Merino server. Each instance maintains its own
28  * session state including a session ID and sequence number that is included in
29  * its requests to Merino.
30  */
31 export class MerinoClient {
32   /**
33    * @returns {object}
34    *   The names of URL search params.
35    */
36   static get SEARCH_PARAMS() {
37     return { ...SEARCH_PARAMS };
38   }
40   /**
41    * @param {string} name
42    *   An optional name for the client. It will be included in log messages.
43    * @param {object} options
44    *   Options object
45    * @param {string} options.cachePeriodMs
46    *   Enables caching when nonzero. The client will cache the response
47    *   suggestions from its most recent successful request for the specified
48    *   period. The client will serve the cached suggestions for all fetches for
49    *   the same URL until either the cache period elapses or a successful fetch
50    *   for a different URL is made (ignoring session-related URL params like
51    *   session ID and sequence number). Caching is per `MerinoClient` instance
52    *   and is not shared across instances.
53    *
54    *   WARNING: Cached suggestions are only ever evicted when new suggestions
55    *   are cached. They are not evicted on a timer. If the client has cached
56    *   some suggestions and no further fetches are made, they'll stay cached
57    *   indefinitely. If your request URLs contain senstive data that should not
58    *   stick around in the object graph indefinitely, you should either not use
59    *   caching or you should implement an eviction mechanism.
60    *
61    *   This cache strategy is intentionally simplistic and designed to be used
62    *   by the urlbar with very short cache periods to make sure Firefox doesn't
63    *   repeatedly call the same Merino URL on each keystroke in a urlbar
64    *   session, which is wasteful and can cause a suggestion to flicker out of
65    *   and into the urlbar panel as the user matches it again and again,
66    *   especially when Merino latency is high. It is not designed to be a
67    *   general caching mechanism. If you need more complex or long-lived
68    *   caching, try working with the Merino team to add cache headers to the
69    *   relevant responses so you can leverage Firefox's HTTP cache.
70    */
71   constructor(name = "anonymous", { cachePeriodMs = 0 } = {}) {
72     this.#name = name;
73     this.#cachePeriodMs = cachePeriodMs;
74     ChromeUtils.defineLazyGetter(this, "logger", () =>
75       lazy.UrlbarUtils.getLogger({ prefix: `MerinoClient [${name}]` })
76     );
77   }
79   /**
80    * @returns {string}
81    *   The name of the client.
82    */
83   get name() {
84     return this.#name;
85   }
87   /**
88    * @returns {number}
89    *   If `resetSession()` is not called within this timeout period after a
90    *   session starts, the session will time out and the next fetch will begin a
91    *   new session.
92    */
93   get sessionTimeoutMs() {
94     return this.#sessionTimeoutMs;
95   }
96   set sessionTimeoutMs(value) {
97     this.#sessionTimeoutMs = value;
98   }
100   /**
101    * @returns {number}
102    *   The current session ID. Null when there is no active session.
103    */
104   get sessionID() {
105     return this.#sessionID;
106   }
108   /**
109    * @returns {number}
110    *   The current sequence number in the current session. Zero when there is no
111    *   active session.
112    */
113   get sequenceNumber() {
114     return this.#sequenceNumber;
115   }
117   /**
118    * @returns {string}
119    *   A string that indicates the status of the last fetch. The values are the
120    *   same as the labels used in the `FX_URLBAR_MERINO_RESPONSE` histogram:
121    *   success, timeout, network_error, http_error
122    */
123   get lastFetchStatus() {
124     return this.#lastFetchStatus;
125   }
127   /**
128    * Fetches Merino suggestions.
129    *
130    * @param {object} options
131    *   Options object
132    * @param {string} options.query
133    *   The search string.
134    * @param {Array} options.providers
135    *   Array of provider names to request from Merino. If this is given it will
136    *   override the `merinoProviders` Nimbus variable and its fallback pref
137    *   `browser.urlbar.merino.providers`.
138    * @param {number} options.timeoutMs
139    *   Timeout in milliseconds. This method will return once the timeout
140    *   elapses, a response is received, or an error occurs, whichever happens
141    *   first.
142    * @param {string} options.extraLatencyHistogram
143    *   If specified, the fetch's latency will be recorded in this histogram in
144    *   addition to the usual Merino latency histogram.
145    * @param {string} options.extraResponseHistogram
146    *   If specified, the fetch's response will be recorded in this histogram in
147    *   addition to the usual Merino response histogram.
148    * @param {object} options.otherParams
149    *   If specified, the otherParams will be added as a query params. Currently
150    *   used for accuweather's location autocomplete endpoint
151    * @returns {Array}
152    *   The Merino suggestions or null if there's an error or unexpected
153    *   response.
154    */
155   async fetch({
156     query,
157     providers = null,
158     timeoutMs = lazy.UrlbarPrefs.get("merinoTimeoutMs"),
159     extraLatencyHistogram = null,
160     extraResponseHistogram = null,
161     otherParams = {},
162   }) {
163     this.logger.debug("Fetch start", { query });
165     // Get the endpoint URL. It's empty by default when running tests so they
166     // don't hit the network.
167     let endpointString = lazy.UrlbarPrefs.get("merinoEndpointURL");
168     if (!endpointString) {
169       return [];
170     }
171     let url;
172     try {
173       url = new URL(endpointString);
174     } catch (error) {
175       this.logger.error("Error creating endpoint URL", error);
176       return [];
177     }
179     // Start setting search params. Leave session-related params for last.
180     url.searchParams.set(SEARCH_PARAMS.QUERY, query);
182     let clientVariants = lazy.UrlbarPrefs.get("merinoClientVariants");
183     if (clientVariants) {
184       url.searchParams.set(SEARCH_PARAMS.CLIENT_VARIANTS, clientVariants);
185     }
187     let providersString;
188     if (providers != null) {
189       if (!Array.isArray(providers)) {
190         throw new Error("providers must be an array if given");
191       }
192       providersString = providers.join(",");
193     } else {
194       let value = lazy.UrlbarPrefs.get("merinoProviders");
195       if (value) {
196         // The Nimbus variable/pref is used only if it's a non-empty string.
197         providersString = value;
198       }
199     }
201     // An empty providers string is a valid value and means Merino should
202     // receive the request but not return any suggestions, so do not do a simple
203     // `if (providersString)` here.
204     if (typeof providersString == "string") {
205       url.searchParams.set(SEARCH_PARAMS.PROVIDERS, providersString);
206     }
208     // if otherParams are present add them to the url
209     for (const [param, value] of Object.entries(otherParams)) {
210       url.searchParams.set(param, value);
211     }
213     // At this point, all search params should be set except for session-related
214     // params.
216     let details = { query, providers, timeoutMs, url: url.toString() };
217     this.logger.debug("Fetch details", details);
219     // If caching is enabled, generate the cache key for this request URL.
220     let cacheKey;
221     if (this.#cachePeriodMs && !MerinoClient._test_disableCache) {
222       url.searchParams.sort();
223       cacheKey = url.toString();
225       // If we have cached suggestions and they're still valid, return them.
226       if (
227         this.#cache.suggestions &&
228         Date.now() < this.#cache.dateMs + this.#cachePeriodMs &&
229         this.#cache.key == cacheKey
230       ) {
231         this.logger.debug("Fetch served from cache");
232         return this.#cache.suggestions;
233       }
234     }
236     // At this point, we're calling Merino.
238     // Set up the Merino session ID and related state. The session ID is a UUID
239     // without leading and trailing braces.
240     if (!this.#sessionID) {
241       let uuid = Services.uuid.generateUUID().toString();
242       this.#sessionID = uuid.substring(1, uuid.length - 1);
243       this.#sequenceNumber = 0;
244       this.#sessionTimer?.cancel();
246       // Per spec, for the user's privacy, the session should time out and a new
247       // session ID should be used if the engagement does not end soon.
248       this.#sessionTimer = new lazy.SkippableTimer({
249         name: "Merino session timeout",
250         time: this.#sessionTimeoutMs,
251         logger: this.logger,
252         callback: () => this.resetSession(),
253       });
254     }
255     url.searchParams.set(SEARCH_PARAMS.SESSION_ID, this.#sessionID);
256     url.searchParams.set(SEARCH_PARAMS.SEQUENCE_NUMBER, this.#sequenceNumber);
257     this.#sequenceNumber++;
259     let recordResponse = category => {
260       this.logger.debug("Fetch done", { status: category });
261       Services.telemetry.getHistogramById(HISTOGRAM_RESPONSE).add(category);
262       if (extraResponseHistogram) {
263         Services.telemetry
264           .getHistogramById(extraResponseHistogram)
265           .add(category);
266       }
267       this.#lastFetchStatus = category;
268       recordResponse = null;
269     };
271     // Set up the timeout timer.
272     let timer = (this.#timeoutTimer = new lazy.SkippableTimer({
273       name: "Merino timeout",
274       time: timeoutMs,
275       logger: this.logger,
276       callback: () => {
277         // The fetch timed out.
278         this.logger.debug("Fetch timed out", { timeoutMs });
279         recordResponse?.("timeout");
280       },
281     }));
283     // If there's an ongoing fetch, abort it so there's only one at a time. By
284     // design we do not abort fetches on timeout or when the query is canceled
285     // so we can record their latency.
286     try {
287       this.#fetchController?.abort();
288     } catch (error) {
289       this.logger.error("Error aborting previous fetch", error);
290     }
292     // Do the fetch.
293     let response;
294     let controller = (this.#fetchController = new AbortController());
295     let stopwatchInstance = (this.#latencyStopwatchInstance = {});
296     TelemetryStopwatch.start(HISTOGRAM_LATENCY, stopwatchInstance);
297     if (extraLatencyHistogram) {
298       TelemetryStopwatch.start(extraLatencyHistogram, stopwatchInstance);
299     }
300     await Promise.race([
301       timer.promise,
302       (async () => {
303         try {
304           // Canceling the timer below resolves its promise, which can resolve
305           // the outer promise created by `Promise.race`. This inner async
306           // function happens not to await anything after canceling the timer,
307           // but if it did, `timer.promise` could win the race and resolve the
308           // outer promise without a value. For that reason, we declare
309           // `response` in the outer scope and set it here instead of returning
310           // the response from this inner function and assuming it will also be
311           // returned by `Promise.race`.
312           response = await fetch(url, { signal: controller.signal });
313           TelemetryStopwatch.finish(HISTOGRAM_LATENCY, stopwatchInstance);
314           if (extraLatencyHistogram) {
315             TelemetryStopwatch.finish(extraLatencyHistogram, stopwatchInstance);
316           }
317           this.logger.debug("Got response", {
318             status: response.status,
319             ...details,
320           });
321           if (!response.ok) {
322             recordResponse?.("http_error");
323           }
324         } catch (error) {
325           TelemetryStopwatch.cancel(HISTOGRAM_LATENCY, stopwatchInstance);
326           if (extraLatencyHistogram) {
327             TelemetryStopwatch.cancel(extraLatencyHistogram, stopwatchInstance);
328           }
329           if (error.name != "AbortError") {
330             this.logger.error("Fetch error", error);
331             recordResponse?.("network_error");
332           }
333         } finally {
334           // Now that the fetch is done, cancel the timeout timer so it doesn't
335           // fire and record a timeout. If it already fired, which it would have
336           // on timeout, or was already canceled, this is a no-op.
337           timer.cancel();
338           if (controller == this.#fetchController) {
339             this.#fetchController = null;
340           }
341           this.#nextResponseDeferred?.resolve(response);
342           this.#nextResponseDeferred = null;
343         }
344       })(),
345     ]);
346     if (timer == this.#timeoutTimer) {
347       this.#timeoutTimer = null;
348     }
350     // Get the response body as an object.
351     let body;
352     try {
353       body = await response?.json();
354     } catch (error) {
355       this.logger.error("Error getting response as JSON", error);
356     }
358     if (body) {
359       this.logger.debug("Response body", body);
360     }
362     if (!body?.suggestions?.length) {
363       recordResponse?.("no_suggestion");
364       return [];
365     }
367     let { suggestions, request_id } = body;
368     if (!Array.isArray(suggestions)) {
369       this.logger.error("Unexpected response", body);
370       recordResponse?.("no_suggestion");
371       return [];
372     }
374     recordResponse?.("success");
375     suggestions = suggestions.map(suggestion => ({
376       ...suggestion,
377       request_id,
378       source: "merino",
379     }));
381     if (cacheKey) {
382       this.#cache = {
383         suggestions,
384         key: cacheKey,
385         dateMs: Date.now(),
386       };
387     }
389     return suggestions;
390   }
392   /**
393    * Resets the Merino session ID and related state.
394    */
395   resetSession() {
396     this.#sessionID = null;
397     this.#sequenceNumber = 0;
398     this.#sessionTimer?.cancel();
399     this.#sessionTimer = null;
400     this.#nextSessionResetDeferred?.resolve();
401     this.#nextSessionResetDeferred = null;
402   }
404   /**
405    * Cancels the timeout timer.
406    */
407   cancelTimeoutTimer() {
408     this.#timeoutTimer?.cancel();
409   }
411   /**
412    * Returns a promise that's resolved when the next response is received or a
413    * network error occurs.
414    *
415    * @returns {Promise}
416    *   The promise is resolved with the `Response` object or undefined if a
417    *   network error occurred.
418    */
419   waitForNextResponse() {
420     if (!this.#nextResponseDeferred) {
421       this.#nextResponseDeferred = Promise.withResolvers();
422     }
423     return this.#nextResponseDeferred.promise;
424   }
426   /**
427    * Returns a promise that's resolved when the session is next reset, including
428    * on session timeout.
429    *
430    * @returns {Promise}
431    */
432   waitForNextSessionReset() {
433     if (!this.#nextSessionResetDeferred) {
434       this.#nextSessionResetDeferred = Promise.withResolvers();
435     }
436     return this.#nextSessionResetDeferred.promise;
437   }
439   static _test_disableCache = false;
441   get _test_sessionTimer() {
442     return this.#sessionTimer;
443   }
445   get _test_timeoutTimer() {
446     return this.#timeoutTimer;
447   }
449   get _test_fetchController() {
450     return this.#fetchController;
451   }
453   get _test_latencyStopwatchInstance() {
454     return this.#latencyStopwatchInstance;
455   }
457   // State related to the current session.
458   #sessionID = null;
459   #sequenceNumber = 0;
460   #sessionTimer = null;
461   #sessionTimeoutMs = SESSION_TIMEOUT_MS;
463   #name;
464   #timeoutTimer = null;
465   #fetchController = null;
466   #latencyStopwatchInstance = null;
467   #lastFetchStatus = null;
468   #nextResponseDeferred = null;
469   #nextSessionResetDeferred = null;
470   #cachePeriodMs = 0;
472   // When caching is enabled, we cache response suggestions from the most recent
473   // successful request.
474   #cache = {
475     // The cached suggestions array.
476     suggestions: null,
477     // The cache key: the stringified request URL without session-related params
478     // (session ID and sequence number).
479     key: null,
480     // The date the suggestions were cached as returned by `Date.now()`.
481     dateMs: 0,
482   };