Bug 1280234 - Expose Telemetry APIs to trusted WebExtensions; r=aswan,gfritzsche
authorJared Hirsch <ohai@6a68.net>
Fri, 13 Jul 2018 12:35:34 -0700
changeset 486743 d3f55ddb602b2c123bfb7eef184e4ff8607bdd19
parent 486742 73efbce701a9880e35a5139dc67757fa68ad892a
child 486744 fb0c0ffeec57fee01a9a75e95ecbd4f2942f7ab3
push id9719
push userffxbld-merge
push dateFri, 24 Aug 2018 17:49:46 +0000
treeherdermozilla-beta@719ec98fba77 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersaswan, gfritzsche
bugs1280234
milestone63.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 1280234 - Expose Telemetry APIs to trusted WebExtensions; r=aswan,gfritzsche MozReview-Commit-ID: 4uQBq3Qvj0M
toolkit/components/extensions/ext-toolkit.json
toolkit/components/extensions/jar.mn
toolkit/components/extensions/parent/ext-telemetry.js
toolkit/components/extensions/schemas/jar.mn
toolkit/components/extensions/schemas/telemetry.json
toolkit/components/extensions/test/xpcshell/test_ext_permissions.js
toolkit/components/extensions/test/xpcshell/test_ext_telemetry.js
toolkit/components/extensions/test/xpcshell/xpcshell-common.ini
--- a/toolkit/components/extensions/ext-toolkit.json
+++ b/toolkit/components/extensions/ext-toolkit.json
@@ -155,16 +155,24 @@
   "storage": {
     "url": "chrome://extensions/content/parent/ext-storage.js",
     "schema": "chrome://extensions/content/schemas/storage.json",
     "scopes": ["addon_parent", "content_parent", "devtools_parent"],
     "paths": [
       ["storage"]
     ]
   },
+  "telemetry": {
+    "url": "chrome://extensions/content/parent/ext-telemetry.js",
+    "schema": "chrome://extensions/content/schemas/telemetry.json",
+    "scopes": ["addon_parent"],
+    "paths": [
+      ["telemetry"]
+    ]
+  },
   "test": {
     "schema": "chrome://extensions/content/schemas/test.json",
     "scopes": ["content_child"]
   },
   "theme": {
     "url": "chrome://extensions/content/parent/ext-theme.js",
     "schema": "chrome://extensions/content/schemas/theme.json",
     "scopes": ["addon_parent"],
--- a/toolkit/components/extensions/jar.mn
+++ b/toolkit/components/extensions/jar.mn
@@ -26,16 +26,17 @@ toolkit.jar:
     content/extensions/parent/ext-notifications.js (parent/ext-notifications.js)
     content/extensions/parent/ext-permissions.js (parent/ext-permissions.js)
     content/extensions/parent/ext-privacy.js (parent/ext-privacy.js)
     content/extensions/parent/ext-protocolHandlers.js (parent/ext-protocolHandlers.js)
     content/extensions/parent/ext-proxy.js (parent/ext-proxy.js)
     content/extensions/parent/ext-runtime.js (parent/ext-runtime.js)
     content/extensions/parent/ext-storage.js (parent/ext-storage.js)
     content/extensions/parent/ext-tabs-base.js (parent/ext-tabs-base.js)
+    content/extensions/parent/ext-telemetry.js (parent/ext-telemetry.js)
     content/extensions/parent/ext-theme.js (parent/ext-theme.js)
     content/extensions/parent/ext-toolkit.js (parent/ext-toolkit.js)
     content/extensions/parent/ext-topSites.js (parent/ext-topSites.js)
     content/extensions/parent/ext-webRequest.js (parent/ext-webRequest.js)
     content/extensions/parent/ext-webNavigation.js (parent/ext-webNavigation.js)
     content/extensions/child/ext-backgroundPage.js (child/ext-backgroundPage.js)
     content/extensions/child/ext-contentScripts.js (child/ext-contentScripts.js)
     content/extensions/child/ext-extension.js (child/ext-extension.js)
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/parent/ext-telemetry.js
@@ -0,0 +1,115 @@
+"use strict";
+
+ChromeUtils.defineModuleGetter(this, "TelemetryController",
+                               "resource://gre/modules/TelemetryController.jsm");
+ChromeUtils.defineModuleGetter(this, "TelemetryUtils",
+                               "resource://gre/modules/TelemetryUtils.jsm");
+ChromeUtils.defineModuleGetter(this, "Services",
+                               "resource://gre/modules/Services.jsm");
+ChromeUtils.defineModuleGetter(this, "ExtensionUtils",
+                               "resource://gre/modules/ExtensionUtils.jsm");
+
+const SCALAR_TYPES = {
+  count: Ci.nsITelemetry.SCALAR_TYPE_COUNT,
+  string: Ci.nsITelemetry.SCALAR_TYPE_STRING,
+  boolean: Ci.nsITelemetry.SCALAR_TYPE_BOOLEAN,
+};
+
+// Currently unsupported on Android: blocked on 1220177.
+// See 1280234 c67 for discussion.
+function desktopCheck() {
+  if (AppConstants.MOZ_BUILD_APP !== "browser") {
+    throw new ExtensionUtils.ExtensionError("This API is only supported on desktop");
+  }
+}
+
+this.telemetry = class extends ExtensionAPI {
+  getAPI(context) {
+    return {
+      telemetry: {
+        submitPing(type, payload, options) {
+          desktopCheck();
+          try {
+            TelemetryController.submitExternalPing(type, payload, options);
+          } catch (ex) {
+            throw new ExtensionUtils.ExtensionError(ex);
+          }
+        },
+        canUpload() {
+          desktopCheck();
+          // Note: remove the ternary and direct pref check when
+          // TelemetryController.canUpload() is implemented (bug 1440089).
+          try {
+            const result = ("canUpload" in TelemetryController) ?
+              TelemetryController.canUpload() :
+              Services.prefs.getBoolPref(TelemetryUtils.Preferences.FhrUploadEnabled, false);
+            return result;
+          } catch (ex) {
+            throw new ExtensionUtils.ExtensionError(ex);
+          }
+        },
+        scalarAdd(name, value) {
+          desktopCheck();
+          try {
+            Services.telemetry.scalarAdd(name, value);
+          } catch (ex) {
+            throw new ExtensionUtils.ExtensionError(ex);
+          }
+        },
+        scalarSet(name, value) {
+          desktopCheck();
+          try {
+            Services.telemetry.scalarSet(name, value);
+          } catch (ex) {
+            throw new ExtensionUtils.ExtensionError(ex);
+          }
+        },
+        scalarSetMaximum(name, value) {
+          desktopCheck();
+          try {
+            Services.telemetry.scalarSetMaximum(name, value);
+          } catch (ex) {
+            throw new ExtensionUtils.ExtensionError(ex);
+          }
+        },
+        recordEvent(category, method, object, value, extra) {
+          desktopCheck();
+          try {
+            Services.telemetry.recordEvent(category, method, object, value, extra);
+          } catch (ex) {
+            throw new ExtensionUtils.ExtensionError(ex);
+          }
+        },
+        registerScalars(category, data) {
+          desktopCheck();
+          try {
+            // For each scalar in `data`, replace scalar.kind with
+            // the appropriate nsITelemetry constant.
+            Object.keys(data).forEach(scalar => {
+              data[scalar].kind = SCALAR_TYPES[data[scalar].kind];
+            });
+            Services.telemetry.registerScalars(category, data);
+          } catch (ex) {
+            throw new ExtensionUtils.ExtensionError(ex);
+          }
+        },
+        setEventRecordingEnabled(category, enabled) {
+          desktopCheck();
+          try {
+            Services.telemetry.setEventRecordingEnabled(category, enabled);
+          } catch (ex) {
+            throw new ExtensionUtils.ExtensionError(ex);
+          }
+        },
+        registerEvents(category, data) {
+          desktopCheck();
+          try {
+            Services.telemetry.registerEvents(category, data);
+          } catch (ex) {
+            throw new ExtensionUtils.ExtensionError(ex);
+          }
+        },
+      },
+    };
+  }
+};
--- a/toolkit/components/extensions/schemas/jar.mn
+++ b/toolkit/components/extensions/schemas/jar.mn
@@ -26,14 +26,15 @@ toolkit.jar:
     content/extensions/schemas/manifest.json
     content/extensions/schemas/native_manifest.json
     content/extensions/schemas/notifications.json
     content/extensions/schemas/permissions.json
     content/extensions/schemas/proxy.json
     content/extensions/schemas/privacy.json
     content/extensions/schemas/runtime.json
     content/extensions/schemas/storage.json
+    content/extensions/schemas/telemetry.json
     content/extensions/schemas/test.json
     content/extensions/schemas/theme.json
     content/extensions/schemas/top_sites.json
     content/extensions/schemas/types.json
     content/extensions/schemas/web_navigation.json
     content/extensions/schemas/web_request.json
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/schemas/telemetry.json
@@ -0,0 +1,301 @@
+[
+  {
+    "namespace": "manifest",
+    "types": [{
+      "$extend": "Permission",
+      "choices": [{
+        "type": "string",
+        "enum": [
+          "telemetry"
+        ]
+      }]
+    }]
+  },
+  {
+    "namespace": "telemetry",
+    "description": "Use the <code>browser.telemetry</code> API to send telemetry data to the Mozilla Telemetry service. Restricted to Mozilla privileged webextensions.",
+    "types": [{
+      "id": "ScalarType",
+      "type": "string",
+      "enum": ["count", "string", "boolean"],
+      "description": "Type of scalar: 'count' for numeric values, 'string' for string values, 'boolean' for boolean values. Maps to <code>nsITelemetry.SCALAR_TYPE_*</code>."
+    }, {
+      "id": "ScalarData",
+      "type": "object",
+      "description": "Represents registration data for a Telemetry scalar.",
+      "properties": {
+        "kind": {
+          "$ref": "ScalarType"
+        },
+        "keyed": {
+          "type": "boolean",
+          "optional": true,
+          "default": false,
+          "description": "True if this is a keyed scalar."
+        },
+        "record_on_release": {
+          "type": "boolean",
+          "optional": true,
+          "default": false,
+          "description": "True if this data should be recorded on release."
+        },
+        "expired": {
+          "type": "boolean",
+          "optional": true,
+          "default": false,
+          "description": "True if this scalar entry is expired. This allows recording it without error, but it will be discarded."
+        }
+      }
+    }, {
+      "id": "EventData",
+      "type": "object",
+      "description": "Represents registration data for a Telemetry event.",
+      "properties": {
+        "methods": {
+          "type": "array",
+          "items": { "type": "string" },
+          "description": "List of methods for this event entry."
+        },
+        "objects": {
+          "type": "array",
+          "items": { "type": "string" },
+          "description": "List of objects for this event entry."
+        },
+        "extra_keys": {
+          "type": "array",
+          "items": { "type": "string" },
+          "description": "List of allowed extra keys for this event entry."
+        },
+        "record_on_release": {
+          "type": "boolean",
+          "optional": true,
+          "default": false,
+          "description": "True if this data should be recorded on release."
+        },
+        "expired": {
+          "type": "boolean",
+          "optional": true,
+          "default": false,
+          "description": "True if this event entry is expired. This allows recording it without error, but it will be discarded."
+        }
+      }
+    }],
+    "permissions": [
+      "telemetry",
+      "mozillaAddons"
+    ],
+    "functions": [{
+      "name": "submitPing",
+      "type": "function",
+      "description": "Submits a custom ping to the Telemetry back-end. See <code>submitExternalPing</code> inside TelemetryController.jsm for more details.",
+      "async": true,
+      "parameters": [
+        {
+          "name": "type",
+          "type": "string",
+          "pattern": "^[a-z0-9][a-z0-9-]+[a-z0-9]$",
+          "description": "The type of the ping."
+        },
+        {
+          "name": "message",
+          "type": "object",
+          "additionalProperties": { "type": "any" },
+          "description": "The data payload for the ping."
+        },
+        {
+          "description": "Options object.",
+          "name": "options",
+          "type": "object",
+          "properties": {
+            "addClientId": {
+              "type": "boolean",
+              "optional": true,
+              "default": false,
+              "description": "True if the ping should contain the client id."
+            },
+            "addEnvironment": {
+              "type": "boolean",
+              "optional": true,
+              "default": false,
+              "description": "True if the ping should contain the environment data."
+            },
+            "overrideEnvironment": {
+              "type": "object",
+              "additionalProperties": { "type": "any" },
+              "optional": true,
+              "default": false,
+              "description": "Set to override the environment data."
+            },
+            "usePingSender": {
+              "type": "boolean",
+              "optional": true,
+              "default": false,
+              "description": "If true, send the ping using the PingSender."
+            }
+          }
+        }
+      ]
+    },
+    {
+      "name": "canUpload",
+      "type": "function",
+      "description": "Checks if Telemetry is enabled.",
+      "parameters": [],
+      "async": true
+    },
+    {
+      "name": "scalarAdd",
+      "type": "function",
+      "description": "Adds the value to the given scalar.",
+      "async": true,
+      "parameters": [
+        {
+          "name": "name",
+          "type": "string",
+          "description": "The scalar name."
+        },
+        {
+          "name": "value",
+          "type": "integer",
+          "minimum": 1,
+          "description": "The numeric value to add to the scalar. Only unsigned integers supported."
+        }
+      ]
+    },
+    {
+      "name": "scalarSet",
+      "type": "function",
+      "description": "Sets the named scalar to the given value. Throws if the value type doesn't match the scalar type.",
+      "async": true,
+      "parameters": [
+        {
+          "name": "name",
+          "type": "string",
+          "description": "The scalar name"
+        },
+        {
+          "name": "value",
+          "description": "The value to set the scalar to",
+          "choices": [
+            { "type": "string" },
+            { "type": "boolean" },
+            { "type": "integer" },
+            { "type": "object", "additionalProperties": { "type": "any" } }
+          ]
+        }
+      ]
+    },
+    {
+      "name": "scalarSetMaximum",
+      "type": "function",
+      "description": "Sets the scalar to the maximum of the current and the passed value",
+      "async": true,
+      "parameters": [
+        {
+          "name": "name",
+          "type": "string",
+          "description": "The scalar name."
+        },
+        {
+          "name": "value",
+          "type": "integer",
+          "minimum": 0,
+          "description": "The numeric value to set the scalar to. Only unsigned integers supported."
+        }
+      ]
+    },
+    {
+      "name": "recordEvent",
+      "type": "function",
+      "description": "Record an event in Telemetry. Throws when trying to record an unknown event.",
+      "async": true,
+      "parameters": [
+        {
+          "name": "category",
+          "type": "string",
+          "description": "The category name."
+        },
+        {
+          "name": "method",
+          "type": "string",
+          "description": "The method name."
+        },
+        {
+          "name": "object",
+          "type": "string",
+          "description": "The object name."
+        },
+        {
+          "name": "value",
+          "type": "integer",
+          "optional": true,
+          "description": "An optional string value to record."
+        },
+        {
+          "name": "extra",
+          "type": "object",
+          "optional": true,
+          "description": "An optional object of the form (string -> string). It should only contain registered extra keys.",
+          "additionalProperties": { "type": "string" }
+        }
+      ]
+    },
+
+    {
+      "name": "registerScalars",
+      "type": "function",
+      "description": "Register new scalars to record them from addons. See nsITelemetry.idl for more details.",
+      "async": true,
+      "parameters": [
+        {
+          "name": "category",
+          "type": "string",
+          "description": "The unique category the scalars are registered in."
+        },
+        {
+          "name": "data",
+          "type": "object",
+          "additionalProperties": { "$ref": "ScalarData" },
+          "description": "An object that contains registration data for multiple scalars. Each property name is the scalar name, and the corresponding property value is an object of ScalarData type."
+        }
+      ]
+    },
+    {
+      "name": "registerEvents",
+      "type": "function",
+      "description": "Register new events to record them from addons. See nsITelemetry.idl for more details.",
+      "async": true,
+      "parameters": [
+        {
+          "name": "category",
+          "type": "string",
+          "description": "The unique category the events are registered in."
+        },
+        {
+          "name": "data",
+          "type": "object",
+          "additionalProperties": { "$ref": "EventData" },
+          "description": "An object that contains registration data for 1+ events. Each property name is the category name, and the corresponding property value is an object of EventData type."
+        }
+      ]
+    },
+    {
+      "name": "setEventRecordingEnabled",
+      "type": "function",
+      "description": "Enable recording of events in a category. Events default to recording disabled. This allows to toggle recording for all events in the specified category.",
+      "async": true,
+      "parameters": [
+        {
+          "name": "category",
+          "type": "string",
+          "description": "The category name."
+        },
+        {
+          "name": "enabled",
+          "type": "boolean",
+          "description": "Whether recording is enabled for events in that category."
+        }
+      ]
+    }]
+  }
+]
--- a/toolkit/components/extensions/test/xpcshell/test_ext_permissions.js
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_permissions.js
@@ -407,16 +407,17 @@ const GRANTED_WITHOUT_USER_PROMPT = [
   "cookies",
   "geckoProfiler",
   "identity",
   "idle",
   "menus",
   "mozillaAddons",
   "search",
   "storage",
+  "telemetry",
   "theme",
   "webRequest",
   "webRequestBlocking",
 ];
 
 add_task(function test_permissions_have_localization_strings() {
   const ns = Schemas.getNamespace("manifest");
 
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_telemetry.js
@@ -0,0 +1,365 @@
+"use strict";
+
+ChromeUtils.import("resource://gre/modules/TelemetryArchive.jsm", this);
+ChromeUtils.import("resource://gre/modules/TelemetryUtils.jsm", this);
+
+function createExtension(backgroundScript, permissions) {
+  let extensionData = {
+    background: backgroundScript,
+    manifest: {permissions},
+  };
+  return ExtensionTestUtils.loadExtension(extensionData);
+}
+
+async function run(test) {
+  let extension = createExtension(test.backgroundScript, test.permissions || ["telemetry"]);
+  await extension.startup();
+  await extension.awaitFinish(test.doneSignal);
+  await extension.unload();
+}
+
+// Currently unsupported on Android: blocked on 1220177.
+// See 1280234 c67 for discussion.
+if (AppConstants.MOZ_BUILD_APP === "browser") {
+  add_task(async function test_telemetry_without_telemetry_permission() {
+    await run({
+      backgroundScript: () => {
+        browser.test.assertTrue(!browser.telemetry, "'telemetry' permission is required");
+        browser.test.notifyPass("telemetry_permission");
+      },
+      permissions: [],
+      doneSignal: "telemetry_permission",
+    });
+  });
+
+  add_task(async function test_telemetry_scalar_add() {
+    Services.telemetry.clearScalars();
+
+    await run({
+      backgroundScript: async () => {
+        await browser.telemetry.scalarAdd("telemetry.test.unsigned_int_kind", 1);
+        browser.test.notifyPass("scalar_add");
+      },
+      doneSignal: "scalar_add",
+    });
+
+    const scalars = Services.telemetry.snapshotScalars(Ci.nsITelemetry.DATASET_RELEASE_CHANNEL_OPTIN);
+    equal(scalars.parent["telemetry.test.unsigned_int_kind"], 1);
+
+    Services.telemetry.clearScalars();
+  });
+
+  add_task(async function test_telemetry_scalar_add_unknown_name() {
+    let {messages} = await promiseConsoleOutput(async () => {
+      await run({
+        backgroundScript: async () => {
+          await browser.telemetry.scalarAdd("telemetry.test.does_not_exist", 1);
+          browser.test.notifyPass("scalar_add_unknown_name");
+        },
+        doneSignal: "scalar_add_unknown_name",
+      });
+    });
+
+    messages = messages.filter(msg => /telemetry.test.does_not_exist - Unknown scalar./);
+    equal(messages.length, 1, "Telemetry should throw if an unknown scalar is incremented");
+  });
+
+  add_task(async function test_telemetry_scalar_add_illegal_value() {
+    await run({
+      backgroundScript: () => {
+        browser.test.assertThrows(
+          () => browser.telemetry.scalarAdd("telemetry.test.unsigned_int_kind", {}),
+          /Incorrect argument types for telemetry.scalarAdd/,
+          "The second 'value' argument to scalarAdd must be an integer, string, or boolean"
+        );
+        browser.test.notifyPass("scalar_add_illegal_value");
+      },
+      doneSignal: "scalar_add_illegal_value",
+    });
+  });
+
+  add_task(async function test_telemetry_scalar_add_invalid_keyed_scalar() {
+    let {messages} = await promiseConsoleOutput(async function() {
+      await run({
+        backgroundScript: async () => {
+          await browser.telemetry.scalarAdd("telemetry.test.keyed_unsigned_int", 1);
+          browser.test.notifyPass("scalar_add_invalid_keyed_scalar");
+        },
+        doneSignal: "scalar_add_invalid_keyed_scalar",
+      });
+    });
+
+    messages = messages.filter(msg => /Attempting to manage a keyed scalar as a scalar/);
+    equal(messages.length, 1, "Telemetry should throw if a keyed scalar is incremented");
+  });
+
+  add_task(async function test_telemetry_scalar_set() {
+    Services.telemetry.clearScalars();
+
+    await run({
+      backgroundScript: async () => {
+        await browser.telemetry.scalarSet("telemetry.test.boolean_kind", true);
+        browser.test.notifyPass("scalar_set");
+      },
+      doneSignal: "scalar_set",
+    });
+
+    const scalars = Services.telemetry.snapshotScalars(Ci.nsITelemetry.DATASET_RELEASE_CHANNEL_OPTIN);
+    equal(scalars.parent["telemetry.test.boolean_kind"], true);
+
+    Services.telemetry.clearScalars();
+  });
+
+  add_task(async function test_telemetry_scalar_set_unknown_name() {
+    let {messages} = await promiseConsoleOutput(async function() {
+      await run({
+        backgroundScript: async () => {
+          await browser.telemetry.scalarSet("telemetry.test.does_not_exist", true);
+          browser.test.notifyPass("scalar_set_unknown_name");
+        },
+        doneSignal: "scalar_set_unknown_name",
+      });
+    });
+
+    messages = messages.filter(msg => /Unknown scalar/);
+    equal(messages.length, 1, "Telemetry should throw if an unknown scalar is set");
+  });
+
+  add_task(async function test_telemetry_scalar_set_maximum() {
+    Services.telemetry.clearScalars();
+
+    await run({
+      backgroundScript: async () => {
+        await browser.telemetry.scalarSetMaximum("telemetry.test.unsigned_int_kind", 123);
+        browser.test.notifyPass("scalar_set_maximum");
+      },
+      doneSignal: "scalar_set_maximum",
+    });
+
+    const scalars = Services.telemetry.snapshotScalars(Ci.nsITelemetry.DATASET_RELEASE_CHANNEL_OPTIN);
+    equal(scalars.parent["telemetry.test.unsigned_int_kind"], 123);
+
+    Services.telemetry.clearScalars();
+  });
+
+  add_task(async function test_telemetry_scalar_set_maximum_unknown_name() {
+    let {messages} = await promiseConsoleOutput(async function() {
+      await run({
+        backgroundScript: async () => {
+          await browser.telemetry.scalarSetMaximum("telemetry.test.does_not_exist", 1);
+          browser.test.notifyPass("scalar_set_maximum_unknown_name");
+        },
+        doneSignal: "scalar_set_maximum_unknown_name",
+      });
+    });
+
+    messages = messages.filter(msg => /Unknown scalar/);
+    equal(messages.length, 1, "Telemetry should throw if an unknown scalar is set");
+  });
+
+  add_task(async function test_telemetry_scalar_set_maximum_illegal_value() {
+    await run({
+      backgroundScript: () => {
+        browser.test.assertThrows(
+          () => browser.telemetry.scalarSetMaximum("telemetry.test.unsigned_int_kind", "string"),
+          /Incorrect argument types for telemetry.scalarSetMaximum/,
+          "The second 'value' argument to scalarSetMaximum must be a scalar");
+        browser.test.notifyPass("scalar_set_maximum_illegal_value");
+      },
+      doneSignal: "scalar_set_maximum_illegal_value",
+    });
+  });
+
+  add_task(async function test_telemetry_record_event() {
+    Services.telemetry.clearEvents();
+    Services.telemetry.setEventRecordingEnabled("telemetry.test", true);
+
+    await run({
+      backgroundScript: async () => {
+        await browser.telemetry.recordEvent("telemetry.test", "test1", "object1");
+        browser.test.notifyPass("record_event_ok");
+      },
+      doneSignal: "record_event_ok",
+    });
+
+    let events = Services.telemetry.snapshotEvents(Ci.nsITelemetry.DATASET_RELEASE_CHANNEL_OPTIN, true);
+    equal(events.parent.length, 1);
+    equal(events.parent[0][1], "telemetry.test");
+
+    Services.telemetry.setEventRecordingEnabled("telemetry.test", false);
+    Services.telemetry.clearEvents();
+  });
+
+  add_task(async function test_telemetry_register_scalars_string() {
+    Services.telemetry.clearScalars();
+
+    await run({
+      backgroundScript: async () => {
+        await browser.telemetry.registerScalars("telemetry.test.dynamic", {
+          "webext_string": {
+            kind: browser.telemetry.ScalarType.STRING,
+            keyed: false,
+            record_on_release: true,
+          },
+        });
+        await browser.telemetry.scalarSet("telemetry.test.dynamic.webext_string", "hello");
+        browser.test.notifyPass("register_scalars_string");
+      },
+      doneSignal: "register_scalars_string",
+    });
+
+    const scalars = Services.telemetry.snapshotScalars(Ci.nsITelemetry.DATASET_RELEASE_CHANNEL_OPTIN);
+    equal(scalars.dynamic["telemetry.test.dynamic.webext_string"], "hello");
+
+    Services.telemetry.clearScalars();
+  });
+
+  add_task(async function test_telemetry_register_scalars_multiple() {
+    Services.telemetry.clearScalars();
+
+    await run({
+      backgroundScript: async () => {
+        await browser.telemetry.registerScalars("telemetry.test.dynamic", {
+          "webext_string": {
+            kind: browser.telemetry.ScalarType.STRING,
+            keyed: false,
+            record_on_release: true,
+          },
+          "webext_string_too": {
+            kind: browser.telemetry.ScalarType.STRING,
+            keyed: false,
+            record_on_release: true,
+          },
+        });
+        await browser.telemetry.scalarSet("telemetry.test.dynamic.webext_string", "hello");
+        await browser.telemetry.scalarSet("telemetry.test.dynamic.webext_string_too", "world");
+        browser.test.notifyPass("register_scalars_multiple");
+      },
+      doneSignal: "register_scalars_multiple",
+    });
+
+    const scalars = Services.telemetry.snapshotScalars(Ci.nsITelemetry.DATASET_RELEASE_CHANNEL_OPTIN);
+    equal(scalars.dynamic["telemetry.test.dynamic.webext_string"], "hello");
+    equal(scalars.dynamic["telemetry.test.dynamic.webext_string_too"], "world");
+
+    Services.telemetry.clearScalars();
+  });
+
+  add_task(async function test_telemetry_register_scalars_boolean() {
+    Services.telemetry.clearScalars();
+
+    await run({
+      backgroundScript: async () => {
+        await browser.telemetry.registerScalars("telemetry.test.dynamic", {
+          "webext_boolean": {
+            kind: browser.telemetry.ScalarType.BOOLEAN,
+            keyed: false,
+            record_on_release: true,
+          },
+        });
+        await browser.telemetry.scalarSet("telemetry.test.dynamic.webext_boolean", true);
+        browser.test.notifyPass("register_scalars_boolean");
+      },
+      doneSignal: "register_scalars_boolean",
+    });
+
+    const scalars = Services.telemetry.snapshotScalars(Ci.nsITelemetry.DATASET_RELEASE_CHANNEL_OPTIN);
+    equal(scalars.dynamic["telemetry.test.dynamic.webext_boolean"], true);
+
+    Services.telemetry.clearScalars();
+  });
+
+  add_task(async function test_telemetry_register_scalars_count() {
+    Services.telemetry.clearScalars();
+
+    await run({
+      backgroundScript: async () => {
+        await browser.telemetry.registerScalars("telemetry.test.dynamic", {
+          "webext_count": {
+            kind: browser.telemetry.ScalarType.COUNT,
+            keyed: false,
+            record_on_release: true,
+          },
+        });
+        await browser.telemetry.scalarSet("telemetry.test.dynamic.webext_count", 123);
+        browser.test.notifyPass("register_scalars_count");
+      },
+      doneSignal: "register_scalars_count",
+    });
+
+    const scalars = Services.telemetry.snapshotScalars(Ci.nsITelemetry.DATASET_RELEASE_CHANNEL_OPTIN);
+    equal(scalars.dynamic["telemetry.test.dynamic.webext_count"], 123);
+
+    Services.telemetry.clearScalars();
+  });
+
+  add_task(async function test_telemetry_register_events() {
+    Services.telemetry.clearEvents();
+
+    await run({
+      backgroundScript: async () => {
+        await browser.telemetry.registerEvents("telemetry.test.dynamic", {
+          "test1": {
+            methods: ["test1"],
+            objects: ["object1"],
+            extra_keys: [],
+          },
+        });
+        await browser.telemetry.recordEvent("telemetry.test.dynamic", "test1", "object1");
+        browser.test.notifyPass("register_events");
+      },
+      doneSignal: "register_events",
+    });
+
+    let events = Services.telemetry.snapshotEvents(Ci.nsITelemetry.DATASET_RELEASE_CHANNEL_OPTIN);
+    let expected = [["telemetry.test.dynamic", "test1", "object1"]];
+    equal(events.dynamic.length, expected.length);
+    deepEqual(events.dynamic.map(e => e.slice(1)), expected);
+
+    Services.telemetry.clearEvents();
+  });
+
+  add_task(async function test_telemetry_submit_ping() {
+    await run({
+      backgroundScript: async () => {
+        await browser.telemetry.submitPing("webext-test", {}, {});
+        browser.test.notifyPass("submit_ping");
+      },
+      doneSignal: "submit_ping",
+    });
+
+    let pings = await TelemetryArchive.promiseArchivedPingList();
+    equal(pings.length, 1);
+    equal(pings[0].type, "webext-test");
+  });
+
+  add_task(async function test_telemetry_can_upload_enabled() {
+    Services.prefs.setBoolPref(TelemetryUtils.Preferences.FhrUploadEnabled, true);
+
+    await run({
+      backgroundScript: async () => {
+        const result = await browser.telemetry.canUpload();
+        browser.test.assertTrue(result);
+        browser.test.notifyPass("can_upload_enabled");
+      },
+      doneSignal: "can_upload_enabled",
+    });
+
+    Services.prefs.clearUserPref(TelemetryUtils.Preferences.FhrUploadEnabled);
+  });
+
+  add_task(async function test_telemetry_can_upload_disabled() {
+    Services.prefs.setBoolPref(TelemetryUtils.Preferences.FhrUploadEnabled, false);
+
+    await run({
+      backgroundScript: async () => {
+        const result = await browser.telemetry.canUpload();
+        browser.test.assertFalse(result);
+        browser.test.notifyPass("can_upload_disabled");
+      },
+      doneSignal: "can_upload_disabled",
+    });
+
+    Services.prefs.clearUserPref(TelemetryUtils.Preferences.FhrUploadEnabled);
+  });
+}
--- a/toolkit/components/extensions/test/xpcshell/xpcshell-common.ini
+++ b/toolkit/components/extensions/test/xpcshell/xpcshell-common.ini
@@ -112,16 +112,17 @@ head = head.js head_sync.js
 skip-if = appname == "thunderbird" || os == "android"
 [test_ext_storage_sync_crypto.js]
 skip-if = appname == "thunderbird" || os == "android"
 [test_ext_storage_tab.js]
 [test_ext_storage_telemetry.js]
 skip-if = os == "android" # checking for telemetry needs to be updated: 1384923
 [test_ext_tab_teardown.js]
 skip-if = os == 'android' # Bug 1258975 on android.
+[test_ext_telemetry.js]
 [test_ext_trustworthy_origin.js]
 [test_ext_topSites.js]
 skip-if = os == "android"
 [test_ext_unload_frame.js]
 skip-if = true # Too frequent intermittent failures
 [test_ext_webRequest_auth.js]
 [test_ext_webRequest_filterResponseData.js]
 [test_ext_webRequest_permission.js]