gecko/mobile/android/base/background/healthreport/HealthReportGenerator.java

553 lines
18 KiB
Java

/* 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/. */
package org.mozilla.gecko.background.healthreport;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Set;
import org.json.JSONException;
import org.json.JSONObject;
import org.mozilla.gecko.background.common.log.Logger;
import org.mozilla.gecko.background.healthreport.HealthReportStorage.Field;
import android.database.Cursor;
import android.util.SparseArray;
public class HealthReportGenerator {
private static final int PAYLOAD_VERSION = 3;
private static final String LOG_TAG = "GeckoHealthGen";
private final HealthReportStorage storage;
public HealthReportGenerator(HealthReportStorage storage) {
this.storage = storage;
}
@SuppressWarnings("static-method")
protected long now() {
return System.currentTimeMillis();
}
/**
* @return null if no environment could be computed, or else the resulting document.
* @throws JSONException if there was an error adding environment data to the resulting document.
*/
public JSONObject generateDocument(long since, long lastPingTime, String profilePath) throws JSONException {
Logger.info(LOG_TAG, "Generating FHR document from " + since + "; last ping " + lastPingTime);
Logger.pii(LOG_TAG, "Generating for profile " + profilePath);
ProfileInformationCache cache = new ProfileInformationCache(profilePath);
if (!cache.restoreUnlessInitialized()) {
Logger.warn(LOG_TAG, "Not enough profile information to compute current environment.");
return null;
}
Environment current = EnvironmentBuilder.getCurrentEnvironment(cache);
return generateDocument(since, lastPingTime, current);
}
/**
* The document consists of:
*
*<ul>
*<li>Basic metadata: last ping time, current ping time, version.</li>
*<li>A map of environments: <code>current</code> and others named by hash. <code>current</code> is fully specified,
* and others are deltas from current.</li>
*<li>A <code>data</code> object. This includes <code>last</code> and <code>days</code>.</li>
*</ul>
*
* <code>days</code> is a map from date strings to <tt>{hash: {measurement: {_v: version, fields...}}}</tt>.
* @throws JSONException if there was an error adding environment data to the resulting document.
*/
public JSONObject generateDocument(long since, long lastPingTime, Environment currentEnvironment) throws JSONException {
final String currentHash = currentEnvironment.getHash();
Logger.debug(LOG_TAG, "Current environment hash: " + currentHash);
if (currentHash == null) {
Logger.warn(LOG_TAG, "Current hash is null; aborting.");
return null;
}
// We want to map field IDs to some strings as we go.
SparseArray<Environment> envs = storage.getEnvironmentRecordsByID();
JSONObject document = new JSONObject();
if (lastPingTime >= HealthReportConstants.EARLIEST_LAST_PING) {
document.put("lastPingDate", HealthReportUtils.getDateString(lastPingTime));
}
document.put("thisPingDate", HealthReportUtils.getDateString(now()));
document.put("version", PAYLOAD_VERSION);
document.put("environments", getEnvironmentsJSON(currentEnvironment, envs));
document.put("data", getDataJSON(currentEnvironment, envs, since));
return document;
}
protected JSONObject getDataJSON(Environment currentEnvironment,
SparseArray<Environment> envs, long since) throws JSONException {
SparseArray<Field> fields = storage.getFieldsByID();
JSONObject days = getDaysJSON(currentEnvironment, envs, fields, since);
JSONObject last = new JSONObject();
JSONObject data = new JSONObject();
data.put("days", days);
data.put("last", last);
return data;
}
protected JSONObject getDaysJSON(Environment currentEnvironment, SparseArray<Environment> envs, SparseArray<Field> fields, long since) throws JSONException {
if (Logger.shouldLogVerbose(LOG_TAG)) {
for (int i = 0; i < envs.size(); ++i) {
Logger.trace(LOG_TAG, "Days environment " + envs.keyAt(i) + ": " + envs.get(envs.keyAt(i)).getHash());
}
}
JSONObject days = new JSONObject();
Cursor cursor = storage.getRawEventsSince(since);
try {
if (!cursor.moveToFirst()) {
return days;
}
// A classic walking partition.
// Columns are "date", "env", "field", "value".
// Note that we care about the type (integer, string) and kind
// (last/counter, discrete) of each field.
// Each field will be accessed once for each date/env pair, so
// Field memoizes these facts.
// We also care about which measurement contains each field.
int lastDate = -1;
int lastEnv = -1;
JSONObject dateObject = null;
JSONObject envObject = null;
while (!cursor.isAfterLast()) {
int cEnv = cursor.getInt(1);
if (cEnv == -1 ||
(cEnv != lastEnv &&
envs.indexOfKey(cEnv) < 0)) {
Logger.warn(LOG_TAG, "Invalid environment " + cEnv + " in cursor. Skipping.");
cursor.moveToNext();
continue;
}
int cDate = cursor.getInt(0);
int cField = cursor.getInt(2);
Logger.trace(LOG_TAG, "Event row: " + cDate + ", " + cEnv + ", " + cField);
boolean dateChanged = cDate != lastDate;
boolean envChanged = cEnv != lastEnv;
if (dateChanged) {
if (dateObject != null) {
days.put(HealthReportUtils.getDateStringForDay(lastDate), dateObject);
}
dateObject = new JSONObject();
lastDate = cDate;
}
if (dateChanged || envChanged) {
envObject = new JSONObject();
// This is safe because we checked above that cEnv is valid.
dateObject.put(envs.get(cEnv).getHash(), envObject);
lastEnv = cEnv;
}
final Field field = fields.get(cField);
JSONObject measurement = envObject.optJSONObject(field.measurementName);
if (measurement == null) {
// We will never have more than one measurement version within a
// single environment -- to do so involves changing the build ID. And
// even if we did, we have no way to represent it. So just build the
// output object once.
measurement = new JSONObject();
measurement.put("_v", field.measurementVersion);
envObject.put(field.measurementName, measurement);
}
// How we record depends on the type of the field, so we
// break this out into a separate method for clarity.
recordMeasurementFromCursor(field, measurement, cursor);
cursor.moveToNext();
continue;
}
days.put(HealthReportUtils.getDateStringForDay(lastDate), dateObject);
} finally {
cursor.close();
}
return days;
}
/**
* Return the {@link JSONObject} parsed from the provided index of the given
* cursor, or {@link JSONObject#NULL} if either SQL <code>NULL</code> or
* string <code>"null"</code> is present at that index.
*/
private static Object getJSONAtIndex(Cursor cursor, int index) throws JSONException {
if (cursor.isNull(index)) {
return JSONObject.NULL;
}
final String value = cursor.getString(index);
if ("null".equals(value)) {
return JSONObject.NULL;
}
return new JSONObject(value);
}
protected static void recordMeasurementFromCursor(final Field field,
JSONObject measurement,
Cursor cursor)
throws JSONException {
if (field.isDiscreteField()) {
// Discrete counted. Increment the named counter.
if (field.isCountedField()) {
if (!field.isStringField()) {
throw new IllegalStateException("Unable to handle non-string counted types.");
}
HealthReportUtils.count(measurement, field.fieldName, cursor.getString(3));
return;
}
// Discrete string or integer. Append it.
if (field.isStringField()) {
HealthReportUtils.append(measurement, field.fieldName, cursor.getString(3));
return;
}
if (field.isJSONField()) {
HealthReportUtils.append(measurement, field.fieldName, getJSONAtIndex(cursor, 3));
return;
}
if (field.isIntegerField()) {
HealthReportUtils.append(measurement, field.fieldName, cursor.getLong(3));
return;
}
throw new IllegalStateException("Unknown field type: " + field.flags);
}
// Non-discrete -- must be LAST or COUNTER, so just accumulate the value.
if (field.isStringField()) {
measurement.put(field.fieldName, cursor.getString(3));
return;
}
if (field.isJSONField()) {
measurement.put(field.fieldName, getJSONAtIndex(cursor, 3));
return;
}
measurement.put(field.fieldName, cursor.getLong(3));
}
public static JSONObject getEnvironmentsJSON(Environment currentEnvironment,
SparseArray<Environment> envs) throws JSONException {
JSONObject environments = new JSONObject();
// Always do this, even if it hasn't recorded anything in the DB.
environments.put("current", jsonify(currentEnvironment, null));
String currentHash = currentEnvironment.getHash();
for (int i = 0; i < envs.size(); i++) {
Environment e = envs.valueAt(i);
if (currentHash.equals(e.getHash())) {
continue;
}
environments.put(e.getHash(), jsonify(e, currentEnvironment));
}
return environments;
}
public static JSONObject jsonify(Environment e, Environment current) throws JSONException {
JSONObject age = getProfileAge(e, current);
JSONObject sysinfo = getSysInfo(e, current);
JSONObject gecko = getGeckoInfo(e, current);
JSONObject appinfo = getAppInfo(e, current);
JSONObject counts = getAddonCounts(e, current);
JSONObject out = new JSONObject();
if (age != null)
out.put("org.mozilla.profile.age", age);
if (sysinfo != null)
out.put("org.mozilla.sysinfo.sysinfo", sysinfo);
if (gecko != null)
out.put("geckoAppInfo", gecko);
if (appinfo != null)
out.put("org.mozilla.appInfo.appinfo", appinfo);
if (counts != null)
out.put("org.mozilla.addons.counts", counts);
JSONObject active = getActiveAddons(e, current);
if (active != null)
out.put("org.mozilla.addons.active", active);
if (current == null) {
out.put("hash", e.getHash());
}
return out;
}
private static JSONObject getProfileAge(Environment e, Environment current) throws JSONException {
JSONObject age = new JSONObject();
int changes = 0;
if (current == null || current.profileCreation != e.profileCreation) {
age.put("profileCreation", e.profileCreation);
changes++;
}
if (current != null && changes == 0) {
return null;
}
age.put("_v", 1);
return age;
}
private static JSONObject getSysInfo(Environment e, Environment current) throws JSONException {
JSONObject sysinfo = new JSONObject();
int changes = 0;
if (current == null || current.cpuCount != e.cpuCount) {
sysinfo.put("cpuCount", e.cpuCount);
changes++;
}
if (current == null || current.memoryMB != e.memoryMB) {
sysinfo.put("memoryMB", e.memoryMB);
changes++;
}
if (current == null || !current.architecture.equals(e.architecture)) {
sysinfo.put("architecture", e.architecture);
changes++;
}
if (current == null || !current.sysName.equals(e.sysName)) {
sysinfo.put("name", e.sysName);
changes++;
}
if (current == null || !current.sysVersion.equals(e.sysVersion)) {
sysinfo.put("version", e.sysVersion);
changes++;
}
if (current != null && changes == 0) {
return null;
}
sysinfo.put("_v", 1);
return sysinfo;
}
private static JSONObject getGeckoInfo(Environment e, Environment current) throws JSONException {
JSONObject gecko = new JSONObject();
int changes = 0;
if (current == null || !current.vendor.equals(e.vendor)) {
gecko.put("vendor", e.vendor);
changes++;
}
if (current == null || !current.appName.equals(e.appName)) {
gecko.put("name", e.appName);
changes++;
}
if (current == null || !current.appID.equals(e.appID)) {
gecko.put("id", e.appID);
changes++;
}
if (current == null || !current.appVersion.equals(e.appVersion)) {
gecko.put("version", e.appVersion);
changes++;
}
if (current == null || !current.appBuildID.equals(e.appBuildID)) {
gecko.put("appBuildID", e.appBuildID);
changes++;
}
if (current == null || !current.platformVersion.equals(e.platformVersion)) {
gecko.put("platformVersion", e.platformVersion);
changes++;
}
if (current == null || !current.platformBuildID.equals(e.platformBuildID)) {
gecko.put("platformBuildID", e.platformBuildID);
changes++;
}
if (current == null || !current.os.equals(e.os)) {
gecko.put("os", e.os);
changes++;
}
if (current == null || !current.xpcomabi.equals(e.xpcomabi)) {
gecko.put("xpcomabi", e.xpcomabi);
changes++;
}
if (current == null || !current.updateChannel.equals(e.updateChannel)) {
gecko.put("updateChannel", e.updateChannel);
changes++;
}
if (current != null && changes == 0) {
return null;
}
gecko.put("_v", 1);
return gecko;
}
private static JSONObject getAppInfo(Environment e, Environment current) throws JSONException {
JSONObject appinfo = new JSONObject();
int changes = 0;
if (current == null || current.isBlocklistEnabled != e.isBlocklistEnabled) {
appinfo.put("isBlocklistEnabled", e.isBlocklistEnabled);
changes++;
}
if (current == null || current.isTelemetryEnabled != e.isTelemetryEnabled) {
appinfo.put("isTelemetryEnabled", e.isTelemetryEnabled);
changes++;
}
if (current != null && changes == 0) {
return null;
}
appinfo.put("_v", 2);
return appinfo;
}
private static JSONObject getAddonCounts(Environment e, Environment current) throws JSONException {
JSONObject counts = new JSONObject();
int changes = 0;
if (current == null || current.extensionCount != e.extensionCount) {
counts.put("extension", e.extensionCount);
changes++;
}
if (current == null || current.pluginCount != e.pluginCount) {
counts.put("plugin", e.pluginCount);
changes++;
}
if (current == null || current.themeCount != e.themeCount) {
counts.put("theme", e.themeCount);
changes++;
}
if (current != null && changes == 0) {
return null;
}
counts.put("_v", 1);
return counts;
}
/**
* Compute the *tree* difference set between the two objects. If the two
* objects are identical, returns <code>null</code>. If <code>from</code> is
* <code>null</code>, returns <code>to</code>. If <code>to</code> is
* <code>null</code>, behaves as if <code>to</code> were an empty object.
*
* (Note that this method does not check for {@link JSONObject#NULL}, because
* by definition it can't be provided as input to this method.)
*
* This behavior is intended to simplify life for callers: a missing object
* can be viewed as (and behaves as) an empty map, to a useful extent, rather
* than throwing an exception.
*
* @param from
* a JSONObject.
* @param to
* a JSONObject.
* @param includeNull
* if true, keys present in <code>from</code> but not in
* <code>to</code> are included as {@link JSONObject#NULL} in the
* output.
*
* @return a JSONObject, or null if the two objects are identical.
* @throws JSONException
* should not occur, but...
*/
public static JSONObject diff(JSONObject from,
JSONObject to,
boolean includeNull) throws JSONException {
if (from == null) {
return to;
}
if (to == null) {
return diff(from, new JSONObject(), includeNull);
}
JSONObject out = new JSONObject();
HashSet<String> toKeys = includeNull ? new HashSet<String>(to.length())
: null;
@SuppressWarnings("unchecked")
Iterator<String> it = to.keys();
while (it.hasNext()) {
String key = it.next();
// Track these as we go if we'll need them later.
if (includeNull) {
toKeys.add(key);
}
Object value = to.get(key);
if (!from.has(key)) {
// It must be new.
out.put(key, value);
continue;
}
// Not new? Then see if it changed.
Object old = from.get(key);
// Two JSONObjects should be diffed.
if (old instanceof JSONObject && value instanceof JSONObject) {
JSONObject innerDiff = diff(((JSONObject) old), ((JSONObject) value),
includeNull);
// No change? No output.
if (innerDiff == null) {
continue;
}
// Otherwise include the diff.
out.put(key, innerDiff);
continue;
}
// A regular value, or a type change. Only skip if they're the same.
if (value.equals(old)) {
continue;
}
out.put(key, value);
}
// Now -- if requested -- include any removed keys.
if (includeNull) {
Set<String> fromKeys = HealthReportUtils.keySet(from);
fromKeys.removeAll(toKeys);
for (String notPresent : fromKeys) {
out.put(notPresent, JSONObject.NULL);
}
}
if (out.length() == 0) {
return null;
}
return out;
}
private static JSONObject getActiveAddons(Environment e, Environment current) throws JSONException {
// Just return the current add-on set, with a version annotation.
// To do so requires copying.
if (current == null) {
JSONObject out = e.getNonIgnoredAddons();
if (out == null) {
Logger.warn(LOG_TAG, "Null add-ons to return in FHR document. Returning {}.");
out = new JSONObject(); // So that we always return something.
}
out.put("_v", 1);
return out;
}
// Otherwise, return the diff.
JSONObject diff = diff(current.getNonIgnoredAddons(), e.getNonIgnoredAddons(), true);
if (diff == null) {
return null;
}
if (diff == e.addons) {
// Again, needs to copy.
return getActiveAddons(e, null);
}
diff.put("_v", 1);
return diff;
}
}