Bug 1302681 - Part 1: Extend Event Telemetry core for recording from addons. r=Dexter
☠☠ backed out by 89d41dea4847 ☠ ☠
authorGeorg Fritzsche <georg.fritzsche@googlemail.com>
Wed, 26 Jul 2017 07:49:00 -0400
changeset 420284 7068c8d4448c81ae1787451ac1969a3fab317458
parent 420283 a37abf878197b5840adf1db527021c71aa50d8a4
child 420285 8e79158a7a1c3d15ec8415c1810f9c7dfd675cf3
push id7566
push usermtabara@mozilla.com
push dateWed, 02 Aug 2017 08:25:16 +0000
treeherdermozilla-beta@86913f512c3c [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersDexter
bugs1302681
milestone56.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 1302681 - Part 1: Extend Event Telemetry core for recording from addons. r=Dexter To cut down on complexity, we don't require specifying any expiry versions. Given that these events will be recorded non-persistently from off-train add-ons, they can be expired by shipping new add-on releases. We also start to use the new "record on release" terminology here instead of opt-in/opt-out, but are not changing the internal functionality yet. Technically, this is implemented by keeping a separate registry for the dynamic event information. Built-in & dynamic events are tracked with separate numeric ids, so introduce a common identifier for both, an EventKey. For actual event storage, the events are treated the same as built-in events. They are simply bucketed into the 'dynamic' process storage. This approach ends up duplicating code paths that use the event info, but keeps a single implementation for recording, storage & serialization.
toolkit/components/telemetry/EventInfo.h
toolkit/components/telemetry/Processes.yaml
toolkit/components/telemetry/Telemetry.cpp
toolkit/components/telemetry/TelemetryEvent.cpp
toolkit/components/telemetry/TelemetryEvent.h
toolkit/components/telemetry/TelemetrySession.jsm
toolkit/components/telemetry/docs/collection/events.rst
toolkit/components/telemetry/nsITelemetry.idl
toolkit/components/telemetry/tests/unit/test_ChildEvents.js
toolkit/components/telemetry/tests/unit/test_TelemetryEvents.js
--- a/toolkit/components/telemetry/EventInfo.h
+++ b/toolkit/components/telemetry/EventInfo.h
@@ -30,28 +30,28 @@ struct CommonEventInfo {
 
   // The dataset this event is recorded in.
   uint32_t dataset;
 
   // Which processes to record this event in.
   mozilla::Telemetry::Common::RecordedProcessType record_in_processes;
 
   // Convenience functions for accessing event strings.
-  const char* expiration_version() const;
-  const char* category() const;
-  const char* extra_key(uint32_t index) const;
+  const nsCString expiration_version() const;
+  const nsCString category() const;
+  const nsCString extra_key(uint32_t index) const;
 };
 
 struct EventInfo {
   // The corresponding CommonEventInfo.
   const CommonEventInfo& common_info;
 
   // Indices for the method & object strings.
   uint32_t method_offset;
   uint32_t object_offset;
 
-  const char* method() const;
-  const char* object() const;
+  const nsCString method() const;
+  const nsCString object() const;
 };
 
 } // namespace
 
 #endif // TelemetryEventInfo_h__
--- a/toolkit/components/telemetry/Processes.yaml
+++ b/toolkit/components/telemetry/Processes.yaml
@@ -14,8 +14,13 @@ content:
 extension:
   gecko_enum: GeckoProcessType_Content
   description: >
     This is the WebExtension process. It is a re-used content process, with the data submitted
     separately to avoid skewing other content process Telemetry.
 gpu:
   gecko_enum: GeckoProcessType_GPU
   description: This is the compositor or GPU process.
+dynamic:
+  gecko_enum: GeckoProcessType_Default
+  description: >
+    This is not a real process, it is used to logically group add-on probes.
+    It contains data of any probes registered at runtime by add-ons.
--- a/toolkit/components/telemetry/Telemetry.cpp
+++ b/toolkit/components/telemetry/Telemetry.cpp
@@ -1802,23 +1802,31 @@ NS_IMETHODIMP
 TelemetryImpl::RecordEvent(const nsACString & aCategory, const nsACString & aMethod,
                            const nsACString & aObject, JS::HandleValue aValue,
                            JS::HandleValue aExtra, JSContext* aCx, uint8_t optional_argc)
 {
   return TelemetryEvent::RecordEvent(aCategory, aMethod, aObject, aValue, aExtra, aCx, optional_argc);
 }
 
 NS_IMETHODIMP
-TelemetryImpl::SnapshotBuiltinEvents(uint32_t aDataset, bool aClear, JSContext* aCx,
+TelemetryImpl::SnapshotEvents(uint32_t aDataset, bool aClear, JSContext* aCx,
                                      uint8_t optional_argc, JS::MutableHandleValue aResult)
 {
   return TelemetryEvent::CreateSnapshots(aDataset, aClear, aCx, optional_argc, aResult);
 }
 
 NS_IMETHODIMP
+TelemetryImpl::RegisterEvents(const nsACString& aCategory,
+                              JS::Handle<JS::Value> aEventData,
+                              JSContext* cx)
+{
+  return TelemetryEvent::RegisterEvents(aCategory, aEventData, cx);
+}
+
+NS_IMETHODIMP
 TelemetryImpl::ClearEvents()
 {
   TelemetryEvent::ClearEvents();
   return NS_OK;
 }
 
 NS_IMETHODIMP
 TelemetryImpl::SetEventRecordingEnabled(const nsACString& aCategory, bool aEnabled)
--- a/toolkit/components/telemetry/TelemetryEvent.cpp
+++ b/toolkit/components/telemetry/TelemetryEvent.cpp
@@ -1,29 +1,31 @@
 /* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
 /* vim: set ts=8 sts=2 et sw=2 tw=80: */
 /* 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/. */
 
 #include <prtime.h>
+#include <limits>
 #include "nsITelemetry.h"
 #include "nsHashKeys.h"
 #include "nsDataHashtable.h"
 #include "nsClassHashtable.h"
 #include "nsTArray.h"
 #include "mozilla/StaticMutex.h"
 #include "mozilla/Unused.h"
 #include "mozilla/Maybe.h"
 #include "mozilla/StaticPtr.h"
 #include "mozilla/Pair.h"
 #include "jsapi.h"
 #include "nsJSUtils.h"
 #include "nsXULAppAPI.h"
 #include "nsUTF8Utils.h"
+#include "nsPrintfCString.h"
 
 #include "TelemetryCommon.h"
 #include "TelemetryEvent.h"
 #include "TelemetryEventData.h"
 #include "ipc/TelemetryIPCAccumulator.h"
 
 using mozilla::StaticMutex;
 using mozilla::StaticMutexAutoLock;
@@ -84,108 +86,159 @@ namespace TelemetryIPCAccumulator = mozi
 ////////////////////////////////////////////////////////////////////////
 //
 // PRIVATE TYPES
 
 namespace {
 
 const uint32_t kEventCount = mozilla::Telemetry::EventID::EventCount;
 // This is a special event id used to mark expired events, to make expiry checks
-// faster at runtime.
-const uint32_t kExpiredEventId = kEventCount + 1;
-static_assert(kEventCount < kExpiredEventId, "Should not overflow.");
+// cheap at runtime.
+const uint32_t kExpiredEventId = std::numeric_limits<uint32_t>::max();
+static_assert(kExpiredEventId > kEventCount,
+              "Built-in event count should be less than the expired event id.");
 
 // This is the hard upper limit on the number of event records we keep in storage.
 // If we cross this limit, we will drop any further event recording until elements
 // are removed from storage.
 const uint32_t kMaxEventRecords = 1000;
 // Maximum length of any passed value string, in UTF8 byte sequence length.
 const uint32_t kMaxValueByteLength = 80;
 // Maximum length of any string value in the extra dictionary, in UTF8 byte sequence length.
 const uint32_t kMaxExtraValueByteLength = 80;
+// Maximum length of dynamic method names, in UTF8 byte sequence length.
+const uint32_t kMaxMethodNameByteLength = 20;
+// Maximum length of dynamic object names, in UTF8 byte sequence length.
+const uint32_t kMaxObjectNameByteLength = 20;
+// Maximum length of extra key names, in UTF8 byte sequence length.
+const uint32_t kMaxExtraKeyNameByteLength = 15;
+// The maximum number of valid extra keys for an event.
+const uint32_t kMaxExtraKeyCount = 10;
 
 typedef nsDataHashtable<nsCStringHashKey, uint32_t> StringUintMap;
 typedef nsClassHashtable<nsCStringHashKey, nsCString> StringMap;
 
+struct EventKey {
+  uint32_t id;
+  bool dynamic;
+};
+
+struct DynamicEventInfo {
+  DynamicEventInfo(const nsACString& category, const nsACString& method,
+                   const nsACString& object, const nsTArray<nsCString>& extra_keys,
+                   bool recordOnRelease)
+    : category(category)
+    , method(method)
+    , object(object)
+    , extra_keys(extra_keys)
+    , recordOnRelease(recordOnRelease)
+  {}
+
+  DynamicEventInfo(const DynamicEventInfo&) = default;
+  DynamicEventInfo& operator=(const DynamicEventInfo&) = delete;
+
+  const nsCString category;
+  const nsCString method;
+  const nsCString object;
+  const nsTArray<nsCString> extra_keys;
+  const bool recordOnRelease;
+
+  size_t
+  SizeOfExcludingThis(mozilla::MallocSizeOf aMallocSizeOf) const
+  {
+    size_t n = 0;
+
+    n += category.SizeOfExcludingThisIfUnshared(aMallocSizeOf);
+    n += method.SizeOfExcludingThisIfUnshared(aMallocSizeOf);
+    n += object.SizeOfExcludingThisIfUnshared(aMallocSizeOf);
+    n += extra_keys.ShallowSizeOfExcludingThis(aMallocSizeOf);
+    for (auto& key : extra_keys) {
+      n += key.SizeOfExcludingThisIfUnshared(aMallocSizeOf);
+    }
+
+    return n;
+  }
+};
+
 enum class RecordEventResult {
   Ok,
   UnknownEvent,
   InvalidExtraKey,
   StorageLimitReached,
   ExpiredEvent,
   WrongProcess,
 };
 
+enum class RegisterEventResult {
+  Ok,
+  AlreadyRegistered,
+};
+
 typedef nsTArray<EventExtraEntry> ExtraArray;
 
 class EventRecord {
 public:
-  EventRecord(double timestamp, uint32_t eventId, const Maybe<nsCString>& value,
+  EventRecord(double timestamp, const EventKey& key, const Maybe<nsCString>& value,
               const ExtraArray& extra)
     : mTimestamp(timestamp)
-    , mEventId(eventId)
+    , mEventKey(key)
     , mValue(value)
     , mExtra(extra)
   {}
 
-  EventRecord(const EventRecord& other)
-    : mTimestamp(other.mTimestamp)
-    , mEventId(other.mEventId)
-    , mValue(other.mValue)
-    , mExtra(other.mExtra)
-  {}
+  EventRecord(const EventRecord& other) = default;
 
   EventRecord& operator=(const EventRecord& other) = delete;
 
   double Timestamp() const { return mTimestamp; }
-  uint32_t EventId() const { return mEventId; }
+  const EventKey& GetEventKey() const { return mEventKey; }
   const Maybe<nsCString>& Value() const { return mValue; }
   const ExtraArray& Extra() const { return mExtra; }
 
   size_t SizeOfExcludingThis(mozilla::MallocSizeOf aMallocSizeOf) const;
 
 private:
   const double mTimestamp;
-  const uint32_t mEventId;
+  const EventKey mEventKey;
   const Maybe<nsCString> mValue;
   const ExtraArray mExtra;
 };
 
 // Implements the methods for EventInfo.
-const char*
+const nsCString
 EventInfo::method() const
 {
-  return &gEventsStringTable[this->method_offset];
+  return nsCString(&gEventsStringTable[this->method_offset]);
 }
 
-const char*
+const nsCString
 EventInfo::object() const
 {
-  return &gEventsStringTable[this->object_offset];
+  return nsCString(&gEventsStringTable[this->object_offset]);
 }
 
 // Implements the methods for CommonEventInfo.
-const char*
+const nsCString
 CommonEventInfo::category() const
 {
-  return &gEventsStringTable[this->category_offset];
+  return nsCString(&gEventsStringTable[this->category_offset]);
 }
 
-const char*
+const nsCString
 CommonEventInfo::expiration_version() const
 {
-  return &gEventsStringTable[this->expiration_version_offset];
+  return nsCString(&gEventsStringTable[this->expiration_version_offset]);
 }
 
-const char*
+const nsCString
 CommonEventInfo::extra_key(uint32_t index) const
 {
   MOZ_ASSERT(index < this->extra_count);
   uint32_t key_index = gExtraKeysTable[this->extra_index + index];
-  return &gEventsStringTable[key_index];
+  return nsCString(&gEventsStringTable[key_index]);
 }
 
 // Implementation for the EventRecord class.
 size_t
 EventRecord::SizeOfExcludingThis(mozilla::MallocSizeOf aMallocSizeOf) const
 {
   size_t n = 0;
 
@@ -212,19 +265,27 @@ UniqueEventName(const nsACString& catego
   name.AppendLiteral("#");
   name.Append(object);
   return name;
 }
 
 nsCString
 UniqueEventName(const EventInfo& info)
 {
-  return UniqueEventName(nsDependentCString(info.common_info.category()),
-                         nsDependentCString(info.method()),
-                         nsDependentCString(info.object()));
+  return UniqueEventName(info.common_info.category(),
+                         info.method(),
+                         info.object());
+}
+
+nsCString
+UniqueEventName(const DynamicEventInfo& info)
+{
+  return UniqueEventName(info.category,
+                         info.method,
+                         info.object);
 }
 
 bool
 IsExpiredDate(uint32_t expires_days_since_epoch) {
   if (expires_days_since_epoch == 0) {
     return false;
   }
 
@@ -250,193 +311,290 @@ TruncateToByteLength(nsCString& str, uin
 namespace {
 
 // Set to true once this global state has been initialized.
 bool gInitDone = false;
 
 bool gCanRecordBase;
 bool gCanRecordExtended;
 
-// The EventName -> EventID cache map.
-StringUintMap gEventNameIDMap(kEventCount);
+// The EventName -> EventKey cache map.
+nsClassHashtable<nsCStringHashKey, EventKey> gEventNameIDMap(kEventCount);
 
 // The CategoryName -> CategoryID cache map.
 StringUintMap gCategoryNameIDMap;
 
 // This tracks the IDs of the categories for which recording is enabled.
-nsTHashtable<nsUint32HashKey> gEnabledCategories;
+nsTHashtable<nsCStringHashKey> gEnabledCategories;
 
 // The main event storage. Events are inserted here, keyed by process id and
 // in recording order.
 typedef nsTArray<EventRecord> EventRecordArray;
 nsClassHashtable<nsUint32HashKey, EventRecordArray> gEventRecords;
 
+// The details on dynamic events that are recorded from addons are registered here.
+nsTArray<DynamicEventInfo> gDynamicEventInfo;
+
 } // namespace
 
 ////////////////////////////////////////////////////////////////////////
 ////////////////////////////////////////////////////////////////////////
 //
 // PRIVATE: thread-safe helpers for event recording.
 
 namespace {
 
+unsigned int
+GetDataset(const StaticMutexAutoLock& lock, const EventKey& eventKey)
+{
+  if (!eventKey.dynamic) {
+    return gEventInfo[eventKey.id].common_info.dataset;
+  }
+
+  return gDynamicEventInfo[eventKey.id].recordOnRelease ?
+           nsITelemetry::DATASET_RELEASE_CHANNEL_OPTOUT :
+           nsITelemetry::DATASET_RELEASE_CHANNEL_OPTIN;
+}
+
+nsCString
+GetCategory(const StaticMutexAutoLock& lock, const EventKey& eventKey)
+{
+  if (!eventKey.dynamic) {
+    return gEventInfo[eventKey.id].common_info.category();
+  }
+
+  return gDynamicEventInfo[eventKey.id].category;
+}
+
 bool
-CanRecordEvent(const StaticMutexAutoLock& lock, const CommonEventInfo& info,
+CanRecordEvent(const StaticMutexAutoLock& lock, const EventKey& eventKey,
                ProcessID process)
 {
   if (!gCanRecordBase) {
     return false;
   }
 
-  if (!CanRecordDataset(info.dataset, gCanRecordBase, gCanRecordExtended)) {
+  if (!CanRecordDataset(GetDataset(lock, eventKey), gCanRecordBase, gCanRecordExtended)) {
     return false;
   }
 
-  if (!CanRecordInProcess(info.record_in_processes, process)) {
-    return false;
+  // We don't allow specifying a process to record in for dynamic events.
+  if (!eventKey.dynamic) {
+    const CommonEventInfo& info = gEventInfo[eventKey.id].common_info;
+    if (!CanRecordInProcess(info.record_in_processes, process)) {
+      return false;
+    }
   }
 
-  return gEnabledCategories.GetEntry(info.category_offset);
+  return gEnabledCategories.GetEntry(GetCategory(lock, eventKey));
+}
+
+bool
+IsExpired(const EventKey& key)
+{
+  return key.id == kExpiredEventId;
 }
 
 EventRecordArray*
-GetEventRecordsForProcess(const StaticMutexAutoLock& lock, ProcessID processType)
+GetEventRecordsForProcess(const StaticMutexAutoLock& lock, ProcessID processType,
+                          const EventKey& eventKey)
 {
   EventRecordArray* eventRecords = nullptr;
   if (!gEventRecords.Get(uint32_t(processType), &eventRecords)) {
     eventRecords = new EventRecordArray();
     gEventRecords.Put(uint32_t(processType), eventRecords);
   }
   return eventRecords;
 }
 
-bool
-GetEventId(const StaticMutexAutoLock& lock, const nsACString& category,
-           const nsACString& method, const nsACString& object,
-           uint32_t* eventId)
+EventKey*
+GetEventKey(const StaticMutexAutoLock& lock, const nsACString& category,
+           const nsACString& method, const nsACString& object)
+{
+  EventKey* event;
+  const nsCString& name = UniqueEventName(category, method, object);
+  if (!gEventNameIDMap.Get(name, &event)) {
+    return nullptr;
+  }
+  return event;
+}
+
+static bool
+CheckExtraKeysValid(const EventKey& eventKey, const ExtraArray& extra)
 {
-  MOZ_ASSERT(eventId);
-  const nsCString& name = UniqueEventName(category, method, object);
-  return gEventNameIDMap.Get(name, eventId);
+  nsTHashtable<nsCStringHashKey> validExtraKeys;
+  if (!eventKey.dynamic) {
+    const CommonEventInfo& common = gEventInfo[eventKey.id].common_info;
+    for (uint32_t i = 0; i < common.extra_count; ++i) {
+      validExtraKeys.PutEntry(common.extra_key(i));
+    }
+  } else {
+    const DynamicEventInfo& info = gDynamicEventInfo[eventKey.id];
+    for (uint32_t i = 0, len = info.extra_keys.Length(); i < len; ++i) {
+      validExtraKeys.PutEntry(info.extra_keys[i]);
+    }
+  }
+
+  for (uint32_t i = 0; i < extra.Length(); ++i) {
+    if (!validExtraKeys.GetEntry(extra[i].key)) {
+      return false;
+    }
+  }
+
+  return true;
 }
 
 RecordEventResult
 RecordEvent(const StaticMutexAutoLock& lock, ProcessID processType,
             double timestamp, const nsACString& category,
             const nsACString& method, const nsACString& object,
             const Maybe<nsCString>& value, const ExtraArray& extra)
 {
-  EventRecordArray* eventRecords = GetEventRecordsForProcess(lock, processType);
+  // Look up the event id.
+  EventKey* eventKey = GetEventKey(lock, category, method, object);
+  if (!eventKey) {
+    return RecordEventResult::UnknownEvent;
+  }
+
+  if (eventKey->dynamic) {
+    processType = ProcessID::Dynamic;
+  }
+
+  EventRecordArray* eventRecords = GetEventRecordsForProcess(lock, processType, *eventKey);
 
   // Apply hard limit on event count in storage.
   if (eventRecords->Length() >= kMaxEventRecords) {
     return RecordEventResult::StorageLimitReached;
   }
 
-  // Look up the event id.
-  uint32_t eventId;
-  if (!GetEventId(lock, category, method, object, &eventId)) {
-    return RecordEventResult::UnknownEvent;
-  }
-
   // If the event is expired or not enabled for this process, we silently drop this call.
   // We don't want recording for expired probes to be an error so code doesn't
   // have to be removed at a specific time or version.
   // Even logging warnings would become very noisy.
-  if (eventId == kExpiredEventId) {
+  if (IsExpired(*eventKey)) {
     return RecordEventResult::ExpiredEvent;
   }
 
   // Check whether we can record this event.
-  const CommonEventInfo& common = gEventInfo[eventId].common_info;
-  if (!CanRecordEvent(lock, common, processType)) {
+  if (!CanRecordEvent(lock, *eventKey, processType)) {
     return RecordEventResult::Ok;
   }
 
   // Check whether the extra keys passed are valid.
-  nsTHashtable<nsCStringHashKey> validExtraKeys;
-  for (uint32_t i = 0; i < common.extra_count; ++i) {
-    validExtraKeys.PutEntry(nsDependentCString(common.extra_key(i)));
-  }
-
-  for (uint32_t i = 0; i < extra.Length(); ++i) {
-    if (!validExtraKeys.GetEntry(extra[i].key)) {
-      return RecordEventResult::InvalidExtraKey;
-    }
+  if (!CheckExtraKeysValid(*eventKey, extra)) {
+    return RecordEventResult::InvalidExtraKey;
   }
 
   // Add event record.
-  eventRecords->AppendElement(EventRecord(timestamp, eventId, value, extra));
+  eventRecords->AppendElement(EventRecord(timestamp, *eventKey, value, extra));
   return RecordEventResult::Ok;
 }
 
 RecordEventResult
 ShouldRecordChildEvent(const StaticMutexAutoLock& lock, const nsACString& category,
                        const nsACString& method, const nsACString& object)
 {
-  uint32_t eventId;
-  if (!GetEventId(lock, category, method, object, &eventId)) {
-    return RecordEventResult::UnknownEvent;
+  EventKey* eventKey = GetEventKey(lock, category, method, object);
+  if (!eventKey) {
+    // This event is unknown in this process, but it might be a dynamic event
+    // that was registered in the parent process.
+    return RecordEventResult::Ok;
   }
 
-  if (eventId == kExpiredEventId) {
+  if (IsExpired(*eventKey)) {
     return RecordEventResult::ExpiredEvent;
   }
 
-  const auto processes = gEventInfo[eventId].common_info.record_in_processes;
+  const auto processes = gEventInfo[eventKey->id].common_info.record_in_processes;
   if (!CanRecordInProcess(processes, XRE_GetProcessType())) {
     return RecordEventResult::WrongProcess;
   }
 
   return RecordEventResult::Ok;
 }
 
+RegisterEventResult
+RegisterEvents(const StaticMutexAutoLock& lock, const nsACString& category,
+               const nsTArray<DynamicEventInfo>& eventInfos,
+               const nsTArray<bool>& eventExpired)
+{
+  MOZ_ASSERT(eventInfos.Length() == eventExpired.Length(), "Event data array sizes should match.");
+
+  // Check that none of the events are already registered.
+  for (auto& info : eventInfos) {
+    if (gEventNameIDMap.Get(UniqueEventName(info))) {
+      return RegisterEventResult::AlreadyRegistered;
+    }
+  }
+
+  // Register the new events.
+  for (uint32_t i = 0, len = eventInfos.Length(); i < len; ++i) {
+    gDynamicEventInfo.AppendElement(eventInfos[i]);
+    uint32_t eventId = eventExpired[i] ? kExpiredEventId : gDynamicEventInfo.Length() - 1;
+    gEventNameIDMap.Put(UniqueEventName(eventInfos[i]), new EventKey{eventId, true});
+  }
+
+  // Now after successful registration enable recording for this category.
+  gEnabledCategories.PutEntry(category);
+
+  return RegisterEventResult::Ok;
+}
+
 } // anonymous namespace
 
 ////////////////////////////////////////////////////////////////////////
 ////////////////////////////////////////////////////////////////////////
 //
 // PRIVATE: thread-unsafe helpers for event handling.
 
 namespace {
 
 nsresult
 SerializeEventsArray(const EventRecordArray& events,
                     JSContext* cx,
-                    JS::MutableHandleObject result)
+                    JS::MutableHandleObject result,
+                    unsigned int dataset)
 {
   // We serialize the events to a JS array.
   JS::RootedObject eventsArray(cx, JS_NewArrayObject(cx, events.Length()));
   if (!eventsArray) {
     return NS_ERROR_FAILURE;
   }
 
   for (uint32_t i = 0; i < events.Length(); ++i) {
     const EventRecord& record = events[i];
-    const EventInfo& info = gEventInfo[record.EventId()];
 
     // Each entry is an array of one of the forms:
     // [timestamp, category, method, object, value]
     // [timestamp, category, method, object, null, extra]
     // [timestamp, category, method, object, value, extra]
     JS::AutoValueVector items(cx);
 
     // Add timestamp.
     JS::Rooted<JS::Value> val(cx);
     if (!items.append(JS::NumberValue(floor(record.Timestamp())))) {
       return NS_ERROR_FAILURE;
     }
 
     // Add category, method, object.
-    const char* strings[] = {
-      info.common_info.category(),
-      info.method(),
-      info.object(),
-    };
-    for (const char* s : strings) {
+    nsCString strings[3];
+    const EventKey& eventKey = record.GetEventKey();
+    if (!eventKey.dynamic) {
+      const EventInfo& info = gEventInfo[eventKey.id];
+      strings[0] = info.common_info.category();
+      strings[1] = info.method();
+      strings[2] = info.object();
+    } else {
+      const DynamicEventInfo& info = gDynamicEventInfo[eventKey.id];
+      strings[0] = info.category;
+      strings[1] = info.method;
+      strings[2] = info.object;
+    }
+
+    for (const nsCString& s : strings) {
       const NS_ConvertUTF8toUTF16 wide(s);
       if (!items.append(JS::StringValue(JS_NewUCStringCopyN(cx, wide.Data(), wide.Length())))) {
         return NS_ERROR_FAILURE;
       }
     }
 
     // Add the optional string value only when needed.
     // When the value field is empty and extra is not set, we can save a little space that way.
@@ -524,30 +682,29 @@ TelemetryEvent::InitializeGlobalState(bo
   const uint32_t eventCount = static_cast<uint32_t>(mozilla::Telemetry::EventID::EventCount);
   for (uint32_t i = 0; i < eventCount; ++i) {
     const EventInfo& info = gEventInfo[i];
     uint32_t eventId = i;
 
     // If this event is expired or not recorded in this process, mark it with
     // a special event id.
     // This avoids doing repeated checks at runtime.
-    if (IsExpiredVersion(info.common_info.expiration_version()) ||
+    if (IsExpiredVersion(info.common_info.expiration_version().get()) ||
         IsExpiredDate(info.common_info.expiration_day)) {
       eventId = kExpiredEventId;
     }
 
-    gEventNameIDMap.Put(UniqueEventName(info), eventId);
-    if (!gCategoryNameIDMap.Contains(nsDependentCString(info.common_info.category()))) {
-      gCategoryNameIDMap.Put(nsDependentCString(info.common_info.category()),
+    gEventNameIDMap.Put(UniqueEventName(info), new EventKey{eventId, false});
+    if (!gCategoryNameIDMap.Contains(info.common_info.category())) {
+      gCategoryNameIDMap.Put(info.common_info.category(),
                              info.common_info.category_offset);
     }
   }
 
 #ifdef DEBUG
-  gEventNameIDMap.MarkImmutable();
   gCategoryNameIDMap.MarkImmutable();
 #endif
   gInitDone = true;
 }
 
 void
 TelemetryEvent::DeInitializeGlobalState()
 {
@@ -714,29 +871,250 @@ TelemetryEvent::RecordEvent(const nsACSt
   switch (res) {
     case RecordEventResult::UnknownEvent: {
       JS_ReportErrorASCII(cx, R"(Unknown event: ["%s", "%s", "%s"])",
                           PromiseFlatCString(aCategory).get(),
                           PromiseFlatCString(aMethod).get(),
                           PromiseFlatCString(aObject).get());
       return NS_ERROR_INVALID_ARG;
     }
-    case RecordEventResult::InvalidExtraKey:
-      LogToBrowserConsole(nsIScriptError::warningFlag,
-                          NS_LITERAL_STRING("Invalid extra key for event."));
+    case RecordEventResult::InvalidExtraKey: {
+      nsPrintfCString msg(R"(Invalid extra key for event ["%s", "%s", "%s"].)",
+                          PromiseFlatCString(aCategory).get(),
+                          PromiseFlatCString(aMethod).get(),
+                          PromiseFlatCString(aObject).get());
+      LogToBrowserConsole(nsIScriptError::warningFlag, NS_ConvertUTF8toUTF16(msg));
       return NS_OK;
+    }
     case RecordEventResult::StorageLimitReached:
       LogToBrowserConsole(nsIScriptError::warningFlag,
                           NS_LITERAL_STRING("Event storage limit reached."));
       return NS_OK;
     default:
       return NS_OK;
   }
 }
 
+static bool
+GetArrayPropertyValues(JSContext* cx, JS::HandleObject obj, const char* property,
+                       nsTArray<nsCString>* results)
+{
+  JS::RootedValue value(cx);
+  if (!JS_GetProperty(cx, obj, property, &value)) {
+    JS_ReportErrorASCII(cx, R"(Missing required property "%s" for event)", property);
+    return false;
+  }
+
+  bool isArray = false;
+  if (!JS_IsArrayObject(cx, value, &isArray) || !isArray) {
+    JS_ReportErrorASCII(cx, R"(Property "%s" for event should be an array)", property);
+    return false;
+  }
+
+  JS::RootedObject arrayObj(cx, &value.toObject());
+  uint32_t arrayLength;
+  if (!JS_GetArrayLength(cx, arrayObj, &arrayLength)) {
+    return false;
+  }
+
+  for (uint32_t arrayIdx = 0; arrayIdx < arrayLength; ++arrayIdx) {
+    JS::Rooted<JS::Value> element(cx);
+    if (!JS_GetElement(cx, arrayObj, arrayIdx, &element)) {
+      return false;
+    }
+
+    if (!element.isString()) {
+      JS_ReportErrorASCII(cx, R"(Array entries for event property "%s" should be strings)", property);
+      return false;
+    }
+
+    nsAutoJSString jsStr;
+    if (!jsStr.init(cx, element)) {
+      return false;
+    }
+
+    results->AppendElement(NS_ConvertUTF16toUTF8(jsStr));
+  }
+
+  return true;
+}
+
+static bool
+IsStringCharValid(const char aChar, const bool allowInfixPeriod)
+{
+  return (aChar >= 'A' && aChar <= 'Z')
+      || (aChar >= 'a' && aChar <= 'z')
+      || (aChar >= '0' && aChar <= '9')
+      || (allowInfixPeriod && (aChar == '.'));
+}
+
+static bool
+IsValidIdentifierString(const nsACString& str, const size_t maxLength,
+                        const bool allowInfixPeriod)
+{
+  // Check string length.
+  if (str.Length() > maxLength) {
+    return false;
+  }
+
+  // Check string characters.
+  const char* first = str.BeginReading();
+  const char* end = str.EndReading();
+
+  for (const char* cur = first; cur < end; ++cur) {
+      const bool allowPeriod = allowInfixPeriod && (cur != first) && (cur != (end - 1));
+      if (!IsStringCharValid(*cur, allowPeriod)) {
+        return false;
+      }
+  }
+
+  return true;
+}
+
+nsresult
+TelemetryEvent::RegisterEvents(const nsACString& aCategory,
+                               JS::Handle<JS::Value> aEventData,
+                               JSContext* cx)
+{
+  if (!IsValidIdentifierString(aCategory, 30, true)) {
+    JS_ReportErrorASCII(cx, "Category parameter should match the identifier pattern.");
+    return NS_ERROR_INVALID_ARG;
+  }
+
+  if (!aEventData.isObject()) {
+    JS_ReportErrorASCII(cx, "Event data parameter should be an object");
+    return NS_ERROR_INVALID_ARG;
+  }
+
+  JS::RootedObject obj(cx, &aEventData.toObject());
+  JS::Rooted<JS::IdVector> eventPropertyIds(cx, JS::IdVector(cx));
+  if (!JS_Enumerate(cx, obj, &eventPropertyIds)) {
+    return NS_ERROR_FAILURE;
+  }
+
+  // Collect the event data into local storage first.
+  // Only after successfully validating all contained events will we register them into global storage.
+  nsTArray<DynamicEventInfo> newEventInfos;
+  nsTArray<bool> newEventExpired;
+
+  for (size_t i = 0, n = eventPropertyIds.length(); i < n; i++) {
+    nsAutoJSString eventName;
+    if (!eventName.init(cx, eventPropertyIds[i])) {
+      return NS_ERROR_FAILURE;
+    }
+
+    if (!IsValidIdentifierString(NS_ConvertUTF16toUTF8(eventName), kMaxMethodNameByteLength, false)) {
+      JS_ReportErrorASCII(cx, "Event names should match the identifier pattern.");
+      return NS_ERROR_INVALID_ARG;
+    }
+
+    JS::RootedValue value(cx);
+    if (!JS_GetPropertyById(cx, obj, eventPropertyIds[i], &value) || !value.isObject()) {
+      return NS_ERROR_FAILURE;
+    }
+    JS::RootedObject eventObj(cx, &value.toObject());
+
+    // Extract the event registration data.
+    nsTArray<nsCString> methods;
+    nsTArray<nsCString> objects;
+    nsTArray<nsCString> extra_keys;
+    bool expired = false;
+    bool recordOnRelease = false;
+
+    // The methods & objects properties are required.
+    if (!GetArrayPropertyValues(cx, eventObj, "methods", &methods)) {
+      return NS_ERROR_FAILURE;
+    }
+
+    if (!GetArrayPropertyValues(cx, eventObj, "objects", &objects)) {
+      return NS_ERROR_FAILURE;
+    }
+
+    // extra_keys is optional.
+    bool hasProperty = false;
+    if (JS_HasProperty(cx, eventObj, "extra_keys", &hasProperty) && hasProperty) {
+      if (!GetArrayPropertyValues(cx, eventObj, "extra_keys", &extra_keys)) {
+        return NS_ERROR_FAILURE;
+      }
+    }
+
+    // expired is optional.
+    if (JS_HasProperty(cx, eventObj, "expired", &hasProperty) && hasProperty) {
+      JS::RootedValue temp(cx);
+      if (!JS_GetProperty(cx, eventObj, "expired", &temp) || !temp.isBoolean()) {
+        return NS_ERROR_FAILURE;
+      }
+
+      expired = temp.toBoolean();
+    }
+
+    // record_on_release is optional.
+    if (JS_HasProperty(cx, eventObj, "record_on_release", &hasProperty) && hasProperty) {
+      JS::RootedValue temp(cx);
+      if (!JS_GetProperty(cx, eventObj, "record_on_release", &temp) || !temp.isBoolean()) {
+        return NS_ERROR_FAILURE;
+      }
+
+      recordOnRelease = temp.toBoolean();
+    }
+
+    // Validate methods.
+    for (auto& method : methods) {
+      if (!IsValidIdentifierString(method, kMaxMethodNameByteLength, false)) {
+        JS_ReportErrorASCII(cx, "Method names should match the identifier pattern.");
+        return NS_ERROR_INVALID_ARG;
+      }
+    }
+
+    // Validate objects.
+    for (auto& object : objects) {
+      if (!IsValidIdentifierString(object, kMaxObjectNameByteLength, false)) {
+        JS_ReportErrorASCII(cx, "Object names should match the identifier pattern.");
+        return NS_ERROR_INVALID_ARG;
+      }
+    }
+
+    // Validate extra keys.
+    if (extra_keys.Length() > kMaxExtraKeyCount) {
+      JS_ReportErrorASCII(cx, "No more than 10 extra keys can be registered.");
+      return NS_ERROR_INVALID_ARG;
+    }
+    for (auto& key : extra_keys) {
+      if (!IsValidIdentifierString(key, kMaxExtraKeyNameByteLength, false)) {
+        JS_ReportErrorASCII(cx, "Extra key names should match the identifier pattern.");
+        return NS_ERROR_INVALID_ARG;
+      }
+    }
+
+    // Append event infos to be registered.
+    for (auto& method : methods) {
+      for (auto& object : objects) {
+        // We defer the actual registration here in case any other event description is invalid.
+        // In that case we don't need to roll back any partial registration.
+        DynamicEventInfo info{nsCString(aCategory), method, object,
+                              nsTArray<nsCString>(extra_keys), recordOnRelease};
+        newEventInfos.AppendElement(info);
+        newEventExpired.AppendElement(expired);
+      }
+    }
+  }
+
+  RegisterEventResult res = ::RegisterEvents(StaticMutexAutoLock(gTelemetryEventsMutex),
+                                             aCategory, newEventInfos, newEventExpired);
+  switch (res) {
+    case RegisterEventResult::AlreadyRegistered:
+      JS_ReportErrorASCII(cx, "Attempt to register event that is already registered.");
+      return NS_ERROR_INVALID_ARG;
+    default:
+      break;
+  }
+
+  return NS_OK;
+}
+
 nsresult
 TelemetryEvent::CreateSnapshots(uint32_t aDataset, bool aClear, JSContext* cx,
                                 uint8_t optional_argc, JS::MutableHandleValue aResult)
 {
   if (!XRE_IsParentProcess()) {
     return NS_ERROR_FAILURE;
   }
 
@@ -757,19 +1135,17 @@ TelemetryEvent::CreateSnapshots(uint32_t
 
     for (auto iter = gEventRecords.Iter(); !iter.Done(); iter.Next()) {
       const EventRecordArray* eventStorage = static_cast<EventRecordArray*>(iter.Data());
       EventRecordArray events;
 
       const uint32_t len = eventStorage->Length();
       for (uint32_t i = 0; i < len; ++i) {
         const EventRecord& record = (*eventStorage)[i];
-        const EventInfo& info = gEventInfo[record.EventId()];
-
-        if (IsInDataset(info.common_info.dataset, aDataset)) {
+        if (IsInDataset(GetDataset(locker, record.GetEventKey()), aDataset)) {
           events.AppendElement(record);
         }
       }
 
       if (events.Length()) {
         const char* processName = GetNameForProcessID(ProcessID(iter.Key()));
         processEvents.AppendElement(mozilla::MakePair(processName, events));
       }
@@ -785,17 +1161,17 @@ TelemetryEvent::CreateSnapshots(uint32_t
   if (!rootObj) {
     return NS_ERROR_FAILURE;
   }
 
   const uint32_t processLength = processEvents.Length();
   for (uint32_t i = 0; i < processLength; ++i)
   {
     JS::RootedObject eventsArray(cx);
-    if (NS_FAILED(SerializeEventsArray(processEvents[i].second(), cx, &eventsArray))) {
+    if (NS_FAILED(SerializeEventsArray(processEvents[i].second(), cx, &eventsArray, aDataset))) {
       return NS_ERROR_FAILURE;
     }
 
     if (!JS_DefineProperty(cx, rootObj, processEvents[i].first(), eventsArray, JSPROP_ENUMERATE)) {
       return NS_ERROR_FAILURE;
     }
   }
 
@@ -826,19 +1202,19 @@ TelemetryEvent::SetEventRecordingEnabled
   uint32_t categoryId;
   if (!gCategoryNameIDMap.Get(category, &categoryId)) {
     LogToBrowserConsole(nsIScriptError::warningFlag,
                         NS_LITERAL_STRING("Unkown category for SetEventRecordingEnabled."));
     return;
   }
 
   if (enabled) {
-    gEnabledCategories.PutEntry(categoryId);
+    gEnabledCategories.PutEntry(category);
   } else {
-    gEnabledCategories.RemoveEntry(categoryId);
+    gEnabledCategories.RemoveEntry(category);
   }
 }
 
 size_t
 TelemetryEvent::SizeOfIncludingThis(mozilla::MallocSizeOf aMallocSizeOf)
 {
   StaticMutexAutoLock locker(gTelemetryEventsMutex);
   size_t n = 0;
@@ -862,10 +1238,15 @@ TelemetryEvent::SizeOfIncludingThis(mozi
 
   n += gCategoryNameIDMap.ShallowSizeOfExcludingThis(aMallocSizeOf);
   for (auto iter = gCategoryNameIDMap.ConstIter(); !iter.Done(); iter.Next()) {
     n += iter.Key().SizeOfExcludingThisIfUnshared(aMallocSizeOf);
   }
 
   n += gEnabledCategories.ShallowSizeOfExcludingThis(aMallocSizeOf);
 
+  n += gDynamicEventInfo.ShallowSizeOfExcludingThis(aMallocSizeOf);
+  for (auto& info : gDynamicEventInfo) {
+    n += info.SizeOfExcludingThis(aMallocSizeOf);
+  }
+
   return n;
 }
--- a/toolkit/components/telemetry/TelemetryEvent.h
+++ b/toolkit/components/telemetry/TelemetryEvent.h
@@ -28,17 +28,21 @@ void DeInitializeGlobalState();
 void SetCanRecordBase(bool b);
 void SetCanRecordExtended(bool b);
 
 // JS API Endpoints.
 nsresult RecordEvent(const nsACString& aCategory, const nsACString& aMethod,
                      const nsACString& aObject, JS::HandleValue aValue,
                      JS::HandleValue aExtra, JSContext* aCx,
                      uint8_t optional_argc);
+
 void SetEventRecordingEnabled(const nsACString& aCategory, bool aEnabled);
+nsresult RegisterEvents(const nsACString& aCategory, JS::Handle<JS::Value> aEventData,
+                        JSContext* cx);
+
 nsresult CreateSnapshots(uint32_t aDataset, bool aClear, JSContext* aCx,
                          uint8_t optional_argc, JS::MutableHandleValue aResult);
 
 // Record events from child processes.
 nsresult RecordChildEvents(mozilla::Telemetry::ProcessID aProcessType,
                            const nsTArray<mozilla::Telemetry::ChildEventData>& aEvents);
 
 // Only to be used for testing.
--- a/toolkit/components/telemetry/TelemetrySession.jsm
+++ b/toolkit/components/telemetry/TelemetrySession.jsm
@@ -1004,18 +1004,18 @@ var Impl = {
 
   getEvents(isSubsession, clearSubsession) {
     if (!isSubsession) {
       // We only support scalars for subsessions.
       this._log.trace("getEvents - We only support events in subsessions.");
       return [];
     }
 
-    let snapshot = Telemetry.snapshotBuiltinEvents(this.getDatasetType(),
-                                                   clearSubsession);
+    let snapshot = Telemetry.snapshotEvents(this.getDatasetType(),
+                                            clearSubsession);
 
     // Don't return the test events outside of test environments.
     if (!this._testing) {
       for (let proc of Object.keys(snapshot)) {
         snapshot[proc] = snapshot[proc].filter(e => !e[1].startsWith("telemetry.test"));
       }
     }
 
@@ -1318,16 +1318,19 @@ var Impl = {
       },
       extension: {
         scalars: scalars["extension"],
         keyedScalars: keyedScalars["extension"],
         histograms: histograms["extension"],
         keyedHistograms: keyedHistograms["extension"],
         events: events["extension"] || [],
       },
+      dynamic: {
+        events: events["dynamic"] || [],
+      },
     };
 
     // Only include the GPU process if we've accumulated data for it.
     if ("gpu" in histograms ||
         "gpu" in keyedHistograms ||
         "gpu" in scalars ||
         "gpu" in keyedScalars) {
       payloadObj.processes.gpu = {
--- a/toolkit/components/telemetry/docs/collection/events.rst
+++ b/toolkit/components/telemetry/docs/collection/events.rst
@@ -170,19 +170,20 @@ Example:
   Services.telemetry.setEventRecordingEnabled("ui", false);
   // ... now "ui" events will not be recorded anymore.
 
 Internal API
 ~~~~~~~~~~~~
 
 .. code-block:: js
 
-  Services.telemetry.snapshotBuiltinEvents(dataset, clear);
+  Services.telemetry.snapshotEvents(dataset, clear);
   Services.telemetry.clearEvents();
 
 These functions are only supposed to be used by Telemetry internally or in tests.
 
 Version History
 ===============
 
 - Firefox 52: Initial event support (`bug 1302663 <https://bugzilla.mozilla.org/show_bug.cgi?id=1302663>`_).
 - Firefox 53: Event recording disabled by default (`bug 1329139 <https://bugzilla.mozilla.org/show_bug.cgi?id=1329139>`_).
 - Firefox 54: Added child process events (`bug 1313326 <https://bugzilla.mozilla.org/show_bug.cgi?id=1313326>`_).
+- Firefox 56: Added support for recording new probes from add-ons (`bug 1302681 <bug https://bugzilla.mozilla.org/show_bug.cgi?id=1302681>`_).
--- a/toolkit/components/telemetry/nsITelemetry.idl
+++ b/toolkit/components/telemetry/nsITelemetry.idl
@@ -493,15 +493,43 @@ interface nsITelemetry : nsISupports
    *     [43258, "category1", "method2", "object1", null, {"key1": "string value"}],
    *     ...
    *   ]
    *
    * @param aDataset DATASET_RELEASE_CHANNEL_OPTOUT or DATASET_RELEASE_CHANNEL_OPTIN.
    * @param [aClear=false] Whether to clear out the scalars after snapshotting.
    */
   [implicit_jscontext, optional_argc]
-  jsval snapshotBuiltinEvents(in uint32_t aDataset, [optional] in boolean aClear);
+  jsval snapshotEvents(in uint32_t aDataset, [optional] in boolean aClear);
+
+  /**
+   * Register new events to record them from addons. This allows registering multiple
+   * events for a category. They will be valid only for the current Firefox session.
+   * Note that events shipping in Firefox should be registered in Events.yaml.
+   *
+   * @param aCategory The unique category the events are registered in.
+   * @param aEventData An object that contains registration data for 1-N events of the form:
+   *   {
+   *     "categoryName": {
+   *       "methods": ["test1"],
+   *       "objects": ["object1"],
+   *       "record_on_release": false,
+   *       "extra_keys": ["key1", "key2"], // optional
+   *       "expired": false // optional, defaults to false.
+   *     },
+   *     ...
+   *   }
+   * @param aEventData.<name>.methods List of methods for this event entry.
+   * @param aEventData.<name>.objects List of objects for this event entry.
+   * @param aEventData.<name>.extra_keys Optional, list of allowed extra keys for this event entry.
+   * @param aEventData.<name>.record_on_release Optional, whether to record this data on release.
+   *                                            Defaults to false.
+   * @param aEventData.<name>.expired Optional, whether this event entry is expired. This allows
+   *                                  recording it without error, but it will be discarded. Defaults to false.
+   */
+  [implicit_jscontext]
+  void registerEvents(in ACString aCategory, in jsval aEventData);
 
   /**
    * Resets all the stored events. This is intended to be only used in tests.
    */
   void clearEvents();
 };
--- a/toolkit/components/telemetry/tests/unit/test_ChildEvents.js
+++ b/toolkit/components/telemetry/tests/unit/test_ChildEvents.js
@@ -31,32 +31,40 @@ const RECORDED_PARENT_EVENTS = [
   ["telemetry.test", "main_and_content", "object1"],
   ["telemetry.test", "main_only", "object1"],
 ];
 
 const UNRECORDED_PARENT_EVENTS = [
   ["telemetry.test", "content_only", "object1"],
 ];
 
+const RECORDED_DYNAMIC_EVENTS = [
+  ["telemetry.test.dynamic", "test1", "object1"],
+  ["telemetry.test.dynamic", "test2", "object1"],
+];
+
 function run_child_test() {
   // Record some events in the "content" process.
   RECORDED_CONTENT_EVENTS.forEach(e => Telemetry.recordEvent(...e));
   // These events should not be recorded for the content process.
   UNRECORDED_CONTENT_EVENTS.forEach(e => Telemetry.recordEvent(...e));
+  // Record some dynamic events from the content process.
+  RECORDED_DYNAMIC_EVENTS.forEach(e => Telemetry.recordEvent(...e));
 }
 
 /**
  * This function waits until content events are reported into the
  * events snapshot.
  */
 async function waitForContentEvents() {
   await ContentTaskUtils.waitForCondition(() => {
     const snapshot =
-      Telemetry.snapshotBuiltinEvents(Ci.nsITelemetry.DATASET_RELEASE_CHANNEL_OPTIN, false);
-    return Object.keys(snapshot).includes("content");
+      Telemetry.snapshotEvents(Ci.nsITelemetry.DATASET_RELEASE_CHANNEL_OPTIN, false);
+    return Object.keys(snapshot).includes("content") &&
+           Object.keys(snapshot).includes("dynamic");
   });
 }
 
 add_task(async function() {
   if (!runningInParent) {
     TelemetryController.testSetupContent();
     run_child_test();
     do_send_remote_message(MESSAGE_CHILD_TEST_DONE);
@@ -69,16 +77,31 @@ add_task(async function() {
   finishAddonManagerStartup();
   Services.prefs.setBoolPref(TelemetryUtils.Preferences.TelemetryEnabled, true);
   await TelemetryController.testSetup();
   // Make sure we don't generate unexpected pings due to pref changes.
   await setEmptyPrefWatchlist();
   // Enable recording for the test event category.
   Telemetry.setEventRecordingEnabled("telemetry.test", true);
 
+  // Register dynamic test events.
+  Telemetry.registerEvents("telemetry.test.dynamic", {
+    // Event with only required fields.
+    "test1": {
+      methods: ["test1"],
+      objects: ["object1"],
+    },
+    // Event with extra_keys.
+    "test2": {
+      methods: ["test2", "test2b"],
+      objects: ["object1"],
+      extra_keys: ["key1", "key2"],
+    },
+  });
+
   // Run test in child, don't wait for it to finish: just wait for the
   // MESSAGE_CHILD_TEST_DONE.
   const timestampBeforeChildEvents = Telemetry.msSinceProcessStart();
   run_test_in_child("test_ChildEvents.js");
   await do_await_remote_message(MESSAGE_CHILD_TEST_DONE);
 
   // Once events are set by the content process, they don't immediately get
   // sent to the parent process. Wait for the Telemetry IPC Timer to trigger
@@ -95,41 +118,50 @@ add_task(async function() {
   const payload = TelemetrySession.getPayload("environment-change");
 
   // Validate the event data is present in the payload.
   Assert.ok("processes" in payload, "Should have processes section");
   Assert.ok("parent" in payload.processes, "Should have main process section");
   Assert.ok("events" in payload.processes.parent, "Main process section should have events.");
   Assert.ok("content" in payload.processes, "Should have child process section");
   Assert.ok("events" in payload.processes.content, "Child process section should have events.");
+  Assert.ok("dynamic" in payload.processes, "Should have dynamic process section");
+  Assert.ok("events" in payload.processes.dynamic, "Dynamic process section should have events.");
 
   // Check that the expected events are present from the content process.
   let contentEvents = payload.processes.content.events.map(e => e.slice(1));
   Assert.equal(contentEvents.length, RECORDED_CONTENT_EVENTS.length, "Should match expected event count.");
   for (let i = 0; i < RECORDED_CONTENT_EVENTS.length; ++i) {
     Assert.deepEqual(contentEvents[i], RECORDED_CONTENT_EVENTS[i], "Should have recorded expected event.");
   }
 
   // Check that the expected events are present from the parent process.
   let parentEvents = payload.processes.parent.events.map(e => e.slice(1));
   Assert.equal(parentEvents.length, RECORDED_PARENT_EVENTS.length, "Should match expected event count.");
   for (let i = 0; i < RECORDED_PARENT_EVENTS.length; ++i) {
     Assert.deepEqual(parentEvents[i], RECORDED_PARENT_EVENTS[i], "Should have recorded expected event.");
   }
 
+  // Check that the expected dynamic events are present.
+  let dynamicEvents = payload.processes.dynamic.events.map(e => e.slice(1));
+  Assert.equal(dynamicEvents.length, RECORDED_DYNAMIC_EVENTS.length, "Should match expected event count.");
+  for (let i = 0; i < RECORDED_DYNAMIC_EVENTS.length; ++i) {
+    Assert.deepEqual(dynamicEvents[i], RECORDED_DYNAMIC_EVENTS[i], "Should have recorded expected event.");
+  }
+
   // Check that the event timestamps are in the expected ranges.
   let contentTimestamps = payload.processes.content.events.map(e => e[0]);
   let parentTimestamps = payload.processes.parent.events.map(e => e[0]);
 
   Assert.ok(contentTimestamps.every(ts => (ts > Math.floor(timestampBeforeChildEvents)) &&
                                           (ts < timestampAfterChildEvents)),
             "All content event timestamps should be in the expected time range.");
   Assert.ok(parentTimestamps.every(ts => (ts >= Math.floor(timestampAfterChildEvents))),
             "All parent event timestamps should be in the expected time range.");
 
   // Make sure all events are cleared from storage properly.
   let snapshot =
-      Telemetry.snapshotBuiltinEvents(Ci.nsITelemetry.DATASET_RELEASE_CHANNEL_OPTIN, true);
+      Telemetry.snapshotEvents(Ci.nsITelemetry.DATASET_RELEASE_CHANNEL_OPTIN, true);
   Assert.greaterOrEqual(Object.keys(snapshot).length, 2, "Should have events from at least two processes.");
   snapshot =
-      Telemetry.snapshotBuiltinEvents(Ci.nsITelemetry.DATASET_RELEASE_CHANNEL_OPTIN, true);
+      Telemetry.snapshotEvents(Ci.nsITelemetry.DATASET_RELEASE_CHANNEL_OPTIN, true);
   Assert.equal(Object.keys(snapshot).length, 0, "Should have cleared all events from storage.");
 });
--- a/toolkit/components/telemetry/tests/unit/test_TelemetryEvents.js
+++ b/toolkit/components/telemetry/tests/unit/test_TelemetryEvents.js
@@ -39,40 +39,40 @@ function checkEventFormat(events) {
 add_task(async function test_recording_state() {
   const events = [
     ["telemetry.test", "test1", "object1"],
     ["telemetry.test.second", "test", "object1"],
   ];
 
   // Both test categories should be off by default.
   events.forEach(e => Telemetry.recordEvent(...e));
-  let snapshot = Telemetry.snapshotBuiltinEvents(OPTIN, true);
+  let snapshot = Telemetry.snapshotEvents(OPTIN, true);
   Assert.equal(Object.keys(snapshot).length, 0, "Should not have recorded any events.");
 
   // Enable one test category and see that we record correctly.
   Telemetry.setEventRecordingEnabled("telemetry.test", true);
   events.forEach(e => Telemetry.recordEvent(...e));
-  snapshot = Telemetry.snapshotBuiltinEvents(OPTIN, true);
+  snapshot = Telemetry.snapshotEvents(OPTIN, true);
   Assert.ok(("parent" in snapshot), "Should have entry for main process.");
   Assert.equal(snapshot.parent.length, 1, "Should have recorded one event.");
   Assert.equal(snapshot.parent[0][1], "telemetry.test", "Should have recorded one event in telemetry.test");
 
   // Also enable the other test category and see that we record correctly.
   Telemetry.setEventRecordingEnabled("telemetry.test.second", true);
   events.forEach(e => Telemetry.recordEvent(...e));
-  snapshot = Telemetry.snapshotBuiltinEvents(OPTIN, true);
+  snapshot = Telemetry.snapshotEvents(OPTIN, true);
   Assert.ok(("parent" in snapshot), "Should have entry for main process.");
   Assert.equal(snapshot.parent.length, 2, "Should have recorded two events.");
   Assert.equal(snapshot.parent[0][1], "telemetry.test", "Should have recorded one event in telemetry.test");
   Assert.equal(snapshot.parent[1][1], "telemetry.test.second", "Should have recorded one event in telemetry.test.second");
 
   // Now turn of one category again and check that this works as expected.
   Telemetry.setEventRecordingEnabled("telemetry.test", false);
   events.forEach(e => Telemetry.recordEvent(...e));
-  snapshot = Telemetry.snapshotBuiltinEvents(OPTIN, true);
+  snapshot = Telemetry.snapshotEvents(OPTIN, true);
   Assert.ok(("parent" in snapshot), "Should have entry for main process.");
   Assert.equal(snapshot.parent.length, 1, "Should have recorded one event.");
   Assert.equal(snapshot.parent[0][1], "telemetry.test.second", "Should have recorded one event in telemetry.test.second");
 });
 
 add_task(async function recording_setup() {
   // Make sure both test categories are enabled for the remaining tests.
   // Otherwise their event recording won't work.
@@ -140,103 +140,103 @@ add_task(async function test_recording()
 
       let recordedData = events[i].slice(1);
       let expectedData = expectedEvents[i].event.slice();
       Assert.deepEqual(recordedData, expectedData, "The recorded event data should match.");
     }
   };
 
   // Check that the expected events were recorded.
-  let snapshot = Telemetry.snapshotBuiltinEvents(OPTIN, false);
+  let snapshot = Telemetry.snapshotEvents(OPTIN, false);
   Assert.ok(("parent" in snapshot), "Should have entry for main process.");
   checkEvents(snapshot.parent, expected);
 
   // Check serializing only opt-out events.
-  snapshot = Telemetry.snapshotBuiltinEvents(OPTOUT, false);
+  snapshot = Telemetry.snapshotEvents(OPTOUT, false);
   Assert.ok(("parent" in snapshot), "Should have entry for main process.");
   let filtered = expected.filter(e => e.optout == true);
   checkEvents(snapshot.parent, filtered);
 });
 
 add_task(async function test_clear() {
   Telemetry.clearEvents();
 
   const COUNT = 10;
   for (let i = 0; i < COUNT; ++i) {
     Telemetry.recordEvent("telemetry.test", "test1", "object1");
     Telemetry.recordEvent("telemetry.test.second", "test", "object1");
   }
 
   // Check that events were recorded.
   // The events are cleared by passing the respective flag.
-  let snapshot = Telemetry.snapshotBuiltinEvents(OPTIN, true);
+  let snapshot = Telemetry.snapshotEvents(OPTIN, true);
   Assert.ok(("parent" in snapshot), "Should have entry for main process.");
   Assert.equal(snapshot.parent.length, 2 * COUNT, `Should have recorded ${2 * COUNT} events.`);
 
   // Now the events should be cleared.
-  snapshot = Telemetry.snapshotBuiltinEvents(OPTIN, false);
+  snapshot = Telemetry.snapshotEvents(OPTIN, false);
   Assert.equal(Object.keys(snapshot).length, 0, `Should have cleared the events.`);
 });
 
 add_task(async function test_expiry() {
   Telemetry.clearEvents();
 
   // Recording call with event that is expired by version.
   Telemetry.recordEvent("telemetry.test", "expired_version", "object1");
-  let snapshot = Telemetry.snapshotBuiltinEvents(OPTIN, true);
+  let snapshot = Telemetry.snapshotEvents(OPTIN, true);
   Assert.equal(Object.keys(snapshot).length, 0, "Should not record event with expired version.");
 
   // Recording call with event that is expired by date.
   Telemetry.recordEvent("telemetry.test", "expired_date", "object1");
-  snapshot = Telemetry.snapshotBuiltinEvents(OPTIN, true);
+  snapshot = Telemetry.snapshotEvents(OPTIN, true);
   Assert.equal(Object.keys(snapshot).length, 0, "Should not record event with expired date.");
 
   // Recording call with event that has expiry_version and expiry_date in the future.
   Telemetry.recordEvent("telemetry.test", "not_expired_optout", "object1");
-  snapshot = Telemetry.snapshotBuiltinEvents(OPTOUT, true);
+  snapshot = Telemetry.snapshotEvents(OPTOUT, true);
   Assert.ok(("parent" in snapshot), "Should have entry for main process.");
   Assert.equal(snapshot.parent.length, 1, "Should record event when date and version are not expired.");
 });
 
 add_task(async function test_invalidParams() {
   Telemetry.clearEvents();
 
   // Recording call with wrong type for value argument.
   Telemetry.recordEvent("telemetry.test", "test1", "object1", 1);
-  let snapshot = Telemetry.snapshotBuiltinEvents(OPTIN, true);
+  let snapshot = Telemetry.snapshotEvents(OPTIN, true);
   Assert.equal(Object.keys(snapshot).length, 0, "Should not record event when value argument with invalid type is passed.");
 
   // Recording call with wrong type for extra argument.
   Telemetry.recordEvent("telemetry.test", "test1", "object1", null, "invalid");
-  snapshot = Telemetry.snapshotBuiltinEvents(OPTIN, true);
+  snapshot = Telemetry.snapshotEvents(OPTIN, true);
   Assert.equal(Object.keys(snapshot).length, 0, "Should not record event when extra argument with invalid type is passed.");
 
   // Recording call with unknown extra key.
   Telemetry.recordEvent("telemetry.test", "test1", "object1", null, {"key3": "x"});
-  snapshot = Telemetry.snapshotBuiltinEvents(OPTIN, true);
+  snapshot = Telemetry.snapshotEvents(OPTIN, true);
   Assert.equal(Object.keys(snapshot).length, 0, "Should not record event when extra argument with invalid key is passed.");
 
   // Recording call with invalid value type.
   Telemetry.recordEvent("telemetry.test", "test1", "object1", null, {"key3": 1});
-  snapshot = Telemetry.snapshotBuiltinEvents(OPTIN, true);
+  snapshot = Telemetry.snapshotEvents(OPTIN, true);
   Assert.equal(Object.keys(snapshot).length, 0, "Should not record event when extra argument with invalid value type is passed.");
 });
 
 add_task(async function test_storageLimit() {
   Telemetry.clearEvents();
 
   // Record more events than the storage limit allows.
   let LIMIT = 1000;
   let COUNT = LIMIT + 10;
   for (let i = 0; i < COUNT; ++i) {
     Telemetry.recordEvent("telemetry.test", "test1", "object1", String(i));
   }
 
   // Check that the right events were recorded.
-  let snapshot = Telemetry.snapshotBuiltinEvents(OPTIN, true);
+  let snapshot = Telemetry.snapshotEvents(OPTIN, true);
   Assert.ok(("parent" in snapshot), "Should have entry for main process.");
   let events = snapshot.parent;
   Assert.equal(events.length, LIMIT, `Should have only recorded ${LIMIT} events`);
   Assert.ok(events.every((e, idx) => e[4] === String(idx)),
             "Should have recorded all events from before hitting the limit.");
 });
 
 add_task(async function test_valueLimits() {
@@ -269,17 +269,17 @@ add_task(async function test_valueLimits
   // Strip off trailing null values to match the serialized events.
   for (let e of expected) {
     while ((e.length >= 3) && (e[e.length - 1] === null)) {
       e.pop();
     }
   }
 
   // Check that the right events were recorded.
-  let snapshot = Telemetry.snapshotBuiltinEvents(OPTIN, true);
+  let snapshot = Telemetry.snapshotEvents(OPTIN, true);
   Assert.ok(("parent" in snapshot), "Should have entry for main process.");
   let events = snapshot.parent;
   Assert.equal(events.length, expected.length,
                "Should have recorded the expected number of events");
   for (let i = 0; i < expected.length; ++i) {
     Assert.deepEqual(events[i].slice(1), expected[i],
                      "Should have recorded the expected event data.");
   }
@@ -289,15 +289,244 @@ add_task(async function test_unicodeValu
   Telemetry.clearEvents();
 
   // Record string values containing unicode characters.
   let value = "漢語";
   Telemetry.recordEvent("telemetry.test", "test1", "object1", value);
   Telemetry.recordEvent("telemetry.test", "test1", "object1", null, {"key1": value});
 
   // Check that the values were correctly recorded.
-  let snapshot = Telemetry.snapshotBuiltinEvents(OPTIN, true);
+  let snapshot = Telemetry.snapshotEvents(OPTIN, true);
   Assert.ok(("parent" in snapshot), "Should have entry for main process.");
   let events = snapshot.parent;
   Assert.equal(events.length, 2, "Should have recorded 2 events.");
   Assert.equal(events[0][4], value, "Should have recorded the right value.");
   Assert.equal(events[1][5]["key1"], value, "Should have recorded the right extra value.");
 });
+
+add_task(function* test_dynamicEvents() {
+  Telemetry.clearEvents();
+  Telemetry.canRecordExtended = true;
+
+  // Register some test events.
+  Telemetry.registerEvents("telemetry.test.dynamic", {
+    // Event with only required fields.
+    "test1": {
+      methods: ["test1"],
+      objects: ["object1"],
+    },
+    // Event with extra_keys.
+    "test2": {
+      methods: ["test2", "test2b"],
+      objects: ["object1"],
+      extra_keys: ["key1", "key2"],
+    },
+    // Expired event.
+    "test3": {
+      methods: ["test3"],
+      objects: ["object1"],
+      expired: true,
+    },
+    // A release-channel recording event.
+    "test4": {
+      methods: ["test4"],
+      objects: ["object1"],
+      record_on_release: true,
+    },
+  });
+
+  // Record some valid events.
+  Telemetry.recordEvent("telemetry.test.dynamic", "test1", "object1");
+  Telemetry.recordEvent("telemetry.test.dynamic", "test2", "object1", null,
+                        {"key1": "foo", "key2": "bar"});
+  Telemetry.recordEvent("telemetry.test.dynamic", "test3", "object1", "some value");
+  Telemetry.recordEvent("telemetry.test.dynamic", "test4", "object1", null);
+
+  // Test recording an unknown event.
+  Assert.throws(() => Telemetry.recordEvent("telemetry.test.dynamic", "unknown", "unknown"),
+                /Error: Unknown event: \["telemetry\.test\.dynamic", "unknown", "unknown"\]/,
+                "Should throw when recording an unknown dynamic event.");
+
+  // Now check that the snapshot contains the expected data.
+  let snapshot = Telemetry.snapshotEvents(OPTIN, false);
+  Assert.ok(("dynamic" in snapshot), "Should have dynamic events in the snapshot.");
+
+  let expected = [
+    ["telemetry.test.dynamic", "test1", "object1"],
+    ["telemetry.test.dynamic", "test2", "object1", null, {key1: "foo", key2: "bar"}],
+    // "test3" is epxired, so it should not be recorded.
+    ["telemetry.test.dynamic", "test4", "object1"],
+  ];
+  let events = snapshot.dynamic;
+  Assert.equal(events.length, expected.length, "Should have recorded the right amount of events.");
+  for (let i = 0; i < expected.length; ++i) {
+    Assert.deepEqual(events[i].slice(1), expected[i],
+                     "Should have recorded the expected event data.");
+  }
+
+  // Check that the opt-out snapshot contains only the one expected event.
+  snapshot = Telemetry.snapshotEvents(OPTOUT, false);
+  Assert.ok(("dynamic" in snapshot), "Should have dynamic events in the snapshot.");
+  Assert.equal(snapshot.dynamic.length, 1, "Should have one opt-out event in the snapshot.");
+  expected = ["telemetry.test.dynamic", "test4", "object1"];
+  Assert.deepEqual(snapshot.dynamic[0].slice(1), expected);
+
+  // Recording with unknown extra keys should be ignored and print an error.
+  Telemetry.clearEvents();
+  Telemetry.recordEvent("telemetry.test.dynamic", "test1", "object1", null, {"key1": "foo"});
+  Telemetry.recordEvent("telemetry.test.dynamic", "test2", "object1", null, {"key1": "foo", "unknown": "bar"});
+  snapshot = Telemetry.snapshotEvents(OPTIN, true);
+  Assert.ok(!("dynamic" in snapshot), "Should have not recorded dynamic events with unknown extra keys.");
+
+  // Other built-in events should not show up in the "dynamic" bucket of the snapshot.
+  Telemetry.recordEvent("telemetry.test", "test1", "object1");
+  snapshot = Telemetry.snapshotEvents(OPTIN, true);
+  Assert.ok(!("dynamic" in snapshot), "Should have not recorded built-in event into dynamic bucket.");
+
+  // Test that recording opt-in and opt-out events works as expected.
+  Telemetry.clearEvents();
+  Telemetry.canRecordExtended = false;
+
+  Telemetry.recordEvent("telemetry.test.dynamic", "test1", "object1");
+  Telemetry.recordEvent("telemetry.test.dynamic", "test4", "object1");
+
+  expected = [
+    // Only "test4" should have been recorded.
+    ["telemetry.test.dynamic", "test4", "object1"],
+  ];
+  snapshot = Telemetry.snapshotEvents(OPTIN, true);
+  Assert.equal(snapshot.dynamic.length, 1, "Should have one opt-out event in the snapshot.");
+  Assert.deepEqual(snapshot.dynamic.map(e => e.slice(1)), expected);
+});
+
+add_task(function* test_dynamicEventRegistrationValidation() {
+  Telemetry.canRecordExtended = true;
+  Telemetry.clearEvents();
+
+  // Test registration of invalid categories.
+  Assert.throws(() => Telemetry.registerEvents("telemetry+test+dynamic", {
+      "test1": {
+        methods: ["test1"],
+        objects: ["object1"],
+      },
+    }),
+    /Category parameter should match the identifier pattern\./,
+    "Should throw when registering category names with invalid characters.");
+  Assert.throws(() => Telemetry.registerEvents("telemetry.test.test.test.test.test.test.test.test", {
+      "test1": {
+        methods: ["test1"],
+        objects: ["object1"],
+      },
+    }),
+    /Category parameter should match the identifier pattern\./,
+    "Should throw when registering overly long category names.");
+
+  // Test registration of invalid event names.
+  Assert.throws(() => Telemetry.registerEvents("telemetry.test.dynamic1", {
+      "test?1": {
+        methods: ["test1"],
+        objects: ["object1"],
+      },
+    }),
+    /Event names should match the identifier pattern\./,
+    "Should throw when registering event names with invalid characters.");
+  Assert.throws(() => Telemetry.registerEvents("telemetry.test.dynamic2", {
+      "test1test1test1test1test1test1test1": {
+        methods: ["test1"],
+        objects: ["object1"],
+      },
+    }),
+    /Event names should match the identifier pattern\./,
+    "Should throw when registering overly long event names.");
+
+  // Test registration of invalid method names.
+  Assert.throws(() => Telemetry.registerEvents("telemetry.test.dynamic3", {
+      "test1": {
+        methods: ["test?1"],
+        objects: ["object1"],
+      },
+    }),
+    /Method names should match the identifier pattern\./,
+    "Should throw when registering method names with invalid characters.");
+  Assert.throws(() => Telemetry.registerEvents("telemetry.test.dynamic", {
+      "test1": {
+        methods: ["test1test1test1test1test1test1test1"],
+        objects: ["object1"],
+      },
+    }),
+    /Method names should match the identifier pattern\./,
+    "Should throw when registering overly long method names.");
+
+  // Test registration of invalid object names.
+  Assert.throws(() => Telemetry.registerEvents("telemetry.test.dynamic4", {
+      "test1": {
+        methods: ["test1"],
+        objects: ["object?1"],
+      },
+    }),
+    /Object names should match the identifier pattern\./,
+    "Should throw when registering object names with invalid characters.");
+  Assert.throws(() => Telemetry.registerEvents("telemetry.test.dynamic5", {
+      "test1": {
+        methods: ["test1"],
+        objects: ["object1object1object1object1object1object1"],
+      },
+    }),
+    /Object names should match the identifier pattern\./,
+    "Should throw when registering overly long object names.");
+
+  // Test validation of invalid key names.
+  Assert.throws(() => Telemetry.registerEvents("telemetry.test.dynamic6", {
+      "test1": {
+        methods: ["test1"],
+        objects: ["object1"],
+        extra_keys: ["a?1"],
+      },
+    }),
+    /Extra key names should match the identifier pattern\./,
+    "Should throw when registering extra key names with invalid characters.");
+
+  // Test validation of key names that are too long - we allow a maximum of 15 characters.
+  Assert.throws(() => Telemetry.registerEvents("telemetry.test.dynamic7", {
+      "test1": {
+        methods: ["test1"],
+        objects: ["object1"],
+        extra_keys: ["a012345678901234"],
+      },
+    }),
+    /Extra key names should match the identifier pattern\./,
+    "Should throw when registering extra key names which are too long.");
+  Telemetry.registerEvents("telemetry.test.dynamic8", {
+    "test1": {
+      methods: ["test1"],
+      objects: ["object1"],
+      extra_keys: ["a01234567890123"],
+    },
+  });
+
+  // Test validation of extra key count - we only allow 10.
+  Assert.throws(() => Telemetry.registerEvents("telemetry.test.dynamic9", {
+      "test1": {
+        methods: ["test1"],
+        objects: ["object1"],
+        extra_keys: ["a1", "a2", "a3", "a4", "a5", "a6", "a7", "a8", "a9", "a10", "a11"],
+      },
+    }),
+    /No more than 10 extra keys can be registered\./,
+    "Should throw when registering too many extra keys.");
+  Telemetry.registerEvents("telemetry.test.dynamic10", {
+    "test1": {
+      methods: ["test1"],
+      objects: ["object1"],
+      extra_keys: ["a1", "a2", "a3", "a4", "a5", "a6", "a7", "a8", "a9", "a10"],
+    },
+  });
+
+  // Test registering an event thats already registered through Events.yaml.
+  Assert.throws(() => Telemetry.registerEvents("telemetry.test", {
+      "test1": {
+        methods: ["test1"],
+        objects: ["object1"],
+      },
+    }),
+    /Attempt to register event that is already registered\./,
+    "Should throw when registering event that already was registered.");
+});