mirror of
				https://github.com/mozilla/gecko-dev.git
				synced 2025-11-04 10:18:41 +02:00 
			
		
		
		
	
		
			
				
	
	
		
			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(({ 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);
 | 
						|
  }
 | 
						|
}
 |