forked from mirrors/gecko-dev
484 lines
14 KiB
JavaScript
484 lines
14 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 { SharedDataMap } from "resource://nimbus/lib/SharedDataMap.sys.mjs";
|
|
|
|
const lazy = {};
|
|
|
|
ChromeUtils.defineESModuleGetters(lazy, {
|
|
FeatureManifest: "resource://nimbus/FeatureManifest.sys.mjs",
|
|
PrefUtils: "resource://normandy/lib/PrefUtils.sys.mjs",
|
|
});
|
|
|
|
const IS_MAIN_PROCESS =
|
|
Services.appinfo.processType === Services.appinfo.PROCESS_TYPE_DEFAULT;
|
|
|
|
// This branch is used to store experiment data
|
|
const SYNC_DATA_PREF_BRANCH = "nimbus.syncdatastore.";
|
|
// This branch is used to store remote rollouts
|
|
const SYNC_DEFAULTS_PREF_BRANCH = "nimbus.syncdefaultsstore.";
|
|
let tryJSONParse = data => {
|
|
try {
|
|
return JSON.parse(data);
|
|
} catch (e) {}
|
|
|
|
return null;
|
|
};
|
|
ChromeUtils.defineLazyGetter(lazy, "syncDataStore", () => {
|
|
let experimentsPrefBranch = Services.prefs.getBranch(SYNC_DATA_PREF_BRANCH);
|
|
let defaultsPrefBranch = Services.prefs.getBranch(SYNC_DEFAULTS_PREF_BRANCH);
|
|
return {
|
|
_tryParsePrefValue(branch, pref) {
|
|
try {
|
|
return tryJSONParse(branch.getStringPref(pref, ""));
|
|
} catch (e) {
|
|
/* This is expected if we don't have anything stored */
|
|
}
|
|
|
|
return null;
|
|
},
|
|
_trySetPrefValue(branch, pref, value) {
|
|
try {
|
|
branch.setStringPref(pref, JSON.stringify(value));
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
},
|
|
_trySetTypedPrefValue(pref, value) {
|
|
let variableType = typeof value;
|
|
switch (variableType) {
|
|
case "boolean":
|
|
Services.prefs.setBoolPref(pref, value);
|
|
break;
|
|
case "number":
|
|
Services.prefs.setIntPref(pref, value);
|
|
break;
|
|
case "string":
|
|
Services.prefs.setStringPref(pref, value);
|
|
break;
|
|
case "object":
|
|
Services.prefs.setStringPref(pref, JSON.stringify(value));
|
|
break;
|
|
}
|
|
},
|
|
_clearBranchChildValues(prefBranch) {
|
|
const variablesBranch = Services.prefs.getBranch(prefBranch);
|
|
const prefChildList = variablesBranch.getChildList("");
|
|
for (let variable of prefChildList) {
|
|
variablesBranch.clearUserPref(variable);
|
|
}
|
|
},
|
|
/**
|
|
* Given a branch pref returns all child prefs and values
|
|
* { childPref: value }
|
|
* where value is parsed to the appropriate type
|
|
*
|
|
* @returns {Object[]}
|
|
*/
|
|
_getBranchChildValues(prefBranch, featureId) {
|
|
const branch = Services.prefs.getBranch(prefBranch);
|
|
const prefChildList = branch.getChildList("");
|
|
let values = {};
|
|
if (!prefChildList.length) {
|
|
return null;
|
|
}
|
|
for (const childPref of prefChildList) {
|
|
let prefName = `${prefBranch}${childPref}`;
|
|
let value = lazy.PrefUtils.getPref(prefName);
|
|
// Try to parse string values that could be stringified objects
|
|
if (
|
|
lazy.FeatureManifest[featureId]?.variables[childPref]?.type === "json"
|
|
) {
|
|
let parsedValue = tryJSONParse(value);
|
|
if (parsedValue) {
|
|
value = parsedValue;
|
|
}
|
|
}
|
|
values[childPref] = value;
|
|
}
|
|
|
|
return values;
|
|
},
|
|
get(featureId) {
|
|
let metadata = this._tryParsePrefValue(experimentsPrefBranch, featureId);
|
|
if (!metadata) {
|
|
return null;
|
|
}
|
|
let prefBranch = `${SYNC_DATA_PREF_BRANCH}${featureId}.`;
|
|
metadata.branch.feature.value = this._getBranchChildValues(
|
|
prefBranch,
|
|
featureId
|
|
);
|
|
|
|
return metadata;
|
|
},
|
|
getDefault(featureId) {
|
|
let metadata = this._tryParsePrefValue(defaultsPrefBranch, featureId);
|
|
if (!metadata) {
|
|
return null;
|
|
}
|
|
let prefBranch = `${SYNC_DEFAULTS_PREF_BRANCH}${featureId}.`;
|
|
metadata.branch.feature.value = this._getBranchChildValues(
|
|
prefBranch,
|
|
featureId
|
|
);
|
|
|
|
return metadata;
|
|
},
|
|
set(featureId, value) {
|
|
/* If the enrollment branch has variables we store those separately
|
|
* in pref branches of appropriate type:
|
|
* { featureId: "foo", value: { enabled: true } }
|
|
* gets stored as `${SYNC_DATA_PREF_BRANCH}foo.enabled=true`
|
|
*/
|
|
if (value.branch?.feature?.value) {
|
|
for (let variable of Object.keys(value.branch.feature.value)) {
|
|
let prefName = `${SYNC_DATA_PREF_BRANCH}${featureId}.${variable}`;
|
|
this._trySetTypedPrefValue(
|
|
prefName,
|
|
value.branch.feature.value[variable]
|
|
);
|
|
}
|
|
this._trySetPrefValue(experimentsPrefBranch, featureId, {
|
|
...value,
|
|
branch: {
|
|
...value.branch,
|
|
feature: {
|
|
...value.branch.feature,
|
|
value: null,
|
|
},
|
|
},
|
|
});
|
|
} else {
|
|
this._trySetPrefValue(experimentsPrefBranch, featureId, value);
|
|
}
|
|
},
|
|
setDefault(featureId, enrollment) {
|
|
/* We store configuration variables separately in pref branches of
|
|
* appropriate type:
|
|
* (feature: "foo") { variables: { enabled: true } }
|
|
* gets stored as `${SYNC_DEFAULTS_PREF_BRANCH}foo.enabled=true`
|
|
*/
|
|
let { feature } = enrollment.branch;
|
|
for (let variable of Object.keys(feature.value)) {
|
|
let prefName = `${SYNC_DEFAULTS_PREF_BRANCH}${featureId}.${variable}`;
|
|
this._trySetTypedPrefValue(prefName, feature.value[variable]);
|
|
}
|
|
this._trySetPrefValue(defaultsPrefBranch, featureId, {
|
|
...enrollment,
|
|
branch: {
|
|
...enrollment.branch,
|
|
feature: {
|
|
...enrollment.branch.feature,
|
|
value: null,
|
|
},
|
|
},
|
|
});
|
|
},
|
|
getAllDefaultBranches() {
|
|
return defaultsPrefBranch.getChildList("").filter(
|
|
// Filter out remote defaults variable prefs
|
|
pref => !pref.includes(".")
|
|
);
|
|
},
|
|
delete(featureId) {
|
|
const prefBranch = `${SYNC_DATA_PREF_BRANCH}${featureId}.`;
|
|
this._clearBranchChildValues(prefBranch);
|
|
try {
|
|
experimentsPrefBranch.clearUserPref(featureId);
|
|
} catch (e) {}
|
|
},
|
|
deleteDefault(featureId) {
|
|
let prefBranch = `${SYNC_DEFAULTS_PREF_BRANCH}${featureId}.`;
|
|
this._clearBranchChildValues(prefBranch);
|
|
try {
|
|
defaultsPrefBranch.clearUserPref(featureId);
|
|
} catch (e) {}
|
|
},
|
|
};
|
|
});
|
|
|
|
const DEFAULT_STORE_ID = "ExperimentStoreData";
|
|
|
|
/**
|
|
* Returns all feature ids associated with the branch provided.
|
|
* Fallback for when `featureIds` was not persisted to disk. Can be removed
|
|
* after bug 1725240 has reached release.
|
|
*
|
|
* @param {Branch} branch
|
|
* @returns {string[]}
|
|
*/
|
|
function getAllBranchFeatureIds(branch) {
|
|
return featuresCompat(branch).map(f => f.featureId);
|
|
}
|
|
|
|
function featuresCompat(branch) {
|
|
if (!branch || (!branch.feature && !branch.features)) {
|
|
return [];
|
|
}
|
|
let { features } = branch;
|
|
// In <=v1.5.0 of the Nimbus API, experiments had single feature
|
|
if (!features) {
|
|
features = [branch.feature];
|
|
}
|
|
|
|
return features;
|
|
}
|
|
|
|
export class ExperimentStore extends SharedDataMap {
|
|
static SYNC_DATA_PREF_BRANCH = SYNC_DATA_PREF_BRANCH;
|
|
static SYNC_DEFAULTS_PREF_BRANCH = SYNC_DEFAULTS_PREF_BRANCH;
|
|
|
|
constructor(sharedDataKey, options = { isParent: IS_MAIN_PROCESS }) {
|
|
super(sharedDataKey || DEFAULT_STORE_ID, options);
|
|
}
|
|
|
|
async init() {
|
|
await super.init();
|
|
|
|
this.getAllActiveExperiments().forEach(({ slug, branch, featureIds }) => {
|
|
(featureIds || getAllBranchFeatureIds(branch)).forEach(featureId =>
|
|
this._emitFeatureUpdate(featureId, "feature-experiment-loaded")
|
|
);
|
|
});
|
|
this.getAllActiveRollouts().forEach(({ featureIds }) => {
|
|
featureIds.forEach(featureId =>
|
|
this._emitFeatureUpdate(featureId, "feature-rollout-loaded")
|
|
);
|
|
});
|
|
|
|
Services.tm.idleDispatchToMainThread(() => this._cleanupOldRecipes());
|
|
}
|
|
|
|
/**
|
|
* Given a feature identifier, find an active experiment that matches that feature identifier.
|
|
* This assumes, for now, that there is only one active experiment per feature per browser.
|
|
* Does not activate the experiment (send an exposure event)
|
|
*
|
|
* @param {string} featureId
|
|
* @returns {Enrollment|undefined} An active experiment if it exists
|
|
* @memberof ExperimentStore
|
|
*/
|
|
getExperimentForFeature(featureId) {
|
|
return (
|
|
this.getAllActiveExperiments().find(
|
|
experiment =>
|
|
experiment.featureIds?.includes(featureId) ||
|
|
// Supports <v1.3.0, which was when .featureIds was added
|
|
getAllBranchFeatureIds(experiment.branch).includes(featureId)
|
|
// Default to the pref store if data is not yet ready
|
|
) || lazy.syncDataStore.get(featureId)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Check if an active experiment already exists for a feature.
|
|
* Does not activate the experiment (send an exposure event)
|
|
*
|
|
* @param {string} featureId
|
|
* @returns {boolean} Does an active experiment exist for that feature?
|
|
* @memberof ExperimentStore
|
|
*/
|
|
hasExperimentForFeature(featureId) {
|
|
if (!featureId) {
|
|
return false;
|
|
}
|
|
return !!this.getExperimentForFeature(featureId);
|
|
}
|
|
|
|
/**
|
|
* @returns {Enrollment[]}
|
|
*/
|
|
getAll() {
|
|
let data = [];
|
|
try {
|
|
data = Object.values(this._data || {});
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
|
|
return data;
|
|
}
|
|
|
|
/**
|
|
* Returns all active experiments
|
|
* @returns {Enrollment[]}
|
|
*/
|
|
getAllActiveExperiments() {
|
|
return this.getAll().filter(
|
|
enrollment => enrollment.active && !enrollment.isRollout
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Returns all active rollouts
|
|
* @returns {Enrollment[]}
|
|
*/
|
|
getAllActiveRollouts() {
|
|
return this.getAll().filter(
|
|
enrollment => enrollment.active && enrollment.isRollout
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Query the store for the remote configuration of a feature
|
|
* @param {string} featureId The feature we want to query for
|
|
* @returns {{Rollout}|undefined} Remote defaults if available
|
|
*/
|
|
getRolloutForFeature(featureId) {
|
|
return (
|
|
this.getAllActiveRollouts().find(r => r.featureIds.includes(featureId)) ||
|
|
lazy.syncDataStore.getDefault(featureId)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Check if an active rollout already exists for a feature.
|
|
* Does not active the experiment (send an exposure event).
|
|
*
|
|
* @param {string} featureId
|
|
* @returns {boolean} Does an active rollout exist for that feature?
|
|
*/
|
|
hasRolloutForFeature(featureId) {
|
|
if (!featureId) {
|
|
return false;
|
|
}
|
|
return !!this.getRolloutForFeature(featureId);
|
|
}
|
|
|
|
/**
|
|
* Remove inactive enrollments older than 6 months
|
|
*/
|
|
_cleanupOldRecipes() {
|
|
// Roughly six months
|
|
const threshold = 15552000000;
|
|
const nowTimestamp = new Date().getTime();
|
|
const recipesToRemove = this.getAll().filter(
|
|
experiment =>
|
|
!experiment.active &&
|
|
// Flip the comparison here to catch scenarios in which lastSeen is
|
|
// invalid or undefined. The result with be a comparison with NaN
|
|
// which is always false
|
|
!(nowTimestamp - new Date(experiment.lastSeen).getTime() < threshold)
|
|
);
|
|
this._removeEntriesByKeys(recipesToRemove.map(r => r.slug));
|
|
}
|
|
|
|
_emitUpdates(enrollment) {
|
|
this.emit(`update:${enrollment.slug}`, enrollment);
|
|
const featureIds =
|
|
enrollment.featureIds || getAllBranchFeatureIds(enrollment.branch);
|
|
const reason = enrollment.isRollout
|
|
? "rollout-updated"
|
|
: "experiment-updated";
|
|
|
|
for (const featureId of featureIds) {
|
|
this._emitFeatureUpdate(featureId, reason);
|
|
}
|
|
}
|
|
|
|
_emitFeatureUpdate(featureId, reason) {
|
|
this.emit(`featureUpdate:${featureId}`, reason);
|
|
}
|
|
|
|
_onFeatureUpdate(featureId, callback) {
|
|
if (this._isReady) {
|
|
const hasExperiment = this.hasExperimentForFeature(featureId);
|
|
if (hasExperiment || this.hasRolloutForFeature(featureId)) {
|
|
callback(
|
|
`featureUpdate:${featureId}`,
|
|
hasExperiment ? "experiment-updated" : "rollout-updated"
|
|
);
|
|
}
|
|
}
|
|
|
|
this.on(`featureUpdate:${featureId}`, callback);
|
|
}
|
|
|
|
_offFeatureUpdate(featureId, callback) {
|
|
this.off(`featureUpdate:${featureId}`, callback);
|
|
}
|
|
|
|
/**
|
|
* Persists early startup experiments or rollouts
|
|
* @param {Enrollment} enrollment Experiment or rollout
|
|
*/
|
|
_updateSyncStore(enrollment) {
|
|
let features = featuresCompat(enrollment.branch);
|
|
for (let feature of features) {
|
|
if (
|
|
lazy.FeatureManifest[feature.featureId]?.isEarlyStartup ||
|
|
feature.isEarlyStartup
|
|
) {
|
|
if (!enrollment.active) {
|
|
// Remove experiments on un-enroll, no need to check if it exists
|
|
if (enrollment.isRollout) {
|
|
lazy.syncDataStore.deleteDefault(feature.featureId);
|
|
} else {
|
|
lazy.syncDataStore.delete(feature.featureId);
|
|
}
|
|
} else {
|
|
let updateEnrollmentSyncStore = enrollment.isRollout
|
|
? lazy.syncDataStore.setDefault.bind(lazy.syncDataStore)
|
|
: lazy.syncDataStore.set.bind(lazy.syncDataStore);
|
|
updateEnrollmentSyncStore(feature.featureId, {
|
|
...enrollment,
|
|
branch: {
|
|
...enrollment.branch,
|
|
feature,
|
|
// Only store the early startup feature
|
|
features: null,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Add an enrollment and notify listeners
|
|
* @param {Enrollment} enrollment
|
|
*/
|
|
addEnrollment(enrollment) {
|
|
if (!enrollment || !enrollment.slug) {
|
|
throw new Error(
|
|
`Tried to add an experiment but it didn't have a .slug property.`
|
|
);
|
|
}
|
|
|
|
this.set(enrollment.slug, enrollment);
|
|
this._updateSyncStore(enrollment);
|
|
this._emitUpdates(enrollment);
|
|
}
|
|
|
|
/**
|
|
* Merge new properties into the properties of an existing experiment
|
|
* @param {string} slug
|
|
* @param {Partial<Enrollment>} newProperties
|
|
*/
|
|
updateExperiment(slug, newProperties) {
|
|
const oldProperties = this.get(slug);
|
|
if (!oldProperties) {
|
|
throw new Error(
|
|
`Tried to update experiment ${slug} but it doesn't exist`
|
|
);
|
|
}
|
|
const updatedExperiment = { ...oldProperties, ...newProperties };
|
|
this.set(slug, updatedExperiment);
|
|
this._updateSyncStore(updatedExperiment);
|
|
this._emitUpdates(updatedExperiment);
|
|
}
|
|
|
|
/**
|
|
* Test only helper for cleanup
|
|
*
|
|
* @param slugOrFeatureId Can be called with slug (which removes the SharedDataMap entry) or
|
|
* with featureId which removes the SyncDataStore entry for the feature
|
|
*/
|
|
_deleteForTests(slugOrFeatureId) {
|
|
super._deleteForTests(slugOrFeatureId);
|
|
lazy.syncDataStore.deleteDefault(slugOrFeatureId);
|
|
lazy.syncDataStore.delete(slugOrFeatureId);
|
|
}
|
|
}
|