Wed, 31 Dec 2014 07:22:50 +0100
Correct previous dual key logic pending first delivery installment.
1 /* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
5 package org.mozilla.gecko.background.healthreport;
7 import java.util.HashSet;
8 import java.util.Iterator;
9 import java.util.Set;
11 import org.json.JSONException;
12 import org.json.JSONObject;
13 import org.mozilla.gecko.background.common.DateUtils.DateFormatter;
14 import org.mozilla.gecko.background.common.log.Logger;
15 import org.mozilla.gecko.background.healthreport.HealthReportStorage.Field;
17 import android.database.Cursor;
18 import android.util.SparseArray;
20 public class HealthReportGenerator {
21 private static final int PAYLOAD_VERSION = 3;
23 private static final String LOG_TAG = "GeckoHealthGen";
25 private final HealthReportStorage storage;
26 private final DateFormatter dateFormatter;
28 public HealthReportGenerator(HealthReportStorage storage) {
29 this.storage = storage;
30 this.dateFormatter = new DateFormatter();
31 }
33 @SuppressWarnings("static-method")
34 protected long now() {
35 return System.currentTimeMillis();
36 }
38 /**
39 * Ensure that you have initialized the Locale to your satisfaction
40 * prior to calling this method.
41 *
42 * @return null if no environment could be computed, or else the resulting document.
43 * @throws JSONException if there was an error adding environment data to the resulting document.
44 */
45 public JSONObject generateDocument(long since, long lastPingTime, String profilePath) throws JSONException {
46 Logger.info(LOG_TAG, "Generating FHR document from " + since + "; last ping " + lastPingTime);
47 Logger.pii(LOG_TAG, "Generating for profile " + profilePath);
49 ProfileInformationCache cache = new ProfileInformationCache(profilePath);
50 if (!cache.restoreUnlessInitialized()) {
51 Logger.warn(LOG_TAG, "Not enough profile information to compute current environment.");
52 return null;
53 }
54 Environment current = EnvironmentBuilder.getCurrentEnvironment(cache);
55 return generateDocument(since, lastPingTime, current);
56 }
58 /**
59 * The document consists of:
60 *
61 *<ul>
62 *<li>Basic metadata: last ping time, current ping time, version.</li>
63 *<li>A map of environments: <code>current</code> and others named by hash. <code>current</code> is fully specified,
64 * and others are deltas from current.</li>
65 *<li>A <code>data</code> object. This includes <code>last</code> and <code>days</code>.</li>
66 *</ul>
67 *
68 * <code>days</code> is a map from date strings to <tt>{hash: {measurement: {_v: version, fields...}}}</tt>.
69 * @throws JSONException if there was an error adding environment data to the resulting document.
70 */
71 public JSONObject generateDocument(long since, long lastPingTime, Environment currentEnvironment) throws JSONException {
72 final String currentHash = currentEnvironment.getHash();
74 Logger.debug(LOG_TAG, "Current environment hash: " + currentHash);
75 if (currentHash == null) {
76 Logger.warn(LOG_TAG, "Current hash is null; aborting.");
77 return null;
78 }
80 // We want to map field IDs to some strings as we go.
81 SparseArray<Environment> envs = storage.getEnvironmentRecordsByID();
83 JSONObject document = new JSONObject();
85 if (lastPingTime >= HealthReportConstants.EARLIEST_LAST_PING) {
86 document.put("lastPingDate", dateFormatter.getDateString(lastPingTime));
87 }
89 document.put("thisPingDate", dateFormatter.getDateString(now()));
90 document.put("version", PAYLOAD_VERSION);
92 document.put("environments", getEnvironmentsJSON(currentEnvironment, envs));
93 document.put("data", getDataJSON(currentEnvironment, envs, since));
95 return document;
96 }
98 protected JSONObject getDataJSON(Environment currentEnvironment,
99 SparseArray<Environment> envs, long since) throws JSONException {
100 SparseArray<Field> fields = storage.getFieldsByID();
102 JSONObject days = getDaysJSON(currentEnvironment, envs, fields, since);
104 JSONObject last = new JSONObject();
106 JSONObject data = new JSONObject();
107 data.put("days", days);
108 data.put("last", last);
109 return data;
110 }
112 protected JSONObject getDaysJSON(Environment currentEnvironment, SparseArray<Environment> envs, SparseArray<Field> fields, long since) throws JSONException {
113 if (Logger.shouldLogVerbose(LOG_TAG)) {
114 for (int i = 0; i < envs.size(); ++i) {
115 Logger.trace(LOG_TAG, "Days environment " + envs.keyAt(i) + ": " + envs.get(envs.keyAt(i)).getHash());
116 }
117 }
119 JSONObject days = new JSONObject();
120 Cursor cursor = storage.getRawEventsSince(since);
121 try {
122 if (!cursor.moveToFirst()) {
123 return days;
124 }
126 // A classic walking partition.
127 // Columns are "date", "env", "field", "value".
128 // Note that we care about the type (integer, string) and kind
129 // (last/counter, discrete) of each field.
130 // Each field will be accessed once for each date/env pair, so
131 // Field memoizes these facts.
132 // We also care about which measurement contains each field.
133 int lastDate = -1;
134 int lastEnv = -1;
135 JSONObject dateObject = null;
136 JSONObject envObject = null;
138 while (!cursor.isAfterLast()) {
139 int cEnv = cursor.getInt(1);
140 if (cEnv == -1 ||
141 (cEnv != lastEnv &&
142 envs.indexOfKey(cEnv) < 0)) {
143 Logger.warn(LOG_TAG, "Invalid environment " + cEnv + " in cursor. Skipping.");
144 cursor.moveToNext();
145 continue;
146 }
148 int cDate = cursor.getInt(0);
149 int cField = cursor.getInt(2);
151 Logger.trace(LOG_TAG, "Event row: " + cDate + ", " + cEnv + ", " + cField);
152 boolean dateChanged = cDate != lastDate;
153 boolean envChanged = cEnv != lastEnv;
155 if (dateChanged) {
156 if (dateObject != null) {
157 days.put(dateFormatter.getDateStringForDay(lastDate), dateObject);
158 }
159 dateObject = new JSONObject();
160 lastDate = cDate;
161 }
163 if (dateChanged || envChanged) {
164 envObject = new JSONObject();
165 // This is safe because we checked above that cEnv is valid.
166 dateObject.put(envs.get(cEnv).getHash(), envObject);
167 lastEnv = cEnv;
168 }
170 final Field field = fields.get(cField);
171 JSONObject measurement = envObject.optJSONObject(field.measurementName);
172 if (measurement == null) {
173 // We will never have more than one measurement version within a
174 // single environment -- to do so involves changing the build ID. And
175 // even if we did, we have no way to represent it. So just build the
176 // output object once.
177 measurement = new JSONObject();
178 measurement.put("_v", field.measurementVersion);
179 envObject.put(field.measurementName, measurement);
180 }
182 // How we record depends on the type of the field, so we
183 // break this out into a separate method for clarity.
184 recordMeasurementFromCursor(field, measurement, cursor);
186 cursor.moveToNext();
187 continue;
188 }
189 days.put(dateFormatter.getDateStringForDay(lastDate), dateObject);
190 } finally {
191 cursor.close();
192 }
193 return days;
194 }
196 /**
197 * Return the {@link JSONObject} parsed from the provided index of the given
198 * cursor, or {@link JSONObject#NULL} if either SQL <code>NULL</code> or
199 * string <code>"null"</code> is present at that index.
200 */
201 private static Object getJSONAtIndex(Cursor cursor, int index) throws JSONException {
202 if (cursor.isNull(index)) {
203 return JSONObject.NULL;
204 }
205 final String value = cursor.getString(index);
206 if ("null".equals(value)) {
207 return JSONObject.NULL;
208 }
209 return new JSONObject(value);
210 }
212 protected static void recordMeasurementFromCursor(final Field field,
213 JSONObject measurement,
214 Cursor cursor)
215 throws JSONException {
216 if (field.isDiscreteField()) {
217 // Discrete counted. Increment the named counter.
218 if (field.isCountedField()) {
219 if (!field.isStringField()) {
220 throw new IllegalStateException("Unable to handle non-string counted types.");
221 }
222 HealthReportUtils.count(measurement, field.fieldName, cursor.getString(3));
223 return;
224 }
226 // Discrete string or integer. Append it.
227 if (field.isStringField()) {
228 HealthReportUtils.append(measurement, field.fieldName, cursor.getString(3));
229 return;
230 }
231 if (field.isJSONField()) {
232 HealthReportUtils.append(measurement, field.fieldName, getJSONAtIndex(cursor, 3));
233 return;
234 }
235 if (field.isIntegerField()) {
236 HealthReportUtils.append(measurement, field.fieldName, cursor.getLong(3));
237 return;
238 }
239 throw new IllegalStateException("Unknown field type: " + field.flags);
240 }
242 // Non-discrete -- must be LAST or COUNTER, so just accumulate the value.
243 if (field.isStringField()) {
244 measurement.put(field.fieldName, cursor.getString(3));
245 return;
246 }
247 if (field.isJSONField()) {
248 measurement.put(field.fieldName, getJSONAtIndex(cursor, 3));
249 return;
250 }
251 measurement.put(field.fieldName, cursor.getLong(3));
252 }
254 public static JSONObject getEnvironmentsJSON(Environment currentEnvironment,
255 SparseArray<Environment> envs) throws JSONException {
256 JSONObject environments = new JSONObject();
258 // Always do this, even if it hasn't recorded anything in the DB.
259 environments.put("current", jsonify(currentEnvironment, null));
261 String currentHash = currentEnvironment.getHash();
262 for (int i = 0; i < envs.size(); i++) {
263 Environment e = envs.valueAt(i);
264 if (currentHash.equals(e.getHash())) {
265 continue;
266 }
267 environments.put(e.getHash(), jsonify(e, currentEnvironment));
268 }
269 return environments;
270 }
272 public static JSONObject jsonify(Environment e, Environment current) throws JSONException {
273 JSONObject age = getProfileAge(e, current);
274 JSONObject sysinfo = getSysInfo(e, current);
275 JSONObject gecko = getGeckoInfo(e, current);
276 JSONObject appinfo = getAppInfo(e, current);
277 JSONObject counts = getAddonCounts(e, current);
279 JSONObject out = new JSONObject();
280 if (age != null)
281 out.put("org.mozilla.profile.age", age);
282 if (sysinfo != null)
283 out.put("org.mozilla.sysinfo.sysinfo", sysinfo);
284 if (gecko != null)
285 out.put("geckoAppInfo", gecko);
286 if (appinfo != null)
287 out.put("org.mozilla.appInfo.appinfo", appinfo);
288 if (counts != null)
289 out.put("org.mozilla.addons.counts", counts);
291 JSONObject active = getActiveAddons(e, current);
292 if (active != null)
293 out.put("org.mozilla.addons.active", active);
295 if (current == null) {
296 out.put("hash", e.getHash());
297 }
298 return out;
299 }
301 private static JSONObject getProfileAge(Environment e, Environment current) throws JSONException {
302 JSONObject age = new JSONObject();
303 int changes = 0;
304 if (current == null || current.profileCreation != e.profileCreation) {
305 age.put("profileCreation", e.profileCreation);
306 changes++;
307 }
308 if (current != null && changes == 0) {
309 return null;
310 }
311 age.put("_v", 1);
312 return age;
313 }
315 private static JSONObject getSysInfo(Environment e, Environment current) throws JSONException {
316 JSONObject sysinfo = new JSONObject();
317 int changes = 0;
318 if (current == null || current.cpuCount != e.cpuCount) {
319 sysinfo.put("cpuCount", e.cpuCount);
320 changes++;
321 }
322 if (current == null || current.memoryMB != e.memoryMB) {
323 sysinfo.put("memoryMB", e.memoryMB);
324 changes++;
325 }
326 if (current == null || !current.architecture.equals(e.architecture)) {
327 sysinfo.put("architecture", e.architecture);
328 changes++;
329 }
330 if (current == null || !current.sysName.equals(e.sysName)) {
331 sysinfo.put("name", e.sysName);
332 changes++;
333 }
334 if (current == null || !current.sysVersion.equals(e.sysVersion)) {
335 sysinfo.put("version", e.sysVersion);
336 changes++;
337 }
338 if (current != null && changes == 0) {
339 return null;
340 }
341 sysinfo.put("_v", 1);
342 return sysinfo;
343 }
345 private static JSONObject getGeckoInfo(Environment e, Environment current) throws JSONException {
346 JSONObject gecko = new JSONObject();
347 int changes = 0;
348 if (current == null || !current.vendor.equals(e.vendor)) {
349 gecko.put("vendor", e.vendor);
350 changes++;
351 }
352 if (current == null || !current.appName.equals(e.appName)) {
353 gecko.put("name", e.appName);
354 changes++;
355 }
356 if (current == null || !current.appID.equals(e.appID)) {
357 gecko.put("id", e.appID);
358 changes++;
359 }
360 if (current == null || !current.appVersion.equals(e.appVersion)) {
361 gecko.put("version", e.appVersion);
362 changes++;
363 }
364 if (current == null || !current.appBuildID.equals(e.appBuildID)) {
365 gecko.put("appBuildID", e.appBuildID);
366 changes++;
367 }
368 if (current == null || !current.platformVersion.equals(e.platformVersion)) {
369 gecko.put("platformVersion", e.platformVersion);
370 changes++;
371 }
372 if (current == null || !current.platformBuildID.equals(e.platformBuildID)) {
373 gecko.put("platformBuildID", e.platformBuildID);
374 changes++;
375 }
376 if (current == null || !current.os.equals(e.os)) {
377 gecko.put("os", e.os);
378 changes++;
379 }
380 if (current == null || !current.xpcomabi.equals(e.xpcomabi)) {
381 gecko.put("xpcomabi", e.xpcomabi);
382 changes++;
383 }
384 if (current == null || !current.updateChannel.equals(e.updateChannel)) {
385 gecko.put("updateChannel", e.updateChannel);
386 changes++;
387 }
388 if (current != null && changes == 0) {
389 return null;
390 }
391 gecko.put("_v", 1);
392 return gecko;
393 }
395 // Null-safe string comparison.
396 private static boolean stringsDiffer(final String a, final String b) {
397 if (a == null) {
398 return b != null;
399 }
400 return !a.equals(b);
401 }
403 private static JSONObject getAppInfo(Environment e, Environment current) throws JSONException {
404 JSONObject appinfo = new JSONObject();
406 Logger.debug(LOG_TAG, "Generating appinfo for v" + e.version + " env " + e.hash);
408 // Is the environment in question newer than the diff target, or is
409 // there no diff target?
410 final boolean outdated = current == null ||
411 e.version > current.version;
413 // Is the environment in question a different version (lower or higher),
414 // or is there no diff target?
415 final boolean differ = outdated || current.version > e.version;
417 // Always produce an output object if there's a version mismatch or this
418 // isn't a diff. Otherwise, track as we go if there's any difference.
419 boolean changed = differ;
421 switch (e.version) {
422 // There's a straightforward correspondence between environment versions
423 // and appinfo versions.
424 case 2:
425 appinfo.put("_v", 3);
426 break;
427 case 1:
428 appinfo.put("_v", 2);
429 break;
430 default:
431 Logger.warn(LOG_TAG, "Unknown environment version: " + e.version);
432 return appinfo;
433 }
435 switch (e.version) {
436 case 2:
437 if (populateAppInfoV2(appinfo, e, current, outdated)) {
438 changed = true;
439 }
440 // Fall through.
442 case 1:
443 // There is no older version than v1, so don't check outdated.
444 if (populateAppInfoV1(e, current, appinfo)) {
445 changed = true;
446 }
447 }
449 if (!changed) {
450 return null;
451 }
453 return appinfo;
454 }
456 private static boolean populateAppInfoV1(Environment e,
457 Environment current,
458 JSONObject appinfo)
459 throws JSONException {
460 boolean changes = false;
461 if (current == null || current.isBlocklistEnabled != e.isBlocklistEnabled) {
462 appinfo.put("isBlocklistEnabled", e.isBlocklistEnabled);
463 changes = true;
464 }
466 if (current == null || current.isTelemetryEnabled != e.isTelemetryEnabled) {
467 appinfo.put("isTelemetryEnabled", e.isTelemetryEnabled);
468 changes = true;
469 }
471 return changes;
472 }
474 private static boolean populateAppInfoV2(JSONObject appinfo,
475 Environment e,
476 Environment current,
477 final boolean outdated)
478 throws JSONException {
479 boolean changes = false;
480 if (outdated ||
481 stringsDiffer(current.osLocale, e.osLocale)) {
482 appinfo.put("osLocale", e.osLocale);
483 changes = true;
484 }
486 if (outdated ||
487 stringsDiffer(current.appLocale, e.appLocale)) {
488 appinfo.put("appLocale", e.appLocale);
489 changes = true;
490 }
492 if (outdated ||
493 stringsDiffer(current.distribution, e.distribution)) {
494 appinfo.put("distribution", e.distribution);
495 changes = true;
496 }
498 if (outdated ||
499 current.acceptLangSet != e.acceptLangSet) {
500 appinfo.put("acceptLangIsUserSet", e.acceptLangSet);
501 changes = true;
502 }
503 return changes;
504 }
506 private static JSONObject getAddonCounts(Environment e, Environment current) throws JSONException {
507 JSONObject counts = new JSONObject();
508 int changes = 0;
509 if (current == null || current.extensionCount != e.extensionCount) {
510 counts.put("extension", e.extensionCount);
511 changes++;
512 }
513 if (current == null || current.pluginCount != e.pluginCount) {
514 counts.put("plugin", e.pluginCount);
515 changes++;
516 }
517 if (current == null || current.themeCount != e.themeCount) {
518 counts.put("theme", e.themeCount);
519 changes++;
520 }
521 if (current != null && changes == 0) {
522 return null;
523 }
524 counts.put("_v", 1);
525 return counts;
526 }
528 /**
529 * Compute the *tree* difference set between the two objects. If the two
530 * objects are identical, returns <code>null</code>. If <code>from</code> is
531 * <code>null</code>, returns <code>to</code>. If <code>to</code> is
532 * <code>null</code>, behaves as if <code>to</code> were an empty object.
533 *
534 * (Note that this method does not check for {@link JSONObject#NULL}, because
535 * by definition it can't be provided as input to this method.)
536 *
537 * This behavior is intended to simplify life for callers: a missing object
538 * can be viewed as (and behaves as) an empty map, to a useful extent, rather
539 * than throwing an exception.
540 *
541 * @param from
542 * a JSONObject.
543 * @param to
544 * a JSONObject.
545 * @param includeNull
546 * if true, keys present in <code>from</code> but not in
547 * <code>to</code> are included as {@link JSONObject#NULL} in the
548 * output.
549 *
550 * @return a JSONObject, or null if the two objects are identical.
551 * @throws JSONException
552 * should not occur, but...
553 */
554 public static JSONObject diff(JSONObject from,
555 JSONObject to,
556 boolean includeNull) throws JSONException {
557 if (from == null) {
558 return to;
559 }
561 if (to == null) {
562 return diff(from, new JSONObject(), includeNull);
563 }
565 JSONObject out = new JSONObject();
567 HashSet<String> toKeys = includeNull ? new HashSet<String>(to.length())
568 : null;
570 @SuppressWarnings("unchecked")
571 Iterator<String> it = to.keys();
572 while (it.hasNext()) {
573 String key = it.next();
575 // Track these as we go if we'll need them later.
576 if (includeNull) {
577 toKeys.add(key);
578 }
580 Object value = to.get(key);
581 if (!from.has(key)) {
582 // It must be new.
583 out.put(key, value);
584 continue;
585 }
587 // Not new? Then see if it changed.
588 Object old = from.get(key);
590 // Two JSONObjects should be diffed.
591 if (old instanceof JSONObject && value instanceof JSONObject) {
592 JSONObject innerDiff = diff(((JSONObject) old), ((JSONObject) value),
593 includeNull);
594 // No change? No output.
595 if (innerDiff == null) {
596 continue;
597 }
599 // Otherwise include the diff.
600 out.put(key, innerDiff);
601 continue;
602 }
604 // A regular value, or a type change. Only skip if they're the same.
605 if (value.equals(old)) {
606 continue;
607 }
608 out.put(key, value);
609 }
611 // Now -- if requested -- include any removed keys.
612 if (includeNull) {
613 Set<String> fromKeys = HealthReportUtils.keySet(from);
614 fromKeys.removeAll(toKeys);
615 for (String notPresent : fromKeys) {
616 out.put(notPresent, JSONObject.NULL);
617 }
618 }
620 if (out.length() == 0) {
621 return null;
622 }
623 return out;
624 }
626 private static JSONObject getActiveAddons(Environment e, Environment current) throws JSONException {
627 // Just return the current add-on set, with a version annotation.
628 // To do so requires copying.
629 if (current == null) {
630 JSONObject out = e.getNonIgnoredAddons();
631 if (out == null) {
632 Logger.warn(LOG_TAG, "Null add-ons to return in FHR document. Returning {}.");
633 out = new JSONObject(); // So that we always return something.
634 }
635 out.put("_v", 1);
636 return out;
637 }
639 // Otherwise, return the diff.
640 JSONObject diff = diff(current.getNonIgnoredAddons(), e.getNonIgnoredAddons(), true);
641 if (diff == null) {
642 return null;
643 }
644 if (diff == e.addons) {
645 // Again, needs to copy.
646 return getActiveAddons(e, null);
647 }
649 diff.put("_v", 1);
650 return diff;
651 }
652 }