Bug 1302663 - Part 2 - Implement Event Telemetry recording. r=froydnj,dexter
authorGeorg Fritzsche <georg.fritzsche@googlemail.com>
Fri, 18 Nov 2016 15:51:58 +0100
changeset 323247 190f6fc837b5f1d403d44750d0a83e58037be8d7
parent 323246 2af0b28f843b099f416eb66fa0fd9e189c533ca5
child 323248 bb4c5358f83a220982cebc4ff52403cde67d1b5b
push id84084
push usergeorg.fritzsche@googlemail.com
push dateFri, 18 Nov 2016 14:52:14 +0000
treeherdermozilla-inbound@58d95de5bd74 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersfroydnj, dexter
bugs1302663
milestone53.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 1302663 - Part 2 - Implement Event Telemetry recording. r=froydnj,dexter This implements the API, storage and serialization for Telemetry event recording.
toolkit/components/telemetry/Telemetry.cpp
toolkit/components/telemetry/TelemetryCommon.cpp
toolkit/components/telemetry/TelemetryCommon.h
toolkit/components/telemetry/TelemetryEvent.cpp
toolkit/components/telemetry/TelemetryEvent.h
toolkit/components/telemetry/TelemetryScalar.cpp
toolkit/components/telemetry/moz.build
toolkit/components/telemetry/nsITelemetry.idl
toolkit/components/telemetry/tests/unit/test_TelemetryEvents.js
toolkit/components/telemetry/tests/unit/xpcshell.ini
--- a/toolkit/components/telemetry/Telemetry.cpp
+++ b/toolkit/components/telemetry/Telemetry.cpp
@@ -39,16 +39,17 @@
 #include "nsIFile.h"
 #include "nsIFileStreams.h"
 #include "nsIMemoryReporter.h"
 #include "nsISeekableStream.h"
 #include "Telemetry.h"
 #include "TelemetryCommon.h"
 #include "TelemetryHistogram.h"
 #include "TelemetryScalar.h"
+#include "TelemetryEvent.h"
 #include "WebrtcTelemetry.h"
 #include "nsTHashtable.h"
 #include "nsHashKeys.h"
 #include "nsBaseHashtable.h"
 #include "nsClassHashtable.h"
 #include "nsXULAppAPI.h"
 #include "nsReadableUtils.h"
 #include "nsThreadUtils.h"
@@ -1857,16 +1858,17 @@ TelemetryImpl::GetCanRecordBase(bool *re
   *ret = TelemetryHistogram::CanRecordBase();
   return NS_OK;
 }
 
 NS_IMETHODIMP
 TelemetryImpl::SetCanRecordBase(bool canRecord) {
   TelemetryHistogram::SetCanRecordBase(canRecord);
   TelemetryScalar::SetCanRecordBase(canRecord);
+  TelemetryEvent::SetCanRecordBase(canRecord);
   return NS_OK;
 }
 
 /**
  * Indicates if Telemetry is allowed to record extended data. Returns false if the user
  * hasn't opted into "extended Telemetry" on the Release channel, when the user has
  * explicitly opted out of Telemetry on Nightly/Aurora/Beta or if manually set to false
  * during tests.
@@ -1877,16 +1879,17 @@ TelemetryImpl::GetCanRecordExtended(bool
   *ret = TelemetryHistogram::CanRecordExtended();
   return NS_OK;
 }
 
 NS_IMETHODIMP
 TelemetryImpl::SetCanRecordExtended(bool canRecord) {
   TelemetryHistogram::SetCanRecordExtended(canRecord);
   TelemetryScalar::SetCanRecordExtended(canRecord);
+  TelemetryEvent::SetCanRecordExtended(canRecord);
   return NS_OK;
 }
 
 
 NS_IMETHODIMP
 TelemetryImpl::GetIsOfficialTelemetry(bool *ret) {
 #if defined(MOZILLA_OFFICIAL) && defined(MOZ_TELEMETRY_REPORTING) && !defined(DEBUG)
   *ret = true;
@@ -1910,16 +1913,19 @@ TelemetryImpl::CreateTelemetryInstance()
   }
 
   // First, initialize the TelemetryHistogram and TelemetryScalar global states.
   TelemetryHistogram::InitializeGlobalState(useTelemetry, useTelemetry);
 
   // Only record scalars from the parent process.
   TelemetryScalar::InitializeGlobalState(XRE_IsParentProcess(), XRE_IsParentProcess());
 
+  // Only record events from the parent process.
+  TelemetryEvent::InitializeGlobalState(XRE_IsParentProcess(), XRE_IsParentProcess());
+
   // Now, create and initialize the Telemetry global state.
   sTelemetry = new TelemetryImpl();
 
   // AddRef for the local reference
   NS_ADDREF(sTelemetry);
   // AddRef for the caller
   nsCOMPtr<nsITelemetry> ret = sTelemetry;
 
@@ -1935,16 +1941,17 @@ TelemetryImpl::ShutdownTelemetry()
   // No point in collecting IO beyond this point
   ClearIOReporting();
   NS_IF_RELEASE(sTelemetry);
 
   // Lastly, de-initialise the TelemetryHistogram and TelemetryScalar global states,
   // so as to release any heap storage that would otherwise be kept alive by it.
   TelemetryHistogram::DeInitializeGlobalState();
   TelemetryScalar::DeInitializeGlobalState();
+  TelemetryEvent::DeInitializeGlobalState();
 }
 
 void
 TelemetryImpl::StoreSlowSQL(const nsACString &sql, uint32_t delay,
                             SanitizedState state)
 {
   AutoHashtable<SlowSQLEntryType>* slowSQLMap = nullptr;
   if (state == Sanitized)
@@ -2299,23 +2306,17 @@ TelemetryImpl::GetFileIOReports(JSContex
   }
   ret.setNull();
   return NS_OK;
 }
 
 NS_IMETHODIMP
 TelemetryImpl::MsSinceProcessStart(double* aResult)
 {
-  bool error;
-  *aResult = (TimeStamp::NowLoRes() -
-              TimeStamp::ProcessCreation(error)).ToMilliseconds();
-  if (error) {
-    return NS_ERROR_NOT_AVAILABLE;
-  }
-  return NS_OK;
+  return Telemetry::Common::MsSinceProcessStart(aResult);
 }
 
 // Telemetry Scalars IDL Implementation
 
 NS_IMETHODIMP
 TelemetryImpl::ScalarAdd(const nsACString& aName, JS::HandleValue aVal, JSContext* aCx)
 {
   return TelemetryScalar::Add(aName, aVal, aCx);
@@ -2371,16 +2372,41 @@ TelemetryImpl::SnapshotKeyedScalars(unsi
 
 NS_IMETHODIMP
 TelemetryImpl::ClearScalars()
 {
   TelemetryScalar::ClearScalars();
   return NS_OK;
 }
 
+// Telemetry Event IDL implementation.
+
+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,
+                                     uint8_t optional_argc, JS::MutableHandleValue aResult)
+{
+  return TelemetryEvent::CreateSnapshots(aDataset, aClear, aCx, optional_argc, aResult);
+}
+
+NS_IMETHODIMP
+TelemetryImpl::ClearEvents()
+{
+  TelemetryEvent::ClearEvents();
+  return NS_OK;
+}
+
+
 NS_IMETHODIMP
 TelemetryImpl::FlushBatchedChildTelemetry()
 {
   TelemetryHistogram::IPCTimerFired(nullptr, nullptr);
   return NS_OK;
 }
 
 size_t
@@ -2409,16 +2435,17 @@ TelemetryImpl::SizeOfIncludingThis(mozil
   // It's a bit gross that we measure this other stuff that lives outside of
   // TelemetryImpl... oh well.
   if (sTelemetryIOObserver) {
     n += sTelemetryIOObserver->SizeOfIncludingThis(aMallocSizeOf);
   }
 
   n += TelemetryHistogram::GetHistogramSizesofIncludingThis(aMallocSizeOf);
   n += TelemetryScalar::GetScalarSizesOfIncludingThis(aMallocSizeOf);
+  n += TelemetryEvent::SizeOfIncludingThis(aMallocSizeOf);
 
   return n;
 }
 
 struct StackFrame
 {
   uintptr_t mPC;      // The program counter at this position in the call stack.
   uint16_t mIndex;    // The number of this frame in the call stack.
--- a/toolkit/components/telemetry/TelemetryCommon.cpp
+++ b/toolkit/components/telemetry/TelemetryCommon.cpp
@@ -1,16 +1,19 @@
 /* -*- 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 "nsITelemetry.h"
 #include "nsVersionComparator.h"
+#include "mozilla/TimeStamp.h"
+#include "nsIConsoleService.h"
+#include "nsThreadUtils.h"
 
 #include "TelemetryCommon.h"
 
 #include <cstring>
 
 namespace mozilla {
 namespace Telemetry {
 namespace Common {
@@ -57,11 +60,45 @@ CanRecordDataset(uint32_t aDataset, bool
       return true;
   }
 
   // We're not recording extended telemetry or this is not the base
   // dataset. Bail out.
   return false;
 }
 
+nsresult
+MsSinceProcessStart(double* aResult)
+{
+  bool error;
+  *aResult = (TimeStamp::NowLoRes() -
+              TimeStamp::ProcessCreation(error)).ToMilliseconds();
+  if (error) {
+    return NS_ERROR_NOT_AVAILABLE;
+  }
+  return NS_OK;
+}
+
+void
+LogToBrowserConsole(uint32_t aLogLevel, const nsAString& aMsg)
+{
+  if (!NS_IsMainThread()) {
+    nsString msg(aMsg);
+    nsCOMPtr<nsIRunnable> task =
+      NS_NewRunnableFunction([aLogLevel, msg]() { LogToBrowserConsole(aLogLevel, msg); });
+    NS_DispatchToMainThread(task.forget(), NS_DISPATCH_NORMAL);
+    return;
+  }
+
+  nsCOMPtr<nsIConsoleService> console(do_GetService("@mozilla.org/consoleservice;1"));
+  if (!console) {
+    NS_WARNING("Failed to log message to console.");
+    return;
+  }
+
+  nsCOMPtr<nsIScriptError> error(do_CreateInstance(NS_SCRIPTERROR_CONTRACTID));
+  error->Init(aMsg, EmptyString(), EmptyString(), 0, 0, aLogLevel, "chrome javascript");
+  console->LogMessage(error);
+}
+
 } // namespace Common
 } // namespace Telemetry
 } // namespace mozilla
--- a/toolkit/components/telemetry/TelemetryCommon.h
+++ b/toolkit/components/telemetry/TelemetryCommon.h
@@ -3,16 +3,17 @@
  * 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/. */
 
 #ifndef TelemetryCommon_h__
 #define TelemetryCommon_h__
 
 #include "nsTHashtable.h"
 #include "jsapi.h"
+#include "nsIScriptError.h"
 
 namespace mozilla {
 namespace Telemetry {
 namespace Common {
 
 template<class EntryType>
 class AutoHashtable : public nsTHashtable<EntryType>
 {
@@ -45,13 +46,30 @@ AutoHashtable<EntryType>::ReflectIntoJS(
   }
   return true;
 }
 
 bool IsExpiredVersion(const char* aExpiration);
 bool IsInDataset(uint32_t aDataset, uint32_t aContainingDataset);
 bool CanRecordDataset(uint32_t aDataset, bool aCanRecordBase, bool aCanRecordExtended);
 
+/**
+ * Return the number of milliseconds since process start using monotonic
+ * timestamps (unaffected by system clock changes).
+ *
+ * @return NS_OK on success, NS_ERROR_NOT_AVAILABLE if TimeStamp doesn't have the data.
+ */
+nsresult MsSinceProcessStart(double* aResult);
+
+/**
+ * Dumps a log message to the Browser Console using the provided level.
+ *
+ * @param aLogLevel The level to use when displaying the message in the browser console
+ *        (e.g. nsIScriptError::warningFlag, ...).
+ * @param aMsg The text message to print to the console.
+ */
+void LogToBrowserConsole(uint32_t aLogLevel, const nsAString& aMsg);
+
 } // namespace Common
 } // namespace Telemetry
 } // namespace mozilla
 
 #endif // TelemetryCommon_h__
new file mode 100644
--- /dev/null
+++ b/toolkit/components/telemetry/TelemetryEvent.cpp
@@ -0,0 +1,683 @@
+/* -*- 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 "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 "jsapi.h"
+#include "nsJSUtils.h"
+#include "nsXULAppAPI.h"
+#include "nsUTF8Utils.h"
+
+#include "TelemetryCommon.h"
+#include "TelemetryEvent.h"
+#include "TelemetryEventData.h"
+
+using mozilla::StaticMutex;
+using mozilla::StaticMutexAutoLock;
+using mozilla::ArrayLength;
+using mozilla::Maybe;
+using mozilla::Nothing;
+using mozilla::Pair;
+using mozilla::StaticAutoPtr;
+using mozilla::Telemetry::Common::AutoHashtable;
+using mozilla::Telemetry::Common::IsExpiredVersion;
+using mozilla::Telemetry::Common::CanRecordDataset;
+using mozilla::Telemetry::Common::IsInDataset;
+using mozilla::Telemetry::Common::MsSinceProcessStart;
+using mozilla::Telemetry::Common::LogToBrowserConsole;
+
+////////////////////////////////////////////////////////////////////////
+////////////////////////////////////////////////////////////////////////
+//
+// Naming: there are two kinds of functions in this file:
+//
+// * Functions taking a StaticMutexAutoLock: these can only be reached via
+//   an interface function (TelemetryEvent::*). They expect the interface
+//   function to have acquired |gTelemetryEventsMutex|, so they do not
+//   have to be thread-safe.
+//
+// * Functions named TelemetryEvent::*. This is the external interface.
+//   Entries and exits to these functions are serialised using
+//   |gTelemetryEventsMutex|.
+//
+// Avoiding races and deadlocks:
+//
+// All functions in the external interface (TelemetryEvent::*) are
+// serialised using the mutex |gTelemetryEventsMutex|. This means
+// that the external interface is thread-safe, and the internal
+// functions can ignore thread safety. But it also brings a danger
+// of deadlock if any function in the external interface can get back
+// to that interface. That is, we will deadlock on any call chain like
+// this:
+//
+// TelemetryEvent::* -> .. any functions .. -> TelemetryEvent::*
+//
+// To reduce the danger of that happening, observe the following rules:
+//
+// * No function in TelemetryEvent::* may directly call, nor take the
+//   address of, any other function in TelemetryEvent::*.
+//
+// * No internal function may call, nor take the address
+//   of, any function in TelemetryEvent::*.
+
+////////////////////////////////////////////////////////////////////////
+////////////////////////////////////////////////////////////////////////
+//
+// 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.");
+
+// 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 = 10000;
+// Maximum length of any passed value string, in UTF8 byte sequence length.
+const uint32_t kMaxValueByteLength = 100;
+// Maximum length of any string value in the extra dictionary, in UTF8 byte sequence length.
+const uint32_t kMaxExtraValueByteLength = 100;
+
+typedef nsDataHashtable<nsCStringHashKey, uint32_t> EventMapType;
+typedef nsClassHashtable<nsCStringHashKey, nsCString> StringMap;
+
+enum class RecordEventResult {
+  Ok,
+  UnknownEvent,
+  InvalidExtraKey,
+  StorageLimitReached,
+};
+
+struct ExtraEntry {
+  const nsCString key;
+  const nsCString value;
+};
+
+typedef nsTArray<ExtraEntry> ExtraArray;
+
+class EventRecord {
+public:
+  EventRecord(double timestamp, uint32_t eventId, const Maybe<nsCString>& value,
+              const ExtraArray& extra)
+    : mTimestamp(timestamp)
+    , mEventId(eventId)
+    , mValue(value)
+    , mExtra(extra)
+  {}
+
+  EventRecord(const EventRecord& other)
+    : mTimestamp(other.mTimestamp)
+    , mEventId(other.mEventId)
+    , mValue(other.mValue)
+    , mExtra(other.mExtra)
+  {}
+
+  EventRecord& operator=(const EventRecord& other) = delete;
+
+  double Timestamp() const { return mTimestamp; }
+  uint32_t EventId() const { return mEventId; }
+  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 Maybe<nsCString> mValue;
+  const ExtraArray mExtra;
+};
+
+// Implements the methods for EventInfo.
+const char*
+EventInfo::method() const
+{
+  return &gEventsStringTable[this->method_offset];
+}
+
+const char*
+EventInfo::object() const
+{
+  return &gEventsStringTable[this->object_offset];
+}
+
+// Implements the methods for CommonEventInfo.
+const char*
+CommonEventInfo::category() const
+{
+  return &gEventsStringTable[this->category_offset];
+}
+
+const char*
+CommonEventInfo::expiration_version() const
+{
+  return &gEventsStringTable[this->expiration_version_offset];
+}
+
+const char*
+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];
+}
+
+// Implementation for the EventRecord class.
+size_t
+EventRecord::SizeOfExcludingThis(mozilla::MallocSizeOf aMallocSizeOf) const
+{
+  size_t n = 0;
+
+  if (mValue) {
+    n += mValue.value().SizeOfExcludingThisIfUnshared(aMallocSizeOf);
+  }
+
+  n += mExtra.ShallowSizeOfExcludingThis(aMallocSizeOf);
+  for (uint32_t i = 0; i < mExtra.Length(); ++i) {
+    n += mExtra[i].key.SizeOfExcludingThisIfUnshared(aMallocSizeOf);
+    n += mExtra[i].value.SizeOfExcludingThisIfUnshared(aMallocSizeOf);
+  }
+
+  return n;
+}
+
+nsCString
+UniqueEventName(const nsACString& category, const nsACString& method, const nsACString& object)
+{
+  nsCString name;
+  name.Append(category);
+  name.AppendLiteral("#");
+  name.Append(method);
+  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()));
+}
+
+bool
+IsExpiredDate(uint32_t expires_days_since_epoch) {
+  if (expires_days_since_epoch == 0) {
+    return false;
+  }
+
+  const uint32_t days_since_epoch = PR_Now() / (PRTime(PR_USEC_PER_SEC) * 24 * 60 * 60);
+  return expires_days_since_epoch <= days_since_epoch;
+}
+
+void
+TruncateToByteLength(nsCString& str, uint32_t length)
+{
+  // last will be the index of the first byte of the current multi-byte sequence.
+  uint32_t last = RewindToPriorUTF8Codepoint(str.get(), length);
+  str.Truncate(last);
+}
+
+} // anonymous namespace
+
+////////////////////////////////////////////////////////////////////////
+////////////////////////////////////////////////////////////////////////
+//
+// PRIVATE STATE, SHARED BY ALL THREADS
+
+namespace {
+
+// Set to true once this global state has been initialized.
+bool gInitDone = false;
+
+bool gCanRecordBase;
+bool gCanRecordExtended;
+
+// The Name -> ID cache map.
+EventMapType gEventNameIDMap(kEventCount);
+
+// The main event storage. Events are inserted here in recording order.
+StaticAutoPtr<nsTArray<EventRecord>> gEventRecords;
+
+} // namespace
+
+////////////////////////////////////////////////////////////////////////
+////////////////////////////////////////////////////////////////////////
+//
+// PRIVATE: thread-unsafe helpers for event recording.
+
+namespace {
+
+bool
+CanRecordEvent(const StaticMutexAutoLock& lock, const CommonEventInfo& info)
+{
+  if (!gCanRecordBase) {
+    return false;
+  }
+
+  return CanRecordDataset(info.dataset, gCanRecordBase, gCanRecordExtended);
+}
+
+RecordEventResult
+RecordEvent(const StaticMutexAutoLock& lock, double timestamp,
+            const nsACString& category, const nsACString& method,
+            const nsACString& object, const Maybe<nsCString>& value,
+            const ExtraArray& extra)
+{
+  // Apply hard limit on event count in storage.
+  if (gEventRecords->Length() >= kMaxEventRecords) {
+    return RecordEventResult::StorageLimitReached;
+  }
+
+  // Look up the event id.
+  const nsCString& name = UniqueEventName(category, method, object);
+  uint32_t eventId;
+  if (!gEventNameIDMap.Get(name, &eventId)) {
+    return RecordEventResult::UnknownEvent;
+  }
+
+  // If the event is expired, 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) {
+    return RecordEventResult::Ok;
+  }
+
+  // Check whether we can record this event.
+  const CommonEventInfo& common = gEventInfo[eventId].common_info;
+  if (!CanRecordEvent(lock, common)) {
+    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;
+    }
+  }
+
+  // Add event record.
+  gEventRecords->AppendElement(EventRecord(timestamp, eventId, value, extra));
+  return RecordEventResult::Ok;
+}
+
+} // anonymous namespace
+
+////////////////////////////////////////////////////////////////////////
+////////////////////////////////////////////////////////////////////////
+//
+// EXTERNALLY VISIBLE FUNCTIONS in namespace TelemetryEvents::
+
+// This is a StaticMutex rather than a plain Mutex (1) so that
+// it gets initialised in a thread-safe manner the first time
+// it is used, and (2) because it is never de-initialised, and
+// a normal Mutex would show up as a leak in BloatView.  StaticMutex
+// also has the "OffTheBooks" property, so it won't show as a leak
+// in BloatView.
+// Another reason to use a StaticMutex instead of a plain Mutex is
+// that, due to the nature of Telemetry, we cannot rely on having a
+// mutex initialized in InitializeGlobalState. Unfortunately, we
+// cannot make sure that no other function is called before this point.
+static StaticMutex gTelemetryEventsMutex;
+
+void
+TelemetryEvent::InitializeGlobalState(bool aCanRecordBase, bool aCanRecordExtended)
+{
+  StaticMutexAutoLock locker(gTelemetryEventsMutex);
+  MOZ_ASSERT(!gInitDone, "TelemetryEvent::InitializeGlobalState "
+             "may only be called once");
+
+  gCanRecordBase = aCanRecordBase;
+  gCanRecordExtended = aCanRecordExtended;
+
+  gEventRecords = new nsTArray<EventRecord>();
+
+  // Populate the static event name->id cache. Note that the event names are
+  // statically allocated and come from the automatically generated TelemetryEventData.h.
+  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, mark it with a special event id.
+    // This avoids doing expensive expiry checks at runtime.
+    if (IsExpiredVersion(info.common_info.expiration_version()) ||
+        IsExpiredDate(info.common_info.expiration_day)) {
+      eventId = kExpiredEventId;
+    }
+
+    gEventNameIDMap.Put(UniqueEventName(info), eventId);
+  }
+
+#ifdef DEBUG
+  gEventNameIDMap.MarkImmutable();
+#endif
+  gInitDone = true;
+}
+
+void
+TelemetryEvent::DeInitializeGlobalState()
+{
+  StaticMutexAutoLock locker(gTelemetryEventsMutex);
+  MOZ_ASSERT(gInitDone);
+
+  gCanRecordBase = false;
+  gCanRecordExtended = false;
+
+  gEventNameIDMap.Clear();
+  gEventRecords->Clear();
+  gEventRecords = nullptr;
+
+  gInitDone = false;
+}
+
+void
+TelemetryEvent::SetCanRecordBase(bool b)
+{
+  StaticMutexAutoLock locker(gTelemetryEventsMutex);
+  gCanRecordBase = b;
+}
+
+void
+TelemetryEvent::SetCanRecordExtended(bool b) {
+  StaticMutexAutoLock locker(gTelemetryEventsMutex);
+  gCanRecordExtended = b;
+}
+
+nsresult
+TelemetryEvent::RecordEvent(const nsACString& aCategory, const nsACString& aMethod,
+                            const nsACString& aObject, JS::HandleValue aValue,
+                            JS::HandleValue aExtra, JSContext* cx,
+                            uint8_t optional_argc)
+{
+  // Currently only recording in the parent process is supported.
+  if (!XRE_IsParentProcess()) {
+    return NS_OK;
+  }
+
+  // Get the current time.
+  double timestamp = -1;
+  nsresult rv = MsSinceProcessStart(&timestamp);
+  if (NS_FAILED(rv)) {
+    LogToBrowserConsole(nsIScriptError::warningFlag,
+                        NS_LITERAL_STRING("Failed to get time since process start."));
+    return NS_OK;
+  }
+
+  // Check value argument.
+  if ((optional_argc > 0) && !aValue.isNull() && !aValue.isString()) {
+    LogToBrowserConsole(nsIScriptError::warningFlag,
+                        NS_LITERAL_STRING("Invalid type for value parameter."));
+    return NS_OK;
+  }
+
+  // Extract value parameter.
+  Maybe<nsCString> value;
+  if (aValue.isString()) {
+    nsAutoJSString jsStr;
+    if (!jsStr.init(cx, aValue)) {
+      LogToBrowserConsole(nsIScriptError::warningFlag,
+                          NS_LITERAL_STRING("Invalid string value for value parameter."));
+      return NS_OK;
+    }
+
+    nsCString str = NS_ConvertUTF16toUTF8(jsStr);
+    if (str.Length() > kMaxValueByteLength) {
+      LogToBrowserConsole(nsIScriptError::warningFlag,
+                          NS_LITERAL_STRING("Value parameter exceeds maximum string length, truncating."));
+      TruncateToByteLength(str, kMaxValueByteLength);
+    }
+    value = mozilla::Some(str);
+  }
+
+  // Check extra argument.
+  if ((optional_argc > 1) && !aExtra.isNull() && !aExtra.isObject()) {
+    LogToBrowserConsole(nsIScriptError::warningFlag,
+                        NS_LITERAL_STRING("Invalid type for extra parameter."));
+    return NS_OK;
+  }
+
+  // Extract extra dictionary.
+  ExtraArray extra;
+  if (aExtra.isObject()) {
+    JS::RootedObject obj(cx, &aExtra.toObject());
+    JS::Rooted<JS::IdVector> ids(cx, JS::IdVector(cx));
+    if (!JS_Enumerate(cx, obj, &ids)) {
+      LogToBrowserConsole(nsIScriptError::warningFlag,
+                          NS_LITERAL_STRING("Failed to enumerate object."));
+      return NS_OK;
+    }
+
+    for (size_t i = 0, n = ids.length(); i < n; i++) {
+      nsAutoJSString key;
+      if (!key.init(cx, ids[i])) {
+        LogToBrowserConsole(nsIScriptError::warningFlag,
+                            NS_LITERAL_STRING("Extra dictionary should only contain string keys."));
+        return NS_OK;
+      }
+
+      JS::Rooted<JS::Value> value(cx);
+      if (!JS_GetPropertyById(cx, obj, ids[i], &value)) {
+        LogToBrowserConsole(nsIScriptError::warningFlag,
+                            NS_LITERAL_STRING("Failed to get extra property."));
+        return NS_OK;
+      }
+
+      nsAutoJSString jsStr;
+      if (!value.isString() || !jsStr.init(cx, value)) {
+        LogToBrowserConsole(nsIScriptError::warningFlag,
+                            NS_LITERAL_STRING("Extra properties should have string values."));
+        return NS_OK;
+      }
+
+      nsCString str = NS_ConvertUTF16toUTF8(jsStr);
+      if (str.Length() > kMaxExtraValueByteLength) {
+        LogToBrowserConsole(nsIScriptError::warningFlag,
+                            NS_LITERAL_STRING("Extra value exceeds maximum string length, truncating."));
+        TruncateToByteLength(str, kMaxExtraValueByteLength);
+      }
+
+      extra.AppendElement(ExtraEntry{NS_ConvertUTF16toUTF8(key), str});
+    }
+  }
+
+  // Lock for accessing internal data.
+  // While the lock is being held, no complex calls like JS calls can be made,
+  // as all of these could record Telemetry, which would result in deadlock.
+  RecordEventResult res;
+  {
+    StaticMutexAutoLock lock(gTelemetryEventsMutex);
+
+    if (!gInitDone) {
+      return NS_ERROR_FAILURE;
+    }
+
+    res = ::RecordEvent(lock, timestamp, aCategory, aMethod, aObject, value, extra);
+  }
+
+  // Trigger warnings or errors where needed.
+  switch (res) {
+    case RecordEventResult::UnknownEvent:
+      JS_ReportErrorASCII(cx, "Unknown event.");
+      return NS_ERROR_INVALID_ARG;
+    case RecordEventResult::InvalidExtraKey:
+      LogToBrowserConsole(nsIScriptError::warningFlag,
+                          NS_LITERAL_STRING("Invalid extra key for event."));
+      return NS_OK;
+    case RecordEventResult::StorageLimitReached:
+      LogToBrowserConsole(nsIScriptError::warningFlag,
+                          NS_LITERAL_STRING("Event storage limit reached."));
+      return NS_OK;
+    default:
+      return NS_OK;
+  }
+}
+
+nsresult
+TelemetryEvent::CreateSnapshots(uint32_t aDataset, bool aClear, JSContext* cx,
+                                uint8_t optional_argc, JS::MutableHandleValue aResult)
+{
+  // Extract the events from storage.
+  nsTArray<EventRecord> events;
+  {
+    StaticMutexAutoLock locker(gTelemetryEventsMutex);
+
+    if (!gInitDone) {
+      return NS_ERROR_FAILURE;
+    }
+
+    uint32_t len = gEventRecords->Length();
+    for (uint32_t i = 0; i < len; ++i) {
+      const EventRecord& record = (*gEventRecords)[i];
+      const EventInfo& info = gEventInfo[record.EventId()];
+
+      if (IsInDataset(info.common_info.dataset, aDataset)) {
+        events.AppendElement(record);
+      }
+    }
+
+    if (aClear) {
+      gEventRecords->Clear();
+    }
+  }
+
+  // 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 the form:
+    // [timestamp, category, method, object, value, extra]
+    JS::RootedObject itemsArray(cx, JS_NewArrayObject(cx, 6));
+    if (!itemsArray) {
+      return NS_ERROR_FAILURE;
+    }
+
+    // Add timestamp.
+    JS::Rooted<JS::Value> val(cx);
+    uint32_t itemIndex = 0;
+    val.setDouble(floor(record.Timestamp()));
+    if (!JS_DefineElement(cx, itemsArray, itemIndex++, val, JSPROP_ENUMERATE)) {
+      return NS_ERROR_FAILURE;
+    }
+
+    // Add category, method, object.
+    const char* strings[] = {
+      info.common_info.category(),
+      info.method(),
+      info.object(),
+    };
+    for (uint32_t s = 0; s < ArrayLength(strings); ++s) {
+      const NS_ConvertUTF8toUTF16 wide(strings[s]);
+      val.setString(JS_NewUCStringCopyN(cx, wide.Data(), wide.Length()));
+      if (!JS_DefineElement(cx, itemsArray, itemIndex++, val, JSPROP_ENUMERATE)) {
+        return NS_ERROR_FAILURE;
+      }
+    }
+
+    // Add the optional string value.
+    if (!record.Value()) {
+      val.setNull();
+    } else {
+      const NS_ConvertUTF8toUTF16 wide(record.Value().value());
+      val.setString(JS_NewUCStringCopyN(cx, wide.Data(), wide.Length()));
+    }
+    if (!JS_DefineElement(cx, itemsArray, itemIndex++, val, JSPROP_ENUMERATE)) {
+      return NS_ERROR_FAILURE;
+    }
+
+    // Add the optional extra dictionary.
+    if (record.Extra().IsEmpty()) {
+      val.setNull();
+    } else {
+      JS::RootedObject obj(cx, JS_NewPlainObject(cx));
+      if (!obj) {
+        return NS_ERROR_FAILURE;
+      }
+
+      const ExtraArray& extra = record.Extra();
+      for (uint32_t i = 0; i < extra.Length(); ++i) {
+        const NS_ConvertUTF8toUTF16 wide(extra[i].value);
+        JS::Rooted<JS::Value> value(cx);
+        value.setString(JS_NewUCStringCopyN(cx, wide.Data(), wide.Length()));
+
+        if (!JS_DefineProperty(cx, obj, extra[i].key.get(), value, JSPROP_ENUMERATE)) {
+          return NS_ERROR_FAILURE;
+        }
+      }
+      val.setObject(*obj);
+    }
+    if (!JS_DefineElement(cx, itemsArray, itemIndex++, val, JSPROP_ENUMERATE)) {
+      return NS_ERROR_FAILURE;
+    }
+
+    // Add the record to the events array.
+    if (!JS_DefineElement(cx, eventsArray, i, itemsArray, JSPROP_ENUMERATE)) {
+      return NS_ERROR_FAILURE;
+    }
+  }
+
+  aResult.setObject(*eventsArray);
+  return NS_OK;
+}
+
+/**
+ * Resets all the stored events. This is intended to be only used in tests.
+ */
+void
+TelemetryEvent::ClearEvents()
+{
+  StaticMutexAutoLock lock(gTelemetryEventsMutex);
+
+  if (!gInitDone) {
+    return;
+  }
+
+  gEventRecords->Clear();
+}
+
+size_t
+TelemetryEvent::SizeOfIncludingThis(mozilla::MallocSizeOf aMallocSizeOf)
+{
+  StaticMutexAutoLock locker(gTelemetryEventsMutex);
+  size_t n = 0;
+
+  n += gEventRecords->ShallowSizeOfIncludingThis(aMallocSizeOf);
+  for (uint32_t i = 0; i < gEventRecords->Length(); ++i) {
+    n += (*gEventRecords)[i].SizeOfExcludingThis(aMallocSizeOf);
+  }
+
+  n += gEventNameIDMap.ShallowSizeOfExcludingThis(aMallocSizeOf);
+  for (auto iter = gEventNameIDMap.ConstIter(); !iter.Done(); iter.Next()) {
+    n += iter.Key().SizeOfExcludingThisIfUnshared(aMallocSizeOf);
+  }
+
+  return n;
+}
new file mode 100644
--- /dev/null
+++ b/toolkit/components/telemetry/TelemetryEvent.h
@@ -0,0 +1,39 @@
+/* -*-  Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2; -*- */
+/* 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/. */
+
+#ifndef TelemetryEvent_h__
+#define TelemetryEvent_h__
+
+#include "mozilla/TelemetryEventEnums.h"
+
+// This module is internal to Telemetry. It encapsulates Telemetry's
+// event recording and storage logic. It should only be used by
+// Telemetry.cpp. These functions should not be used anywhere else.
+// For the public interface to Telemetry functionality, see Telemetry.h.
+
+namespace TelemetryEvent {
+
+void InitializeGlobalState(bool canRecordBase, bool canRecordExtended);
+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);
+nsresult CreateSnapshots(uint32_t aDataset, bool aClear, JSContext* aCx,
+                         uint8_t optional_argc, JS::MutableHandleValue aResult);
+
+// Only to be used for testing.
+void ClearEvents();
+
+size_t SizeOfIncludingThis(mozilla::MallocSizeOf aMallocSizeOf);
+
+} // namespace TelemetryEvent
+
+#endif // TelemetryEvent_h__
--- a/toolkit/components/telemetry/TelemetryScalar.cpp
+++ b/toolkit/components/telemetry/TelemetryScalar.cpp
@@ -7,32 +7,31 @@
 #include "nsITelemetry.h"
 #include "nsIVariant.h"
 #include "nsVariant.h"
 #include "nsHashKeys.h"
 #include "nsBaseHashtable.h"
 #include "nsClassHashtable.h"
 #include "nsIXPConnect.h"
 #include "nsContentUtils.h"
-#include "nsIConsoleService.h"
-#include "nsIScriptError.h"
 #include "nsThreadUtils.h"
 #include "mozilla/StaticMutex.h"
 #include "mozilla/Unused.h"
 
 #include "TelemetryCommon.h"
 #include "TelemetryScalar.h"
 #include "TelemetryScalarData.h"
 
 using mozilla::StaticMutex;
 using mozilla::StaticMutexAutoLock;
 using mozilla::Telemetry::Common::AutoHashtable;
 using mozilla::Telemetry::Common::IsExpiredVersion;
 using mozilla::Telemetry::Common::CanRecordDataset;
 using mozilla::Telemetry::Common::IsInDataset;
+using mozilla::Telemetry::Common::LogToBrowserConsole;
 
 ////////////////////////////////////////////////////////////////////////
 ////////////////////////////////////////////////////////////////////////
 //
 // Naming: there are two kinds of functions in this file:
 //
 // * Functions named internal_*: these can only be reached via an
 //   interface function (TelemetryScalar::*). They expect the interface
@@ -748,45 +747,16 @@ KeyedScalarStorageMapType gKeyedScalarSt
 // back into the TelemetryScalar interface, hence trying to re-acquire the mutex.
 //
 // This means that these functions potentially race against threads, but
 // that seems preferable to risking deadlock.
 
 namespace {
 
 /**
- * Dumps a log message to the Browser Console using the provided level.
- *
- * @param aLogLevel The level to use when displaying the message in the browser console
- *        (e.g. nsIScriptError::warningFlag, ...).
- * @param aMsg The text message to print to the console.
- */
-void
-internal_LogToBrowserConsole(uint32_t aLogLevel, const nsAString& aMsg)
-{
-  if (!NS_IsMainThread()) {
-    nsString msg(aMsg);
-    nsCOMPtr<nsIRunnable> task =
-      NS_NewRunnableFunction([aLogLevel, msg]() { internal_LogToBrowserConsole(aLogLevel, msg); });
-    NS_DispatchToMainThread(task.forget(), NS_DISPATCH_NORMAL);
-    return;
-  }
-
-  nsCOMPtr<nsIConsoleService> console(do_GetService("@mozilla.org/consoleservice;1"));
-  if (!console) {
-    NS_WARNING("Failed to log message to console.");
-    return;
-  }
-
-  nsCOMPtr<nsIScriptError> error(do_CreateInstance(NS_SCRIPTERROR_CONTRACTID));
-  error->Init(aMsg, EmptyString(), EmptyString(), 0, 0, aLogLevel, "chrome javascript");
-  console->LogMessage(error);
-}
-
-/**
  * Checks if the error should be logged.
  *
  * @param aSr The error code.
  * @return true if the error should be logged, false otherwise.
  */
 bool
 internal_ShouldLogError(ScalarResult aSr)
 {
@@ -836,17 +806,17 @@ internal_LogScalarError(const nsACString
     case ScalarResult::UnsignedTruncatedValue:
       errorMessage.Append(NS_LITERAL_STRING(" - Truncating float/double number."));
       break;
     default:
       // Nothing.
       return;
   }
 
-  internal_LogToBrowserConsole(nsIScriptError::warningFlag, errorMessage);
+  LogToBrowserConsole(nsIScriptError::warningFlag, errorMessage);
 }
 
 } // namespace
 
 ////////////////////////////////////////////////////////////////////////
 ////////////////////////////////////////////////////////////////////////
 //
 // PRIVATE: thread-unsafe helpers for the external interface
--- a/toolkit/components/telemetry/moz.build
+++ b/toolkit/components/telemetry/moz.build
@@ -27,27 +27,29 @@ BROWSER_CHROME_MANIFESTS += ['tests/brow
 
 XPIDL_SOURCES += [
     'nsITelemetry.idl',
 ]
 
 XPIDL_MODULE = 'telemetry'
 
 EXPORTS.mozilla += [
+    '!TelemetryEventEnums.h',
     '!TelemetryHistogramEnums.h',
     '!TelemetryScalarEnums.h',
     'ProcessedStack.h',
     'Telemetry.h',
     'TelemetryComms.h',
     'ThreadHangStats.h',
 ]
 
 SOURCES += [
     'Telemetry.cpp',
     'TelemetryCommon.cpp',
+    'TelemetryEvent.cpp',
     'TelemetryHistogram.cpp',
     'TelemetryScalar.cpp',
     'WebrtcTelemetry.cpp',
 ]
 
 EXTRA_COMPONENTS += [
     'TelemetryStartup.js',
     'TelemetryStartup.manifest'
--- a/toolkit/components/telemetry/nsITelemetry.idl
+++ b/toolkit/components/telemetry/nsITelemetry.idl
@@ -326,17 +326,17 @@ interface nsITelemetry : nsISupports
    * arrays of size three, representing startup, normal, and shutdown stages.
    * Each stage's entry is either null or an array with the layout
    * [total_time, #creates, #reads, #writes, #fsyncs, #stats]
    */
   [implicit_jscontext]
   readonly attribute jsval fileIOReports;
 
   /**
-   * Return the number of seconds since process start using monotonic
+   * Return the number of milliseconds since process start using monotonic
    * timestamps (unaffected by system clock changes).
    * @throws NS_ERROR_NOT_AVAILABLE if TimeStamp doesn't have the data.
    */
   double msSinceProcessStart();
 
   /**
    * Adds the value to the given scalar.
    *
@@ -425,9 +425,45 @@ interface nsITelemetry : nsISupports
    */
   void clearScalars();
 
   /**
    * Immediately sends any Telemetry batched on this process to the parent
    * process. This is intended only to be used on process shutdown.
    */
   void flushBatchedChildTelemetry();
+
+  /**
+   * Record an event in Telemetry.
+   *
+   * @param aCategory The category name.
+   * @param aMethod The method name.
+   * @param aMethod The object name.
+   * @param aValue An optional string value to record.
+   * @param aExtra An optional object of the form (string -> string).
+   *               It should only contain registered extra keys.
+   *
+   * @throws NS_ERROR_INVALID_ARG When trying to record an unknown event.
+   */
+  [implicit_jscontext, optional_argc]
+  void recordEvent(in ACString aCategory, in ACString aMethod, in ACString aObject, [optional] in jsval aValue, [optional] in jsval extra);
+
+  /**
+   * Serializes the recorded events to a JSON-appropriate array and optionally resets them.
+   * The returned structure looks like this:
+   *   [
+   *     // [timestamp, category, method, object, stringValue, extraValues]
+   *     [43245, "category1", "method1", "object1", "string value", null],
+   *     [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);
+
+  /**
+   * Resets all the stored events. This is intended to be only used in tests.
+   */
+  void clearEvents();
 };
new file mode 100644
--- /dev/null
+++ b/toolkit/components/telemetry/tests/unit/test_TelemetryEvents.js
@@ -0,0 +1,232 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/
+*/
+
+const OPTIN = Ci.nsITelemetry.DATASET_RELEASE_CHANNEL_OPTIN;
+const OPTOUT = Ci.nsITelemetry.DATASET_RELEASE_CHANNEL_OPTOUT;
+
+function checkEventFormat(events) {
+  Assert.ok(Array.isArray(events), "Events should be serialized to an array.");
+  for (let e of events) {
+    Assert.ok(Array.isArray(e), "Event should be an array.");
+    Assert.equal(e.length, 6, "Event should have 6 elements.");
+
+    Assert.equal(typeof(e[0]), "number", "Element 0 should be a number.");
+    Assert.equal(typeof(e[1]), "string", "Element 1 should be a string.");
+    Assert.equal(typeof(e[2]), "string", "Element 2 should be a string.");
+    Assert.equal(typeof(e[3]), "string", "Element 3 should be a string.");
+
+    Assert.ok(e[4] === null || typeof(e[4]) == "string",
+              "Event element 4 should be null or a string.");
+    Assert.ok(e[5] === null || typeof(e[5]) == "object",
+              "Event element 4 should be null or an object.");
+
+    let extra = e[5];
+    if (extra) {
+      Assert.ok(Object.keys(extra).every(k => typeof(k) == "string"),
+                "All extra keys should be strings.");
+      Assert.ok(Object.values(extra).every(v => typeof(v) == "string"),
+                "All extra values should be strings.");
+    }
+  }
+}
+
+add_task(function* test_recording() {
+  Telemetry.clearEvents();
+
+  // Record some events.
+  let expected = [
+    {optout: false, event: ["telemetry.test", "test1", "object1"]},
+    {optout: false, event: ["telemetry.test", "test2", "object2"]},
+
+    {optout: false, event: ["telemetry.test", "test1", "object1", "value"]},
+    {optout: false, event: ["telemetry.test", "test1", "object1", "value", null]},
+    {optout: false, event: ["telemetry.test", "test1", "object1", null, {"key1": "value1"}]},
+    {optout: false, event: ["telemetry.test", "test1", "object1", "value", {"key1": "value1", "key2": "value2"}]},
+
+    {optout: true,  event: ["telemetry.test", "test_optout", "object1"]},
+    {optout: false, event: ["telemetry.test.second", "test", "object1"]},
+    {optout: false, event: ["telemetry.test.second", "test", "object1", null, {"key1": "value1"}]},
+  ];
+
+  for (let entry of expected) {
+    entry.tsBefore = Math.floor(Telemetry.msSinceProcessStart());
+    try {
+      Telemetry.recordEvent(...entry.event);
+    } catch (ex) {
+      Assert.ok(false, `Failed to record event ${JSON.stringify(entry.event)}: ${ex}`);
+    }
+    entry.tsAfter = Math.floor(Telemetry.msSinceProcessStart());
+  }
+
+  // The following should not result in any recorded events.
+  Assert.throws(() => Telemetry.recordEvent("unknown.category", "test1", "object1"),
+                /Error: Unknown event\./,
+                "Should throw on unknown category.");
+  Assert.throws(() => Telemetry.recordEvent("telemetry.test", "unknown", "object1"),
+                /Error: Unknown event\./,
+                "Should throw on unknown method.");
+  Assert.throws(() => Telemetry.recordEvent("telemetry.test", "test1", "unknown"),
+                /Error: Unknown event\./,
+                "Should throw on unknown object.");
+
+  let checkEvents = (events, expectedEvents) => {
+    checkEventFormat(events);
+    Assert.equal(events.length, expectedEvents.length,
+                 "Snapshot should have the right number of events.");
+
+    for (let i = 0; i < events.length; ++i) {
+      let {tsBefore, tsAfter} = expectedEvents[i];
+      let ts = events[i][0];
+      Assert.greaterOrEqual(ts, tsBefore, "The recorded timestamp should be greater than the one before recording.");
+      Assert.lessOrEqual(ts, tsAfter, "The recorded timestamp should be less than the one after recording.");
+
+      let recordedData = events[i].slice(1);
+      let expectedData = expectedEvents[i].event.slice();
+      for (let j = expectedData.length; j < 5; ++j) {
+        expectedData.push(null);
+      }
+      Assert.deepEqual(recordedData, expectedData, "The recorded event data should match.");
+    }
+  };
+
+  // Check that the expected events were recorded.
+  let events = Telemetry.snapshotBuiltinEvents(OPTIN, false);
+  checkEvents(events, expected);
+
+  // Check serializing only opt-out events.
+  events = Telemetry.snapshotBuiltinEvents(OPTOUT, false);
+  filtered = expected.filter(e => e.optout == true);
+  checkEvents(events, filtered);
+});
+
+add_task(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 events = Telemetry.snapshotBuiltinEvents(OPTIN, true);
+  Assert.equal(events.length, 2 * COUNT, `Should have recorded ${2 * COUNT} events.`);
+
+  // Now the events should be cleared.
+  events = Telemetry.snapshotBuiltinEvents(OPTIN, false);
+  Assert.equal(events.length, 0, `Should have cleared the events.`);
+});
+
+add_task(function* test_expiry() {
+  Telemetry.clearEvents();
+
+  // Recording call with event that is expired by version.
+  Telemetry.recordEvent("telemetry.test", "test_expired_version", "object1");
+  let events = Telemetry.snapshotBuiltinEvents(OPTIN, true);
+  Assert.equal(events.length, 0, "Should not record event with expired version.");
+
+  // Recording call with event that is expired by date.
+  Telemetry.recordEvent("telemetry.test", "test_expired_date", "object1");
+  events = Telemetry.snapshotBuiltinEvents(OPTIN, true);
+  Assert.equal(events.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", "test_not_expired_optout", "object1");
+  events = Telemetry.snapshotBuiltinEvents(OPTOUT, true);
+  Assert.equal(events.length, 1, "Should record event when date and version are not expired.");
+});
+
+add_task(function* test_invalidParams() {
+  Telemetry.clearEvents();
+
+  // Recording call with wrong type for value argument.
+  Telemetry.recordEvent("telemetry.test", "test1", "object1", 1);
+  let events = Telemetry.snapshotBuiltinEvents(OPTIN, true);
+  Assert.equal(events.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");
+  events = Telemetry.snapshotBuiltinEvents(OPTIN, true);
+  Assert.equal(events.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"});
+  events = Telemetry.snapshotBuiltinEvents(OPTIN, true);
+  Assert.equal(events.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});
+  events = Telemetry.snapshotBuiltinEvents(OPTIN, true);
+  Assert.equal(events.length, 0, "Should not record event when extra argument with invalid value type is passed.");
+});
+
+add_task(function* test_storageLimit() {
+  Telemetry.clearEvents();
+
+  // Record more events than the storage limit allows.
+  let LIMIT = 10000;
+  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 events = Telemetry.snapshotBuiltinEvents(OPTIN, true);
+  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(function* test_valueLimits() {
+  Telemetry.clearEvents();
+
+  // Record values that are at or over the limits for string lengths.
+  let LIMIT = 100;
+  let expected = [
+    ["telemetry.test", "test1", "object1", "a".repeat(LIMIT - 10), null],
+    ["telemetry.test", "test1", "object1", "a".repeat(LIMIT     ), null],
+    ["telemetry.test", "test1", "object1", "a".repeat(LIMIT +  1), null],
+    ["telemetry.test", "test1", "object1", "a".repeat(LIMIT + 10), null],
+
+    ["telemetry.test", "test1", "object1", null, {key1: "a".repeat(LIMIT - 10)}],
+    ["telemetry.test", "test1", "object1", null, {key1: "a".repeat(LIMIT     )}],
+    ["telemetry.test", "test1", "object1", null, {key1: "a".repeat(LIMIT +  1)}],
+    ["telemetry.test", "test1", "object1", null, {key1: "a".repeat(LIMIT + 10)}],
+  ];
+
+  for (let event of expected) {
+    Telemetry.recordEvent(...event);
+    if (event[3]) {
+      event[3] = event[3].substr(0, 100);
+    }
+    if (event[4]) {
+      event[4].key1 = event[4].key1.substr(0, 100);
+    }
+  }
+
+  // Check that the right events were recorded.
+  let events = Telemetry.snapshotBuiltinEvents(OPTIN, true);
+  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.");
+  }
+});
+
+add_task(function* test_unicodeValues() {
+  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 events = Telemetry.snapshotBuiltinEvents(OPTIN, true);
+  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.");
+});
--- a/toolkit/components/telemetry/tests/unit/xpcshell.ini
+++ b/toolkit/components/telemetry/tests/unit/xpcshell.ini
@@ -55,8 +55,9 @@ run-sequentially = Bug 1046307, test can
 [test_ChildHistograms.js]
 skip-if = os == "android"
 tags = addons
 [test_TelemetryReportingPolicy.js]
 tags = addons
 [test_TelemetryScalars.js]
 [test_TelemetryTimestamps.js]
 skip-if = toolkit == 'android'
+[test_TelemetryEvents.js]