Record telemetry for the host of geo.provider.network.url as GLS, BeaconDB or something else, and record that category in the geolocation.network_provider labeled counter for every request the NetworkGeolocationProvider sends. Differential Revision: https://phabricator.services.mozilla.com/D325201
527 lines
15 KiB
JavaScript
527 lines
15 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, {
|
|
LocationHelper: "resource://gre/modules/LocationHelper.sys.mjs",
|
|
clearTimeout: "resource://gre/modules/Timer.sys.mjs",
|
|
setTimeout: "resource://gre/modules/Timer.sys.mjs",
|
|
});
|
|
|
|
// GeolocationPositionError has no interface object, so we can't use that here.
|
|
const POSITION_UNAVAILABLE = 2;
|
|
|
|
XPCOMUtils.defineLazyPreferenceGetter(
|
|
lazy,
|
|
"gNetworkGeolocationLogLevel",
|
|
"geo.provider.network.loglevel",
|
|
"Off"
|
|
);
|
|
|
|
ChromeUtils.defineLazyGetter(lazy, "log", () => {
|
|
let consoleOptions = {
|
|
maxLogLevelPref: lazy.gNetworkGeolocationLogLevel,
|
|
prefix: "NetworkGeolocationProvider",
|
|
};
|
|
return console.createInstance(consoleOptions);
|
|
});
|
|
|
|
// Registrable domains of the network geolocation services we report on
|
|
// individually, mapped to their geolocation.network_provider Glean label.
|
|
const KNOWN_PROVIDER_DOMAINS = new Map([
|
|
["googleapis.com", "google"],
|
|
["beacondb.net", "beacondb"],
|
|
]);
|
|
|
|
/**
|
|
* Categorize the configured network geolocation endpoint for telemetry.
|
|
*
|
|
* @param {string} url The url
|
|
* @returns {string} The geolocation.network_provider label to record:
|
|
* a value in KNOWN_PROVIDER_DOMAINS, "other" for an
|
|
* unrecognized host, or "unknown" if the URL has no host we
|
|
* can parse.
|
|
*/
|
|
export function networkProviderLabel(url) {
|
|
let host;
|
|
try {
|
|
host = Services.io.newURI(url).host;
|
|
} catch {
|
|
return "unknown";
|
|
}
|
|
|
|
if (!host) {
|
|
return "unknown";
|
|
}
|
|
|
|
for (let [domain, label] of KNOWN_PROVIDER_DOMAINS) {
|
|
if (host == domain || host.endsWith("." + domain)) {
|
|
return label;
|
|
}
|
|
}
|
|
|
|
return "other";
|
|
}
|
|
|
|
function CachedRequest(loc, wifiList) {
|
|
this.location = loc;
|
|
|
|
let wifis = new Set();
|
|
if (wifiList) {
|
|
for (let i = 0; i < wifiList.length; i++) {
|
|
wifis.add(wifiList[i].macAddress);
|
|
}
|
|
}
|
|
|
|
this.hasWifis = () => wifis.size > 0;
|
|
|
|
// if 50% of the SSIDS match
|
|
this.isWifiApproxEqual = function (wifiList) {
|
|
if (!this.hasWifis()) {
|
|
return false;
|
|
}
|
|
|
|
// if either list is a 50% subset of the other, they are equal
|
|
let common = 0;
|
|
for (let i = 0; i < wifiList.length; i++) {
|
|
if (wifis.has(wifiList[i].macAddress)) {
|
|
common++;
|
|
}
|
|
}
|
|
let kPercentMatch = 0.5;
|
|
return common >= Math.max(wifis.size, wifiList.length) * kPercentMatch;
|
|
};
|
|
|
|
this.isGeoip = function () {
|
|
return !this.hasWifis();
|
|
};
|
|
}
|
|
|
|
/** @type {CachedRequest?} */
|
|
var gCachedRequest = null;
|
|
var gDebugCacheReasoning = ""; // for logging the caching logic
|
|
|
|
// This function serves two purposes:
|
|
// 1) do we have a cached request
|
|
// 2) is the cached request better than what newWifiList will obtain
|
|
// If the cached request exists, and we know it to have greater accuracy
|
|
// by the nature of its origin (wifi/geoip), use its cached location.
|
|
//
|
|
// If there is more source info than the cached request had, return false
|
|
// In other cases, MLS is known to produce better/worse accuracy based on the
|
|
// inputs, so base the decision on that.
|
|
function isCachedRequestMoreAccurateThanServerRequest(newWifiList) {
|
|
gDebugCacheReasoning = "";
|
|
let isNetworkRequestCacheEnabled = Services.prefs.getBoolPref(
|
|
"geo.provider.network.debug.requestCache.enabled",
|
|
true
|
|
);
|
|
// Mochitest needs this pref to simulate request failure
|
|
if (!isNetworkRequestCacheEnabled) {
|
|
gCachedRequest = null;
|
|
}
|
|
|
|
if (!gCachedRequest || !isNetworkRequestCacheEnabled) {
|
|
gDebugCacheReasoning = "No cached data";
|
|
return false;
|
|
}
|
|
|
|
if (!newWifiList) {
|
|
gDebugCacheReasoning = "New req. is GeoIP.";
|
|
return true;
|
|
}
|
|
|
|
let hasEqualWifis = false;
|
|
if (newWifiList) {
|
|
hasEqualWifis = gCachedRequest.isWifiApproxEqual(newWifiList);
|
|
}
|
|
|
|
gDebugCacheReasoning = `EqualWifis: ${hasEqualWifis}`;
|
|
|
|
if (gCachedRequest.hasWifis() && hasEqualWifis) {
|
|
gDebugCacheReasoning += ", Wifi only.";
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function NetworkGeoCoordsObject(lat, lon, acc) {
|
|
this.latitude = lat;
|
|
this.longitude = lon;
|
|
this.accuracy = acc;
|
|
|
|
// Neither GLS nor MLS return the following properties, so set them to NaN
|
|
// here. nsGeoPositionCoords will convert NaNs to null for optional properties
|
|
// of the JavaScript Coordinates object.
|
|
this.altitude = NaN;
|
|
this.altitudeAccuracy = NaN;
|
|
this.heading = NaN;
|
|
this.speed = NaN;
|
|
}
|
|
|
|
NetworkGeoCoordsObject.prototype = {
|
|
QueryInterface: ChromeUtils.generateQI(["nsIDOMGeoPositionCoords"]),
|
|
};
|
|
|
|
function NetworkGeoPositionObject(lat, lng, acc) {
|
|
this.coords = new NetworkGeoCoordsObject(lat, lng, acc);
|
|
this.address = null;
|
|
this.timestamp = Date.now();
|
|
}
|
|
|
|
NetworkGeoPositionObject.prototype = {
|
|
QueryInterface: ChromeUtils.generateQI(["nsIDOMGeoPosition"]),
|
|
};
|
|
|
|
export function NetworkGeolocationProvider() {
|
|
/*
|
|
The _wifiMonitorTimeout controls how long we wait on receiving an update
|
|
from the Wifi subsystem. If this timer fires, we believe the Wifi scan has
|
|
had a problem and we no longer can use Wifi to position the user this time
|
|
around (we will continue to be hopeful that Wifi will recover).
|
|
*/
|
|
XPCOMUtils.defineLazyPreferenceGetter(
|
|
this,
|
|
"_wifiMonitorTimeout",
|
|
"geo.provider.network.timeToWaitBeforeSending",
|
|
5000
|
|
);
|
|
|
|
XPCOMUtils.defineLazyPreferenceGetter(
|
|
this,
|
|
"_wifiScanningEnabled",
|
|
"geo.provider.network.scan",
|
|
true
|
|
);
|
|
|
|
// Upper bound for the exponential backoff applied to the repeating request
|
|
// timer after consecutive network failures.
|
|
XPCOMUtils.defineLazyPreferenceGetter(
|
|
this,
|
|
"_backoffMaxMs",
|
|
"geo.provider.network.backoffMaxMs",
|
|
20 * 1000 // 20sec
|
|
);
|
|
|
|
// Rate at which to scale the repeating request timer duration after
|
|
// consecutive network failures.
|
|
XPCOMUtils.defineLazyPreferenceGetter(
|
|
this,
|
|
"_backoffScale",
|
|
"geo.provider.network.backoffScale",
|
|
1.1 // 10% increase
|
|
);
|
|
|
|
this.wifiService = null;
|
|
this.timer = null;
|
|
this.started = false;
|
|
// Current repeating-timer interval; grows on failure (up to _backoffMaxMs),
|
|
// resets to _wifiMonitorTimeout on a new request or a success.
|
|
this._currentTimerInterval = null;
|
|
}
|
|
|
|
NetworkGeolocationProvider.prototype = {
|
|
classID: Components.ID("{77DA64D3-7458-4920-9491-86CC9914F904}"),
|
|
name: "NetworkGeolocationProvider",
|
|
QueryInterface: ChromeUtils.generateQI([
|
|
"nsIGeolocationProvider",
|
|
"nsIWifiListener",
|
|
"nsITimerCallback",
|
|
"nsIObserver",
|
|
"nsINamed",
|
|
]),
|
|
listener: null,
|
|
|
|
get isWifiScanningEnabled() {
|
|
return Cc["@mozilla.org/wifi/monitor;1"] && this._wifiScanningEnabled;
|
|
},
|
|
|
|
resetTimer() {
|
|
if (this.timer) {
|
|
this.timer.cancel();
|
|
this.timer = null;
|
|
}
|
|
if (this._currentTimerInterval == null) {
|
|
this._currentTimerInterval = this._wifiMonitorTimeout;
|
|
}
|
|
// Wifi thread triggers NetworkGeolocationProvider to proceed. With no wifi,
|
|
// do manual timeout. The interval is extended by _increaseBackoff() while
|
|
// requests are failing and restored by _resetBackoff().
|
|
this.timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
|
|
this.timer.initWithCallback(
|
|
this,
|
|
this._currentTimerInterval,
|
|
this.timer.TYPE_REPEATING_SLACK
|
|
);
|
|
},
|
|
|
|
_resetBackoff() {
|
|
this._currentTimerInterval = this._wifiMonitorTimeout;
|
|
},
|
|
|
|
_increaseBackoff() {
|
|
let current = this._currentTimerInterval || this._wifiMonitorTimeout;
|
|
this._currentTimerInterval = Math.min(
|
|
current * this._backoffScale,
|
|
this._backoffMaxMs
|
|
);
|
|
},
|
|
|
|
startup() {
|
|
lazy.log.debug("startup called.");
|
|
|
|
// startup() may be called again for each new geolocation request (per
|
|
// nsIGeolocationProvider). Treat it as a fresh request and restart the
|
|
// failure backoff so the request is served at the normal cadence instead
|
|
// of waiting out a prior failure's backoff.
|
|
this._resetBackoff();
|
|
|
|
if (this.started) {
|
|
// Already running: re-arm the repeating timer at the reset interval.
|
|
this.resetTimer();
|
|
return;
|
|
}
|
|
|
|
this.started = true;
|
|
|
|
if (this.isWifiScanningEnabled) {
|
|
if (this.wifiService) {
|
|
this.wifiService.stopWatching(this);
|
|
}
|
|
this.wifiService = Cc["@mozilla.org/wifi/monitor;1"].getService(
|
|
Ci.nsIWifiMonitor
|
|
);
|
|
this.wifiService.startWatching(this, false);
|
|
}
|
|
|
|
this.resetTimer();
|
|
},
|
|
|
|
watch(c) {
|
|
lazy.log.debug("watch called");
|
|
this.listener = c;
|
|
this.notify();
|
|
this.resetTimer();
|
|
},
|
|
|
|
shutdown() {
|
|
lazy.log.debug("shutdown called");
|
|
if (!this.started) {
|
|
return;
|
|
}
|
|
|
|
// The request cache is intentionally retained across shutdown so a recent
|
|
// position can be reused across the provider's stop/restart cycles within a
|
|
// browser run, instead of cold-starting a network request for each
|
|
// intermittent geolocation use.
|
|
|
|
if (this.timer) {
|
|
this.timer.cancel();
|
|
this.timer = null;
|
|
}
|
|
|
|
if (this.wifiService) {
|
|
this.wifiService.stopWatching(this);
|
|
this.wifiService = null;
|
|
}
|
|
|
|
this.listener = null;
|
|
this.started = false;
|
|
},
|
|
|
|
setHighAccuracy(enable) {
|
|
// Mochitest wants to check this value
|
|
if (Services.prefs.getBoolPref("geo.provider.testing", false)) {
|
|
Services.obs.notifyObservers(
|
|
null,
|
|
"testing-geolocation-high-accuracy",
|
|
enable
|
|
);
|
|
}
|
|
},
|
|
|
|
onChange(accessPoints) {
|
|
// we got some wifi data, rearm the timer.
|
|
this.resetTimer();
|
|
|
|
let wifiData = null;
|
|
if (accessPoints) {
|
|
wifiData = lazy.LocationHelper.formatWifiAccessPoints(accessPoints);
|
|
}
|
|
this.sendLocationRequest(wifiData);
|
|
},
|
|
|
|
onError(code) {
|
|
lazy.log.debug("wifi error: " + code);
|
|
this.sendLocationRequest(null);
|
|
},
|
|
|
|
onStatus(err, statusMessage) {
|
|
if (!this.listener) {
|
|
return;
|
|
}
|
|
lazy.log.debug("onStatus called." + statusMessage);
|
|
|
|
if (statusMessage && this.listener.notifyStatus) {
|
|
this.listener.notifyStatus(statusMessage);
|
|
}
|
|
|
|
if (err && this.listener.notifyError) {
|
|
this.listener.notifyError(POSITION_UNAVAILABLE, statusMessage);
|
|
}
|
|
},
|
|
|
|
notify() {
|
|
this.onStatus(false, "wifi-timeout");
|
|
this.sendLocationRequest(null);
|
|
},
|
|
|
|
/**
|
|
* After wifi data has been gathered, this method is invoked to perform the
|
|
* request to network geolocation provider.
|
|
* The result of each request is sent to all registered listener (@see watch)
|
|
* by invoking its respective `update`, `notifyError` or `notifyStatus`
|
|
* callbacks.
|
|
* `update` is called upon a successful request with its response data; this will be a `NetworkGeoPositionObject` instance.
|
|
* `notifyError` is called whenever the request gets an error from the local
|
|
* network subsystem, the server or simply times out.
|
|
* `notifyStatus` is called for each status change of the request that may be
|
|
* of interest to the consumer of this class. Currently the following status
|
|
* changes are reported: 'xhr-start', 'xhr-timeout', 'xhr-error' and
|
|
* 'xhr-empty'.
|
|
*
|
|
* @param {Array} wifiData Optional set of publicly available wifi networks
|
|
* in the following structure:
|
|
* <code>
|
|
* [
|
|
* { macAddress: <mac1>, signalStrength: <signal1> },
|
|
* { macAddress: <mac2>, signalStrength: <signal2> }
|
|
* ]
|
|
* </code>
|
|
*/
|
|
async sendLocationRequest(wifiData) {
|
|
let data = { wifiAccessPoints: undefined };
|
|
// Zero or one entry means lookup is only by our own IP address.
|
|
if (wifiData && wifiData.length >= 2) {
|
|
data.wifiAccessPoints = wifiData;
|
|
}
|
|
|
|
let useCached = isCachedRequestMoreAccurateThanServerRequest(
|
|
data.wifiAccessPoints
|
|
);
|
|
|
|
lazy.log.debug(
|
|
"Use request cache:" + useCached + " reason:" + gDebugCacheReasoning
|
|
);
|
|
|
|
if (useCached) {
|
|
Glean.geolocation.geolocationCacheHit.NetworkGeolocationProvider.add();
|
|
|
|
gCachedRequest.location.timestamp = Date.now();
|
|
if (this.listener) {
|
|
this.listener.update(gCachedRequest.location);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// From here on, do a network geolocation request //
|
|
let url = Services.urlFormatter.formatURLPref("geo.provider.network.url");
|
|
let logStr = data.wifiAccessPoints ? " with wifi APs" : "";
|
|
lazy.log.info(
|
|
`Sending IP-address-based geolocation request${logStr} to network service: ${url}`
|
|
);
|
|
|
|
let result;
|
|
try {
|
|
result = await this.fetchLocation(url, wifiData);
|
|
lazy.log.info(
|
|
`geo provider reported: ${result.location.lng}:${result.location.lat}`
|
|
);
|
|
let newLocation = new NetworkGeoPositionObject(
|
|
result.location.lat,
|
|
result.location.lng,
|
|
result.accuracy
|
|
);
|
|
|
|
if (this.listener) {
|
|
this.listener.update(newLocation);
|
|
}
|
|
|
|
gCachedRequest = new CachedRequest(newLocation, data.wifiAccessPoints);
|
|
|
|
// Recovered: if we had backed off, return the timer to normal cadence.
|
|
if (this._currentTimerInterval !== this._wifiMonitorTimeout) {
|
|
this._resetBackoff();
|
|
this.resetTimer();
|
|
}
|
|
} catch (err) {
|
|
lazy.log.error("Location request hit error: " + err.name);
|
|
console.error(err);
|
|
if (err.name == "AbortError") {
|
|
this.onStatus(true, "xhr-timeout");
|
|
} else {
|
|
this.onStatus(true, "xhr-error");
|
|
}
|
|
// Slow down the repeating retry timer while the endpoint keeps failing,
|
|
// to avoid hammering it (and draining battery). Capped at _backoffMaxMs.
|
|
let prevInterval = this._currentTimerInterval;
|
|
this._increaseBackoff();
|
|
if (this._currentTimerInterval !== prevInterval) {
|
|
this.resetTimer();
|
|
}
|
|
}
|
|
},
|
|
|
|
async fetchLocation(url, wifiData) {
|
|
this.onStatus(false, "xhr-start");
|
|
|
|
let fetchController = new AbortController();
|
|
let fetchOpts = {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json; charset=UTF-8" },
|
|
credentials: "omit",
|
|
signal: fetchController.signal,
|
|
};
|
|
|
|
if (wifiData) {
|
|
fetchOpts.body = JSON.stringify({ wifiAccessPoints: wifiData });
|
|
}
|
|
|
|
let timeoutId = lazy.setTimeout(
|
|
() => fetchController.abort(),
|
|
Services.prefs.getIntPref("geo.provider.network.timeout", 60000)
|
|
);
|
|
|
|
let isWifi = wifiData && wifiData.length >= 2;
|
|
let label = isWifi ? "network_wifi_and_ip" : "network_ip";
|
|
Glean.geolocation.geolocationService[label].add();
|
|
Glean.geolocation.networkProvider[networkProviderLabel(url)].add();
|
|
|
|
let response;
|
|
try {
|
|
response = await fetch(url, fetchOpts);
|
|
} catch (err) {
|
|
Glean.geolocation.networkFailures[label].add();
|
|
throw err;
|
|
} finally {
|
|
lazy.clearTimeout(timeoutId);
|
|
}
|
|
|
|
if (!response.ok) {
|
|
Glean.geolocation.networkFailures[label].add();
|
|
throw new Error(
|
|
`The geolocation provider returned a non-ok status ${response.status}`,
|
|
{ cause: await response.text() }
|
|
);
|
|
}
|
|
|
|
let result = response.json();
|
|
return result;
|
|
},
|
|
};
|