Bug 718067 - Part 2: Define types for representing metrics data; r=rnewman
authorGregory Szorc <gps@mozilla.com>
Mon, 05 Nov 2012 12:50:11 -0800
changeset 113821 1a386fa321b2a67b3363d4b62f6c60d7ef288f2f
parent 113820 1e58a9949cede1e152e951ce5f88281fabc14dc2
child 113822 9abaed8040943cd0ea1f4bff29b4bb9cf72f6db4
push id23890
push userryanvm@gmail.com
push dateWed, 21 Nov 2012 02:43:32 +0000
treeherdermozilla-central@4f19e7fd8bea [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersrnewman
bugs718067
milestone20.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 718067 - Part 2: Define types for representing metrics data; r=rnewman
services/metrics/Makefile.in
services/metrics/dataprovider.jsm
services/metrics/modules-testing/mocks.jsm
services/metrics/tests/xpcshell/test_load_modules.js
services/metrics/tests/xpcshell/test_metrics_collection_result.js
services/metrics/tests/xpcshell/test_metrics_measurement.js
services/metrics/tests/xpcshell/test_metrics_provider.js
services/metrics/tests/xpcshell/xpcshell.ini
--- a/services/metrics/Makefile.in
+++ b/services/metrics/Makefile.in
@@ -5,19 +5,21 @@
 DEPTH     = @DEPTH@
 topsrcdir = @top_srcdir@
 srcdir    = @srcdir@
 VPATH     = @srcdir@
 
 include $(DEPTH)/config/autoconf.mk
 
 modules := \
+  dataprovider.jsm \
   $(NULL)
 
 testing_modules := \
+  mocks.jsm \
   $(NULL)
 
 TEST_DIRS += tests
 
 MODULES_FILES := $(modules)
 MODULES_DEST = $(FINAL_TARGET)/modules/services/metrics
 INSTALL_TARGETS += MODULES
 
new file mode 100644
--- /dev/null
+++ b/services/metrics/dataprovider.jsm
@@ -0,0 +1,476 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+this.EXPORTED_SYMBOLS = [
+  "MetricsCollectionResult",
+  "MetricsMeasurement",
+  "MetricsProvider",
+];
+
+const {utils: Cu} = Components;
+
+Cu.import("resource://gre/modules/commonjs/promise/core.js");
+Cu.import("resource://services-common/log4moz.js");
+
+
+/**
+ * Represents a measurement of data.
+ *
+ * This is how data is recorded and represented. Each instance of this type
+ * represents a related set of data.
+ *
+ * Each data set has some basic metadata associated with it. This includes a
+ * name and version.
+ *
+ * This type is meant to be an abstract base type. Child types should define
+ * a `fields` property which is a mapping of field names to metadata describing
+ * that field. This field constitutes the "schema" of the measurement/type.
+ *
+ * Data is added to instances by calling `setValue()`. Values are validated
+ * against the schema at add time.
+ *
+ * Field Specification
+ * ===================
+ *
+ * The `fields` property is a mapping of string field names to a mapping of
+ * metadata describing the field. This mapping can have the following
+ * properties:
+ *
+ *   type -- A string corresponding to the TYPE_* property name describing a
+ *           field type. The TYPE_* properties are defined on this type. e.g.
+ *           "TYPE_STRING".
+ *
+ *   optional -- If true, this field is optional. If omitted, the field is
+ *               required.
+ *
+ * @param name
+ *        (string) Name of this data set.
+ * @param version
+ *        (Number) Integer version of the data in this set.
+ */
+this.MetricsMeasurement = function MetricsMeasurement(name, version) {
+  if (!this.fields) {
+    throw new Error("fields not defined on instance. You are likely using " +
+                    "this type incorrectly.");
+  }
+
+  if (!name) {
+    throw new Error("Must define a name for this measurement.");
+  }
+
+  if (!version) {
+    throw new Error("Must define a version for this measurement.");
+  }
+
+  if (!Number.isInteger(version)) {
+    throw new Error("version must be an integer: " + version);
+  }
+
+  this.name = name;
+  this.version = version;
+
+  this.values = new Map();
+}
+
+MetricsMeasurement.prototype = {
+  /**
+   * An unsigned integer field stored in 32 bits.
+   *
+   * This holds values from 0 to 2^32 - 1.
+   */
+  TYPE_UINT32: {
+    validate: function validate(value) {
+      if (!Number.isInteger(value)) {
+        throw new Error("UINT32 field expects an integer. Got " + value);
+      }
+
+      if (value < 0) {
+        throw new Error("UINT32 field expects a positive integer. Got " + value);
+      }
+
+      if (value >= 0xffffffff) {
+        throw new Error("Value is too large to fit within 32 bits: " + value);
+      }
+    },
+  },
+
+  /**
+   * A string field.
+   *
+   * Values must be valid UTF-8 strings.
+   */
+  TYPE_STRING: {
+    validate: function validate(value) {
+      if (typeof(value) != "string") {
+        throw new Error("STRING field expects a string. Got " + typeof(value));
+      }
+    },
+  },
+
+  /**
+   * Set the value of a field.
+   *
+   * This is ultimately how fields are set. All field sets should go through
+   * this function.
+   *
+   * Values are validated when they are set. If the value passed does not
+   * validate against the field's specification, an Error will be thrown.
+   *
+   * @param name
+   *        (string) The name of the field whose value to set.
+   * @param value
+   *        The value to set the field to.
+   */
+  setValue: function setValue(name, value) {
+    if (!this.fields[name]) {
+      throw new Error("Attempting to set unknown field: " + name);
+    }
+
+    let type = this.fields[name].type;
+
+    if (!(type in this)) {
+      throw new Error("Unknown field type: " + type);
+    }
+
+    this[type].validate(value);
+    this.values.set(name, value);
+  },
+
+  /**
+   * Obtain the value of a named field.
+   *
+   * @param name
+   *        (string) The name of the field to retrieve.
+   */
+  getValue: function getValue(name) {
+    return this.values.get(name);
+  },
+
+  /**
+   * Validate that this instance is in conformance with the specification.
+   *
+   * This ensures all required fields are present. Field value validation
+   * occurs when individual fields are set.
+   */
+  validate: function validate() {
+    for (let field in this.fields) {
+      let spec = this.fields[field];
+
+      if (!spec.optional && !(field in this.values)) {
+        throw new Error("Required field not defined: " + field);
+      }
+    }
+  },
+
+  toJSON: function toJSON() {
+    let fields = {};
+    for (let [k, v] of this.values) {
+      fields[k] = v;
+    }
+
+    return {
+      name: this.name,
+      version: this.version,
+      fields: fields,
+    };
+  },
+};
+
+Object.freeze(MetricsMeasurement.prototype);
+
+
+/**
+ * Entity which provides metrics data for recording.
+ *
+ * This essentially provides an interface that different systems must implement
+ * to provide collected metrics data.
+ *
+ * This type consists of various collect* functions. These functions are called
+ * by the metrics collector at different points during the application's
+ * lifetime. These functions return a `MetricsCollectionResult` instance.
+ * This type behaves a lot like a promise. It has a `onFinished()` that can chain
+ * deferred events until after the result is populated.
+ *
+ * Implementations of collect* functions should call `createResult()` to create
+ * a new `MetricsCollectionResult` instance. When called, they should
+ * initiate population of this instance. Once population has finished (perhaps
+ * asynchronously), they should call `finish()` on the instance.
+ *
+ * Receivers of created `MetricsCollectionResult` instances should wait
+ * until population has finished. They can do this by chaining on to the
+ * promise inside that instance by calling `onFinished()`.
+ *
+ * The collect* functions can return null to signify that they will never
+ * provide any data. This is the default implementation. An implemented
+ * collect* function should *never* return null. Instead, it should return
+ * a `MetricsCollectionResult` with expected measurements that has finished
+ * populating (i.e. an empty result).
+ *
+ * @param name
+ *        (string) The name of this provider.
+ */
+this.MetricsProvider = function MetricsProvider(name) {
+  if (!name) {
+    throw new Error("MetricsProvider must have a name.");
+  }
+
+  if (typeof(name) != "string") {
+    throw new Error("name must be a string. Got: " + typeof(name));
+  }
+
+  this._log = Log4Moz.repository.getLogger("Services.Metrics.MetricsProvider");
+
+  this.name = name;
+}
+
+MetricsProvider.prototype = {
+  /**
+   * Collects constant measurements.
+   *
+   * Constant measurements are data that doesn't change during the lifetime of
+   * the application/process. The metrics collector only needs to call this
+   * once per `MetricsProvider` instance per process lifetime.
+   */
+  collectConstantMeasurements: function collectConstantMeasurements() {
+    return null;
+  },
+
+  /**
+   * Create a new `MetricsCollectionResult` tied to this provider.
+   */
+  createResult: function createResult() {
+    return new MetricsCollectionResult(this.name);
+  },
+};
+
+Object.freeze(MetricsProvider.prototype);
+
+
+/**
+ * Holds the result of metrics collection.
+ *
+ * This is the type eventually returned by the MetricsProvider.collect*
+ * functions. It holds all results and any state/errors that occurred while
+ * collecting.
+ *
+ * This type is essentially a container for `MetricsMeasurement` instances that
+ * provides some smarts useful for capturing state.
+ *
+ * The first things consumers of new instances should do is define the set of
+ * expected measurements this result will contain via `expectMeasurement`. If
+ * population of this instance is aborted or times out, downstream consumers
+ * will know there is missing data.
+ *
+ * Next, they should add empty `MetricsMeasurement` instances to it via
+ * `addMeasurement`. Finally, they should populate these measurements with
+ * `setValue`.
+ *
+ * It is preferred to populate via this type instead of directly on
+ * `MetricsMeasurement` instances so errors with data population can be
+ * captured and reported.
+ *
+ * Once population has finished, `finish()` must be called.
+ *
+ * @param name
+ *        (string) The name of the provider this result came from.
+ */
+this.MetricsCollectionResult = function MetricsCollectionResult(name) {
+  if (!name || typeof(name) != "string") {
+    throw new Error("Must provide name argument to MetricsCollectionResult.");
+  }
+
+  this._log = Log4Moz.repository.getLogger("Services.Metrics.MetricsCollectionResult");
+
+  this.name = name;
+
+  this.measurements = new Map();
+  this.expectedMeasurements = new Set();
+  this.errors = [];
+
+  this._deferred = Promise.defer();
+}
+
+MetricsCollectionResult.prototype = {
+  /**
+   * The Set of `MetricsMeasurement` names currently missing from this result.
+   */
+  get missingMeasurements() {
+    let missing = new Set();
+
+    for (let name of this.expectedMeasurements) {
+      if (this.measurements.has(name)) {
+        continue;
+      }
+
+      missing.add(name);
+    }
+
+    return missing;
+  },
+
+  /**
+   * Record that this result is expected to provide a named measurement.
+   *
+   * This function should be called ASAP on new `MetricsCollectionResult`
+   * instances. It defines expectations about what data should be present.
+   *
+   * @param name
+   *        (string) The name of the measurement this result should contain.
+   */
+  expectMeasurement: function expectMeasurement(name) {
+    this.expectedMeasurements.add(name);
+  },
+
+  /**
+   * Add a `MetricsMeasurement` to this result.
+   */
+  addMeasurement: function addMeasurement(data) {
+    if (!(data instanceof MetricsMeasurement)) {
+      throw new Error("addMeasurement expects a MetricsMeasurement instance.");
+    }
+
+    if (!this.expectedMeasurements.has(data.name)) {
+      throw new Error("Not expecting this measurement: " + data.name);
+    }
+
+    if (this.measurements.has(data.name)) {
+      throw new Error("Measurement of this name already present: " + data.name);
+    }
+
+    this.measurements.set(data.name, data);
+  },
+
+  /**
+   * Sets the value of a field in a registered measurement instance.
+   *
+   * This is a convenience function to set a field on a measurement. If an
+   * error occurs, it will record that error in the errors container.
+   *
+   * Attempting to set a value on a measurement that does not exist results
+   * in an Error being thrown. Attempting a bad assignment on an existing
+   * measurement will not throw unless `rethrow` is true.
+   *
+   * @param name
+   *        (string) The `MetricsMeasurement` on which to set the value.
+   * @param field
+   *        (string) The field we are setting.
+   * @param value
+   *        The value being set.
+   * @param rethrow
+   *        (bool) Whether to rethrow any errors encountered.
+   *
+   * @return bool
+   *         Whether the assignment was successful.
+   */
+  setValue: function setValue(name, field, value, rethrow=false) {
+    let m = this.measurements.get(name);
+    if (!m) {
+      throw new Error("Attempting to operate on an undefined measurement: " +
+                      name);
+    }
+
+    try {
+      m.setValue(field, value);
+      return true;
+    } catch (ex) {
+      this.addError(ex);
+
+      if (rethrow) {
+        throw ex;
+      }
+
+      return false;
+    }
+  },
+
+  /**
+   * Record an error that was encountered when populating this result.
+   */
+  addError: function addError(error) {
+    this.errors.push(error);
+  },
+
+  /**
+   * Aggregate another MetricsCollectionResult into this one.
+   *
+   * Instances can only be aggregated together if they belong to the same
+   * provider (they have the same name).
+   */
+  aggregate: function aggregate(other) {
+    if (!(other instanceof MetricsCollectionResult)) {
+      throw new Error("aggregate expects a MetricsCollectionResult instance.");
+    }
+
+    if (this.name != other.name) {
+      throw new Error("Can only aggregate MetricsCollectionResult from " +
+                      "the same provider. " + this.name + " != " + other.name);
+    }
+
+    for (let name of other.expectedMeasurements) {
+      this.expectedMeasurements.add(name);
+    }
+
+    for (let [name, m] of other.measurements) {
+      if (this.measurements.has(name)) {
+        throw new Error("Incoming result has same measurement as us: " + name);
+      }
+
+      this.measurements.set(name, m);
+    }
+
+    this.errors = this.errors.concat(other.errors);
+  },
+
+  toJSON: function toJSON() {
+    let o = {
+      measurements: {},
+      missing: [],
+      errors: [],
+    };
+
+    for (let [name, value] of this.measurements) {
+      o.measurements[name] = value;
+    }
+
+    for (let missing of this.missingMeasurements) {
+      o.missing.push(missing);
+    }
+
+    for (let error of this.errors) {
+      if (error.message) {
+        o.errors.push(error.message);
+      } else {
+        o.errors.push(error);
+      }
+    }
+
+    return o;
+  },
+
+  /**
+   * Signal that population of the result has finished.
+   *
+   * This will resolve the internal promise.
+   */
+  finish: function finish() {
+    this._deferred.resolve(this);
+  },
+
+  /**
+   * Chain deferred behavior until after the result has finished population.
+   *
+   * This is a wrapped around the internal promise's `then`.
+   *
+   * We can't call this "then" because the core promise library will get
+   * confused.
+   */
+  onFinished: function onFinished(onFulfill, onError) {
+    return this._deferred.promise.then(onFulfill, onError);
+  },
+};
+
+Object.freeze(MetricsCollectionResult.prototype);
+
new file mode 100644
--- /dev/null
+++ b/services/metrics/modules-testing/mocks.jsm
@@ -0,0 +1,55 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+this.EXPORTED_SYMBOLS = [
+  "DummyMeasurement",
+  "DummyProvider",
+];
+
+const {utils: Cu} = Components;
+
+Cu.import("resource://gre/modules/services/metrics/dataprovider.jsm");
+
+this.DummyMeasurement = function DummyMeasurement(name="DummyMeasurement") {
+  MetricsMeasurement.call(this, name, 2);
+}
+DummyMeasurement.prototype = {
+  __proto__: MetricsMeasurement.prototype,
+
+  fields: {
+    "string": {
+      type: "TYPE_STRING",
+    },
+
+    "uint32": {
+      type: "TYPE_UINT32",
+      optional: true,
+    },
+  },
+};
+
+
+this.DummyProvider = function DummyProvider(name="DummyProvider") {
+  MetricsProvider.call(this, name);
+
+  this.constantMeasurementName = "DummyMeasurement";
+}
+DummyProvider.prototype = {
+  __proto__: MetricsProvider.prototype,
+
+  collectConstantMeasurements: function collectConstantMeasurements() {
+    let result = this.createResult();
+    result.expectMeasurement(this.constantMeasurementName);
+    result.addMeasurement(new DummyMeasurement(this.constantMeasurementName));
+
+    result.setValue(this.constantMeasurementName, "string", "foo");
+    result.setValue(this.constantMeasurementName, "uint32", 24);
+
+    result.finish();
+
+    return result;
+  },
+};
--- a/services/metrics/tests/xpcshell/test_load_modules.js
+++ b/services/metrics/tests/xpcshell/test_load_modules.js
@@ -1,17 +1,19 @@
 /* Any copyright is dedicated to the Public Domain.
  * http://creativecommons.org/publicdomain/zero/1.0/ */
 
 "use strict";
 
 const modules = [
+  "dataprovider.jsm",
 ];
 
 const test_modules = [
+  "mocks.jsm",
 ];
 
 function run_test() {
   for (let m of modules) {
     let resource = "resource://gre/modules/services/metrics/" + m;
     Components.utils.import(resource, {});
   }
 
new file mode 100644
--- /dev/null
+++ b/services/metrics/tests/xpcshell/test_metrics_collection_result.js
@@ -0,0 +1,231 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const {utils: Cu} = Components;
+
+Cu.import("resource://gre/modules/services/metrics/dataprovider.jsm");
+Cu.import("resource://testing-common/services/metrics/mocks.jsm");
+
+
+function run_test() {
+  run_next_test();
+};
+
+add_test(function test_constructor() {
+  let result = new MetricsCollectionResult("foo");
+  do_check_eq(result.name, "foo");
+
+  let failed = false;
+  try {
+    new MetricsCollectionResult();
+  } catch(ex) {
+    do_check_true(ex.message.startsWith("Must provide name argument to Metrics"));
+    failed = true;
+  } finally {
+    do_check_true(failed);
+  }
+
+  run_next_test();
+});
+
+add_test(function test_expected_measurements() {
+  let result = new MetricsCollectionResult("foo");
+  do_check_eq(result.missingMeasurements.size(), 0);
+
+  result.expectMeasurement("foo");
+  result.expectMeasurement("bar");
+  do_check_eq(result.missingMeasurements.size(), 2);
+  do_check_true(result.missingMeasurements.has("foo"));
+  do_check_true(result.missingMeasurements.has("bar"));
+
+  run_next_test();
+});
+
+add_test(function test_missing_measurements() {
+  let result = new MetricsCollectionResult("foo");
+
+  let missing = result.missingMeasurements;
+  do_check_eq(missing.size(), 0);
+
+  result.expectMeasurement("DummyMeasurement");
+  result.expectMeasurement("b");
+
+  missing = result.missingMeasurements;
+  do_check_eq(missing.size(), 2);
+  do_check_true(missing.has("DummyMeasurement"));
+  do_check_true(missing.has("b"));
+
+  result.addMeasurement(new DummyMeasurement());
+  missing = result.missingMeasurements;
+  do_check_eq(missing.size(), 1);
+  do_check_true(missing.has("b"));
+
+  run_next_test();
+});
+
+add_test(function test_add_measurement() {
+  let result = new MetricsCollectionResult("add_measurement");
+
+  let failed = false;
+  try {
+    result.addMeasurement(new DummyMeasurement());
+  } catch (ex) {
+    do_check_true(ex.message.startsWith("Not expecting this measurement"));
+    failed = true;
+  } finally {
+    do_check_true(failed);
+    failed = false;
+  }
+
+  result.expectMeasurement("foo");
+  result.addMeasurement(new DummyMeasurement("foo"));
+
+  do_check_eq(result.measurements.size(), 1);
+  do_check_true(result.measurements.has("foo"));
+
+  run_next_test();
+});
+
+add_test(function test_set_value() {
+  let result = new MetricsCollectionResult("set_value");
+  result.expectMeasurement("DummyMeasurement");
+  result.addMeasurement(new DummyMeasurement());
+
+  do_check_true(result.setValue("DummyMeasurement", "string", "hello world"));
+
+  let failed = false;
+  try {
+    result.setValue("unknown", "irrelevant", "irrelevant");
+  } catch (ex) {
+    do_check_true(ex.message.startsWith("Attempting to operate on an undefined measurement"));
+    failed = true;
+  } finally {
+    do_check_true(failed);
+    failed = false;
+  }
+
+  do_check_eq(result.errors.length, 0);
+  do_check_false(result.setValue("DummyMeasurement", "string", 42));
+  do_check_eq(result.errors.length, 1);
+
+  try {
+    result.setValue("DummyMeasurement", "string", 42, true);
+  } catch (ex) {
+    failed = true;
+  } finally {
+    do_check_true(failed);
+    failed = false;
+  }
+
+  run_next_test();
+});
+
+add_test(function test_aggregate_bad_argument() {
+  let result = new MetricsCollectionResult("bad_argument");
+
+  let failed = false;
+  try {
+    result.aggregate(null);
+  } catch (ex) {
+    do_check_true(ex.message.startsWith("aggregate expects a MetricsCollection"));
+    failed = true;
+  } finally {
+    do_check_true(failed);
+    failed = false;
+  }
+
+  try {
+    let result2 = new MetricsCollectionResult("bad_argument2");
+    result.aggregate(result2);
+  } catch (ex) {
+    do_check_true(ex.message.startsWith("Can only aggregate"));
+    failed = true;
+  } finally {
+    do_check_true(failed);
+    failed = false;
+  }
+
+  run_next_test();
+});
+
+add_test(function test_aggregate_side_effects() {
+  let result1 = new MetricsCollectionResult("aggregate");
+  let result2 = new MetricsCollectionResult("aggregate");
+
+  result1.expectMeasurement("dummy1");
+  result1.expectMeasurement("foo");
+
+  result2.expectMeasurement("dummy2");
+  result2.expectMeasurement("bar");
+
+  result1.addMeasurement(new DummyMeasurement("dummy1"));
+  result1.setValue("dummy1", "invalid", "invalid");
+
+  result2.addMeasurement(new DummyMeasurement("dummy2"));
+  result2.setValue("dummy2", "another", "invalid");
+
+  result1.aggregate(result2);
+
+  do_check_eq(result1.expectedMeasurements.size(), 4);
+  do_check_true(result1.expectedMeasurements.has("bar"));
+
+  do_check_eq(result1.measurements.size(), 2);
+  do_check_true(result1.measurements.has("dummy1"));
+  do_check_true(result1.measurements.has("dummy2"));
+
+  do_check_eq(result1.missingMeasurements.size(), 2);
+  do_check_true(result1.missingMeasurements.has("bar"));
+
+  do_check_eq(result1.errors.length, 2);
+
+  run_next_test();
+});
+
+add_test(function test_json() {
+  let result = new MetricsCollectionResult("json");
+  result.expectMeasurement("dummy1");
+  result.expectMeasurement("dummy2");
+  result.expectMeasurement("missing1");
+  result.expectMeasurement("missing2");
+
+  result.addMeasurement(new DummyMeasurement("dummy1"));
+  result.addMeasurement(new DummyMeasurement("dummy2"));
+
+  result.setValue("dummy1", "string", "hello world");
+  result.setValue("dummy2", "uint32", 42);
+  result.setValue("dummy1", "invalid", "irrelevant");
+
+  let json = JSON.parse(JSON.stringify(result));
+
+  do_check_eq(Object.keys(json).length, 3);
+  do_check_true("measurements" in json);
+  do_check_true("missing" in json);
+  do_check_true("errors" in json);
+
+  do_check_eq(Object.keys(json.measurements).length, 2);
+  do_check_true("dummy1" in json.measurements);
+  do_check_true("dummy2" in json.measurements);
+
+  do_check_eq(json.missing.length, 2);
+  let missing = new Set(json.missing);
+  do_check_true(missing.has("missing1"));
+  do_check_true(missing.has("missing2"));
+
+  do_check_eq(json.errors.length, 1);
+
+  run_next_test();
+});
+
+add_test(function test_finish() {
+  let result = new MetricsCollectionResult("finish");
+
+  result.onFinished(function onFinished(result2) {
+    do_check_eq(result, result2);
+
+    run_next_test();
+  });
+
+  result.finish();
+});
new file mode 100644
--- /dev/null
+++ b/services/metrics/tests/xpcshell/test_metrics_measurement.js
@@ -0,0 +1,115 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const {utils: Cu} = Components;
+
+Cu.import("resource://testing-common/services/metrics/mocks.jsm");
+
+
+function run_test() {
+  run_next_test();
+};
+
+add_test(function test_constructor() {
+  let m = new DummyMeasurement();
+  do_check_eq(m.name, "DummyMeasurement");
+  do_check_eq(m.version, 2);
+
+  run_next_test();
+});
+
+add_test(function test_add_string() {
+  let m = new DummyMeasurement();
+
+  m.setValue("string", "hello world");
+  do_check_eq(m.getValue("string"), "hello world");
+
+  let failed = false;
+  try {
+    m.setValue("string", 46);
+  } catch (ex) {
+    do_check_true(ex.message.startsWith("STRING field expects a string"));
+    failed = true;
+  } finally {
+    do_check_true(failed);
+  }
+
+  run_next_test();
+});
+
+add_test(function test_add_uint32() {
+  let m = new DummyMeasurement();
+
+  m.setValue("uint32", 52342);
+  do_check_eq(m.getValue("uint32"), 52342);
+
+  let failed = false;
+  try {
+    m.setValue("uint32", -1);
+  } catch (ex) {
+    failed = true;
+    do_check_true(ex.message.startsWith("UINT32 field expects a positive"));
+  } finally {
+    do_check_true(failed);
+    failed = false;
+  }
+
+  try {
+    m.setValue("uint32", "foo");
+  } catch (ex) {
+    failed = true;
+    do_check_true(ex.message.startsWith("UINT32 field expects an integer"));
+  } finally {
+    do_check_true(failed);
+    failed = false;
+  }
+
+  try {
+    m.setValue("uint32", Math.pow(2, 32));
+  } catch (ex) {
+    failed = true;
+    do_check_true(ex.message.startsWith("Value is too large"));
+  } finally {
+    do_check_true(failed);
+    failed = false;
+  }
+
+  run_next_test();
+});
+
+add_test(function test_validate() {
+  let m = new DummyMeasurement();
+
+  let failed = false;
+  try {
+    m.validate();
+  } catch (ex) {
+    failed = true;
+    do_check_true(ex.message.startsWith("Required field not defined"));
+  } finally {
+    do_check_true(failed);
+    failed = false;
+  }
+
+  run_next_test();
+});
+
+add_test(function test_toJSON() {
+  let m = new DummyMeasurement();
+
+  m.setValue("string", "foo bar");
+
+  let json = JSON.parse(JSON.stringify(m));
+  do_check_eq(Object.keys(json).length, 3);
+  do_check_eq(json.name, "DummyMeasurement");
+  do_check_eq(json.version, 2);
+  do_check_true("fields" in json);
+
+  do_check_eq(Object.keys(json.fields).length, 1);
+  do_check_eq(json.fields.string, "foo bar");
+
+  run_next_test();
+});
+
new file mode 100644
--- /dev/null
+++ b/services/metrics/tests/xpcshell/test_metrics_provider.js
@@ -0,0 +1,63 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const {utils: Cu} = Components;
+
+Cu.import("resource://gre/modules/services/metrics/dataprovider.jsm");
+Cu.import("resource://testing-common/services/metrics/mocks.jsm");
+
+
+function run_test() {
+  run_next_test();
+};
+
+add_test(function test_constructor() {
+  let provider = new MetricsProvider("foo");
+
+  let failed = false;
+  try {
+    new MetricsProvider();
+  } catch(ex) {
+    do_check_true(ex.message.startsWith("MetricsProvider must have a name"));
+    failed = true;
+  }
+  finally {
+    do_check_true(failed);
+  }
+
+  run_next_test();
+});
+
+add_test(function test_default_collectors() {
+  let provider = new MetricsProvider("foo");
+
+  for (let property in MetricsProvider.prototype) {
+    if (!property.startsWith("collect")) {
+      continue;
+    }
+
+    let result = provider[property]();
+    do_check_null(result);
+  }
+
+  run_next_test();
+});
+
+add_test(function test_collect_synchronous() {
+  let provider = new DummyProvider();
+
+  let result = provider.collectConstantMeasurements();
+  do_check_true(result instanceof MetricsCollectionResult);
+
+  result.onFinished(function onResult(res2) {
+    do_check_eq(result, res2);
+
+    let m = result.measurements.get("DummyMeasurement");
+    do_check_eq(m.getValue("uint32"), 24);
+
+    run_next_test();
+  });
+});
+
--- a/services/metrics/tests/xpcshell/xpcshell.ini
+++ b/services/metrics/tests/xpcshell/xpcshell.ini
@@ -1,5 +1,8 @@
 [DEFAULT]
 head = head.js
 tail =
 
 [test_load_modules.js]
+[test_metrics_collection_result.js]
+[test_metrics_measurement.js]
+[test_metrics_provider.js]