Repository: ember-cli/ember-cli
Pull request #8616 (2019-04-30 18:28:03)

[ENHANCEMENT] Gather hardware information when creating instrumentation summaries
While analyzing the speed of slow builds, we found it handy to have a number of hardware and hardware-related statistics which may affect build speed available.

This adds the following fields to `summary.platform` within the instrumentation files produced when `BROCCOLI_VIZ=1`:

* `isUsingBattery` — `true` if the build host is running off battery power, `false` if it is not, or `null` if it cannot be determined.
* `memorySwapUsed` — The amount of swap memory currently used by the host, in bytes, or `null` if it cannot be determined.
* `memoryTotal` — The total amount of physical memory available on the build host.
* `memoryUsed` — The "Resident Set Size" reported by the Node process.
* `processorCount` — The number of logical processors on the build host.
* `processorLoad` — An array of three values representing the build host's processor load, over a 1-, 5-, and 15- minute range, or `null` if it cannot be determined.
* `processorSpeed` — The average speed of the build host's processor(s).

These changes have been tested on Node 6.17.1, 8.16.0, 10.15.3, and 11.14.0.  The following platforms have also been tested:

- [x] FreeBSD 12.0
- [x] macOS 10.14
- [x] OpenBSD 6.4
- [x] RHEL 7
- [x] Ubuntu 16.04
- [x] Ubuntu 18.04
- [x] Windows 7
- [x] Windows 8.1
- [x] Windows 10


Patch:
diff --git a/lib/models/hardware-info.js b/lib/models/hardware-info.js
new file mode 100644
index 000000000..70c0fba4e
--- /dev/null
+++ b/lib/models/hardware-info.js
@@ -0,0 +1,369 @@
+'use strict';
+
+const execa = require('execa');
+const fs = require('fs');
+const logger = require('heimdalljs-logger')('ember-cli:hardware-info');
+const os = require('os');
+
+function isUsingBatteryAcpi() {
+  try {
+    const { stdout } = execa.sync('acpi', ['--ac-adapter']);
+    const lines = stdout.split('\n').filter(Boolean);
+
+    return lines.every(line => (/off-line/).test(line));
+  } catch (ex) {
+    logger.warn(`Could not get battery status from acpi: ${ex}`);
+    logger.warn(ex.stack);
+
+    return null;
+  }
+}
+
+function isUsingBatteryApm() {
+  try {
+    const { stdout } = execa.sync('apm', ['-a']);
+
+    return parseInt(stdout, 10) === 0;
+  } catch (ex) {
+    logger.warn(`Could not get battery status from apm: ${ex}`);
+    logger.warn(ex.stack);
+
+    return null;
+  }
+}
+
+function isUsingBatteryBsd() {
+  const apm = isUsingBatteryApm();
+
+  if (apm !== null) {
+    return apm;
+  }
+
+  return isUsingBatteryUpower();
+}
+
+function isUsingBatteryDarwin() {
+  try {
+    const { stdout } = execa.sync('pmset', ['-g', 'batt']);
+
+    return stdout.indexOf('Battery Power') !== -1;
+  } catch (ex) {
+    logger.warn(`Could not get battery status from pmset: ${ex}`);
+    logger.warn(ex.stack);
+
+    return null;
+  }
+}
+
+function isUsingBatteryLinux() {
+  const sysClassPowerSupply = isUsingBatterySysClassPowerSupply();
+
+  if (sysClassPowerSupply !== null) {
+    return sysClassPowerSupply;
+  }
+
+  const acpi = isUsingBatteryAcpi();
+
+  if (acpi !== null) {
+    return acpi;
+  }
+
+  return isUsingBatteryUpower();
+}
+
+function isUsingBatterySysClassPowerSupply() {
+  try {
+    const value = fs.readFileSync('/sys/class/power_supply/AC/online');
+
+    return parseInt(value, 10) === 0;
+  } catch (ex) {
+    logger.warn(`Could not get battery status from /sys/class/power_supply: ${ex}`);
+    logger.warn(ex.stack);
+
+    return null;
+  }
+}
+
+function isUsingBatteryUpower() {
+  try {
+    const { stdout } = execa.sync('upower', ['--enumerate']);
+    const devices = stdout.split('\n').filter(Boolean);
+
+    return devices.some(device => {
+      const { stdout } = execa.sync('upower', ['--show-info', device]);
+
+      return (/\bpower supply:\s+yes\b/).test(stdout) && (/\bstate:\s+discharging\b/).test(stdout);
+    });
+  } catch (ex) {
+    logger.warn(`Could not get battery status from upower: ${ex}`);
+    logger.warn(ex.stack);
+
+    return null;
+  }
+}
+
+function isUsingBatteryWindows() {
+  try {
+    const { stdout } = execa.sync('wmic', ['/namespace:', '\\\\root\\WMI', 'path', 'BatteryStatus', 'get', 'PowerOnline', '/format:list']);
+
+    return (/\bPowerOnline=FALSE\b/).test(stdout);
+  } catch (ex) {
+    logger.warn(`Could not get battery status from wmic: ${ex}`);
+    logger.warn(ex.stack);
+
+    return null;
+  }
+}
+
+function memorySwapUsedDarwin() {
+  try {
+    const { stdout } = execa.sync('sysctl', ['vm.swapusage']);
+    const match = (/\bused = (\d+\.\d+)M\b/).exec(stdout);
+
+    if (!match) {
+      throw new Error('vm.swapusage not in output.');
+    }
+
+    // convert from fractional megabytes to bytes
+    return parseFloat(match[1]) * 1048576;
+  } catch (ex) {
+    logger.warn(`Could not get swap status from sysctl: ${ex}`);
+    logger.warn(ex.stack);
+
+    return null;
+  }
+}
+
+function memorySwapUsedBsd() {
+  try {
+    const { stdout } = execa.sync('pstat', ['-s']);
+    const devices = stdout.split('\n').filter(Boolean);
+    const header = devices.shift();
+    const match = (/^Device\s+(\d+)(K?)-blocks\s+Used\b/).exec(header);
+
+    if (!match) {
+      throw new Error('Block size not found in output.');
+    }
+
+    const blockSize = parseInt(match[1], 10) * (match[2] === 'K' ? 1024 : 1);
+
+    return devices.reduce((total, line) => {
+      const match = (/^\S+\s+\d+\s+(\d+)/).exec(line);
+
+      if (!match) {
+        throw new Error(`Unrecognized line in output: '${line}'`);
+      }
+
+      return total + (parseInt(match[1], 10) * blockSize);
+    }, 0);
+  } catch (ex) {
+    logger.warn(`Could not get swap status from pstat: ${ex}`);
+    logger.warn(ex.stack);
+
+    return null;
+  }
+}
+
+function memorySwapUsedLinux() {
+  try {
+    const { stdout } = execa.sync('free', ['-b']);
+    const lines = stdout.split('\n').filter(Boolean);
+    const header = lines.shift();
+    const columns = header.split(/\s+/).filter(Boolean);
+    const columnUsed = columns.reduce((columnUsed, column, index) => {
+      if (columnUsed !== undefined) {
+        return columnUsed;
+      }
+
+      if ((/used/i).test(column)) {
+        // there is no heading on the first column, so indices are off by 1
+        return index + 1;
+      }
+    }, undefined);
+
+    if (columnUsed === undefined) {
+      throw new Error('Could not find "used" column.');
+    }
+
+    for (const line of lines) {
+      const columns = line.split(/\s+/).filter(Boolean);
+
+      if ((/swap/i).test(columns[0])) {
+        return parseInt(columns[columnUsed], 10);
+      }
+    }
+
+    throw new Error('Could not find "swap" row.');
+  } catch (ex) {
+    logger.warn(`Could not get swap status from free: ${ex}`);
+    logger.warn(ex.stack);
+
+    return null;
+  }
+}
+
+function memorySwapUsedWindows() {
+  try {
+    const { stdout } = execa.sync('wmic', ['PAGEFILE', 'get', 'CurrentUsage', '/format:list']);
+    const match = (/\bCurrentUsage=(\d+)/).exec(stdout);
+
+    if (!match) {
+      throw new Error('Page file usage info not in output.');
+    }
+
+    return parseInt(match[1], 10) * 1048576;
+  } catch (ex) {
+    logger.warn(`Could not get swap status from wmic: ${ex}`);
+    logger.warn(ex.stack);
+
+    return null;
+  }
+}
+
+const hwinfo = {
+  /**
+   * Inidcates whether the host is running on battery power.  This can cause
+   * performance degredation.
+   *
+   * @method isUsingBattery
+   * @for HardwareInfo
+   * @param {String=process.platform} platform The current hardware platform.
+   *                                           USED FOR TESTING ONLY.
+   * @return {null|Boolean} `true` iff the host is running on battery power or
+   *                         `false` if not.  `null` if the battery status
+   *                         cannot be determined.
+   */
+  isUsingBattery(platform = process.platform) {
+    switch (platform) {
+      case 'darwin':
+        return isUsingBatteryDarwin();
+
+      case 'freebsd':
+      case 'openbsd':
+        return isUsingBatteryBsd();
+
+      case 'linux':
+        return isUsingBatteryLinux();
+
+      case 'win32':
+        return isUsingBatteryWindows();
+    }
+
+    logger.warn(`Battery status is unsupported on the '${platform}' platform.`);
+
+    return null;
+  },
+
+  /**
+   * Determines the amount of swap/virtual memory currently in use.
+   *
+   * @method memorySwapUsed
+   * @param {String=process.platform} platform The current hardware platform.
+   *                                           USED FOR TESTING ONLY.
+   * @return {null|Number} The amount of used swap space, in bytes.  `null` if
+   *                        the used swap space cannot be determined.
+   */
+  memorySwapUsed(platform = process.platform) {
+    switch (platform) {
+      case 'darwin':
+        return memorySwapUsedDarwin();
+
+      case 'freebsd':
+      case 'openbsd':
+        return memorySwapUsedBsd();
+
+      case 'linux':
+        return memorySwapUsedLinux();
+
+      case 'win32':
+        return memorySwapUsedWindows();
+    }
+
+    logger.warn(`Swap status is unsupported on the '${platform}' platform.`);
+
+    return null;
+  },
+
+  /**
+   * Determines the total amount of memory avilable to the host, as from
+   * `os.totalmem`.
+   *
+   * @method memoryTotal
+   * @return {Number} The total memory in bytes.
+   */
+  memoryTotal() {
+    return os.totalmem();
+  },
+
+  /**
+   * Determines the amount of memory currently being used by the current Node
+   * process, as from `process.memoryUsage`.
+   *
+   * @method memoryUsed
+   * @return {Object} The Resident Set Size, as reported by
+   *                  `process.memoryUsage`.
+   */
+  memoryUsed() {
+    return process.memoryUsage().rss;
+  },
+
+  /**
+   * Determines the number of logical processors available to the host, as from
+   * `os.cpus`.
+   *
+   * @method processorCount
+   * @return {Number} The number of logical processors.
+   */
+  processorCount() {
+    return os.cpus().length;
+  },
+
+  /**
+   * Determines the average processor load accross the system.  This is
+   * expressed as a fractional number between 0 and the number of logical
+   * processors.
+   *
+   * @method processorLoad
+   * @param {String=process.platform} platform The current hardware platform.
+   *                                           USED FOR TESTING ONLY.
+   * @return {Array<Number>} The one-, five-, and fifteen-minute processor load
+   *                         averages.
+   */
+  processorLoad(platform = process.platform) {
+    // The os.loadavg() call works on win32, but never returns correct
+    // data.  Better to intercept and warn that it's unsupported.
+    if (platform === 'win32') {
+      logger.warn(`Processor load is unsupported on the '${platform}' platform.`);
+
+      return null;
+    }
+
+    return os.loadavg();
+  },
+
+  /**
+   * Gets the speed of the host's processors.
+   *
+   * If more than one processor is found, the average of their speeds is taken.
+   *
+   * @method processorSpeed
+   * @return {Number} The average processor speed in MHz.
+   */
+  processorSpeed() {
+    const cpus = os.cpus();
+
+    return cpus.reduce((sum, cpu) => sum + cpu.speed, 0) / cpus.length;
+  },
+
+  /**
+   * Determines the time since the host was started, as from `os.uptime`.
+   *
+   * @method uptime
+   * @return {Number} The number of seconds since the host was started.
+   */
+  uptime() {
+    return os.uptime();
+  },
+};
+
+module.exports = hwinfo;
diff --git a/lib/models/instrumentation.js b/lib/models/instrumentation.js
index 9ea4f8869..5c550eeaa 100644
--- a/lib/models/instrumentation.js
+++ b/lib/models/instrumentation.js
@@ -5,6 +5,7 @@ const chalk = require('chalk');
 const heimdallGraph = require('heimdalljs-graph');
 const utilsInstrumentation = require('../utilities/instrumentation');
 const logger = require('heimdalljs-logger')('ember-cli:instrumentation');
+const hwinfo = require('./hardware-info');
 
 let vizEnabled = utilsInstrumentation.vizEnabled;
 let instrumentationEnabled = utilsInstrumentation.instrumentationEnabled;
@@ -125,6 +126,8 @@ class Instrumentation {
       buildSteps,
     };
 
+    Object.getOwnPropertyNames(hwinfo).forEach(metric => summary.platform[metric] = hwinfo[metric]());
+
     if (result) {
       summary.build.outputChangedFiles = result.outputChanges;
       summary.output = result.directory;
@@ -140,16 +143,20 @@ class Instrumentation {
   }
 
   _initSummary(tree) {
-    return {
+    const summary = {
       totalTime: totalTime(tree),
       platform: {
         name: process.platform,
       },
     };
+
+    Object.getOwnPropertyNames(hwinfo).forEach(metric => summary.platform[metric] = hwinfo[metric]());
+
+    return summary;
   }
 
   _commandSummary(tree, commandName, commandArgs) {
-    return {
+    const summary = {
       name: commandName,
       args: commandArgs,
       totalTime: totalTime(tree),
@@ -157,15 +164,23 @@ class Instrumentation {
         name: process.platform,
       },
     };
+
+    Object.getOwnPropertyNames(hwinfo).forEach(metric => summary.platform[metric] = hwinfo[metric]());
+
+    return summary;
   }
 
   _shutdownSummary(tree) {
-    return {
+    const summary = {
       totalTime: totalTime(tree),
       platform: {
         name: process.platform,
       },
     };
+
+    Object.getOwnPropertyNames(hwinfo).forEach(metric => summary.platform[metric] = hwinfo[metric]());
+
+    return summary;
   }
 
   _instrumentationFor(name) {


Test patch:
diff --git a/tests/unit/models/hardware-info-test.js b/tests/unit/models/hardware-info-test.js
new file mode 100644
index 000000000..9926573c5
--- /dev/null
+++ b/tests/unit/models/hardware-info-test.js
@@ -0,0 +1,481 @@
+'use strict';
+
+const { expect } = require('../../chai');
+const execa = require('execa');
+const fs = require('fs');
+const os = require('os');
+const td = require('testdouble');
+
+const hwinfo = require('../../../lib/models/hardware-info');
+
+const UPOWER_AC_OFF = `  native-path:          /sys/devices/LNXSYSTM:00/device:00/PNP0C0A:00/power_supply/BAT0
+  vendor:               NOTEBOOK
+  model:                BAT
+  serial:               0001
+  power supply:         yes
+  updated:              Thu Feb  9 18:42:15 2012 (1 seconds ago)
+  has history:          yes
+  has statistics:       yes
+  battery
+    present:             yes
+    rechargeable:        yes
+    state:               discharging
+    energy:              22.3998 Wh
+    energy-empty:        0 Wh
+    energy-full:         52.6473 Wh
+    energy-full-design:  62.16 Wh
+    energy-rate:         31.6905 W
+    voltage:             12.191 V
+    time to full:        57.3 minutes
+    percentage:          42.5469%
+    capacity:            84.6964%
+    technology:          lithium-ion
+  History (charge):
+    1328809335  42.547  charging
+    1328809305  42.020  charging
+    1328809275  41.472  charging
+    1328809245  41.008  charging
+  History (rate):
+    1328809335  31.691  charging
+    1328809305  32.323  charging
+    1328809275  33.133  charging
+`;
+
+const UPOWER_AC_ON = `  native-path:          /sys/devices/LNXSYSTM:00/device:00/PNP0C0A:00/power_supply/BAT0
+  vendor:               NOTEBOOK
+  model:                BAT
+  serial:               0001
+  power supply:         yes
+  updated:              Thu Feb  9 18:42:15 2012 (1 seconds ago)
+  has history:          yes
+  has statistics:       yes
+  battery
+    present:             yes
+    rechargeable:        yes
+    state:               charging
+    energy:              22.3998 Wh
+    energy-empty:        0 Wh
+    energy-full:         52.6473 Wh
+    energy-full-design:  62.16 Wh
+    energy-rate:         31.6905 W
+    voltage:             12.191 V
+    time to full:        57.3 minutes
+    percentage:          42.5469%
+    capacity:            84.6964%
+    technology:          lithium-ion
+  History (charge):
+    1328809335  42.547  charging
+    1328809305  42.020  charging
+    1328809275  41.472  charging
+    1328809245  41.008  charging
+  History (rate):
+    1328809335  31.691  charging
+    1328809305  32.323  charging
+    1328809275  33.133  charging
+`;
+
+// helper function for creating test doubles of execa.sync
+function stdout(value) {
+  return () => ({
+    stdout: value,
+  });
+}
+
+describe('models/hardware-info.js', function() {
+  afterEach(function() {
+    td.reset();
+  });
+
+  describe('.isUsingBattery', function() {
+    it('returns null for unsupported platforms', function() {
+      expect(hwinfo.isUsingBattery('not-a-real-platform')).to.be.null;
+    });
+
+    describe('on FreeBSD', function() {
+      it('returns false via apm when not on battery', function() {
+        const stub = td.function(execa.sync);
+
+        td.when(stub('apm'), { ignoreExtraArgs: true }).thenReturn({ stdout: '1\n' });
+        td.when(stub('upower'), { ignoreExtraArgs: true }).thenThrow(new Error('command not found'));
+        td.replace(execa, 'sync', stub);
+
+        expect(hwinfo.isUsingBattery('freebsd')).to.be.false;
+      });
+
+      it('returns true via apm when on battery', function() {
+        const stub = td.function(execa.sync);
+
+        td.when(stub('apm'), { ignoreExtraArgs: true }).thenReturn({ stdout: '0\n' });
+        td.when(stub('upower'), { ignoreExtraArgs: true }).thenThrow(new Error('command not found'));
+        td.replace(execa, 'sync', stub);
+
+        expect(hwinfo.isUsingBattery('freebsd')).to.be.true;
+      });
+
+      it('returns false via upower when not on battery', function() {
+        const stub = td.function(execa.sync);
+
+        td.when(stub('apm'), { ignoreExtraArgs: true }).thenThrow(new Error('command not found'));
+        td.when(stub('upower'), { ignoreExtraArgs: true }).thenReturn({ stdout: UPOWER_AC_ON });
+        td.replace(execa, 'sync', stub);
+
+        expect(hwinfo.isUsingBattery('freebsd')).to.be.false;
+      });
+
+      it('returns true via upower when on battery', function() {
+        const stub = td.function(execa.sync);
+
+        td.when(stub('apm'), { ignoreExtraArgs: true }).thenThrow(new Error('command not found'));
+        td.when(stub('upower'), { ignoreExtraArgs: true }).thenReturn({ stdout: UPOWER_AC_OFF });
+        td.replace(execa, 'sync', stub);
+
+        expect(hwinfo.isUsingBattery('freebsd')).to.be.true;
+      });
+
+      it('returns null when battery status cannot be determined', function() {
+        const stub = td.function(execa.sync);
+
+        td.when(stub(), { ignoreExtraArgs: true }).thenThrow(new Error('command not found'));
+        td.replace(execa, 'sync', stub);
+
+        expect(hwinfo.isUsingBattery('freebsd')).to.be.null;
+      });
+    });
+
+    describe('on Linux', function() {
+      it('returns false via /sys/class/power_supply when not on battery', function() {
+        const execaStub = td.function(execa.sync);
+
+        td.when(execaStub(), { ignoreExtraArgs: true }).thenThrow(new Error('command not found'));
+        td.replace(execa, 'sync', execaStub);
+
+        const readFileStub = td.function(fs.readFileSync);
+
+        td.when(readFileStub(), { ignoreExtraArgs: true }).thenReturn('1\n');
+        td.replace(fs, 'readFileSync', readFileStub);
+
+        expect(hwinfo.isUsingBattery('linux')).to.be.false;
+      });
+
+      it('returns true via /sys/class/power_supply when on battery', function() {
+        const execaStub = td.function(execa.sync);
+
+        td.when(execaStub(), { ignoreExtraArgs: true }).thenThrow(new Error('command not found'));
+        td.replace(execa, 'sync', execaStub);
+
+        const fsStub = td.function(fs.readFileSync);
+
+        td.when(fsStub(), { ignoreExtraArgs: true }).thenReturn('0\n');
+        td.replace(fs, 'readFileSync', fsStub);
+
+        expect(hwinfo.isUsingBattery('linux')).to.be.true;
+      });
+
+      it('returns false via acpi when not on battery', function() {
+        const execaStub = td.function(execa.sync);
+
+        td.when(execaStub('acpi'), { ignoreExtraArgs: true }).thenReturn({ stdout: 'Adapter 0: on-line\n' });
+        td.when(execaStub('upower'), { ignoreExtraArgs: true }).thenThrow(new Error('command not found'));
+        td.replace(execa, 'sync', execaStub);
+
+        const fsStub = td.function(fs.readFileSync);
+
+        td.when(fsStub(), { ignoreExtraArgs: true }).thenThrow(new Error('file not found'));
+        td.replace(fs, 'readFileSync', fsStub);
+
+        expect(hwinfo.isUsingBattery('linux')).to.be.false;
+      });
+
+      it('returns true via acpi when on battery', function() {
+        const execaStub = td.function(execa.sync);
+
+        td.when(execaStub('acpi'), { ignoreExtraArgs: true }).thenReturn({ stdout: 'Adapter 0: off-line\n' });
+        td.when(execaStub('upower'), { ignoreExtraArgs: true }).thenThrow(new Error('command not found'));
+        td.replace(execa, 'sync', execaStub);
+
+        const fsStub = td.function(fs.readFileSync);
+
+        td.when(fsStub(), { ignoreExtraArgs: true }).thenThrow(new Error('file not found'));
+        td.replace(fs, 'readFileSync', fsStub);
+
+        expect(hwinfo.isUsingBattery('linux')).to.be.true;
+      });
+
+      it('returns false via upower when not on battery', function() {
+        const execaStub = td.function(execa.sync);
+
+        td.when(execaStub('acpi'), { ignoreExtraArgs: true }).thenThrow(new Error('command not found'));
+        td.when(execaStub('upower', ['--enumerate'])).thenReturn({ stdout: 'foo\n' });
+        td.when(execaStub('upower', ['--show-info', 'foo'])).thenReturn({ stdout: UPOWER_AC_ON });
+        td.replace(execa, 'sync', execaStub);
+
+        const fsStub = td.function(fs.readFileSync);
+
+        td.when(fsStub(), { ignoreExtraArgs: true }).thenThrow(new Error('file not found'));
+        td.replace(fs, 'readFileSync', fsStub);
+
+        expect(hwinfo.isUsingBattery('linux')).to.be.false;
+      });
+
+      it('returns true via upower when on battery', function() {
+        const execaStub = td.function(execa.sync);
+
+        td.when(execaStub('acpi'), { ignoreExtraArgs: true }).thenThrow(new Error('command not found'));
+        td.when(execaStub('upower', ['--enumerate'])).thenReturn({ stdout: 'foo\n' });
+        td.when(execaStub('upower', ['--show-info', 'foo'])).thenReturn({ stdout: UPOWER_AC_OFF });
+        td.replace(execa, 'sync', execaStub);
+
+        const fsStub = td.function(fs.readFileSync);
+
+        td.when(fsStub(), { ignoreExtraArgs: true }).thenThrow(new Error('file not found'));
+        td.replace(fs, 'readFileSync', fsStub);
+
+        expect(hwinfo.isUsingBattery('linux')).to.be.true;
+      });
+
+      it('returns null when battery status cannot be determined', function() {
+        const execaStub = td.function(execa.sync);
+
+        td.when(execaStub(), { ignoreExtraArgs: true }).thenThrow(new Error('command not found'));
+        td.replace(execa, 'sync', execaStub);
+
+        const fsStub = td.function(fs.readFileSync);
+
+        td.when(fsStub(), { ignoreExtraArgs: true }).thenThrow(new Error('file not found'));
+        td.replace(fs, 'readFileSync', fsStub);
+
+        expect(hwinfo.isUsingBattery('linux')).to.be.null;
+      });
+    });
+
+    describe('on macOS', function() {
+      it('returns false when not on battery', function() {
+        td.replace(execa, 'sync', stdout(`Now drawing from 'AC Power'
+-InternalBattery-0 (id=5636195)	100%; charged; 0:00 remaining present: true
+`));
+
+        expect(hwinfo.isUsingBattery('darwin')).to.be.false;
+      });
+
+      it('returns true when on battery', function() {
+        td.replace(execa, 'sync', stdout(`Now drawing from 'Battery Power'
+-InternalBattery-0 (id=5636195)	100%; discharging; (no estimate) present: true
+`));
+
+        expect(hwinfo.isUsingBattery('darwin')).to.be.true;
+      });
+
+      it('returns null when an error occurs', function() {
+        const stub = td.function(execa.sync);
+
+        td.when(stub(), { ignoreExtraArgs: true }).thenThrow(new Error('whoops!'));
+        td.replace(execa, 'sync', stub);
+
+        expect(hwinfo.isUsingBattery('darwin')).to.be.null;
+      });
+    });
+
+    describe('on OpenBSD', function() {
+      it('returns false via apm when not on battery', function() {
+        const stub = td.function(execa.sync);
+
+        td.when(stub('apm'), { ignoreExtraArgs: true }).thenReturn({ stdout: '1\n' });
+        td.when(stub('upower'), { ignoreExtraArgs: true }).thenThrow(new Error('command not found'));
+        td.replace(execa, 'sync', stub);
+
+        expect(hwinfo.isUsingBattery('openbsd')).to.be.false;
+      });
+
+      it('returns true via apm when on battery', function() {
+        const stub = td.function(execa.sync);
+
+        td.when(stub('apm'), { ignoreExtraArgs: true }).thenReturn({ stdout: '0\n' });
+        td.when(stub('upower'), { ignoreExtraArgs: true }).thenThrow(new Error('command not found'));
+        td.replace(execa, 'sync', stub);
+
+        expect(hwinfo.isUsingBattery('openbsd')).to.be.true;
+      });
+
+      it('returns false via upower when not on battery', function() {
+        const stub = td.function(execa.sync);
+
+        td.when(stub('apm'), { ignoreExtraArgs: true }).thenThrow(new Error('command not found'));
+        td.when(stub('upower'), { ignoreExtraArgs: true }).thenReturn({ stdout: UPOWER_AC_ON });
+        td.replace(execa, 'sync', stub);
+
+        expect(hwinfo.isUsingBattery('openbsd')).to.be.false;
+      });
+
+      it('returns true via upower when on battery', function() {
+        const stub = td.function(execa.sync);
+
+        td.when(stub('apm'), { ignoreExtraArgs: true }).thenThrow(new Error('command not found'));
+        td.when(stub('upower'), { ignoreExtraArgs: true }).thenReturn({ stdout: UPOWER_AC_OFF });
+        td.replace(execa, 'sync', stub);
+
+        expect(hwinfo.isUsingBattery('openbsd')).to.be.true;
+      });
+
+      it('returns null when battery status cannot be determined', function() {
+        const stub = td.function(execa.sync);
+
+        td.when(stub(), { ignoreExtraArgs: true }).thenThrow(new Error('command not found'));
+        td.replace(execa, 'sync', stub);
+
+        expect(hwinfo.isUsingBattery('openbsd')).to.be.null;
+      });
+    });
+
+    describe('on Windows', function() {
+      it('returns false when not on battery', function() {
+        td.replace(execa, 'sync', stdout(`
+
+PowerOnline=TRUE
+
+
+
+`));
+
+        expect(hwinfo.isUsingBattery('win32')).to.be.false;
+      });
+
+      it('returns true when on battery', function() {
+        td.replace(execa, 'sync', stdout(`
+
+PowerOnline=FALSE
+
+
+
+`));
+
+        expect(hwinfo.isUsingBattery('win32')).to.be.true;
+      });
+
+      it('returns null when an error occurs', function() {
+        const stub = td.function(execa.sync);
+
+        td.when(stub(), { ignoreExtraArgs: true }).thenThrow(new Error('whoops!'));
+        td.replace(execa, 'sync', stub);
+
+        expect(hwinfo.isUsingBattery('win32')).to.be.null;
+      });
+    });
+  });
+
+  describe('.memorySwapUsed', function() {
+    it('returns null for unsupported platforms', function() {
+      expect(hwinfo.memorySwapUsed('not-a-real-platform')).to.be.null;
+    });
+
+    it('returns the expected value on FreeBSD', function() {
+      td.replace(execa, 'sync', stdout(`Device           1K-blocks     Used    Avail Capacity
+/dev/gpt/swapfs1   1048576      837  1047739     0%
+/dev/gpt/swapfs2   1048576      985  1047591     0%
+`));
+
+      expect(hwinfo.memorySwapUsed('freebsd')).to.equal(1865728);
+    });
+
+    it('returns null on FreeBSD when an error occurs', function() {
+      const stub = td.function(execa.sync);
+
+      td.when(stub(), { ignoreExtraArgs: true }).thenThrow(new Error('whoops!'));
+      td.replace(execa, 'sync', stub);
+
+      expect(hwinfo.memorySwapUsed('freebsd')).to.be.null;
+    });
+
+    it('returns the expected value on Linux', function() {
+      td.replace(execa, 'sync', stdout(`              total        used        free      shared  buff/cache   available
+Mem:    67275370496  2743869440 41266409472  3447558144 23265091584 60482338816
+Swap:   67448598528   121593856 67327004672
+`));
+
+      expect(hwinfo.memorySwapUsed('linux')).to.equal(121593856);
+    });
+
+    it('returns null on Linux when an error occurs', function() {
+      const stub = td.function(execa.sync);
+
+      td.when(stub(), { ignoreExtraArgs: true }).thenThrow(new Error('whoops!'));
+      td.replace(execa, 'sync', stub);
+
+      expect(hwinfo.memorySwapUsed('linux')).to.be.null;
+    });
+
+    it('returns the expected value on macOS', function() {
+      td.replace(execa, 'sync', stdout(`vm.swapusage: total = 6144.00M  used = 4987.75M  free = 1156.25M  (encrypted)
+`));
+
+      expect(hwinfo.memorySwapUsed('darwin')).to.equal(5230034944);
+    });
+
+    it('returns null on macOS when an error occurs', function() {
+      const stub = td.function(execa.sync);
+
+      td.when(stub(), { ignoreExtraArgs: true }).thenThrow(new Error('whoops!'));
+      td.replace(execa, 'sync', stub);
+
+      expect(hwinfo.memorySwapUsed('darwin')).to.be.null;
+    });
+
+    it('returns the expected value on OpenBSD', function() {
+      td.replace(execa, 'sync', stdout(`Device          512-blocks     Used    Avail Capacity
+/dev/gpt/swapfs1   1048576      837  1047739     0%
+/dev/gpt/swapfs2   1048576      985  1047591     0%
+`));
+
+      expect(hwinfo.memorySwapUsed('openbsd')).to.equal(932864);
+    });
+
+    it('returns null on OpenBSD when an error occurs', function() {
+      const stub = td.function(execa.sync);
+
+      td.when(stub(), { ignoreExtraArgs: true }).thenThrow(new Error('whoops!'));
+      td.replace(execa, 'sync', stub);
+
+      expect(hwinfo.memorySwapUsed('openbsd')).to.be.null;
+    });
+
+    it('returns the expected value on Windows', function() {
+      td.replace(execa, 'sync', stdout(`
+
+CurrentUsage=325
+
+
+
+`));
+
+      expect(hwinfo.memorySwapUsed('win32')).to.equal(340787200);
+    });
+
+    it('returns null on Windows when an error occurs', function() {
+      const stub = td.function(execa.sync);
+
+      td.when(stub(), { ignoreExtraArgs: true }).thenThrow(new Error('whoops!'));
+      td.replace(execa, 'sync', stub);
+
+      expect(hwinfo.memorySwapUsed('win32')).to.be.null;
+    });
+  });
+
+  describe('.processorLoad', function() {
+    it('returns null on Windows', function() {
+      expect(hwinfo.processorLoad('win32')).to.be.null;
+    });
+  });
+
+  describe('.processorSpeed', function() {
+    it('averages the processors\' speeds', function() {
+      td.replace(os, 'cpus', () => [
+        { speed: 1 },
+        { speed: 2 },
+        { speed: 3 },
+        { speed: 4 },
+        { speed: 5 },
+      ]);
+
+      expect(hwinfo.processorSpeed()).to.equal(3);
+    });
+  });
+});
diff --git a/tests/unit/models/instrumentation-test.js b/tests/unit/models/instrumentation-test.js
index 0a4fceb61..8160dac94 100644
--- a/tests/unit/models/instrumentation-test.js
+++ b/tests/unit/models/instrumentation-test.js
@@ -13,6 +13,7 @@ const Yam = require('yam');
 
 const MockProject = require('../../helpers/mock-project');
 const mkTmpDirIn = require('../../../lib/utilities/mk-tmp-dir-in');
+const hwinfo = require('../../../lib/models/hardware-info');
 const Instrumentation = require('../../../lib/models/instrumentation');
 
 const expect = chai.expect;
@@ -804,6 +805,7 @@ describe('models/instrumentation.js', function() {
         expect(summary.buildSteps).to.eql(2); // 2 uncached broccli nodes
         expect(summary.totalTime).to.be.within(0, 2000000); //2ms (in nanoseconds)
         expect(summary).to.have.nested.property('platform.name', process.platform);
+        expect(Object.keys(summary.platform)).to.eql(['name', ...Object.keys(hwinfo)]);
       });
 
       it('computes rebuild summaries', function() {
@@ -839,6 +841,7 @@ describe('models/instrumentation.js', function() {
         expect(summary.buildSteps).to.eql(2); // 2 uncached broccli nodes
         expect(summary.totalTime).to.be.within(0, 2000000); //2ms (in nanoseconds)
         expect(summary).to.have.nested.property('platform.name', process.platform);
+        expect(Object.keys(summary.platform)).to.eql(['name', ...Object.keys(hwinfo)]);
       });
     });
 
@@ -850,6 +853,7 @@ describe('models/instrumentation.js', function() {
 
         expect(summary.totalTime).to.be.within(0, 2000000); //2ms (in nanoseconds)
         expect(summary).to.have.nested.property('platform.name', process.platform);
+        expect(Object.keys(summary.platform)).to.eql(['name', ...Object.keys(hwinfo)]);
       });
     });
 
@@ -863,6 +867,7 @@ describe('models/instrumentation.js', function() {
         expect(summary.args).to.eql(['--like', '--whatever']);
         expect(summary.totalTime).to.be.within(0, 2000000); //2ms (in nanoseconds)
         expect(summary).to.have.nested.property('platform.name', process.platform);
+        expect(Object.keys(summary.platform)).to.eql(['name', ...Object.keys(hwinfo)]);
       });
     });
 
@@ -874,6 +879,7 @@ describe('models/instrumentation.js', function() {
 
         expect(summary.totalTime).to.be.within(0, 2000000); //2ms (in nanoseconds)
         expect(summary).to.have.nested.property('platform.name', process.platform);
+        expect(Object.keys(summary.platform)).to.eql(['name', ...Object.keys(hwinfo)]);
       });
     });
   });