Source code

Revision control

Copy as Markdown

Other Tools

/* 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 { Log } from "resource://gre/modules/Log.sys.mjs";
import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
const LOGGER_NAME = "Toolkit.Telemetry";
const LOGGER_PREFIX = "ClientID::";
// Must match ID in TelemetryUtils
const CANARY_CLIENT_ID = "c0ffeec0-ffee-c0ff-eec0-ffeec0ffeec0";
const CANARY_PROFILE_GROUP_ID = "decafdec-afde-cafd-ecaf-decafdecafde";
const CANARY_USAGE_PROFILE_ID = "beefbeef-beef-beef-beef-beeefbeefbee";
const DRS_STATE_VERSION = 2;
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
});
ChromeUtils.defineLazyGetter(lazy, "CryptoHash", () => {
return Components.Constructor(
"@mozilla.org/security/hash;1",
"nsICryptoHash",
"initWithString"
);
});
ChromeUtils.defineLazyGetter(lazy, "gDatareportingPath", () => {
return PathUtils.join(
Services.dirsvc.get("ProfD", Ci.nsIFile).path,
"datareporting"
);
});
ChromeUtils.defineLazyGetter(lazy, "gStateFilePath", () => {
return PathUtils.join(lazy.gDatareportingPath, "state.json");
});
const PREF_CACHED_CLIENTID = "toolkit.telemetry.cachedClientID";
const PREF_CACHED_PROFILEGROUPID = "toolkit.telemetry.cachedProfileGroupID";
const PREF_CACHED_USAGE_PROFILEID = "datareporting.dau.cachedUsageProfileID";
/**
* Checks if the string is a valid UUID (without braces).
*
* @param {String} id A string containing an ID.
* @return {Boolean} True when the ID has valid format, or False otherwise.
*/
function isValidUUID(id) {
const UUID_REGEX =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
return UUID_REGEX.test(id);
}
export var ClientID = Object.freeze({
/**
* This returns a promise resolving to the stable client ID we use for
* data reporting.
*
* @return {Promise<string>} The stable client ID.
*/
getClientID() {
return ClientIDImpl.getClientID();
},
/**
* This returns a promise resolving to the stable profile group ID we use for
* data reporting.
*
* @return {Promise<string>} The stable profile group ID.
*/
getProfileGroupID() {
return ClientIDImpl.getProfileGroupID();
},
/**
* This returns a promise resolving to the stable Usage Profile ID we use for
* DAU reporting.
*
* @return {Promise<string>} The stable Usage Profile ID.
*/
getUsageProfileID() {
return ClientIDImpl.getUsageProfileID();
},
/**
* Asynchronously updates the stable profile group ID we use for data
* reporting.
*
* @param {String} id A string containing the profile group ID.
* @return {Promise} Resolves when the ID is updated.
*/
setProfileGroupID(id) {
return ClientIDImpl.setProfileGroupID(id);
},
/**
* Asynchronously updates the stable Usage Profile ID we use for DAU
* reporting.
*
* @param {String} id A string containing the Usage Profile ID.
* @return {Promise} Resolves when the ID is updated.
*/
setUsageProfileID(id) {
return ClientIDImpl.setUsageProfileID(id);
},
/**
* Get the client id synchronously without hitting the disk.
* This returns:
* - the current on-disk client id if it was already loaded
* - the client id that we cached into preferences (if any)
* - null otherwise
*/
getCachedClientID() {
return ClientIDImpl.getCachedClientID();
},
/**
* Get the profile group ID synchronously without hitting the disk.
* This returns:
* - the current on-disk profile group ID if it was already loaded
* - the profile group ID that we cached into preferences (if any)
* - null otherwise
*/
getCachedProfileGroupID() {
return ClientIDImpl.getCachedProfileGroupID();
},
/**
* Get the Usage Profile ID synchronously without hitting the disk.
* This returns:
* - the current on-disk Usage Profile ID if it was already loaded
* - the Usage Profile ID that we cached into preferences (if any)
* - null otherwise
*/
getCachedUsageProfileID() {
return ClientIDImpl.getCachedUsageProfileID();
},
async getClientIdHash() {
return ClientIDImpl.getClientIdHash();
},
/**
* Sets the client ID and profile group ID to the canary (known) identifiers,
* writing it to disk and updating the cached version.
*
* Use `resetIdentifiers` to clear the existing identifiers and generate new,
* random ones if required.
*
* @return {Promise<void>}
*/
setCanaryIdentifiers() {
return ClientIDImpl.setCanaryIdentifiers();
},
/**
* Sets the Usage Profile ID to the canary (known) identifier,
* writing it to disk and updating the cached version.
*
* Does not touch the client ID or profile group ID.
*
* Use `resetUsageProfileIdentifier` to clear the existing identifier
* and generate a new, random one if required.
*
* @return {Promise<void>}
*/
setCanaryUsageProfileIdentifier() {
return ClientIDImpl.setCanaryUsageProfileIdentifier();
},
/**
* Assigns new random values to client ID and profile group ID. Should only be
* used if a reset is explicitly requested by the user.
*
* @return {Promise<void>}
*/
resetIdentifiers() {
return ClientIDImpl.resetIdentifiers();
},
/**
* Assigns a new random value to the Usage Profile ID.
* Should only be used if a reset is explicitly requested by the user.
*
* Does not touch the client ID or profile group ID.
*
* @return {Promise<void>}
*/
resetUsageProfileIdentifier() {
return ClientIDImpl.resetUsageProfileIdentifier();
},
/**
* Only used for testing. Invalidates the cached client ID and profile group
* ID so that they are read again from file, but doesn't remove the existing
* IDs from disk.
*/
_reset() {
return ClientIDImpl._reset();
},
});
var ClientIDImpl = {
_clientID: null,
_clientIDHash: null,
_profileGroupID: null,
_usageProfileID: null,
_loadClientIdTask: null,
_saveDataReportingStateTask: null,
_resetIdentifiersTask: null,
_resetUsageProfileIdentifierTask: null,
_logger: null,
_loadDataReportingState() {
if (this._loadClientIdTask) {
return this._loadClientIdTask;
}
this._loadClientIdTask = this._doLoadDataReportingState();
let clear = () => (this._loadClientIdTask = null);
this._loadClientIdTask.then(clear, clear);
return this._loadClientIdTask;
},
/**
* Load the client ID, profile group ID and Usage Profile ID from the DataReporting Service
* state file. If any are missing, we generate a new one.
*/
async _doLoadDataReportingState() {
this._log.trace(`_doLoadDataReportingState`);
// If there's a removal in progress, let's wait for it
await this._resetIdentifiersTask;
await this._resetUsageProfileIdentifierTask;
// Try to load the client id from the DRS state file.
let hasCurrentClientID = false;
let hasCurrentProfileGroupID = false;
let hasCurrentUsageProfileID = false;
try {
let state = await IOUtils.readJSON(lazy.gStateFilePath);
if (state) {
if (!("version" in state)) {
// Old version, clear out any previously generated profile group ID.
delete state.profileGroupID;
}
hasCurrentClientID = this.updateClientID(state.clientID);
hasCurrentProfileGroupID = this.updateProfileGroupID(
state.profileGroupID
);
hasCurrentUsageProfileID = this.updateUsageProfileID(
state.usageProfileID
);
if (!hasCurrentProfileGroupID && hasCurrentClientID) {
// A pre-existing profile should be assigned the existing client ID.
hasCurrentProfileGroupID = this.updateProfileGroupID(this._clientID);
if (hasCurrentProfileGroupID) {
this._saveDataReportingStateTask = this._saveDataReportingState();
await this._saveDataReportingStateTask;
}
}
if (
hasCurrentClientID &&
hasCurrentProfileGroupID &&
hasCurrentUsageProfileID
) {
this._log.trace(
`_doLoadDataReportingState: Client ID, Profile Group ID and Usage Profile ID loaded from state.`
);
return {
clientID: this._clientID,
profileGroupID: this._profileGroupID,
usageProfileID: this._usageProfileID,
};
}
}
} catch (e) {
// fall through to next option
}
// Absent or broken state file? Check prefs as last resort.
if (!hasCurrentClientID) {
const cachedID = this.getCachedClientID();
// Calling `updateClientID` with `null` logs an error, which breaks tests.
if (cachedID) {
hasCurrentClientID = this.updateClientID(cachedID);
}
}
if (!hasCurrentProfileGroupID) {
const cachedID = this.getCachedProfileGroupID();
// Calling `updateProfileGroupID` with `null` logs an error, which breaks tests.
if (cachedID) {
hasCurrentProfileGroupID = this.updateProfileGroupID(cachedID);
}
}
if (!hasCurrentUsageProfileID) {
const cachedID = this.getCachedUsageProfileID();
// Calling `updateUsageProfileID` with `null` logs an error, which breaks tests.
if (cachedID) {
hasCurrentUsageProfileID = this.updateUsageProfileID(cachedID);
}
}
// We're missing the ID from the DRS state file and prefs.
// Generate a new one.
if (!hasCurrentClientID) {
this.updateClientID(lazy.CommonUtils.generateUUID());
}
if (!hasCurrentProfileGroupID) {
this.updateProfileGroupID(lazy.CommonUtils.generateUUID());
}
if (!hasCurrentUsageProfileID) {
this.updateUsageProfileID(lazy.CommonUtils.generateUUID());
}
this._saveDataReportingStateTask = this._saveDataReportingState();
// Wait on persisting the id. Otherwise failure to save the ID would result in
// the client creating and subsequently sending multiple IDs to the server.
// This would appear as multiple clients submitting similar data, which would
// result in orphaning.
await this._saveDataReportingStateTask;
this._log.trace(
"_doLoadDataReportingState: client ID, profile group ID and Usage Profile ID loaded and persisted."
);
return {
clientID: this._clientID,
profileGroupID: this._profileGroupID,
usageProfileID: this._usageProfileID,
};
},
/**
* Save the client ID, profile group ID and Usage Profile ID to the DRS state file.
*
* @return {Promise} A promise resolved when the DRS state file is saved to disk.
*/
async _saveDataReportingState() {
try {
this._log.trace(`_saveDataReportingState`);
let obj = {
version: DRS_STATE_VERSION,
clientID: this._clientID,
profileGroupID: this._profileGroupID,
usageProfileID: this._usageProfileID,
};
await IOUtils.makeDirectory(lazy.gDatareportingPath);
await IOUtils.writeJSON(lazy.gStateFilePath, obj, {
tmpPath: `${lazy.gStateFilePath}.tmp`,
});
this._saveDataReportingStateTask = null;
} catch (ex) {
if (!DOMException.isInstance(ex) || ex.name !== "AbortError") {
throw ex;
}
}
},
/**
* This returns a promise resolving to the stable client ID we use for
* data reporting (FHR & Telemetry).
*
* @return {Promise<string>} The stable client ID.
*/
async getClientID() {
if (!this._clientID) {
let { clientID } = await this._loadDataReportingState();
if (AppConstants.platform != "android") {
Glean.legacyTelemetry.clientId.set(clientID);
}
return clientID;
}
return Promise.resolve(this._clientID);
},
/**
* This returns a promise resolving to the stable profile group ID we use for
* data reporting (FHR & Telemetry).
*
* @return {Promise<string>} The stable profile group ID.
*/
async getProfileGroupID() {
if (!this._profileGroupID) {
let { profileGroupID } = await this._loadDataReportingState();
if (AppConstants.platform != "android") {
Glean.legacyTelemetry.profileGroupId.set(profileGroupID);
}
return profileGroupID;
}
return this._profileGroupID;
},
/**
* This returns a promise resolving to the stable Usage Profile ID we use for
* DAU reporting.
*
* @return {Promise<string>} The stable Usage Profile ID.
*/
async getUsageProfileID() {
if (!this._usageProfileID) {
let { usageProfileID } = await this._loadDataReportingState();
if (AppConstants.platform != "android") {
Glean.usage.profileId.set(usageProfileID);
}
return usageProfileID;
}
return this._usageProfileID;
},
/**
* Asynchronously updates the stable profile group ID we use for data reporting
* (FHR & Telemetry).
*
* @param {String} id A string containing the profile group ID.
* @return {Promise} Resolves when the ID is updated.
*/
async setProfileGroupID(id) {
// Make sure that we have loaded the client ID. Do this before updating the
// profile group ID as loading would clobber that.
if (!this._clientID) {
await this._loadDataReportingState();
}
if (!(await this.updateProfileGroupID(id))) {
this._log.error(
"setProfileGroupID - invalid profile group ID passed, not updating"
);
throw new Error("Invalid profile group ID");
}
// If there is already a save in progress, wait for it to complete.
await this._saveDataReportingStateTask;
this._saveDataReportingStateTask = this._saveDataReportingState();
await this._saveDataReportingStateTask;
},
/**
* Asynchronously updates the stable Usage Profile ID we use for DAU reporting.
*
* @param {String} id A string containing the Usage Profile ID.
* @return {Promise} Resolves when the ID is updated.
*/
async setUsageProfileID(id) {
// Make sure that we have loaded the client ID. Do this before updating the
// Usage Profile ID as loading would clobber that.
if (!this._clientID) {
await this._loadDataReportingState();
}
if (!(await this.updateUsageProfileID(id))) {
this._log.error(
"setUsageProfileID - invalid Usage Profile ID passed, not updating"
);
throw new Error("Invalid Usage Profile ID");
}
// If there is already a save in progress, wait for it to complete.
await this._saveDataReportingStateTask;
this._saveDataReportingStateTask = this._saveDataReportingState();
await this._saveDataReportingStateTask;
},
/**
* Get the client id synchronously without hitting the disk.
* This returns:
* - the current on-disk client id if it was already loaded
* - the client id that we cached into preferences (if any)
* - null otherwise
*/
getCachedClientID() {
if (this._clientID) {
// Already loaded the client id from disk.
return this._clientID;
}
// If the client id cache contains a value of the wrong type,
// reset the pref. We need to do this before |getStringPref| since
// it will just return |null| in that case and we won't be able
// to distinguish between the missing pref and wrong type cases.
if (
Services.prefs.prefHasUserValue(PREF_CACHED_CLIENTID) &&
Services.prefs.getPrefType(PREF_CACHED_CLIENTID) !=
Ci.nsIPrefBranch.PREF_STRING
) {
this._log.error(
"getCachedClientID - invalid client id type in preferences, resetting"
);
Services.prefs.clearUserPref(PREF_CACHED_CLIENTID);
}
// Not yet loaded, return the cached client id if we have one.
let id = Services.prefs.getStringPref(PREF_CACHED_CLIENTID, null);
if (id === null) {
return null;
}
if (!isValidUUID(id)) {
this._log.error(
"getCachedClientID - invalid client id in preferences, resetting",
id
);
Services.prefs.clearUserPref(PREF_CACHED_CLIENTID);
return null;
}
return id;
},
/**
* Get the profile group ID synchronously without hitting the disk.
* This returns:
* - the current on-disk profile group ID if it was already loaded
* - the profile group ID that we cached into preferences (if any)
* - null otherwise
*/
getCachedProfileGroupID() {
if (this._profileGroupID) {
// Already loaded the profile group ID from disk.
return this._profileGroupID;
}
// If the profile group ID cache contains a value of the wrong type,
// reset the pref. We need to do this before |getStringPref| since
// it will just return |null| in that case and we won't be able
// to distinguish between the missing pref and wrong type cases.
if (
Services.prefs.prefHasUserValue(PREF_CACHED_PROFILEGROUPID) &&
Services.prefs.getPrefType(PREF_CACHED_PROFILEGROUPID) !=
Ci.nsIPrefBranch.PREF_STRING
) {
this._log.error(
"getCachedProfileGroupID - invalid profile group ID type in preferences, resetting"
);
Services.prefs.clearUserPref(PREF_CACHED_PROFILEGROUPID);
}
// Not yet loaded, return the cached profile group ID if we have one.
let id = Services.prefs.getStringPref(PREF_CACHED_PROFILEGROUPID, null);
if (id === null) {
return null;
}
if (!isValidUUID(id)) {
this._log.error(
"getCachedProfileGroupID - invalid profile group ID in preferences, resetting",
id
);
Services.prefs.clearUserPref(PREF_CACHED_PROFILEGROUPID);
return null;
}
return id;
},
/**
* Get the Usage Profile ID synchronously without hitting the disk.
* This returns:
* - the current on-disk Usage Profile ID if it was already loaded
* - the Usage Profile ID that we cached into preferences (if any)
* - null otherwise
*/
getCachedUsageProfileID() {
if (this._usageProfileID) {
// Already loaded the Usage Profile ID from disk.
return this._usageProfileID;
}
// If the Usage Profile ID cache contains a value of the wrong type,
// reset the pref. We need to do this before |getStringPref| since
// it will just return |null| in that case and we won't be able
// to distinguish between the missing pref and wrong type cases.
if (
Services.prefs.prefHasUserValue(PREF_CACHED_USAGE_PROFILEID) &&
Services.prefs.getPrefType(PREF_CACHED_USAGE_PROFILEID) !=
Ci.nsIPrefBranch.PREF_STRING
) {
this._log.error(
"getCachedUsageProfileID - invalid Usage Profile ID type in preferences, resetting"
);
Services.prefs.clearUserPref(PREF_CACHED_USAGE_PROFILEID);
}
// Not yet loaded, return the cached Usage Profile ID if we have one.
let id = Services.prefs.getStringPref(PREF_CACHED_USAGE_PROFILEID, null);
if (id === null) {
return null;
}
if (!isValidUUID(id)) {
this._log.error(
"getCachedUsageProfileID - invalid Usage Profile ID in preferences, resetting",
id
);
Services.prefs.clearUserPref(PREF_CACHED_USAGE_PROFILEID);
return null;
}
return id;
},
async getClientIdHash() {
if (!this._clientIDHash) {
let byteArr = new TextEncoder().encode(await this.getClientID());
let hash = new lazy.CryptoHash("sha256");
hash.update(byteArr, byteArr.length);
this._clientIDHash = lazy.CommonUtils.bytesAsHex(hash.finish(false));
}
return this._clientIDHash;
},
/**
* Resets the module. This is for testing only.
*/
async _reset() {
await this._loadClientIdTask;
await this._saveDataReportingStateTask;
this._clientID = null;
this._clientIDHash = null;
this._profileGroupID = null;
this._usageProfileID = null;
},
async setCanaryIdentifiers() {
this._log.trace("setCanaryIdentifiers");
this.updateClientID(CANARY_CLIENT_ID);
this.updateProfileGroupID(CANARY_PROFILE_GROUP_ID);
this._saveDataReportingStateTask = this._saveDataReportingState();
await this._saveDataReportingStateTask;
return this._clientID;
},
async setCanaryUsageProfileIdentifier() {
this._log.trace("setCanaryUsageProfileIdentifier");
this.updateUsageProfileID(CANARY_USAGE_PROFILE_ID);
this._saveDataReportingStateTask = this._saveDataReportingState();
await this._saveDataReportingStateTask;
return this._usageProfileID;
},
async _doResetIdentifiers() {
this._log.trace("_doResetIdentifiers");
// Reset the cached client ID.
this.updateClientID(lazy.CommonUtils.generateUUID());
this._clientIDHash = null;
// Reset the cached profile group ID.
this.updateProfileGroupID(lazy.CommonUtils.generateUUID());
// If there is a save in progress, wait for it to complete.
await this._saveDataReportingStateTask;
// Save the new identifiers to disk.
this._saveDataReportingStateTask = this._saveDataReportingState();
await this._saveDataReportingStateTask;
},
async _doResetUsageProfileIdentifier() {
this._log.trace("_doResetUsageProfileIdentifier");
// Reset the cached Usage Profile ID.
this.updateUsageProfileID(lazy.CommonUtils.generateUUID());
// If there is a save in progress, wait for it to complete.
await this._saveDataReportingStateTask;
// Save the new identifiers to disk.
this._saveDataReportingStateTask = this._saveDataReportingState();
await this._saveDataReportingStateTask;
},
async resetIdentifiers() {
this._log.trace("resetIdentifiers");
// Wait for the removal.
// Asynchronous calls to getClientID will also be blocked on this.
this._resetIdentifiersTask = this._doResetIdentifiers();
let clear = () => (this._resetIdentifiersTask = null);
this._resetIdentifiersTask.then(clear, clear);
await this._resetIdentifiersTask;
},
async resetUsageProfileIdentifier() {
this._log.trace("resetUsageProfileIdentifier");
// Wait for the removal.
// Asynchronous calls to getClientID will also be blocked on this.
this._resetUsageProfileIdentifierTask =
this._doResetUsageProfileIdentifier();
let clear = () => (this._resetUsageProfileIdentifierTask = null);
this._resetUsageProfileIdentifierTask.then(clear, clear);
await this._resetUsageProfileIdentifierTask;
},
/**
* Sets the client id to the given value and updates the value cached in
* preferences only if the given id is a valid UUID.
*
* @param {String} id A string containing the client ID.
* @return {Boolean} True when the client ID has valid format, or False
* otherwise.
*/
updateClientID(id) {
if (!isValidUUID(id)) {
this._log.error("updateClientID - invalid client ID", id);
return false;
}
this._clientID = id;
if (AppConstants.platform != "android") {
Glean.legacyTelemetry.clientId.set(id);
}
this._clientIDHash = null;
Services.prefs.setStringPref(PREF_CACHED_CLIENTID, this._clientID);
return true;
},
/**
* Sets the profile group ID to the given value and updates the value cached
* in preferences only if the given id is a valid UUID.
*
* @param {String} id A string containing the profile group ID.
* @return {Boolean} True when the profile group ID has valid format, or False
* otherwise.
*/
updateProfileGroupID(id) {
if (!isValidUUID(id)) {
this._log.error("updateProfileGroupID - invalid profile group ID", id);
return false;
}
this._profileGroupID = id;
if (AppConstants.platform != "android") {
Glean.legacyTelemetry.profileGroupId.set(id);
}
Services.prefs.setStringPref(
PREF_CACHED_PROFILEGROUPID,
this._profileGroupID
);
return true;
},
updateUsageProfileID(id) {
if (!isValidUUID(id)) {
this._log.error("updateUsageProfileID - invalid Usage Profile ID", id);
return false;
}
this._usageProfileID = id;
if (AppConstants.platform != "android") {
Glean.usage.profileId.set(id);
}
Services.prefs.setStringPref(
PREF_CACHED_USAGE_PROFILEID,
this._usageProfileID
);
return true;
},
/**
* A helper for getting access to telemetry logger.
*/
get _log() {
if (!this._logger) {
this._logger = Log.repository.getLoggerWithMessagePrefix(
LOGGER_NAME,
LOGGER_PREFIX
);
}
return this._logger;
},
};