Bug 1555176 - Add enrollmentIds to all Normandy telemetry. r=Gijs
authorMichael Cooper <mcooper@mozilla.com>
Thu, 26 Sep 2019 17:35:15 +0000
changeset 495154 f8a352e9f455aeb9a7af5b8ffa84117466c04160
parent 495153 858991b684efa86e069eb0a919d640781ccb12dd
child 495155 e9c17f675eb93d61d5afe6446433ee261e3b5d7e
push id36623
push usershindli@mozilla.com
push dateFri, 27 Sep 2019 04:28:18 +0000
treeherdermozilla-central@dcfdecc355c0 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersGijs
bugs1555176
milestone71.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1555176 - Add enrollmentIds to all Normandy telemetry. r=Gijs Differential Revision: https://phabricator.services.mozilla.com/D47149
toolkit/components/normandy/actions/AddonRollbackAction.jsm
toolkit/components/normandy/actions/AddonRolloutAction.jsm
toolkit/components/normandy/actions/BranchedAddonStudyAction.jsm
toolkit/components/normandy/actions/PreferenceRollbackAction.jsm
toolkit/components/normandy/actions/PreferenceRolloutAction.jsm
toolkit/components/normandy/actions/ShowHeartbeatAction.jsm
toolkit/components/normandy/docs/data-collection.rst
toolkit/components/normandy/lib/AddonStudies.jsm
toolkit/components/normandy/lib/ClientEnvironment.jsm
toolkit/components/normandy/lib/NormandyUtils.jsm
toolkit/components/normandy/lib/PreferenceExperiments.jsm
toolkit/components/normandy/lib/PreferenceRollouts.jsm
toolkit/components/normandy/test/NormandyTestUtils.jsm
toolkit/components/normandy/test/browser/browser_AddonStudies.js
toolkit/components/normandy/test/browser/browser_ClientEnvironment.js
toolkit/components/normandy/test/browser/browser_PreferenceExperiments.js
toolkit/components/normandy/test/browser/browser_PreferenceRollouts.js
toolkit/components/normandy/test/browser/browser_actions_AddonRollbackAction.js
toolkit/components/normandy/test/browser/browser_actions_AddonRolloutAction.js
toolkit/components/normandy/test/browser/browser_actions_AddonStudyAction.js
toolkit/components/normandy/test/browser/browser_actions_BranchedAddonStudyAction.js
toolkit/components/normandy/test/browser/browser_actions_PreferenceExperimentAction.js
toolkit/components/normandy/test/browser/browser_actions_PreferenceRollbackAction.js
toolkit/components/normandy/test/browser/browser_actions_PreferenceRolloutAction.js
toolkit/components/normandy/test/browser/browser_actions_ShowHeartbeatAction.js
toolkit/components/normandy/test/browser/head.js
toolkit/components/telemetry/Events.yaml
--- a/toolkit/components/normandy/actions/AddonRollbackAction.jsm
+++ b/toolkit/components/normandy/actions/AddonRollbackAction.jsm
@@ -46,30 +46,31 @@ class AddonRollbackAction extends BaseAc
         if (addon) {
           try {
             await addon.uninstall();
           } catch (err) {
             TelemetryEvents.sendEvent(
               "unenrollFailed",
               "addon_rollback",
               rolloutSlug,
-              { reason: "uninstall-failed" }
+              { reason: "uninstall-failed", enrollmentId: rollout.enrollmentId }
             );
             throw err;
           }
         } else {
           this.log.warn(
             `Could not uninstall addon ${
               rollout.addonId
             } for rollback ${rolloutSlug}: it is not installed.`
           );
         }
 
         TelemetryEvents.sendEvent("unenroll", "addon_rollback", rolloutSlug, {
           reason: "rollback",
+          enrollmentId: rollout.enrollmentId,
         });
         TelemetryEnvironment.setExperimentInactive(rolloutSlug);
         break;
       }
 
       case AddonRollouts.STATE_ROLLED_BACK: {
         return; // Do nothing
       }
--- a/toolkit/components/normandy/actions/AddonRolloutAction.jsm
+++ b/toolkit/components/normandy/actions/AddonRolloutAction.jsm
@@ -11,16 +11,17 @@ const { BaseAction } = ChromeUtils.impor
   "resource://normandy/actions/BaseAction.jsm"
 );
 
 XPCOMUtils.defineLazyModuleGetters(this, {
   ActionSchemas: "resource://normandy/actions/schemas/index.js",
   AddonRollouts: "resource://normandy/lib/AddonRollouts.jsm",
   NormandyAddonManager: "resource://normandy/lib/NormandyAddonManager.jsm",
   NormandyApi: "resource://normandy/lib/NormandyApi.jsm",
+  NormandyUtils: "resource://normandy/lib/NormandyUtils.jsm",
   Services: "resource://gre/modules/Services.jsm",
   TelemetryEnvironment: "resource://gre/modules/TelemetryEnvironment.jsm",
   TelemetryEvents: "resource://normandy/lib/TelemetryEvents.jsm",
 });
 
 var EXPORTED_SYMBOLS = ["AddonRolloutAction"];
 
 class AddonRolloutError extends Error {
@@ -75,16 +76,19 @@ class AddonRolloutAction extends BaseAct
   async _run(recipe) {
     const { extensionApiId, slug } = recipe.arguments;
 
     const existingRollout = await AddonRollouts.get(slug);
     const eventName = existingRollout ? "update" : "enroll";
     const extensionDetails = await NormandyApi.fetchExtensionDetails(
       extensionApiId
     );
+    let enrollmentId = existingRollout
+      ? existingRollout.enrollmentId
+      : undefined;
 
     // Check if the existing rollout matches the current rollout
     if (
       existingRollout &&
       existingRollout.addonId === extensionDetails.extension_id
     ) {
       const versionCompare = Services.vc.compare(
         existingRollout.addonVersion,
@@ -109,34 +113,39 @@ class AddonRolloutAction extends BaseAct
       rollout =>
         rollout.slug !== slug &&
         rollout.addonId === extensionDetails.extension_id
     );
     if (conflictingRollout) {
       const conflictError = createError("conflict", {
         addonId: conflictingRollout.addonId,
         conflictingSlug: conflictingRollout.slug,
+        enrollmentId: conflictingRollout.enrollmentId,
       });
       this.reportError(conflictError, "enrollFailed");
       throw conflictError;
     }
 
     const onInstallStarted = (install, installDeferred) => {
       const existingAddon = install.existingAddon;
 
       if (existingRollout && existingRollout.addonId !== install.addon.id) {
-        installDeferred.reject(createError("addon-id-changed"));
+        installDeferred.reject(
+          createError("addon-id-changed", { enrollmentId })
+        );
         return false; // cancel the upgrade, the add-on ID has changed
       }
 
       if (
         existingAddon &&
         Services.vc.compare(existingAddon.version, install.addon.version) > 0
       ) {
-        installDeferred.reject(createError("upgrade-required"));
+        installDeferred.reject(
+          createError("upgrade-required", { enrollmentId })
+        );
         return false; // cancel the installation, must be an upgrade
       }
 
       return true;
     };
 
     const applyNormandyChanges = async install => {
       const details = {
@@ -149,20 +158,22 @@ class AddonRolloutAction extends BaseAct
       };
 
       if (existingRollout) {
         await AddonRollouts.update({
           ...existingRollout,
           ...details,
         });
       } else {
+        enrollmentId = NormandyUtils.generateUuid();
         await AddonRollouts.add({
           recipeId: recipe.id,
           state: AddonRollouts.STATE_ACTIVE,
           slug,
+          enrollmentId,
           ...details,
         });
       }
     };
 
     const undoNormandyChanges = async () => {
       if (existingRollout) {
         await AddonRollouts.update(existingRollout);
@@ -195,16 +206,17 @@ class AddonRolloutAction extends BaseAct
         }
       );
     }
 
     // All done, report success to Telemetry
     TelemetryEvents.sendEvent(eventName, "addon_rollout", slug, {
       addonId: installedId,
       addonVersion: installedVersion,
+      enrollmentId,
     });
   }
 
   reportError(error, eventName) {
     if (error instanceof AddonRolloutError) {
       // One of our known errors. Report it nicely to telemetry
       TelemetryEvents.sendEvent(
         eventName,
--- a/toolkit/components/normandy/actions/BranchedAddonStudyAction.jsm
+++ b/toolkit/components/normandy/actions/BranchedAddonStudyAction.jsm
@@ -19,16 +19,17 @@ const { BaseStudyAction } = ChromeUtils.
 );
 
 XPCOMUtils.defineLazyModuleGetters(this, {
   ActionSchemas: "resource://normandy/actions/schemas/index.js",
   AddonManager: "resource://gre/modules/AddonManager.jsm",
   AddonStudies: "resource://normandy/lib/AddonStudies.jsm",
   ClientEnvironment: "resource://normandy/lib/ClientEnvironment.jsm",
   NormandyApi: "resource://normandy/lib/NormandyApi.jsm",
+  NormandyUtils: "resource://normandy/lib/NormandyUtils.jsm",
   PromiseUtils: "resource://gre/modules/PromiseUtils.jsm",
   Sampling: "resource://gre/modules/components-utils/Sampling.jsm",
   Services: "resource://gre/modules/Services.jsm",
   TelemetryEnvironment: "resource://gre/modules/TelemetryEnvironment.jsm",
   TelemetryEvents: "resource://normandy/lib/TelemetryEvents.jsm",
 });
 
 var EXPORTED_SYMBOLS = ["BranchedAddonStudyAction"];
@@ -172,26 +173,29 @@ class BranchedAddonStudyAction extends B
    *
    * @param recipe Object describing the study to enroll in.
    * @param extensionDetails Object describing the addon to be installed.
    * @param onInstallStarted A function that returns a callback for the install listener.
    * @param onComplete A callback function that is run on completion of the download.
    * @param onFailedInstall A callback function that is run if the installation fails.
    * @param errorClass The class of error to be thrown when exceptions occur.
    * @param reportError A function that reports errors to Telemetry.
+   * @param [errorExtra] Optional, an object that will be merged into the
+   *                     `extra` field of the error generated, if any.
    */
   async downloadAndInstall({
     recipe,
     extensionDetails,
     branchSlug,
     onInstallStarted,
     onComplete,
     onFailedInstall,
     errorClass,
     reportError,
+    errorExtra = {},
   }) {
     const { slug } = recipe.arguments;
     const { hash, hash_algorithm } = extensionDetails;
 
     const downloadDeferred = PromiseUtils.defer();
     const installDeferred = PromiseUtils.defer();
 
     const install = await AddonManager.getInstallForURL(extensionDetails.xpi, {
@@ -201,16 +205,17 @@ class BranchedAddonStudyAction extends B
 
     const listener = {
       onDownloadFailed() {
         downloadDeferred.reject(
           new errorClass(slug, {
             reason: "download-failure",
             branch: branchSlug,
             detail: AddonManager.errorToString(install.error),
+            ...errorExtra,
           })
         );
       },
 
       onDownloadEnded() {
         downloadDeferred.resolve();
         return false; // temporarily pause installation for Normandy bookkeeping
       },
@@ -310,46 +315,50 @@ class BranchedAddonStudyAction extends B
 
     const { slug, userFacingName, userFacingDescription } = recipe.arguments;
     const branch = await this.chooseBranch({
       slug: recipe.arguments.slug,
       branches: recipe.arguments.branches,
     });
     this.log.debug(`Enrolling in branch ${branch.slug}`);
 
+    const enrollmentId = NormandyUtils.generateUuid();
+
     if (branch.extensionApiId === null) {
       const study = {
         recipeId: recipe.id,
         slug,
         userFacingName,
         userFacingDescription,
         branch: branch.slug,
         addonId: null,
         addonVersion: null,
         addonUrl: null,
         extensionApiId: null,
         extensionHash: null,
         extensionHashAlgorithm: null,
         active: true,
         studyStartDate: new Date(),
         studyEndDate: null,
+        enrollmentId,
       };
 
       try {
         await AddonStudies.add(study);
       } catch (err) {
         this.reportEnrollError(err);
         throw err;
       }
 
       // All done, report success to Telemetry
       TelemetryEvents.sendEvent("enroll", "addon_study", slug, {
         addonId: AddonStudies.NO_ADDON_MARKER,
         addonVersion: AddonStudies.NO_ADDON_MARKER,
         branch: branch.slug,
+        enrollmentId,
       });
     } else {
       const extensionDetails = await NormandyApi.fetchExtensionDetails(
         branch.extensionApiId
       );
 
       const onInstallStarted = installDeferred => cbInstall => {
         const versionMatches =
@@ -371,32 +380,34 @@ class BranchedAddonStudyAction extends B
               reason: "metadata-mismatch",
             })
           );
           return false; // cancel the installation, server metadata does not match downloaded add-on
         }
         return true;
       };
 
+      let study;
       const onComplete = async (install, listener) => {
-        const study = {
+        study = {
           recipeId: recipe.id,
           slug,
           userFacingName,
           userFacingDescription,
           branch: branch.slug,
           addonId: install.addon.id,
           addonVersion: install.addon.version,
           addonUrl: extensionDetails.xpi,
           extensionApiId: branch.extensionApiId,
           extensionHash: extensionDetails.hash,
           extensionHashAlgorithm: extensionDetails.hash_algorithm,
           active: true,
           studyStartDate: new Date(),
           studyEndDate: null,
+          enrollmentId,
         };
 
         try {
           await AddonStudies.add(study);
         } catch (err) {
           this.reportEnrollError(err);
           install.removeListener(listener);
           install.cancel();
@@ -419,21 +430,23 @@ class BranchedAddonStudyAction extends B
         reportError: this.reportEnrollError,
       });
 
       // All done, report success to Telemetry
       TelemetryEvents.sendEvent("enroll", "addon_study", slug, {
         addonId: installedId,
         addonVersion: installedVersion,
         branch: branch.slug,
+        enrollmentId,
       });
     }
 
     TelemetryEnvironment.setExperimentActive(slug, branch.slug, {
       type: "normandy-addonstudy",
+      enrollmentId,
     });
   }
 
   /**
    * Update the study represented by the given recipe.
    * @param recipe Object describing the study to be updated.
    * @param extensionDetails Object describing the addon to be installed.
    */
@@ -456,27 +469,29 @@ class BranchedAddonStudyAction extends B
     );
 
     let error;
 
     if (study.addonId && study.addonId !== extensionDetails.extension_id) {
       error = new AddonStudyUpdateError(slug, {
         branch: branch.slug,
         reason: "addon-id-mismatch",
+        enrollmentId: study.enrollmentId,
       });
     }
 
     const versionCompare = Services.vc.compare(
       study.addonVersion,
       extensionDetails.version
     );
     if (versionCompare > 0) {
       error = new AddonStudyUpdateError(slug, {
         branch: branch.slug,
         reason: "no-downgrade",
+        enrollmentId: study.enrollmentId,
       });
     } else if (versionCompare === 0) {
       return; // Unchanged, do nothing
     }
 
     if (error) {
       this.reportUpdateError(error);
       throw error;
@@ -487,24 +502,26 @@ class BranchedAddonStudyAction extends B
         cbInstall.addon.version === extensionDetails.version;
       const idMatches = cbInstall.addon.id === extensionDetails.extension_id;
 
       if (!cbInstall.existingAddon) {
         installDeferred.reject(
           new AddonStudyUpdateError(slug, {
             branch: branch.slug,
             reason: "addon-does-not-exist",
+            enrollmentId: study.enrollmentId,
           })
         );
         return false; // cancel the installation, must upgrade an existing add-on
       } else if (!versionMatches || !idMatches) {
         installDeferred.reject(
           new AddonStudyUpdateError(slug, {
             branch: branch.slug,
             reason: "metadata-mismatch",
+            enrollmentId: study.enrollmentId,
           })
         );
         return false; // cancel the installation, server metadata do not match downloaded add-on
       }
 
       return true;
     };
 
@@ -534,23 +551,25 @@ class BranchedAddonStudyAction extends B
       recipe,
       extensionDetails,
       branchSlug: branch.slug,
       onInstallStarted,
       onComplete,
       onFailedInstall,
       errorClass: AddonStudyUpdateError,
       reportError: this.reportUpdateError,
+      errorExtra: { enrollmentId: study.enrollmentId },
     });
 
     // All done, report success to Telemetry
     TelemetryEvents.sendEvent("update", "addon_study", slug, {
       addonId: installedId,
       addonVersion: installedVersion,
       branch: branch.slug,
+      enrollmentId: study.enrollmentId,
     });
   }
 
   reportEnrollError(error) {
     if (error instanceof AddonStudyEnrollError) {
       // One of our known errors. Report it nicely to telemetry
       TelemetryEvents.sendEvent(
         "enrollFailed",
--- a/toolkit/components/normandy/actions/PreferenceRollbackAction.jsm
+++ b/toolkit/components/normandy/actions/PreferenceRollbackAction.jsm
@@ -56,32 +56,32 @@ class PreferenceRollbackAction extends B
         for (const { preferenceName, previousValue } of rollout.preferences) {
           PrefUtils.setPref("default", preferenceName, previousValue);
         }
         await PreferenceRollouts.update(rollout);
         TelemetryEvents.sendEvent(
           "unenroll",
           "preference_rollback",
           rolloutSlug,
-          { reason: "rollback" }
+          { reason: "rollback", enrollmentId: rollout.enrollmentId }
         );
         TelemetryEnvironment.setExperimentInactive(rolloutSlug);
         break;
       }
       case PreferenceRollouts.STATE_ROLLED_BACK: {
         // The rollout has already been rolled back, so nothing to do here.
         break;
       }
       case PreferenceRollouts.STATE_GRADUATED: {
         // graduated rollouts can't be rolled back
         TelemetryEvents.sendEvent(
           "unenrollFailed",
           "preference_rollback",
           rolloutSlug,
-          { reason: "graduated" }
+          { reason: "graduated", enrollmentId: rollout.enrollmentId }
         );
         throw new Error(
           `Cannot rollback already graduated rollout ${rolloutSlug}`
         );
       }
       default: {
         throw new Error(
           `Unexpected state when rolling back ${rolloutSlug}: ${rollout.state}`
--- a/toolkit/components/normandy/actions/PreferenceRolloutAction.jsm
+++ b/toolkit/components/normandy/actions/PreferenceRolloutAction.jsm
@@ -28,16 +28,21 @@ ChromeUtils.defineModuleGetter(
   "ActionSchemas",
   "resource://normandy/actions/schemas/index.js"
 );
 ChromeUtils.defineModuleGetter(
   this,
   "TelemetryEvents",
   "resource://normandy/lib/TelemetryEvents.jsm"
 );
+ChromeUtils.defineModuleGetter(
+  this,
+  "NormandyUtils",
+  "resource://normandy/lib/NormandyUtils.jsm"
+);
 
 var EXPORTED_SYMBOLS = ["PreferenceRolloutAction"];
 
 const PREFERENCE_TYPE_MAP = {
   boolean: Services.prefs.PREF_BOOL,
   string: Services.prefs.PREF_STRING,
   number: Services.prefs.PREF_INT,
 };
@@ -71,16 +76,17 @@ class PreferenceRolloutAction extends Ba
         newRollout
       );
 
       // If anything was different about the new rollout, write it to the db and send an event about it
       if (anyChanged) {
         await PreferenceRollouts.update(newRollout);
         TelemetryEvents.sendEvent("update", "preference_rollout", args.slug, {
           previousState: existingRollout.state,
+          enrollmentId: existingRollout.enrollmentId,
         });
 
         switch (existingRollout.state) {
           case PreferenceRollouts.STATE_ACTIVE: {
             this.log.debug(`Updated preference rollout ${args.slug}`);
             break;
           }
           case PreferenceRollouts.STATE_GRADUATED: {
@@ -120,27 +126,33 @@ class PreferenceRolloutAction extends Ba
           { reason: "would-be-no-op" }
         );
         // Throw so that this recipe execution is marked as a failure
         throw new Error(
           `New rollout ${args.slug} does not change any preferences.`
         );
       }
 
+      let enrollmentId = NormandyUtils.generateUuid();
+      newRollout.enrollmentId = enrollmentId;
+
       await PreferenceRollouts.add(newRollout);
 
       for (const { preferenceName, value } of args.preferences) {
         PrefUtils.setPref("default", preferenceName, value);
       }
 
       this.log.debug(`Enrolled in preference rollout ${args.slug}`);
       TelemetryEnvironment.setExperimentActive(args.slug, newRollout.state, {
         type: "normandy-prefrollout",
+        enrollmentId,
       });
-      TelemetryEvents.sendEvent("enroll", "preference_rollout", args.slug, {});
+      TelemetryEvents.sendEvent("enroll", "preference_rollout", args.slug, {
+        enrollmentId,
+      });
     }
   }
 
   /**
    * Check that all the preferences in a rollout are ok to set. This means 1) no
    * other rollout is managing them, and 2) they match the types of the builtin
    * values.
    * @param {PreferenceRollout} rollout The arguments from a rollout recipe.
--- a/toolkit/components/normandy/actions/ShowHeartbeatAction.jsm
+++ b/toolkit/components/normandy/actions/ShowHeartbeatAction.jsm
@@ -41,22 +41,20 @@ ChromeUtils.defineModuleGetter(
   "Storage",
   "resource://normandy/lib/Storage.jsm"
 );
 ChromeUtils.defineModuleGetter(
   this,
   "UpdateUtils",
   "resource://gre/modules/UpdateUtils.jsm"
 );
-
-XPCOMUtils.defineLazyServiceGetter(
+ChromeUtils.defineModuleGetter(
   this,
-  "uuidGenerator",
-  "@mozilla.org/uuid-generator;1",
-  "nsIUUIDGenerator"
+  "NormandyUtils",
+  "resource://normandy/lib/NormandyUtils.jsm"
 );
 
 var EXPORTED_SYMBOLS = ["ShowHeartbeatAction"];
 
 XPCOMUtils.defineLazyGetter(this, "gAllRecipeStorage", function() {
   return new Storage("normandy-heartbeat");
 });
 
@@ -95,17 +93,17 @@ class ShowHeartbeatAction extends BaseAc
     const heartbeat = new Heartbeat(targetWindow, {
       surveyId: this.generateSurveyId(recipe),
       message,
       engagementButtonLabel,
       thanksMessage,
       learnMoreMessage,
       learnMoreUrl,
       postAnswerUrl: await this.generatePostAnswerURL(recipe),
-      flowId: this.uuid(),
+      flowId: NormandyUtils.generateUuid(),
       surveyVersion: recipe.revision_id,
     });
 
     heartbeat.eventEmitter.once(
       "Voted",
       this.updateLastInteraction.bind(this, recipeStorage)
     );
     heartbeat.eventEmitter.once(
@@ -198,25 +196,16 @@ class ShowHeartbeatAction extends BaseAc
   generateSurveyId(recipe) {
     const { includeTelemetryUUID, surveyId } = recipe.arguments;
     if (includeTelemetryUUID) {
       return `${surveyId}::${ClientEnvironment.userId}`;
     }
     return surveyId;
   }
 
-  /*
-   * Generate a UUID without surrounding brackets, as expected by Heartbeat
-   * telemetry.
-   */
-  uuid() {
-    let rv = uuidGenerator.generateUUID().toString();
-    return rv.slice(1, rv.length - 1);
-  }
-
   /**
    * Generate the appropriate post-answer URL for a recipe.
    * @param  recipe
    * @return {String} URL with post-answer query params
    */
   async generatePostAnswerURL(recipe) {
     const { postAnswerUrl, message, includeTelemetryUUID } = recipe.arguments;
 
--- a/toolkit/components/normandy/docs/data-collection.rst
+++ b/toolkit/components/normandy/docs/data-collection.rst
@@ -156,16 +156,20 @@ Enrollment
    extra
       branch
          The name of the branch the user was assigned to (example:
          ``"control"`` or ``"experiment"``).
       experimentType
          The type of preference experiment. Currently this can take
          values "exp" and "exp-highpop", the latter being for
          experiments targeting large numbers of users.
+      enrollmentId
+         A UUID that is unique to this users enrollment in this study. It
+         will be included in all future telemetry for this user in this
+         study.
 
 Unenrollment
    method
       The string ``"unenroll"``.
    object
       The string ``"preference_study"``.
    value
       The name of the study (``recipe.arguments.slug``).
@@ -187,31 +191,38 @@ Unenrollment
            changed the preference, or that some other mechanism set a
            non-default value for the preference.
          * ``"user-preference-changed-sideload"``: The study
            preference was changed on the user branch while Normandy was
            inactive. This could mean that the value was manually
            changed in a profile while Firefox was not running.
          * ``"unknown"``: A reason was not specified. This should be
            considered a bug.
+      enrollmentId
+         The ID that was generated at enrollment.
+
 
 Add-on Studies
 ^^^^^^^^^^^^^^
 Enrollment
    method
       The string ``"enroll"``
    object
       The string ``"addon_study"``
    value
       The name of the study (``recipe.arguments.name``).
    extra
       addonId
          The add-on's ID (example: ``"feature-study@shield.mozilla.com"``).
       addonVersion
          The add-on's version (example: ``"1.2.3"``).
+      enrollmentId
+         A UUID that is unique to this users enrollment in this study. It
+         will be included in all future telemetry for this user in this
+         study.
 
 Enroll Failure
    method
       The string ``"enrollFailed"``
    object
       The string ``"addon_study"``
    value
       The name of the study (``recipe.arguments.name``).
@@ -228,29 +239,34 @@ Update
       The string ``"addon_study"``,
    value
       The name of the study (``recipe.arguments.name``).
    extra
       addonId
          The add-on's ID (example: ``"feature-study@shield.mozilla.com"``).
       addonVersion
          The add-on's version (example: ``"1.2.3"``).
+      enrollmentId
+         The ID that was generated at enrollment.
 
 Update Failure
    method
       The string ``"updateFailed"``
    object
       The string ``"addon_study"``
    value
       The name of the study (``recipe.arguments.name``).
-   reason
-      A string containing the filename and line number of the code
-      that failed, and the name of the error thrown. This information
-      is purposely limited to avoid leaking personally identifiable
-      information. This should be considered a bug.
+   extra
+      reason
+         A string containing the filename and line number of the code
+         that failed, and the name of the error thrown. This information
+         is purposely limited to avoid leaking personally identifiable
+         information. This should be considered a bug.
+      enrollmentId
+         The ID that was generated at enrollment.
 
 Unenrollment
    method
       The string ``"unenroll"``.
    object
       The string ``"addon_study"``.
    value
       The name of the study (``recipe.arguments.name``).
@@ -275,8 +291,10 @@ Unenrollment
            mechanism. For example, this could be a user action or the
            add-on self-uninstalling.
          * ``"uninstalled-sideload"``: The study's add-on was
            uninstalled while Normandy was inactive. This could be that
            the add-on is no longer compatible, or was manually removed
            from a profile.
          * ``"unknown"``: A reason was not specified. This should be
            considered a bug.
+      enrollmentId
+         The ID that was generated at enrollment.
--- a/toolkit/components/normandy/lib/AddonStudies.jsm
+++ b/toolkit/components/normandy/lib/AddonStudies.jsm
@@ -29,16 +29,20 @@
  * @property {string} extensionHash
  *   The hash of the XPI file.
  * @property {string} extensionHashAlgorithm
  *   The algorithm used to hash the XPI file.
  * @property {string} studyStartDate
  *   Date when the study was started.
  * @property {Date} studyEndDate
  *   Date when the study was ended.
+ * @property {string} enrollmentId
+ *   A random ID generated at time of enrollment. It should be included on all
+ *   telemetry related to this study. It should not be re-used by other studies,
+ *   or any other purpose. May be null on old study.
  */
 
 const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
 
 ChromeUtils.defineModuleGetter(
   this,
   "IndexedDB",
   "resource://gre/modules/IndexedDB.jsm"
@@ -343,16 +347,17 @@ var AddonStudies = {
     await getStore(db, "readwrite").put(study);
 
     Services.obs.notifyObservers(study, STUDY_ENDED_TOPIC, `${study.recipeId}`);
     TelemetryEvents.sendEvent("unenroll", "addon_study", study.slug, {
       addonId: study.addonId || AddonStudies.NO_ADDON_MARKER,
       addonVersion: study.addonVersion || AddonStudies.NO_ADDON_MARKER,
       reason,
       branch: study.branch,
+      enrollmentId: study.enrollmentId,
     });
     TelemetryEnvironment.setExperimentInactive(study.slug);
 
     await this.callUnenrollListeners(study.addonId, reason);
   },
 
   // Maps extension id -> Set(callbacks)
   _unenrollListeners: new Map(),
--- a/toolkit/components/normandy/lib/ClientEnvironment.jsm
+++ b/toolkit/components/normandy/lib/ClientEnvironment.jsm
@@ -16,19 +16,20 @@ ChromeUtils.defineModuleGetter(
   "ClientEnvironmentBase",
   "resource://gre/modules/components-utils/ClientEnvironment.jsm"
 );
 ChromeUtils.defineModuleGetter(
   this,
   "PreferenceExperiments",
   "resource://normandy/lib/PreferenceExperiments.jsm"
 );
-
-const { generateUUID } = Cc["@mozilla.org/uuid-generator;1"].getService(
-  Ci.nsIUUIDGenerator
+ChromeUtils.defineModuleGetter(
+  this,
+  "NormandyUtils",
+  "resource://normandy/lib/NormandyUtils.jsm"
 );
 
 var EXPORTED_SYMBOLS = ["ClientEnvironment"];
 
 // Cached API request for client attributes that are determined by the Normandy
 // service.
 let _classifyRequest = null;
 
@@ -63,20 +64,17 @@ class ClientEnvironment extends ClientEn
       await testFunction();
       _classifyRequest = oldRequest;
     };
   }
 
   static get userId() {
     let id = Services.prefs.getCharPref("app.normandy.user_id", "");
     if (!id) {
-      // generateUUID adds leading and trailing "{" and "}". strip them off.
-      id = generateUUID()
-        .toString()
-        .slice(1, -1);
+      id = NormandyUtils.generateUuid();
       Services.prefs.setCharPref("app.normandy.user_id", id);
     }
     return id;
   }
 
   static get country() {
     return (async () => {
       const { country } = await ClientEnvironment.getClientClassification();
new file mode 100644
--- /dev/null
+++ b/toolkit/components/normandy/lib/NormandyUtils.jsm
@@ -0,0 +1,28 @@
+/* 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/. */
+
+"use strict";
+
+var EXPORTED_SYMBOLS = ["NormandyUtils"];
+
+const { XPCOMUtils } = ChromeUtils.import(
+  "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyServiceGetter(
+  this,
+  "uuidGenerator",
+  "@mozilla.org/uuid-generator;1",
+  "nsIUUIDGenerator"
+);
+
+var NormandyUtils = {
+  generateUuid() {
+    // Generate a random UUID, convert it to a string, and slice the braces off the ends.
+    return uuidGenerator
+      .generateUUID()
+      .toString()
+      .slice(1, -1);
+  },
+};
--- a/toolkit/components/normandy/lib/PreferenceExperiments.jsm
+++ b/toolkit/components/normandy/lib/PreferenceExperiments.jsm
@@ -38,16 +38,20 @@
  *   ISO-formatted date string of when the experiment was last seen from the
  *   recipe server.
  * @property {Object} preferences
  *   An object consisting of all the preferences that are set by this experiment.
  *   Keys are the name of each preference affected by this experiment. Values are
  *   Preference Objects, about which see below.
  * @property {string} experimentType
  *   The type to report to Telemetry's experiment marker API.
+ * @property {string} enrollmentId
+ *   A random ID generated at time of enrollment. It should be included on all
+ *   telemetry related to this experiment. It should not be re-used by other
+ *   studies, or any other purpose. May be null on old experiments.
  */
 
 /**
  * Each Preference stores information about a preference that an
  * experiment sets.
  * @property {string|integer|boolean} preferenceValue
  *   Value to change the preference to during the experiment.
  * @property {string} preferenceType
@@ -92,16 +96,21 @@ ChromeUtils.defineModuleGetter(
   "TelemetryEnvironment",
   "resource://gre/modules/TelemetryEnvironment.jsm"
 );
 ChromeUtils.defineModuleGetter(
   this,
   "TelemetryEvents",
   "resource://normandy/lib/TelemetryEvents.jsm"
 );
+ChromeUtils.defineModuleGetter(
+  this,
+  "NormandyUtils",
+  "resource://normandy/lib/NormandyUtils.jsm"
+);
 
 var EXPORTED_SYMBOLS = ["PreferenceExperiments"];
 
 const EXPERIMENT_FILE = "shield-preference-experiments.json";
 const STARTUP_EXPERIMENT_PREFS_BRANCH = "app.normandy.startupExperimentPrefs.";
 
 const MAX_EXPERIMENT_TYPE_LENGTH = 20; // enforced by TelemetryEnvironment
 const EXPERIMENT_TYPE_PREFIX = "normandy-";
@@ -259,17 +268,20 @@ var PreferenceExperiments = {
       if (stopped) {
         continue;
       }
 
       // Notify Telemetry of experiments we're running, since they don't persist between restarts
       TelemetryEnvironment.setExperimentActive(
         experiment.slug,
         experiment.branch,
-        { type: EXPERIMENT_TYPE_PREFIX + experiment.experimentType }
+        {
+          type: EXPERIMENT_TYPE_PREFIX + experiment.experimentType,
+          enrollmentId: experiment.enrollmentId,
+        }
       );
 
       // Watch for changes to the experiment's preference
       this.startObserver(experiment.slug, experiment.preferences);
     }
   },
 
   /**
@@ -377,16 +389,17 @@ var PreferenceExperiments = {
    * @param {string} experiment.slug
    * @param {string} experiment.actionName  The action who knows about this
    *   experiment and is responsible for cleaning it up. This should
    *   correspond to the name of some BaseAction subclass.
    * @param {string} experiment.branch
    * @param {string} experiment.preferenceName
    * @param {string|integer|boolean} experiment.preferenceValue
    * @param {PreferenceBranchType} experiment.preferenceBranchType
+   * @returns {Experiment} The experiment object stored in the data store
    * @rejects {Error}
    *   - If an experiment with the given name already exists
    *   - if an experiment for the given preference is active
    *   - If the given preferenceType does not match the existing stored preference
    */
   async start({
     name = null, // To check if old code is still using `name` instead of `slug`, and provide a nice error message
     slug,
@@ -512,40 +525,47 @@ var PreferenceExperiments = {
         preferenceBranch,
         preferenceName,
         preferenceType,
         preferenceValue
       );
     }
     PreferenceExperiments.startObserver(slug, preferences);
 
+    const enrollmentId = NormandyUtils.generateUuid();
+
     /** @type {Experiment} */
     const experiment = {
       slug,
       actionName,
       branch,
       expired: false,
       lastSeen: new Date().toJSON(),
       preferences,
       experimentType,
       userFacingName,
       userFacingDescription,
+      enrollmentId,
     };
 
     store.data.experiments[slug] = experiment;
     store.saveSoon();
 
     TelemetryEnvironment.setExperimentActive(slug, branch, {
       type: EXPERIMENT_TYPE_PREFIX + experimentType,
+      enrollmentId,
     });
     TelemetryEvents.sendEvent("enroll", "preference_study", slug, {
       experimentType,
       branch,
+      enrollmentId,
     });
     await this.saveStartupPrefs();
+
+    return experiment;
   },
 
   /**
    * Register a preference observer that stops an experiment when the user
    * modifies the preference.
    * @param {string} experimentSlug
    * @param {string} preferenceName
    * @param {string|integer|boolean} preferenceValue
@@ -687,17 +707,17 @@ var PreferenceExperiments = {
     }
 
     const experiment = store.data.experiments[experimentSlug];
     if (experiment.expired) {
       TelemetryEvents.sendEvent(
         "unenrollFailed",
         "preference_study",
         experimentSlug,
-        { reason: "already-unenrolled" }
+        { reason: "already-unenrolled", enrollmentId: experiment.enrollmentId }
       );
       throw new Error(
         `Cannot stop preference experiment "${experimentSlug}" because it is already expired`
       );
     }
 
     if (PreferenceExperiments.hasObserver(experimentSlug)) {
       PreferenceExperiments.stopObserver(experimentSlug);
@@ -739,16 +759,17 @@ var PreferenceExperiments = {
     experiment.expired = true;
     store.saveSoon();
 
     TelemetryEnvironment.setExperimentInactive(experimentSlug);
     TelemetryEvents.sendEvent("unenroll", "preference_study", experimentSlug, {
       didResetValue: resetValue ? "true" : "false",
       branch: experiment.branch,
       reason,
+      enrollmentId: experiment.enrollmentId,
     });
     await this.saveStartupPrefs();
   },
 
   /**
    * Clone an experiment using knowledge of its structure to avoid
    * having to serialize/deserialize it.
    *
--- a/toolkit/components/normandy/lib/PreferenceRollouts.jsm
+++ b/toolkit/components/normandy/lib/PreferenceRollouts.jsm
@@ -57,16 +57,20 @@ const log = LogManager.getLogger("recipe
  *   The preference to modify.
  * @property {string} preferenceType
  *   Type of the preference being set.
  * @property {string|integer|boolean} value
  *   The value to change the preference to.
  * @property {string|integer|boolean} previousValue
  *   The value the preference would have on the default branch if this rollout
  *   were not active.
+ * @property {string} enrollmentId
+ *   A random ID generated at time of enrollment. It should be included on all
+ *   telemetry related to this rollout. It should not be re-used by other
+ *   studies, or any other purpose. May be null on old rollouts.
  */
 
 var EXPORTED_SYMBOLS = ["PreferenceRollouts"];
 const STARTUP_PREFS_BRANCH = "app.normandy.startupRolloutPrefs.";
 const DB_NAME = "normandy-preference-rollout";
 const STORE_NAME = "preference-rollouts";
 const DB_VERSION = 1;
 
@@ -146,31 +150,34 @@ var PreferenceRollouts = {
         // of the rollout's changes redundant, so graduate the rollout.
         rollout.state = this.STATE_GRADUATED;
         changed = true;
         log.debug(`Graduating rollout: ${rollout.slug}`);
         TelemetryEvents.sendEvent(
           "graduate",
           "preference_rollout",
           rollout.slug,
-          {}
+          {
+            enrollmentId: rollout.enrollmentId,
+          }
         );
       }
 
       if (changed) {
         const db = await getDatabase();
         await getStore(db, "readwrite").put(rollout);
       }
     }
   },
 
   async init() {
     for (const rollout of await this.getAllActive()) {
       TelemetryEnvironment.setExperimentActive(rollout.slug, rollout.state, {
         type: "normandy-prefrollout",
+        enrollmentId: rollout.enrollmentId,
       });
     }
   },
 
   async uninit() {
     await this.saveStartupPrefs();
   },
 
@@ -194,16 +201,19 @@ var PreferenceRollouts = {
     };
   },
 
   /**
    * Add a new rollout
    * @param {PreferenceRollout} rollout
    */
   async add(rollout) {
+    if (!rollout.enrollmentId) {
+      throw new Error("Rollout must have an enrollment ID");
+    }
     const db = await getDatabase();
     return getStore(db, "readwrite").add(rollout);
   },
 
   /**
    * Update an existing rollout
    * @param {PreferenceRollout} rollout
    * @throws If a matching rollout does not exist.
--- a/toolkit/components/normandy/test/NormandyTestUtils.jsm
+++ b/toolkit/components/normandy/test/NormandyTestUtils.jsm
@@ -1,16 +1,18 @@
 /* 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/. */
 "use strict";
 
 ChromeUtils.import("resource://normandy/lib/AddonStudies.jsm", this);
+ChromeUtils.import("resource://normandy/lib/NormandyUtils.jsm", this);
 
 const FIXTURE_ADDON_ID = "normandydriver-a@example.com";
+const UUID_REGEX = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
 
 var EXPORTED_SYMBOLS = ["NormandyTestUtils"];
 
 // Factory IDs
 let _addonStudyFactoryId = 0;
 let _preferenceStudyFactoryId = 0;
 
 let testGlobals = {};
@@ -42,16 +44,17 @@ const NormandyTestUtils = {
           addonUrl: "http://test/addon.xpi",
           addonVersion: "1.0.0",
           studyStartDate: new Date(),
           studyEndDate: null,
           extensionApiId: 1,
           extensionHash:
             "ade1c14196ec4fe0aa0a6ba40ac433d7c8d1ec985581a8a94d43dc58991b5171",
           extensionHashAlgorithm: "sha256",
+          enrollmentId: NormandyUtils.generateUuid(),
         },
         attrs
       );
     },
 
     branchedAddonStudyFactory(attrs) {
       return NormandyTestUtils.factories.addonStudyFactory(
         Object.assign(
@@ -146,9 +149,13 @@ const NormandyTestUtils = {
    *     async function myTest(mockPreferences, mockApi) {
    *       // Do a test
    *     }
    *   );
    */
   decorate_task(...args) {
     return testGlobals.add_task(NormandyTestUtils.decorate(...args));
   },
+
+  isUuid(s) {
+    return UUID_REGEX.test(s);
+  },
 };
--- a/toolkit/components/normandy/test/browser/browser_AddonStudies.js
+++ b/toolkit/components/normandy/test/browser/browser_AddonStudies.js
@@ -140,20 +140,25 @@ decorate_task(
         "unenroll",
         "addon_study",
         activeUninstalledStudy.slug,
         {
           addonId: activeUninstalledStudy.addonId,
           addonVersion: activeUninstalledStudy.addonVersion,
           reason: "uninstalled-sideload",
           branch: AddonStudies.NO_BRANCHES_MARKER,
+          enrollmentId: events[0][5].enrollmentId,
         },
       ],
       "AddonStudies.init() should send the correct telemetry event"
     );
+    ok(
+      NormandyTestUtils.isUuid(events[0][5].enrollmentId),
+      "enrollment ID should be a UUID"
+    );
 
     const newInactiveStudy = await AddonStudies.get(inactiveStudy.recipeId);
     is(
       newInactiveStudy.studyEndDate.getFullYear(),
       2012,
       "init does not modify inactive studies."
     );
 
--- a/toolkit/components/normandy/test/browser/browser_ClientEnvironment.js
+++ b/toolkit/components/normandy/test/browser/browser_ClientEnvironment.js
@@ -1,16 +1,17 @@
 "use strict";
 
 ChromeUtils.import("resource://gre/modules/Services.jsm", this);
 ChromeUtils.import("resource://gre/modules/TelemetryController.jsm", this);
 ChromeUtils.import("resource://gre/modules/AddonManager.jsm", this);
 ChromeUtils.import("resource://testing-common/AddonTestUtils.jsm", this);
 ChromeUtils.import("resource://normandy/lib/ClientEnvironment.jsm", this);
 ChromeUtils.import("resource://normandy/lib/PreferenceExperiments.jsm", this);
+ChromeUtils.import("resource://testing-common/NormandyTestUtils.jsm", this);
 
 add_task(async function testTelemetry() {
   // setup
   await SpecialPowers.pushPrefEnv({
     set: [["privacy.reduceTimerPrecision", true]],
   });
 
   await TelemetryController.submitExternalPing("testfoo", { foo: 1 });
@@ -31,17 +32,17 @@ add_task(async function testTelemetry() 
     telemetry.testbar.payload.bar,
     2,
     "telemetry filters pull from submitted telemetry pings"
   );
 });
 
 add_task(async function testUserId() {
   // Test that userId is available
-  ok(UUID_REGEX.test(ClientEnvironment.userId), "userId available");
+  ok(NormandyTestUtils.isUuid(ClientEnvironment.userId), "userId available");
 
   // test that it pulls from the right preference
   await SpecialPowers.pushPrefEnv({
     set: [["app.normandy.user_id", "fake id"]],
   });
   is(ClientEnvironment.userId, "fake id", "userId is pulled from preferences");
 });
 
--- a/toolkit/components/normandy/test/browser/browser_PreferenceExperiments.js
+++ b/toolkit/components/normandy/test/browser/browser_PreferenceExperiments.js
@@ -1,15 +1,17 @@
 "use strict";
 
 ChromeUtils.import("resource://gre/modules/Preferences.jsm", this);
 ChromeUtils.import("resource://gre/modules/TelemetryEnvironment.jsm", this);
 ChromeUtils.import("resource://normandy/lib/PreferenceExperiments.jsm", this);
 ChromeUtils.import("resource://normandy/lib/CleanupManager.jsm", this);
 ChromeUtils.import("resource://normandy/lib/TelemetryEvents.jsm", this);
+ChromeUtils.import("resource://normandy/lib/NormandyUtils.jsm", this);
+ChromeUtils.import("resource://testing-common/NormandyTestUtils.jsm", this);
 
 // Save ourselves some typing
 const { withMockExperiments } = PreferenceExperiments;
 const DefaultPreferences = new Preferences({ defaultBranch: true });
 const startupPrefs = "app.normandy.startupExperimentPrefs";
 
 function experimentFactory(attrs) {
   const defaultPref = {
@@ -30,16 +32,17 @@ function experimentFactory(attrs) {
 
   return Object.assign(
     {
       slug: "fakeslug",
       branch: "fakebranch",
       expired: false,
       lastSeen: NOW.toJSON(),
       experimentType: "exp",
+      enrollmentId: NormandyUtils.generateUuid(),
     },
     attrs,
     {
       preferences,
     }
   );
 }
 
@@ -1211,17 +1214,20 @@ decorate_task(
     experiments,
     mockPreferences,
     setActiveStub,
     startObserverStub
   ) {
     mockPreferences.set("fake.pref", "experiment value");
     await PreferenceExperiments.init();
     ok(
-      setActiveStub.calledWith("test", "branch", { type: "normandy-exp" }),
+      setActiveStub.calledWith("test", "branch", {
+        type: "normandy-exp",
+        enrollmentId: experiments[0].enrollmentId,
+      }),
       "Experiment is registered by init"
     );
   }
 );
 
 // init should use the provided experiment type
 decorate_task(
   withMockExperiments([
@@ -1245,16 +1251,17 @@ decorate_task(
     setActiveStub,
     startObserverStub
   ) {
     mockPreferences.set("fake.pref", "experiment value");
     await PreferenceExperiments.init();
     ok(
       setActiveStub.calledWith("test", "branch", {
         type: "normandy-pref-test",
+        enrollmentId: sinon.match(NormandyTestUtils.isUuid),
       }),
       "init should use the provided experiment type"
     );
   }
 );
 
 // starting and stopping experiments should register in telemetry
 decorate_task(
@@ -1263,32 +1270,37 @@ decorate_task(
   withStub(TelemetryEnvironment, "setExperimentInactive"),
   withSendEventStub,
   async function testStartAndStopTelemetry(
     experiments,
     setActiveStub,
     setInactiveStub,
     sendEventStub
   ) {
-    await PreferenceExperiments.start({
+    let { enrollmentId } = await PreferenceExperiments.start({
       slug: "test",
       actionName: "SomeAction",
       branch: "branch",
       preferences: {
         "fake.preference": {
           preferenceValue: "value",
           preferenceType: "string",
           preferenceBranchType: "default",
         },
       },
     });
 
+    ok(
+      NormandyTestUtils.isUuid(enrollmentId),
+      "Experiment should have a UUID enrollmentId"
+    );
+
     Assert.deepEqual(
       setActiveStub.getCall(0).args,
-      ["test", "branch", { type: "normandy-exp" }],
+      ["test", "branch", { type: "normandy-exp", enrollmentId }],
       "Experiment is registered by start()"
     );
     await PreferenceExperiments.stop("test", { reason: "test-reason" });
     Assert.deepEqual(
       setInactiveStub.args,
       [["test"]],
       "Experiment is unregistered by stop()"
     );
@@ -1296,26 +1308,28 @@ decorate_task(
     sendEventStub.assertEvents([
       [
         "enroll",
         "preference_study",
         "test",
         {
           experimentType: "exp",
           branch: "branch",
+          enrollmentId,
         },
       ],
       [
         "unenroll",
         "preference_study",
         "test",
         {
           reason: "test-reason",
           didResetValue: "true",
           branch: "branch",
+          enrollmentId,
         },
       ],
     ]);
   }
 );
 
 // starting experiments should use the provided experiment type
 decorate_task(
@@ -1324,44 +1338,45 @@ decorate_task(
   withStub(TelemetryEnvironment, "setExperimentInactive"),
   withSendEventStub,
   async function testInitTelemetryExperimentType(
     experiments,
     setActiveStub,
     setInactiveStub,
     sendEventStub
   ) {
-    await PreferenceExperiments.start({
+    const { enrollmentId } = await PreferenceExperiments.start({
       slug: "test",
       actionName: "SomeAction",
       branch: "branch",
       preferences: {
         "fake.preference": {
           preferenceValue: "value",
           preferenceType: "string",
           preferenceBranchType: "default",
         },
       },
       experimentType: "pref-test",
     });
 
     Assert.deepEqual(
       setActiveStub.getCall(0).args,
-      ["test", "branch", { type: "normandy-pref-test" }],
+      ["test", "branch", { type: "normandy-pref-test", enrollmentId }],
       "start() should register the experiment with the provided type"
     );
 
     sendEventStub.assertEvents([
       [
         "enroll",
         "preference_study",
         "test",
         {
           experimentType: "pref-test",
           branch: "branch",
+          enrollmentId,
         },
       ],
     ]);
 
     // start sets the passed preference in a way that is hard to mock.
     // Reset the preference so it doesn't interfere with other tests.
     Services.prefs.getDefaultBranch("fake.preference").deleteBranch("");
   }
@@ -1815,13 +1830,14 @@ decorate_task(
       [
         "unenroll",
         "preference_study",
         "test",
         {
           didResetValue: "false",
           reason: "user-preference-changed",
           branch: "fakebranch",
+          enrollmentId: mockExperiments[0].enrollmentId,
         },
       ],
     ]);
   }
 );
--- a/toolkit/components/normandy/test/browser/browser_PreferenceRollouts.js
+++ b/toolkit/components/normandy/test/browser/browser_PreferenceRollouts.js
@@ -15,16 +15,17 @@ decorate_task(PreferenceRollouts.withTes
 
 decorate_task(
   PreferenceRollouts.withTestMock,
   async function testAddUpdateAndGet() {
     const rollout = {
       slug: "test-rollout",
       state: PreferenceRollouts.STATE_ACTIVE,
       preferences: [],
+      enrollmentId: "test-enrollment-id",
     };
     await PreferenceRollouts.add(rollout);
     let storedRollout = await PreferenceRollouts.get(rollout.slug);
     Assert.deepEqual(
       rollout,
       storedRollout,
       "get should retrieve a rollout from storage."
     );
@@ -56,18 +57,26 @@ decorate_task(
     ok(
       !(await PreferenceRollouts.has("test-rollout")),
       "rollout should not have been added"
     );
   }
 );
 
 decorate_task(PreferenceRollouts.withTestMock, async function testGetAll() {
-  const rollout1 = { slug: "test-rollout-1", preference: [] };
-  const rollout2 = { slug: "test-rollout-2", preference: [] };
+  const rollout1 = {
+    slug: "test-rollout-1",
+    preference: [],
+    enrollmentId: "test-enrollment-id-1",
+  };
+  const rollout2 = {
+    slug: "test-rollout-2",
+    preference: [],
+    enrollmentId: "test-enrollment-id-2",
+  };
   await PreferenceRollouts.add(rollout1);
   await PreferenceRollouts.add(rollout2);
 
   const storedRollouts = await PreferenceRollouts.getAll();
   Assert.deepEqual(
     storedRollouts.sort((a, b) => a.id - b.id),
     [rollout1, rollout2],
     "getAll should return every stored rollout."
@@ -75,40 +84,47 @@ decorate_task(PreferenceRollouts.withTes
 });
 
 decorate_task(
   PreferenceRollouts.withTestMock,
   async function testGetAllActive() {
     const rollout1 = {
       slug: "test-rollout-1",
       state: PreferenceRollouts.STATE_ACTIVE,
+      enrollmentId: "test-enrollment-1",
     };
     const rollout2 = {
       slug: "test-rollout-2",
       state: PreferenceRollouts.STATE_GRADUATED,
+      enrollmentId: "test-enrollment-2",
     };
     const rollout3 = {
       slug: "test-rollout-3",
       state: PreferenceRollouts.STATE_ROLLED_BACK,
+      enrollmentId: "test-enrollment-3",
     };
     await PreferenceRollouts.add(rollout1);
     await PreferenceRollouts.add(rollout2);
     await PreferenceRollouts.add(rollout3);
 
     const activeRollouts = await PreferenceRollouts.getAllActive();
     Assert.deepEqual(
       activeRollouts,
       [rollout1],
       "getAllActive should return only active rollouts"
     );
   }
 );
 
 decorate_task(PreferenceRollouts.withTestMock, async function testHas() {
-  const rollout = { slug: "test-rollout", preferences: [] };
+  const rollout = {
+    slug: "test-rollout",
+    preferences: [],
+    enrollmentId: "test-enrollment",
+  };
   await PreferenceRollouts.add(rollout);
   ok(
     await PreferenceRollouts.has(rollout.slug),
     "has should return true for an existing rollout"
   );
   ok(
     !(await PreferenceRollouts.has("does not exist")),
     "has should return false for a missing rollout"
@@ -120,29 +136,31 @@ decorate_task(
   PreferenceRollouts.withTestMock,
   async function testRecordOriginalValuesUpdatesPreviousValues() {
     await PreferenceRollouts.add({
       slug: "test-rollout",
       state: PreferenceRollouts.STATE_ACTIVE,
       preferences: [
         { preferenceName: "test.pref", value: 2, previousValue: null },
       ],
+      enrollmentId: "test-enrollment",
     });
 
     await PreferenceRollouts.recordOriginalValues({ "test.pref": 1 });
 
     Assert.deepEqual(
       await PreferenceRollouts.getAll(),
       [
         {
           slug: "test-rollout",
           state: PreferenceRollouts.STATE_ACTIVE,
           preferences: [
             { preferenceName: "test.pref", value: 2, previousValue: 1 },
           ],
+          enrollmentId: "test-enrollment",
         },
       ],
       "rollout in database should be updated"
     );
   }
 );
 
 // recordOriginalValue should graduate a study when it is no longer relevant.
@@ -152,16 +170,17 @@ decorate_task(
   async function testRecordOriginalValuesUpdatesPreviousValues(sendEventStub) {
     await PreferenceRollouts.add({
       slug: "test-rollout",
       state: PreferenceRollouts.STATE_ACTIVE,
       preferences: [
         { preferenceName: "test.pref1", value: 2, previousValue: null },
         { preferenceName: "test.pref2", value: 2, previousValue: null },
       ],
+      enrollmentId: "test-enrollment-id",
     });
 
     // one pref being the same isn't enough to graduate
     await PreferenceRollouts.recordOriginalValues({
       "test.pref1": 1,
       "test.pref2": 2,
     });
     let rollout = await PreferenceRollouts.get("test-rollout");
@@ -181,47 +200,64 @@ decorate_task(
     rollout = await PreferenceRollouts.get("test-rollout");
     is(
       rollout.state,
       PreferenceRollouts.STATE_GRADUATED,
       "rollouts should graduate when all prefs matches the built-in defaults"
     );
 
     sendEventStub.assertEvents([
-      ["graduate", "preference_rollout", "test-rollout"],
+      [
+        "graduate",
+        "preference_rollout",
+        "test-rollout",
+        { enrollmentId: "test-enrollment-id" },
+      ],
     ]);
   }
 );
 
 // init should mark active rollouts in telemetry
 decorate_task(
   PreferenceRollouts.withTestMock,
   withStub(TelemetryEnvironment, "setExperimentActive"),
   async function testInitTelemetry(setExperimentActiveStub) {
     await PreferenceRollouts.add({
       slug: "test-rollout-active-1",
       state: PreferenceRollouts.STATE_ACTIVE,
+      enrollmentId: "test-enrollment-1",
     });
     await PreferenceRollouts.add({
       slug: "test-rollout-active-2",
       state: PreferenceRollouts.STATE_ACTIVE,
+      enrollmentId: "test-enrollment-2",
     });
     await PreferenceRollouts.add({
       slug: "test-rollout-rolled-back",
       state: PreferenceRollouts.STATE_ROLLED_BACK,
+      enrollmentId: "test-enrollment-3",
     });
     await PreferenceRollouts.add({
       slug: "test-rollout-graduated",
       state: PreferenceRollouts.STATE_GRADUATED,
+      enrollmentId: "test-enrollment-4",
     });
 
     await PreferenceRollouts.init();
 
     Assert.deepEqual(
       setExperimentActiveStub.args,
       [
-        ["test-rollout-active-1", "active", { type: "normandy-prefrollout" }],
-        ["test-rollout-active-2", "active", { type: "normandy-prefrollout" }],
+        [
+          "test-rollout-active-1",
+          "active",
+          { type: "normandy-prefrollout", enrollmentId: "test-enrollment-1" },
+        ],
+        [
+          "test-rollout-active-2",
+          "active",
+          { type: "normandy-prefrollout", enrollmentId: "test-enrollment-2" },
+        ],
       ],
       "init should set activate a telemetry experiment for active preferences"
     );
   }
 );
--- a/toolkit/components/normandy/test/browser/browser_actions_AddonRollbackAction.js
+++ b/toolkit/components/normandy/test/browser/browser_actions_AddonRollbackAction.js
@@ -1,16 +1,17 @@
 "use strict";
 
 ChromeUtils.import("resource://gre/modules/Services.jsm", this);
 ChromeUtils.import("resource://gre/modules/TelemetryEnvironment.jsm", this);
 ChromeUtils.import("resource://normandy/actions/AddonRollbackAction.jsm", this);
 ChromeUtils.import("resource://normandy/actions/AddonRolloutAction.jsm", this);
 ChromeUtils.import("resource://normandy/lib/AddonRollouts.jsm", this);
 ChromeUtils.import("resource://normandy/lib/TelemetryEvents.jsm", this);
+ChromeUtils.import("resource://testing-common/NormandyTestUtils.jsm", this);
 
 // Test that a simple recipe unenrolls as expected
 decorate_task(
   AddonRollouts.withTestMock,
   ensureAddonCleanup,
   withMockNormandyApi,
   withStub(TelemetryEnvironment, "setExperimentInactive"),
   withSendEventStub,
@@ -50,33 +51,39 @@ decorate_task(
     };
 
     const rollbackAction = new AddonRollbackAction();
     await rollbackAction.runRecipe(rollbackRecipe);
 
     const addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID);
     is(addon, undefined, "add-on is uninstalled");
 
+    const rollouts = await AddonRollouts.getAll();
     Assert.deepEqual(
-      await AddonRollouts.getAll(),
+      rollouts,
       [
         {
           recipeId: rolloutRecipe.id,
           slug: "test-rollout",
           state: AddonRollouts.STATE_ROLLED_BACK,
           extensionApiId: 1,
           addonId: FIXTURE_ADDON_ID,
           addonVersion: "1.0",
           xpiUrl: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].url,
           xpiHash: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].hash,
           xpiHashAlgorithm: "sha256",
+          enrollmentId: rollouts[0].enrollmentId,
         },
       ],
       "Rollback should be stored in db"
     );
+    ok(
+      NormandyTestUtils.isUuid(rollouts[0].enrollmentId),
+      "enrollmentId should be a UUID"
+    );
 
     sendEventStub.assertEvents([
       ["enroll", "addon_rollout", rollbackRecipe.arguments.rolloutSlug],
       ["unenroll", "addon_rollback", rollbackRecipe.arguments.rolloutSlug],
     ]);
 
     Assert.deepEqual(
       setExperimentInactiveStub.args,
@@ -127,33 +134,39 @@ decorate_task(
     await addon.uninstall();
 
     const rollbackAction = new AddonRollbackAction();
     await rollbackAction.runRecipe(rollbackRecipe);
 
     addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID);
     is(addon, undefined, "add-on is uninstalled");
 
+    const rollouts = await AddonRollouts.getAll();
     Assert.deepEqual(
-      await AddonRollouts.getAll(),
+      rollouts,
       [
         {
           recipeId: rolloutRecipe.id,
           slug: "test-rollout",
           state: AddonRollouts.STATE_ROLLED_BACK,
           extensionApiId: 1,
           addonId: FIXTURE_ADDON_ID,
           addonVersion: "1.0",
           xpiUrl: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].url,
           xpiHash: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].hash,
           xpiHashAlgorithm: "sha256",
+          enrollmentId: rollouts[0].enrollmentId,
         },
       ],
       "Rollback should be stored in db"
     );
+    ok(
+      NormandyTestUtils.isUuid(rollouts[0].enrollmentId),
+      "enrollment ID should be a UUID"
+    );
 
     sendEventStub.assertEvents([
       ["enroll", "addon_rollout", rollbackRecipe.arguments.rolloutSlug],
       ["unenroll", "addon_rollback", rollbackRecipe.arguments.rolloutSlug],
     ]);
   }
 );
 
--- a/toolkit/components/normandy/test/browser/browser_actions_AddonRolloutAction.js
+++ b/toolkit/components/normandy/test/browser/browser_actions_AddonRolloutAction.js
@@ -1,15 +1,16 @@
 "use strict";
 
 ChromeUtils.import("resource://gre/modules/Services.jsm", this);
 ChromeUtils.import("resource://gre/modules/TelemetryEnvironment.jsm", this);
 ChromeUtils.import("resource://normandy/actions/AddonRolloutAction.jsm", this);
 ChromeUtils.import("resource://normandy/lib/AddonRollouts.jsm", this);
 ChromeUtils.import("resource://normandy/lib/TelemetryEvents.jsm", this);
+ChromeUtils.import("resource://testing-common/NormandyTestUtils.jsm", this);
 
 // Test that a simple recipe enrolls as expected
 decorate_task(
   AddonRollouts.withTestMock,
   ensureAddonCleanup,
   withMockNormandyApi,
   withStub(TelemetryEnvironment, "setExperimentActive"),
   withSendEventStub,
@@ -41,33 +42,39 @@ decorate_task(
 
     await webExtStartupPromise;
 
     // addon was installed
     const addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID);
     is(addon.id, FIXTURE_ADDON_ID, "addon should be installed");
 
     // rollout was stored
+    const rollouts = await AddonRollouts.getAll();
     Assert.deepEqual(
-      await AddonRollouts.getAll(),
+      rollouts,
       [
         {
           recipeId: recipe.id,
           slug: "test-rollout",
           state: AddonRollouts.STATE_ACTIVE,
           extensionApiId: 1,
           addonId: FIXTURE_ADDON_ID,
           addonVersion: "1.0",
           xpiUrl: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].url,
           xpiHash: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].hash,
           xpiHashAlgorithm: "sha256",
+          enrollmentId: rollouts[0].enrollmentId,
         },
       ],
       "Rollout should be stored in db"
     );
+    ok(
+      NormandyTestUtils.isUuid(rollouts[0].enrollmentId),
+      "enrollmentId should be a UUID"
+    );
 
     sendEventStub.assertEvents([
       ["enroll", "addon_rollout", recipe.arguments.slug],
     ]);
     Assert.deepEqual(
       setExperimentActiveStub.args,
       [["test-rollout", "active", { type: "normandy-addonrollout" }]],
       "a telemetry experiment should be activated"
@@ -131,33 +138,39 @@ decorate_task(
 
     await webExtStartupPromise;
 
     addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID);
     is(addon.id, FIXTURE_ADDON_ID, "addon should still be installed");
     is(addon.version, "2.0", "addon should be the correct version");
 
     // rollout in the DB has been updated
+    const rollouts = await AddonRollouts.getAll();
     Assert.deepEqual(
-      await AddonRollouts.getAll(),
+      rollouts,
       [
         {
           recipeId: recipe.id,
           slug: "test-rollout",
           state: AddonRollouts.STATE_ACTIVE,
           extensionApiId: 2,
           addonId: FIXTURE_ADDON_ID,
           addonVersion: "2.0",
           xpiUrl: FIXTURE_ADDON_DETAILS["normandydriver-a-2.0"].url,
           xpiHash: FIXTURE_ADDON_DETAILS["normandydriver-a-2.0"].hash,
           xpiHashAlgorithm: "sha256",
+          enrollmentId: rollouts[0].enrollmentId,
         },
       ],
       "Rollout should be stored in db"
     );
+    ok(
+      NormandyTestUtils.isUuid(rollouts[0].enrollmentId),
+      "enrollmentId should be a UUID"
+    );
 
     sendEventStub.assertEvents([
       ["enroll", "addon_rollout", "test-rollout"],
       ["update", "addon_rollout", "test-rollout"],
     ]);
 
     // Cleanup
     await addon.uninstall();
@@ -204,33 +217,39 @@ decorate_task(
     await action.runRecipe(recipe);
     is(action.lastError, null, "lastError should be null");
 
     addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID);
     is(addon.id, FIXTURE_ADDON_ID, "addon should still be installed");
     is(addon.version, "1.0", "addon should be the correct version");
 
     // rollout in the DB has not been updated
+    const rollouts = await AddonRollouts.getAll();
     Assert.deepEqual(
-      await AddonRollouts.getAll(),
+      rollouts,
       [
         {
           recipeId: recipe.id,
           slug: "test-rollout",
           state: AddonRollouts.STATE_ACTIVE,
           extensionApiId: 1,
           addonId: FIXTURE_ADDON_ID,
           addonVersion: "1.0",
           xpiUrl: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].url,
           xpiHash: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].hash,
           xpiHashAlgorithm: "sha256",
+          enrollmentId: rollouts[0].enrollmentId,
         },
       ],
       "Rollout should be stored in db"
     );
+    ok(
+      NormandyTestUtils.isUuid(rollouts[0].enrollmentId),
+      "Enrollment ID should be a UUID"
+    );
 
     sendEventStub.assertEvents([["enroll", "addon_rollout", "test-rollout"]]);
 
     // Cleanup
     await addon.uninstall();
   }
 );
 
@@ -281,37 +300,50 @@ decorate_task(
     });
     is(action.lastError, null, "lastError should be null");
 
     addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID);
     is(addon.id, FIXTURE_ADDON_ID, "addon should still be installed");
     is(addon.version, "1.0", "addon should be the correct version");
 
     // rollout in the DB has not been updated
+    const rollouts = await AddonRollouts.getAll();
     Assert.deepEqual(
-      await AddonRollouts.getAll(),
+      rollouts,
       [
         {
           recipeId: recipe.id,
           slug: "test-rollout",
           state: AddonRollouts.STATE_ACTIVE,
           extensionApiId: 1,
           addonId: FIXTURE_ADDON_ID,
           addonVersion: "1.0",
           xpiUrl: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].url,
           xpiHash: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].hash,
           xpiHashAlgorithm: "sha256",
+          enrollmentId: rollouts[0].enrollmentId,
         },
       ],
       "Rollout should be stored in db"
     );
+    ok(NormandyTestUtils.isUuid(rollouts[0].enrollmentId));
 
     sendEventStub.assertEvents([
-      ["enroll", "addon_rollout", "test-rollout"],
-      ["enrollFailed", "addon_rollout", "test-conflict"],
+      [
+        "enroll",
+        "addon_rollout",
+        "test-rollout",
+        { addonId: FIXTURE_ADDON_ID, enrollmentId: rollouts[0].enrollmentId },
+      ],
+      [
+        "enrollFailed",
+        "addon_rollout",
+        "test-conflict",
+        { enrollmentId: rollouts[0].enrollmentId, reason: "conflict" },
+      ],
     ]);
 
     // Cleanup
     await addon.uninstall();
   }
 );
 
 // Add-on ID changed
@@ -362,33 +394,39 @@ decorate_task(
     await action.runRecipe(recipe);
     is(action.lastError, null, "lastError should be null");
 
     addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID);
     is(addon.id, FIXTURE_ADDON_ID, "addon should still be installed");
     is(addon.version, "1.0", "addon should be the correct version");
 
     // rollout in the DB has not been updated
+    const rollouts = await AddonRollouts.getAll();
     Assert.deepEqual(
-      await AddonRollouts.getAll(),
+      rollouts,
       [
         {
           recipeId: recipe.id,
           slug: "test-rollout",
           state: AddonRollouts.STATE_ACTIVE,
           extensionApiId: 1,
           addonId: FIXTURE_ADDON_ID,
           addonVersion: "1.0",
           xpiUrl: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].url,
           xpiHash: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].hash,
           xpiHashAlgorithm: "sha256",
+          enrollmentId: rollouts[0].enrollmentId,
         },
       ],
       "Rollout should be stored in db"
     );
+    ok(
+      NormandyTestUtils.isUuid(rollouts[0].enrollmentId),
+      "enrollment ID should be a UUID"
+    );
 
     sendEventStub.assertEvents([
       ["enroll", "addon_rollout", "test-rollout"],
       [
         "updateFailed",
         "addon_rollout",
         "test-rollout",
         { reason: "addon-id-changed" },
@@ -447,33 +485,39 @@ decorate_task(
     await action.runRecipe(recipe);
     is(action.lastError, null, "lastError should be null");
 
     addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID);
     is(addon.id, FIXTURE_ADDON_ID, "addon should still be installed");
     is(addon.version, "2.0", "addon should be the correct version");
 
     // rollout in the DB has not been updated
+    const rollouts = await AddonRollouts.getAll();
     Assert.deepEqual(
-      await AddonRollouts.getAll(),
+      rollouts,
       [
         {
           recipeId: recipe.id,
           slug: "test-rollout",
           state: AddonRollouts.STATE_ACTIVE,
           extensionApiId: 1,
           addonId: FIXTURE_ADDON_ID,
           addonVersion: "2.0",
           xpiUrl: FIXTURE_ADDON_DETAILS["normandydriver-a-2.0"].url,
           xpiHash: FIXTURE_ADDON_DETAILS["normandydriver-a-2.0"].hash,
           xpiHashAlgorithm: "sha256",
+          enrollmentId: rollouts[0].enrollmentId,
         },
       ],
       "Rollout should be stored in db"
     );
+    ok(
+      NormandyTestUtils.isUuid(rollouts[0].enrollmentId),
+      "enrollment ID should be a UUID"
+    );
 
     sendEventStub.assertEvents([
       ["enroll", "addon_rollout", "test-rollout"],
       [
         "updateFailed",
         "addon_rollout",
         "test-rollout",
         { reason: "upgrade-required" },
--- a/toolkit/components/normandy/test/browser/browser_actions_AddonStudyAction.js
+++ b/toolkit/components/normandy/test/browser/browser_actions_AddonStudyAction.js
@@ -307,28 +307,34 @@ decorate_task(
         addonVersion: "1.0",
         addonUrl,
         active: true,
         studyStartDate: study.studyStartDate, // checked below
         studyEndDate: null,
         extensionApiId: recipe.arguments.extensionApiId,
         extensionHash: extensionDetails.hash,
         extensionHashAlgorithm: extensionDetails.hash_algorithm,
+        enrollmentId: study.enrollmentId,
       },
       "study data should be stored"
     );
     ok(study.studyStartDate, "a start date should be assigned");
     is(study.studyEndDate, null, "an end date should not be assigned");
+    ok(NormandyTestUtils.isUuid(study.enrollmentId));
 
     sendEventStub.assertEvents([
       [
         "enroll",
         "addon_study",
         recipe.arguments.name,
-        { addonId: FIXTURE_ADDON_ID, addonVersion: "1.0" },
+        {
+          addonId: FIXTURE_ADDON_ID,
+          addonVersion: "1.0",
+          enrollmentId: study.enrollmentId,
+        },
       ],
     ]);
 
     // cleanup
     await safeUninstallAddon(addon);
     Assert.deepEqual(
       (await AddonManager.getAllAddons()).map(addon => addon.id),
       initialAddonIds,
@@ -390,16 +396,17 @@ decorate_task(
       [
         "update",
         "addon_study",
         recipe.arguments.name,
         {
           addonId: FIXTURE_ADDON_ID,
           addonVersion: "2.0",
           branch: AddonStudies.NO_BRANCHES_MARKER,
+          enrollmentId: study.enrollmentId,
         },
       ],
     ]);
 
     const updatedStudy = await AddonStudies.get(recipe.id);
     Assert.deepEqual(
       updatedStudy,
       {
@@ -462,16 +469,17 @@ decorate_task(
     ok(updateSpy.called, "update should be called");
     sendEventStub.assertEvents([
       [
         "updateFailed",
         "addon_study",
         recipe.arguments.name,
         {
           reason: "addon-id-mismatch",
+          enrollmentId: study.enrollmentId,
         },
       ],
     ]);
 
     const updatedStudy = await AddonStudies.get(recipe.id);
     Assert.deepEqual(updatedStudy, study, "study data should be unchanged");
 
     let addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID);
@@ -523,16 +531,17 @@ decorate_task(
     ok(updateSpy.called, "update should be called");
     sendEventStub.assertEvents([
       [
         "updateFailed",
         "addon_study",
         recipe.arguments.name,
         {
           reason: "addon-does-not-exist",
+          enrollmentId: study.enrollmentId,
         },
       ],
     ]);
 
     const updatedStudy = await AddonStudies.get(recipe.id);
     Assert.deepEqual(updatedStudy, study, "study data should be unchanged");
 
     let addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID);
@@ -589,16 +598,17 @@ decorate_task(
     sendEventStub.assertEvents([
       [
         "updateFailed",
         "addon_study",
         recipe.arguments.name,
         {
           reason: "download-failure",
           detail: "ERROR_NETWORK_FAILURE",
+          enrollmentId: study.enrollmentId,
         },
       ],
     ]);
 
     const updatedStudy = await AddonStudies.get(recipe.id);
     Assert.deepEqual(updatedStudy, study, "study data should be unchanged");
 
     const addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID);
@@ -653,16 +663,17 @@ decorate_task(
     sendEventStub.assertEvents([
       [
         "updateFailed",
         "addon_study",
         recipe.arguments.name,
         {
           reason: "download-failure",
           detail: "ERROR_INCORRECT_HASH",
+          enrollmentId: study.enrollmentId,
         },
       ],
     ]);
 
     const updatedStudy = await AddonStudies.get(recipe.id);
     Assert.deepEqual(updatedStudy, study, "study data should be unchanged");
 
     const addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID);
@@ -716,16 +727,17 @@ decorate_task(
     ok(updateSpy.called, "update should be called");
     sendEventStub.assertEvents([
       [
         "updateFailed",
         "addon_study",
         recipe.arguments.name,
         {
           reason: "no-downgrade",
+          enrollmentId: study.enrollmentId,
         },
       ],
     ]);
 
     const updatedStudy = await AddonStudies.get(recipe.id);
     Assert.deepEqual(updatedStudy, study, "study data should be unchanged");
 
     const addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID);
@@ -782,16 +794,17 @@ decorate_task(
     ok(updateSpy.called, "update should be called");
     sendEventStub.assertEvents([
       [
         "updateFailed",
         "addon_study",
         recipe.arguments.name,
         {
           reason: "metadata-mismatch",
+          enrollmentId: study.enrollmentId,
         },
       ],
     ]);
 
     const updatedStudy = await AddonStudies.get(recipe.id);
     Assert.deepEqual(updatedStudy, study, "study data should be unchanged");
 
     const addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID);
@@ -873,16 +886,17 @@ decorate_task(
       [
         "unenroll",
         "addon_study",
         study.slug,
         {
           addonId,
           addonVersion: study.addonVersion,
           reason: "test-reason",
+          enrollmentId: study.enrollmentId,
         },
       ],
     ]);
   }
 );
 
 // If the add-on for a study isn't installed, a warning should be logged, but the action is still successful
 decorate_task(
@@ -905,16 +919,17 @@ decorate_task(
       [
         "unenroll",
         "addon_study",
         study.slug,
         {
           addonId: study.addonId,
           addonVersion: study.addonVersion,
           reason: "unknown",
+          enrollmentId: study.enrollmentId,
         },
       ],
     ]);
 
     SimpleTest.endMonitorConsole();
   }
 );
 
@@ -1038,16 +1053,17 @@ decorate_task(
     sendEventStub.assertEvents([
       [
         "update",
         "addon_study",
         recipe.arguments.name,
         {
           addonId: FIXTURE_ADDON_ID,
           addonVersion: "2.0",
+          enrollmentId: study.enrollmentId,
         },
       ],
     ]);
 
     const addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID);
     ok(addon.version === "2.0", "add-on should be updated");
   }
 );
@@ -1111,16 +1127,17 @@ decorate_task(
     sendEventStub.assertEvents([
       [
         "update",
         "addon_study",
         study.slug,
         {
           addonId: "normandydriver-a@example.com",
           addonVersion: "2.0",
+          enrollmentId: study.enrollmentId,
         },
       ],
     ]);
 
     const updatedStudy = await AddonStudies.get(recipe.id);
     Assert.deepEqual(
       updatedStudy,
       {
--- a/toolkit/components/normandy/test/browser/browser_actions_BranchedAddonStudyAction.js
+++ b/toolkit/components/normandy/test/browser/browser_actions_BranchedAddonStudyAction.js
@@ -324,16 +324,17 @@ decorate_task(
       [
         "update",
         "addon_study",
         recipe.arguments.name,
         {
           addonId: FIXTURE_ADDON_ID,
           addonVersion: "2.0",
           branch: "a",
+          enrollmentId: study.enrollmentId,
         },
       ],
     ]);
 
     const updatedStudy = await AddonStudies.get(recipe.id);
     Assert.deepEqual(
       updatedStudy,
       {
@@ -388,16 +389,17 @@ decorate_task(
     sendEventStub.assertEvents([
       [
         "updateFailed",
         "addon_study",
         recipe.arguments.name,
         {
           reason: "addon-id-mismatch",
           branch: "a",
+          enrollmentId: study.enrollmentId,
         },
       ],
     ]);
 
     const updatedStudy = await AddonStudies.get(recipe.id);
     Assert.deepEqual(updatedStudy, study, "study data should be unchanged");
 
     let addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID);
@@ -441,16 +443,17 @@ decorate_task(
     sendEventStub.assertEvents([
       [
         "updateFailed",
         "addon_study",
         recipe.arguments.name,
         {
           reason: "addon-does-not-exist",
           branch: "a",
+          enrollmentId: study.enrollmentId,
         },
       ],
     ]);
 
     const updatedStudy = await AddonStudies.get(recipe.id);
     Assert.deepEqual(updatedStudy, study, "study data should be unchanged");
 
     let addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID);
@@ -499,16 +502,17 @@ decorate_task(
       [
         "updateFailed",
         "addon_study",
         recipe.arguments.name,
         {
           branch: "a",
           reason: "download-failure",
           detail: "ERROR_NETWORK_FAILURE",
+          enrollmentId: study.enrollmentId,
         },
       ],
     ]);
 
     const updatedStudy = await AddonStudies.get(recipe.id);
     Assert.deepEqual(updatedStudy, study, "study data should be unchanged");
 
     const addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID);
@@ -555,16 +559,17 @@ decorate_task(
       [
         "updateFailed",
         "addon_study",
         recipe.arguments.name,
         {
           branch: "a",
           reason: "download-failure",
           detail: "ERROR_INCORRECT_HASH",
+          enrollmentId: study.enrollmentId,
         },
       ],
     ]);
 
     const updatedStudy = await AddonStudies.get(recipe.id);
     Assert.deepEqual(updatedStudy, study, "study data should be unchanged");
 
     const addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID);
@@ -610,16 +615,17 @@ decorate_task(
     sendEventStub.assertEvents([
       [
         "updateFailed",
         "addon_study",
         recipe.arguments.name,
         {
           reason: "no-downgrade",
           branch: "a",
+          enrollmentId: study.enrollmentId,
         },
       ],
     ]);
 
     const updatedStudy = await AddonStudies.get(recipe.id);
     Assert.deepEqual(updatedStudy, study, "study data should be unchanged");
 
     const addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID);
@@ -668,16 +674,17 @@ decorate_task(
     sendEventStub.assertEvents([
       [
         "updateFailed",
         "addon_study",
         recipe.arguments.name,
         {
           branch: "a",
           reason: "metadata-mismatch",
+          enrollmentId: study.enrollmentId,
         },
       ],
     ]);
 
     const updatedStudy = await AddonStudies.get(recipe.id);
     Assert.deepEqual(updatedStudy, study, "study data should be unchanged");
 
     const addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID);
@@ -765,16 +772,17 @@ decorate_task(
       [
         "unenroll",
         "addon_study",
         study.name,
         {
           addonId,
           addonVersion: study.addonVersion,
           reason: "test-reason",
+          enrollmentId: study.enrollmentId,
         },
       ],
     ]);
 
     Assert.deepEqual(
       setExperimentInactiveStub.args,
       [[study.slug]],
       "setExperimentInactive should be called"
@@ -802,16 +810,17 @@ decorate_task(
       [
         "unenroll",
         "addon_study",
         study.name,
         {
           addonId: study.addonId,
           addonVersion: study.addonVersion,
           reason: "unknown",
+          enrollmentId: study.enrollmentId,
         },
       ],
     ]);
 
     SimpleTest.endMonitorConsole();
   }
 );
 
@@ -927,16 +936,17 @@ decorate_task(
     sendEventStub.assertEvents([
       [
         "update",
         "addon_study",
         recipe.arguments.name,
         {
           addonId: FIXTURE_ADDON_ID,
           addonVersion: "2.0",
+          enrollmentId: study.enrollmentId,
         },
       ],
     ]);
 
     const addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID);
     ok(addon.version === "2.0", "add-on should be updated");
   }
 );
@@ -1022,44 +1032,51 @@ const successEnrollBranchedTest = decora
     const action = new BranchedAddonStudyAction();
     const chooseBranchStub = sinon.stub(action, "chooseBranch");
     chooseBranchStub.callsFake(async ({ branches }) =>
       branches.find(b => b.slug === branch)
     );
     await action.runRecipe(recipe);
     is(action.lastError, null, "lastError should be null");
 
+    const study = await AddonStudies.get(recipe.id);
     sendEventStub.assertEvents([
       [
         "enroll",
         "addon_study",
         recipe.arguments.slug,
         {
           addonId,
           addonVersion: "1.0",
           branch,
+          enrollmentId: study.enrollmentId,
         },
       ],
     ]);
 
     Assert.deepEqual(
       setExperimentActiveStub.args,
-      [[recipe.arguments.slug, branch, { type: "normandy-addonstudy" }]],
+      [
+        [
+          recipe.arguments.slug,
+          branch,
+          { type: "normandy-addonstudy", enrollmentId: study.enrollmentId },
+        ],
+      ],
       "setExperimentActive should be called"
     );
 
     const addon = await AddonManager.getAddonByID(addonId);
     ok(addon, "The chosen branch's add-on should be installed");
     is(
       await AddonManager.getAddonByID(otherBranchAddonId),
       null,
       "The other branch's add-on should not be installed"
     );
 
-    const study = await AddonStudies.get(recipe.id);
     Assert.deepEqual(
       study,
       {
         recipeId: recipe.id,
         slug: recipe.arguments.slug,
         userFacingName: recipe.arguments.userFacingName,
         userFacingDescription: recipe.arguments.userFacingDescription,
         addonId,
@@ -1067,16 +1084,17 @@ const successEnrollBranchedTest = decora
         addonUrl: FIXTURE_ADDON_DETAILS[`normandydriver-${branch}-1.0`].url,
         active: true,
         branch,
         studyStartDate: study.studyStartDate, // This is checked below
         studyEndDate: null,
         extensionApiId: extensionDetails.id,
         extensionHash: extensionDetails.hash,
         extensionHashAlgorithm: extensionDetails.hash_algorithm,
+        enrollmentId: study.enrollmentId,
       },
       "the correct study data should be stored"
     );
 
     // cleanup
     await safeUninstallAddon(addon);
     Assert.deepEqual(
       (await AddonManager.getAllAddons()).map(addon => addon.id),
@@ -1140,16 +1158,17 @@ decorate_task(
         "unenroll",
         "addon_study",
         study.name,
         {
           addonId,
           addonVersion: study.addonVersion,
           reason: "branch-removed",
           branch: "a", // the original study branch
+          enrollmentId: study.enrollmentId,
         },
       ],
     ]);
 
     is(
       await AddonManager.getAddonByID(addonId),
       null,
       "the add-on should be uninstalled"
@@ -1181,36 +1200,37 @@ decorate_task(
       },
     });
 
     let action = new BranchedAddonStudyAction();
     await action.runRecipe(recipe);
     await action.finalize();
     is(action.lastError, null, "lastError should be null");
 
+    let study = await AddonStudies.get(recipe.id);
     sendEventStub.assertEvents([
       [
         "enroll",
         "addon_study",
         recipe.arguments.name,
         {
           addonId: AddonStudies.NO_ADDON_MARKER,
           addonVersion: AddonStudies.NO_ADDON_MARKER,
           branch: "a",
+          enrollmentId: study.enrollmentId,
         },
       ],
     ]);
 
     Assert.deepEqual(
       (await AddonManager.getAllAddons()).map(addon => addon.id),
       initialAddonIds,
       "No add-on should be installed for the study"
     );
 
-    let study = await AddonStudies.get(recipe.id);
     Assert.deepEqual(
       study,
       {
         recipeId: recipe.id,
         slug: recipe.arguments.slug,
         userFacingName: recipe.arguments.userFacingName,
         userFacingDescription: recipe.arguments.userFacingDescription,
         addonId: null,
@@ -1218,47 +1238,51 @@ decorate_task(
         addonUrl: null,
         active: true,
         branch: "a",
         studyStartDate: study.studyStartDate, // This is checked below
         studyEndDate: null,
         extensionApiId: null,
         extensionHash: null,
         extensionHashAlgorithm: null,
+        enrollmentId: study.enrollmentId,
       },
       "the correct study data should be stored"
     );
     ok(study.studyStartDate, "studyStartDate should have a value");
+    NormandyTestUtils.isUuid(study.enrollmentId);
 
     // Now unenroll
     action = new BranchedAddonStudyAction();
     await action.finalize();
     is(action.lastError, null, "lastError should be null");
 
     sendEventStub.assertEvents([
       // The event from before
       [
         "enroll",
         "addon_study",
         recipe.arguments.name,
         {
           addonId: AddonStudies.NO_ADDON_MARKER,
           addonVersion: AddonStudies.NO_ADDON_MARKER,
           branch: "a",
+          enrollmentId: study.enrollmentId,
         },
       ],
       // And a new unenroll event
       [
         "unenroll",
         "addon_study",
         recipe.arguments.name,
         {
           addonId: AddonStudies.NO_ADDON_MARKER,
           addonVersion: AddonStudies.NO_ADDON_MARKER,
           branch: "a",
+          enrollmentId: study.enrollmentId,
         },
       ],
     ]);
 
     Assert.deepEqual(
       (await AddonManager.getAllAddons()).map(addon => addon.id),
       initialAddonIds,
       "The set of add-ons should not change"
@@ -1277,15 +1301,17 @@ decorate_task(
         addonUrl: null,
         active: false,
         branch: "a",
         studyStartDate: study.studyStartDate, // This is checked below
         studyEndDate: study.studyEndDate, // This is checked below
         extensionApiId: null,
         extensionHash: null,
         extensionHashAlgorithm: null,
+        enrollmentId: study.enrollmentId,
       },
       "the correct study data should be stored"
     );
     ok(study.studyStartDate, "studyStartDate should have a value");
     ok(study.studyEndDate, "studyEndDate should have a value");
+    NormandyTestUtils.isUuid(study.enrollmentId);
   }
 );
--- a/toolkit/components/normandy/test/browser/browser_actions_PreferenceExperimentAction.js
+++ b/toolkit/components/normandy/test/browser/browser_actions_PreferenceExperimentAction.js
@@ -458,16 +458,17 @@ decorate_task(
             previousPreferenceValue: "oldvalue",
           },
         },
         expired: false,
         lastSeen: activeExperiments[0].lastSeen, // can't predict date
         experimentType: "exp",
         userFacingName: "userFacingName",
         userFacingDescription: "userFacingDescription",
+        enrollmentId: activeExperiments[0].enrollmentId,
       },
     ]);
 
     // Session 2: recipe is filtered out and so does not run.
     const action2 = new PreferenceExperimentAction();
     await action2.finalize();
 
     // Experiment should be unenrolled
--- a/toolkit/components/normandy/test/browser/browser_actions_PreferenceRollbackAction.js
+++ b/toolkit/components/normandy/test/browser/browser_actions_PreferenceRollbackAction.js
@@ -18,28 +18,29 @@ decorate_task(
   withSendEventStub,
   async function simple_rollback(setExperimentInactiveStub, sendEventStub) {
     Services.prefs.getDefaultBranch("").setIntPref("test.pref1", 2);
     Services.prefs
       .getDefaultBranch("")
       .setCharPref("test.pref2", "rollout value");
     Services.prefs.getDefaultBranch("").setBoolPref("test.pref3", true);
 
-    PreferenceRollouts.add({
+    await PreferenceRollouts.add({
       slug: "test-rollout",
       state: PreferenceRollouts.STATE_ACTIVE,
       preferences: [
         { preferenceName: "test.pref1", value: 2, previousValue: 1 },
         {
           preferenceName: "test.pref2",
           value: "rollout value",
           previousValue: "builtin value",
         },
         { preferenceName: "test.pref3", value: true, previousValue: false },
       ],
+      enrollmentId: "test-enrollment-id",
     });
 
     const recipe = { id: 1, arguments: { rolloutSlug: "test-rollout" } };
 
     const action = new PreferenceRollbackAction();
     await action.runRecipe(recipe);
     await action.finalize();
     is(action.lastError, null, "lastError should be null");
@@ -74,31 +75,33 @@ decorate_task(
     );
     is(
       Services.prefs.getPrefType("app.normandy.startupRolloutPrefs.test.pref3"),
       Services.prefs.PREF_INVALID,
       "boolean startup pref should be unset"
     );
 
     // rollout in db was updated
+    const rollouts = await PreferenceRollouts.getAll();
     Assert.deepEqual(
-      await PreferenceRollouts.getAll(),
+      rollouts,
       [
         {
           slug: "test-rollout",
           state: PreferenceRollouts.STATE_ROLLED_BACK,
           preferences: [
             { preferenceName: "test.pref1", value: 2, previousValue: 1 },
             {
               preferenceName: "test.pref2",
               value: "rollout value",
               previousValue: "builtin value",
             },
             { preferenceName: "test.pref3", value: true, previousValue: false },
           ],
+          enrollmentId: rollouts[0].enrollmentId,
         },
       ],
       "Rollout should be updated in db"
     );
 
     // Telemetry is updated
     sendEventStub.assertEvents([
       [
@@ -128,16 +131,17 @@ decorate_task(
   async function cant_rollback_graduated(sendEventStub) {
     Services.prefs.getDefaultBranch("").setIntPref("test.pref", 1);
     await PreferenceRollouts.add({
       slug: "graduated-rollout",
       state: PreferenceRollouts.STATE_GRADUATED,
       preferences: [
         { preferenceName: "test.pref", value: 1, previousValue: 1 },
       ],
+      enrollmentId: "test-enrollment-id",
     });
 
     let recipe = { id: 1, arguments: { rolloutSlug: "graduated-rollout" } };
 
     const action = new PreferenceRollbackAction();
     await action.runRecipe(recipe);
     await action.finalize();
     is(action.lastError, null, "lastError should be null");
@@ -154,27 +158,28 @@ decorate_task(
       await PreferenceRollouts.getAll(),
       [
         {
           slug: "graduated-rollout",
           state: PreferenceRollouts.STATE_GRADUATED,
           preferences: [
             { preferenceName: "test.pref", value: 1, previousValue: 1 },
           ],
+          enrollmentId: "test-enrollment-id",
         },
       ],
       "Rollout should not change in db"
     );
 
     sendEventStub.assertEvents([
       [
         "unenrollFailed",
         "preference_rollback",
         "graduated-rollout",
-        { reason: "graduated" },
+        { reason: "graduated", enrollmentId: "test-enrollment-id" },
       ],
     ]);
 
     // Cleanup
     Services.prefs.getDefaultBranch("").deleteBranch("test.pref");
   }
 );
 
@@ -213,18 +218,19 @@ decorate_task(
 
     const recipe = { id: 1, arguments: { rolloutSlug: "test-rollout" } };
     const rollout = {
       slug: "test-rollout",
       state: PreferenceRollouts.STATE_ROLLED_BACK,
       preferences: [
         { preferenceName: "test.pref", value: 2, previousValue: 1 },
       ],
+      enrollmentId: "test-rollout-id",
     };
-    PreferenceRollouts.add(rollout);
+    await PreferenceRollouts.add(rollout);
 
     const action = new PreferenceRollbackAction();
     await action.runRecipe(recipe);
     await action.finalize();
     is(action.lastError, null, "lastError should be null");
 
     is(Services.prefs.getIntPref("test.pref"), 1, "pref shouldn't change");
     is(
@@ -256,26 +262,27 @@ decorate_task(
 // Test that a rollback doesn't affect user prefs
 decorate_task(PreferenceRollouts.withTestMock, async function simple_rollback(
   setExperimentInactiveStub,
   sendEventStub
 ) {
   Services.prefs.getDefaultBranch("").setCharPref("test.pref", "rollout value");
   Services.prefs.setCharPref("test.pref", "user value");
 
-  PreferenceRollouts.add({
+  await PreferenceRollouts.add({
     slug: "test-rollout",
     state: PreferenceRollouts.STATE_ACTIVE,
     preferences: [
       {
         preferenceName: "test.pref",
         value: "rollout value",
         previousValue: "builtin value",
       },
     ],
+    enrollmentId: "test-enrollment-id",
   });
 
   const recipe = { id: 1, arguments: { rolloutSlug: "test-rollout" } };
 
   const action = new PreferenceRollbackAction();
   await action.runRecipe(recipe);
   await action.finalize();
   is(action.lastError, null, "lastError should be null");
--- a/toolkit/components/normandy/test/browser/browser_actions_PreferenceRolloutAction.js
+++ b/toolkit/components/normandy/test/browser/browser_actions_PreferenceRolloutAction.js
@@ -4,16 +4,17 @@ ChromeUtils.import("resource://gre/modul
 ChromeUtils.import("resource://gre/modules/Preferences.jsm", this);
 ChromeUtils.import("resource://gre/modules/TelemetryEnvironment.jsm", this);
 ChromeUtils.import(
   "resource://normandy/actions/PreferenceRolloutAction.jsm",
   this
 );
 ChromeUtils.import("resource://normandy/lib/PreferenceRollouts.jsm", this);
 ChromeUtils.import("resource://normandy/lib/TelemetryEvents.jsm", this);
+ChromeUtils.import("resource://testing-common/NormandyTestUtils.jsm", this);
 
 // Test that a simple recipe enrolls as expected
 decorate_task(
   PreferenceRollouts.withTestMock,
   withStub(TelemetryEnvironment, "setExperimentActive"),
   withSendEventStub,
   async function simple_recipe_enrollment(
     setExperimentActiveStub,
@@ -66,42 +67,62 @@ decorate_task(
     );
     is(
       Services.prefs.getCharPref("app.normandy.startupRolloutPrefs.test.pref3"),
       "it works",
       "string startup pref should be set"
     );
 
     // rollout was stored
+    let rollouts = await PreferenceRollouts.getAll();
     Assert.deepEqual(
-      await PreferenceRollouts.getAll(),
+      rollouts,
       [
         {
           slug: "test-rollout",
           state: PreferenceRollouts.STATE_ACTIVE,
           preferences: [
             { preferenceName: "test.pref1", value: 1, previousValue: null },
             { preferenceName: "test.pref2", value: true, previousValue: null },
             {
               preferenceName: "test.pref3",
               value: "it works",
               previousValue: null,
             },
           ],
+          enrollmentId: rollouts[0].enrollmentId,
         },
       ],
       "Rollout should be stored in db"
     );
+    ok(
+      NormandyTestUtils.isUuid(rollouts[0].enrollmentId),
+      "Rollout should have a UUID enrollmentId"
+    );
 
     sendEventStub.assertEvents([
-      ["enroll", "preference_rollout", recipe.arguments.slug],
+      [
+        "enroll",
+        "preference_rollout",
+        recipe.arguments.slug,
+        { enrollmentId: rollouts[0].enrollmentId },
+      ],
     ]);
     Assert.deepEqual(
       setExperimentActiveStub.args,
-      [["test-rollout", "active", { type: "normandy-prefrollout" }]],
+      [
+        [
+          "test-rollout",
+          "active",
+          {
+            type: "normandy-prefrollout",
+            enrollmentId: rollouts[0].enrollmentId,
+          },
+        ],
+      ],
       "a telemetry experiment should be activated"
     );
 
     // Cleanup
     Services.prefs.getDefaultBranch("").deleteBranch("test.pref1");
     Services.prefs.getDefaultBranch("").deleteBranch("test.pref2");
     Services.prefs.getDefaultBranch("").deleteBranch("test.pref3");
   }
@@ -177,38 +198,44 @@ decorate_task(
     );
     is(
       Services.prefs.getIntPref("app.normandy.startupRolloutPrefs.test.pref3"),
       2,
       "startup pref3 should be added"
     );
 
     // rollout in the DB has been updated
+    const rollouts = await PreferenceRollouts.getAll();
     Assert.deepEqual(
-      await PreferenceRollouts.getAll(),
+      rollouts,
       [
         {
           slug: "test-rollout",
           state: PreferenceRollouts.STATE_ACTIVE,
           preferences: [
             { preferenceName: "test.pref2", value: 2, previousValue: null },
             { preferenceName: "test.pref3", value: 2, previousValue: null },
           ],
         },
       ],
       "Rollout should be updated in db"
     );
 
     sendEventStub.assertEvents([
-      ["enroll", "preference_rollout", "test-rollout"],
+      [
+        "enroll",
+        "preference_rollout",
+        "test-rollout",
+        { enrollmentId: rollouts[0].enrollmentId },
+      ],
       [
         "update",
         "preference_rollout",
         "test-rollout",
-        { previousState: "active" },
+        { previousState: "active", enrollmentId: rollouts[0].enrollmentId },
       ],
     ]);
 
     // Cleanup
     Services.prefs.getDefaultBranch("").deleteBranch("test.pref1");
     Services.prefs.getDefaultBranch("").deleteBranch("test.pref2");
     Services.prefs.getDefaultBranch("").deleteBranch("test.pref3");
   }
@@ -221,16 +248,17 @@ decorate_task(
   async function ungraduate_enrollment(sendEventStub) {
     Services.prefs.getDefaultBranch("").setIntPref("test.pref", 1);
     await PreferenceRollouts.add({
       slug: "test-rollout",
       state: PreferenceRollouts.STATE_GRADUATED,
       preferences: [
         { preferenceName: "test.pref", value: 1, previousValue: 1 },
       ],
+      enrollmentId: "test-enrollment-id",
     });
 
     let recipe = {
       id: 1,
       arguments: {
         slug: "test-rollout",
         preferences: [{ preferenceName: "test.pref", value: 2 }],
       },
@@ -244,18 +272,19 @@ decorate_task(
     is(Services.prefs.getIntPref("test.pref"), 2, "pref should be updated");
     is(
       Services.prefs.getIntPref("app.normandy.startupRolloutPrefs.test.pref"),
       2,
       "startup pref should be set"
     );
 
     // rollout in the DB has been ungraduated
+    const rollouts = await PreferenceRollouts.getAll();
     Assert.deepEqual(
-      await PreferenceRollouts.getAll(),
+      rollouts,
       [
         {
           slug: "test-rollout",
           state: PreferenceRollouts.STATE_ACTIVE,
           preferences: [
             { preferenceName: "test.pref", value: 2, previousValue: 1 },
           ],
         },
@@ -263,17 +292,17 @@ decorate_task(
       "Rollout should be updated in db"
     );
 
     sendEventStub.assertEvents([
       [
         "update",
         "preference_rollout",
         "test-rollout",
-        { previousState: "graduated" },
+        { previousState: "graduated", enrollmentId: "test-enrollment-id" },
       ],
     ]);
 
     // Cleanup
     Services.prefs.getDefaultBranch("").deleteBranch("test.pref");
   }
 );
 
@@ -345,26 +374,28 @@ decorate_task(
     );
     is(
       Services.prefs.getPrefType("app.normandy.startupRolloutPrefs.test.pref3"),
       Services.prefs.PREF_INVALID,
       "startup pref3 is not set"
     );
 
     // only successful rollout was stored
+    const rollouts = await PreferenceRollouts.getAll();
     Assert.deepEqual(
-      await PreferenceRollouts.getAll(),
+      rollouts,
       [
         {
           slug: "test-rollout-1",
           state: PreferenceRollouts.STATE_ACTIVE,
           preferences: [
             { preferenceName: "test.pref1", value: 1, previousValue: null },
             { preferenceName: "test.pref2", value: 1, previousValue: null },
           ],
+          enrollmentId: rollouts[0].enrollmentId,
         },
       ],
       "Only recipe1's rollout should be stored in db"
     );
 
     sendEventStub.assertEvents([
       ["enroll", "preference_rollout", recipe1.arguments.slug],
       [
@@ -464,29 +495,31 @@ decorate_task(
       "user branch value should be preserved"
     );
     is(
       Services.prefs.getDefaultBranch("").getCharPref("test.pref"),
       "rollout value",
       "default branch value should change"
     );
 
+    const rollouts = await PreferenceRollouts.getAll();
     Assert.deepEqual(
-      await PreferenceRollouts.getAll(),
+      rollouts,
       [
         {
           slug: "test-rollout",
           state: PreferenceRollouts.STATE_ACTIVE,
           preferences: [
             {
               preferenceName: "test.pref",
               value: "rollout value",
               previousValue: "builtin value",
             },
           ],
+          enrollmentId: rollouts[0].enrollmentId,
         },
       ],
       "the rollout is added to the db with the correct previous value"
     );
 
     // Cleanup
     Services.prefs.getDefaultBranch("").deleteBranch("test.pref");
     Services.prefs.deleteBranch("test.pref");
@@ -555,33 +588,41 @@ decorate_task(
     is(action.lastError, null, "lastError should be null");
 
     // run a second time
     action = new PreferenceRolloutAction();
     await action.runRecipe(recipe);
     await action.finalize();
     is(action.lastError, null, "lastError should be null");
 
-    sendEventStub.assertEvents([
-      ["enroll", "preference_rollout", "test-rollout"],
-    ]);
+    const rollouts = await PreferenceRollouts.getAll();
 
     Assert.deepEqual(
-      await PreferenceRollouts.getAll(),
+      rollouts,
       [
         {
           slug: "test-rollout",
           state: PreferenceRollouts.STATE_ACTIVE,
           preferences: [
             { preferenceName: "test.pref", value: 1, previousValue: null },
           ],
+          enrollmentId: rollouts[0].enrollmentId,
         },
       ],
       "the DB should have the correct value stored for previousValue"
     );
+
+    sendEventStub.assertEvents([
+      [
+        "enroll",
+        "preference_rollout",
+        "test-rollout",
+        { enrollmentId: rollouts[0].enrollmentId },
+      ],
+    ]);
   }
 );
 
 // New rollouts that are no-ops should send errors
 decorate_task(
   PreferenceRollouts.withTestMock,
   withStub(TelemetryEnvironment, "setExperimentActive"),
   withSendEventStub,
--- a/toolkit/components/normandy/test/browser/browser_actions_ShowHeartbeatAction.js
+++ b/toolkit/components/normandy/test/browser/browser_actions_ShowHeartbeatAction.js
@@ -1,15 +1,16 @@
 "use strict";
 
 ChromeUtils.import("resource://normandy/actions/ShowHeartbeatAction.jsm", this);
 ChromeUtils.import("resource://normandy/lib/ClientEnvironment.jsm", this);
 ChromeUtils.import("resource://normandy/lib/Heartbeat.jsm", this);
 ChromeUtils.import("resource://normandy/lib/Storage.jsm", this);
 ChromeUtils.import("resource://normandy/lib/Uptake.jsm", this);
+ChromeUtils.import("resource://testing-common/NormandyTestUtils.jsm", this);
 
 const HOUR_IN_MS = 60 * 60 * 1000;
 
 function heartbeatRecipeFactory(overrides = {}) {
   const defaults = {
     revision_id: 1,
     name: "Test Recipe",
     action: "show-heartbeat",
@@ -112,18 +113,17 @@ decorate_task(
             flowId: options.flowId,
             surveyVersion: recipe.revision_id,
           },
         ],
       ],
       "expected arguments were passed"
     );
 
-    const uuidRegex = /[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/i;
-    ok(options.flowId.match(uuidRegex), "flowId should be a uuid");
+    ok(NormandyTestUtils.isUuid(options.flowId, "flowId should be a uuid"));
 
     // postAnswerUrl gains several query string parameters. Check that the prefix is right
     ok(options.postAnswerUrl.startsWith(recipe.arguments.postAnswerUrl));
 
     ok(
       heartbeatInstanceStub.eventEmitter.once.calledWith("Voted"),
       "Voted event handler should be registered"
     );
--- a/toolkit/components/normandy/test/browser/head.js
+++ b/toolkit/components/normandy/test/browser/head.js
@@ -27,18 +27,16 @@ const { sinon } = ChromeUtils.import("re
 // Make sinon assertions fail in a way that mochitest understands
 sinon.assert.fail = function(message) {
   ok(false, message);
 };
 
 // Prep Telemetry to receive events from tests
 TelemetryEvents.init();
 
-this.UUID_REGEX = /[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/;
-
 this.TEST_XPI_URL = (function() {
   const dir = getChromeDir(getResolvedURI(gTestPath));
   dir.append("addons");
   dir.append("normandydriver-a-1.0.xpi");
   return Services.io.newFileURI(dir).spec;
 })();
 
 this.withWebExtension = function(manifestOverrides = {}) {
@@ -342,17 +340,17 @@ this.withSendEventStub = function(testFu
         { clear: false }
       );
     };
     Services.telemetry.clearEvents();
     try {
       await testFunction(...args, stub);
     } finally {
       stub.restore();
-      Assert.ok(!stub.threw(), "some telemetry call failed");
+      Assert.ok(!stub.threw(), "Telemetry events should not fail");
     }
   };
 };
 
 let _recipeId = 1;
 this.recipeFactory = function(overrides = {}) {
   return Object.assign(
     {
--- a/toolkit/components/telemetry/Events.yaml
+++ b/toolkit/components/telemetry/Events.yaml
@@ -418,16 +418,17 @@ normandy:
       Sent when applying a Normandy recipe of the above types has succeeded.
     extra_keys:
       experimentType: >
         For preference_study recipes, the type of experiment this is ("exp" or "exp-highpop").
       branch: >
         The slug of the branch that was chosen for this client.
       addonId: For addon_study recipes, the ID of the addon that was installed.
       addonVersion: For addon_study recipes, the version of the addon that was installed.
+      enrollmentId: A unique ID for this enrollment that will be included in all related Telemetry.
     bug_numbers: [1443560]
     notification_emails: ["normandy-notifications@mozilla.com"]
     products:
       - "firefox"
       - "fennec"
       - "geckoview"
     record_in_processes: [main]
     release_channel_collection: opt-out
@@ -443,17 +444,18 @@ normandy:
       preference: >
         For preference_rollout when reason=conflict, the name of the preference
         that was going to be modified.
       detail: >
         For addon_study and branched_addon study, extra text describing the failure.
       branch: >
         The branch that failed to enroll.
       addonId: The ID of the addon for the rollout when reason=conflict.
-      conflictingSlug: The slug for the conlicting rollout.
+      conflictingSlug: The slug for the conflicting rollout.
+      enrollmentId: The enrollment ID of the conflicting rollout.
     bug_numbers: [1443560]
     notification_emails: ["normandy-notifications@mozilla.com"]
     products:
       - "firefox"
       - "fennec"
       - "geckoview"
     record_in_processes: [main]
     release_channel_collection: opt-out
@@ -468,16 +470,17 @@ normandy:
       fetched from the server.
     extra_keys:
       previousState: >
         For preference_rollout recipes, the state of the rollout that had been applied
         previously.
       addonId: For addon_study recipes, the ID of the addon that was updated.
       addonVersion: For addon_study recipes, the version of the addon that was installed.
       branch: The branch that was updated.
+      enrollmentId: A unique ID for this enrollment that will be included in all related Telemetry.
     bug_numbers: [1443560, 1474413]
     notification_emails: ["normandy-notifications@mozilla.com"]
     products:
       - "firefox"
       - "fennec"
       - "geckoview"
     record_in_processes: [main]
     release_channel_collection: opt-out
@@ -489,16 +492,17 @@ normandy:
     description: >
       Sent when applying a new version of a Normandy recipe of the above types (over an
       existing, older version previously fetched from the server) has failed.
     extra_keys:
       reason: An error code describing the failure.
       detail: >
         Extra text describing the failure. Currently only provided for addon_study.
       branch: The branch that failed to update.
+      enrollmentId: A unique ID for this enrollment that will be included in all related Telemetry.
     bug_numbers: [1474413]
     notification_emails: ["normandy-notifications@mozilla.com"]
     products:
       - "firefox"
       - "fennec"
       - "geckoview"
     record_in_processes: [main]
     release_channel_collection: opt-out
@@ -512,16 +516,17 @@ normandy:
       recipe that "ends" is a corresponding preference_rollout).
     extra_keys:
       reason: A code describing the reason why the recipe ended.
       didResetValue: >
         For preference_study, "true" or "false" according to whether we put the preference back the way it was.
       addonId: For addon_study, the ID of the addon that ended.
       addonVersion: For addon_study, the version of the addon for which the recipe ended.
       branch: The branch of the experiment that this client was on.
+      enrollmentId: A unique ID for this enrollment that will be included in all related Telemetry.
     bug_numbers: [1443560]
     notification_emails: ["normandy-notifications@mozilla.com"]
     products:
       - "firefox"
       - "fennec"
       - "geckoview"
     record_in_processes: [main]
     release_channel_collection: opt-out
@@ -529,16 +534,17 @@ normandy:
 
   unenroll_failed:
     methods: ["unenrollFailed"]
     description: >
       Sent when unenrolling a user fails (see the unenroll event).
     objects: ["preference_rollback", "preference_study", "addon_rollback"]
     extra_keys:
       reason: A code describing the reason the unenroll failed.
+      enrollmentId: A unique ID for this enrollment that will be included in all related Telemetry.
     bug_numbers: [1443560]
     notification_emails: ["normandy-notifications@mozilla.com"]
     products:
       - "firefox"
       - "fennec"
       - "geckoview"
     record_in_processes: [main]
     release_channel_collection: opt-out
@@ -553,16 +559,18 @@ normandy:
     notification_emails: ["normandy-notifications@mozilla.com"]
     products:
       - "firefox"
       - "fennec"
       - "geckoview"
     record_in_processes: [main]
     release_channel_collection: opt-out
     expiry_version: never
+    extra_keys:
+      enrollmentId: A unique ID for this enrollment that will be included in all related Telemetry.
 
 pwmgr:
   open_management:
     objects: ["aboutprotections", "autocomplete", "capturedoorhanger", "contextmenu", "direct", "fxamenu", "mainmenu", "pageinfo", "preferences"]
     methods: ["open_management"]
     description: >
       Sent when opening the password management UI.
     bug_numbers: [1543499, 1454733, 1545172, 1550631]