Bug 1280234 - Expose Telemetry APIs to trusted WebExtensions; r=aswan draft
authorJared Hirsch <ohai@6a68.net>
Fri, 13 Jul 2018 12:35:34 -0700
changeset 829403 1aa5e7ac74fec495e78d819c8e3538a067b6102e
parent 828887 8b39d1161075364a95bc2d1577b389411fe5c342
push id118777
push userbmo:jhirsch@mozilla.com
push dateWed, 15 Aug 2018 19:15:39 +0000
reviewersaswan
bugs1280234
milestone63.0a1
Bug 1280234 - Expose Telemetry APIs to trusted WebExtensions; r=aswan 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]