Added collector for runtime restarts.

Bug: 115906683
Test: RuntimeRestartCollectorTest.java(added); MetricUtilTest.java
(updated)
Change-Id: Iaa111663d54a26e20f508dece1eae817bd09e11a
diff --git a/src/com/android/tradefed/device/metric/RuntimeRestartCollector.java b/src/com/android/tradefed/device/metric/RuntimeRestartCollector.java
new file mode 100644
index 0000000..16e28c1
--- /dev/null
+++ b/src/com/android/tradefed/device/metric/RuntimeRestartCollector.java
@@ -0,0 +1,349 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.tradefed.device.metric;
+
+import static com.android.tradefed.util.proto.TfMetricProtoUtil.stringToMetric;
+
+import com.android.os.AtomsProto.Atom;
+import com.android.os.StatsLog.EventMetricData;
+import com.android.os.StatsLog.StatsdStatsReport;
+import com.android.tradefed.config.OptionClass;
+import com.android.tradefed.device.DeviceNotAvailableException;
+import com.android.tradefed.device.ITestDevice;
+import com.android.tradefed.log.LogUtil.CLog;
+import com.android.tradefed.metrics.proto.MetricMeasurement.Metric;
+import com.android.tradefed.util.statsd.ConfigUtil;
+import com.android.tradefed.util.statsd.MetricUtil;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.Iterables;
+import com.google.protobuf.InvalidProtocolBufferException;
+
+import java.io.IOException;
+import java.text.SimpleDateFormat;
+import java.util.Arrays;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.NoSuchElementException;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
+
+/**
+ * Collector that collects timestamps of runtime restarts (system server crashes) during the test
+ * run, if any.
+ *
+ * <p>Outputs results in counts, wall clock time in seconds and in HH:mm:ss format, and system
+ * uptime in nanoseconds and HH:mm:ss format.
+ *
+ * <p>This collector uses two sources for system server crashes:
+ *
+ * <p>
+ *
+ * <ol>
+ *   <li>The system_restart_sec list from StatsdStatsReport, which is a rolling list of 20
+ *       timestamps when the system server crashes, in seconds, with newer crashes appended to the
+ *       end (when the list fills up, older timestamps fall off the beginning).
+ *   <li>The AppCrashOccurred statsd atom, where a system server crash shows up as a system_server
+ *       process crash (this behavior is documented in the statsd atoms.proto definition). The event
+ *       metric gives the device uptime when the crash occurs.
+ * </ol>
+ *
+ * <p>Both can be useful information, as the former makes it easy to correlate timestamps in logs,
+ * while the latter serves as a longevity metric.
+ */
+@OptionClass(alias = "runtime-restart-collector")
+public class RuntimeRestartCollector extends BaseDeviceMetricCollector {
+    private static final String METRIC_SEP = "-";
+    public static final String METRIC_PREFIX = "runtime-restart";
+    public static final String METRIC_SUFFIX_COUNT = "count";
+    public static final String METRIC_SUFFIX_SYSTEM_TIMESTAMP_SECS = "timestamps_secs";
+    public static final String METRIC_SUFFIX_SYSTEM_TIMESTAMP_FORMATTED = "timestamps_str";
+    public static final String METRIC_SUFFIX_UPTIME_NANOS = "uptime_nanos";
+    public static final String METRIC_SUFFIX_UPTIME_FORMATTED = "uptime_str";
+    public static final SimpleDateFormat TIME_FORMATTER = new SimpleDateFormat("HH:mm:ss");
+    public static final String SYSTEM_SERVER_KEYWORD = "system_server";
+
+    private List<ITestDevice> mTestDevices;
+    // Map to store the runtime restart timestamps for each device, keyed by device serial numbers.
+    private Map<String, List<Integer>> mExistingTimestamps = new HashMap<>();
+    // Map to store the statsd config IDs for each device, keyed by device serial numbers.
+    private Map<String, Long> mDeviceConfigIds = new HashMap<>();
+    // Flag variable to indicate whether including the device serial in the metrics is needed.
+    // Device serial is included when there are more than one device under test.
+    private boolean mIncludeDeviceSerial = false;
+
+    /**
+     * Store the existing timestamps of system server restarts prior to the test run as statsd keeps
+     * a running log of them, and push the config to collect app crashes.
+     */
+    @Override
+    public void onTestRunStart(DeviceMetricData runData) {
+        mTestDevices = getDevices();
+        mIncludeDeviceSerial = (mTestDevices.size() > 1);
+        for (ITestDevice device : mTestDevices) {
+            // Pull statsd metadata (StatsdStatsReport) from the device and keep track of existing
+            // runtime restart timestamps.
+            try {
+                List<Integer> existingTimestamps =
+                        getStatsdMetadata(device).getSystemRestartSecList();
+                mExistingTimestamps.put(device.getSerialNumber(), existingTimestamps);
+            } catch (DeviceNotAvailableException | InvalidProtocolBufferException e) {
+                // Error is not thrown as we still want to process the other devices.
+                CLog.e(
+                        "Failed to get statsd metadata from device %s. Exception: %s.",
+                        device.getSerialNumber(), e);
+            }
+
+            // Register statsd config to collect app crashes.
+            try {
+                mDeviceConfigIds.put(
+                        device.getSerialNumber(),
+                        pushStatsConfig(
+                                device, Arrays.asList(Atom.APP_CRASH_OCCURRED_FIELD_NUMBER)));
+            } catch (DeviceNotAvailableException | IOException e) {
+                // Error is not thrown as we still want to push the config to other devices.
+                CLog.e(
+                        "Failed to push statsd config to device %s. Exception: %s.",
+                        device.getSerialNumber(), e);
+            }
+        }
+    }
+
+    /**
+     * Pull the timestamps at the end of the test run and report the difference with existing ones,
+     * if any.
+     */
+    @Override
+    public void onTestRunEnd(
+            DeviceMetricData runData, final Map<String, Metric> currentRunMetrics) {
+        for (ITestDevice device : mTestDevices) {
+            // Pull statsd metadata again to look at the changes of system server crash timestamps
+            // during the test run, and report them.
+            // A copy of the list is created as the list returned by the proto is not modifiable.
+            List<Integer> updatedTimestamps = new ArrayList<>();
+            try {
+                updatedTimestamps.addAll(getStatsdMetadata(device).getSystemRestartSecList());
+            } catch (DeviceNotAvailableException | InvalidProtocolBufferException e) {
+                // Error is not thrown as we still want to process the other devices.
+                CLog.e(
+                        "Failed to get statsd metadata from device %s. Exception: %s.",
+                        device.getSerialNumber(), e);
+                continue;
+            }
+            if (!mExistingTimestamps.containsKey(device.getSerialNumber())) {
+                CLog.e("No prior state recorded for device %s.", device.getSerialNumber());
+                addStatsdStatsBasedMetrics(
+                        currentRunMetrics, updatedTimestamps, device.getSerialNumber());
+            } else {
+                try {
+                    int lastTimestampBeforeTestRun =
+                            Iterables.getLast(mExistingTimestamps.get(device.getSerialNumber()));
+                    // If the last timestamp is not found, lastIndexOf(...) + 1 returns 0 which is
+                    // what is needed in that situation.
+                    addStatsdStatsBasedMetrics(
+                            currentRunMetrics,
+                            getAllValuesAfter(lastTimestampBeforeTestRun, updatedTimestamps),
+                            device.getSerialNumber());
+                } catch (NoSuchElementException e) {
+                    // The exception occurs when no prior runtime restarts had been recorded. It is
+                    // thrown from Iterables.getLast().
+                    addStatsdStatsBasedMetrics(
+                            currentRunMetrics, updatedTimestamps, device.getSerialNumber());
+                }
+            }
+        }
+
+        // Pull the AppCrashOccurred event metric reports and report the system server crashes
+        // within.
+        for (ITestDevice device : mTestDevices) {
+            if (!mDeviceConfigIds.containsKey(device.getSerialNumber())) {
+                CLog.e("No config ID is associated with device %s.", device.getSerialNumber());
+                continue;
+            }
+            long configId = mDeviceConfigIds.get(device.getSerialNumber());
+            List<Long> uptimeListNanos = new ArrayList<>();
+            try {
+                List<EventMetricData> metricData = getEventMetricData(device, configId);
+                uptimeListNanos.addAll(
+                        metricData
+                                .stream()
+                                .filter(d -> d.hasElapsedTimestampNanos())
+                                .filter(d -> d.hasAtom())
+                                .filter(d -> d.getAtom().hasAppCrashOccurred())
+                                .filter(d -> d.getAtom().getAppCrashOccurred().hasProcessName())
+                                .filter(
+                                        d ->
+                                                SYSTEM_SERVER_KEYWORD.equals(
+                                                        d.getAtom()
+                                                                .getAppCrashOccurred()
+                                                                .getProcessName()))
+                                .map(d -> d.getElapsedTimestampNanos())
+                                .collect(Collectors.toList()));
+            } catch (DeviceNotAvailableException e) {
+                // Error is not thrown as we still want to process other devices.
+                CLog.e(
+                        "Failed to retrieve event metric data from device %s. Exception: %s.",
+                        device.getSerialNumber(), e);
+            }
+            addAtomBasedMetrics(currentRunMetrics, uptimeListNanos, device.getSerialNumber());
+            try {
+                removeConfig(device, configId);
+            } catch (DeviceNotAvailableException e) {
+                // Error is not thrown as we still want to remove the config from other devices.
+                CLog.e(
+                        "Failed to remove statsd config from device %s. Exception: %s.",
+                        device.getSerialNumber(), e);
+            }
+        }
+    }
+
+    /** Helper method to add metrics from StatsdStatsReport according to timestamps. */
+    private void addStatsdStatsBasedMetrics(
+            final Map<String, Metric> metrics, List<Integer> timestampsSecs, String serial) {
+        // If there are runtime restarts, add a comma-separated list of timestamps.
+        if (!timestampsSecs.isEmpty()) {
+            // Store both the raw timestamp and the formatted, more readable version.
+
+            String timestampMetricKey =
+                    createMetricKey(METRIC_SUFFIX_SYSTEM_TIMESTAMP_SECS, serial);
+            metrics.put(
+                    timestampMetricKey,
+                    stringToMetric(
+                            timestampsSecs
+                                    .stream()
+                                    .map(t -> String.valueOf(t))
+                                    .collect(Collectors.joining(","))));
+
+            String formattedTimestampMetricKey =
+                    createMetricKey(METRIC_SUFFIX_SYSTEM_TIMESTAMP_FORMATTED, serial);
+            metrics.put(
+                    formattedTimestampMetricKey,
+                    stringToMetric(
+                            timestampsSecs
+                                    .stream()
+                                    .map(t -> timestampToHoursMinutesSeconds(t))
+                                    .collect(Collectors.joining(","))));
+        }
+    }
+
+    /** Helper method to add metrics from the AppCrashOccurred atoms according to timestamps. */
+    private void addAtomBasedMetrics(
+            final Map<String, Metric> metrics, List<Long> timestampsNanos, String serial) {
+        // Always add a count of system server crashes, regardless of whether there are any.
+        // The atom-based count is used as the statsd-metadata-based one tops out at 20.
+        String countMetricKey = createMetricKey(METRIC_SUFFIX_COUNT, serial);
+        metrics.put(countMetricKey, stringToMetric(String.valueOf(timestampsNanos.size())));
+        // If there are runtime restarts, add a comma-separated list of device uptime timestamps.
+        if (!timestampsNanos.isEmpty()) {
+            // Store both the raw timestamp and the formatted, more readable version.
+
+            String uptimeNanosMetricKey = createMetricKey(METRIC_SUFFIX_UPTIME_NANOS, serial);
+            metrics.put(
+                    uptimeNanosMetricKey,
+                    stringToMetric(
+                            timestampsNanos
+                                    .stream()
+                                    .map(t -> String.valueOf(t))
+                                    .collect(Collectors.joining(","))));
+
+            String formattedUptimeMetricKey =
+                    createMetricKey(METRIC_SUFFIX_UPTIME_FORMATTED, serial);
+            metrics.put(
+                    formattedUptimeMetricKey,
+                    stringToMetric(
+                            timestampsNanos
+                                    .stream()
+                                    .map(t -> nanosToHoursMinutesSeconds(t))
+                                    .collect(Collectors.joining(","))));
+        }
+    }
+
+    private String createMetricKey(String suffix, String serial) {
+        return mIncludeDeviceSerial
+                ? String.join(METRIC_SEP, METRIC_PREFIX, serial, suffix)
+                : String.join(METRIC_SEP, METRIC_PREFIX, suffix);
+    }
+
+    /**
+     * Forwarding logic to {@link ConfigUtil} for testing as it is a static utility class.
+     *
+     * @hide
+     */
+    @VisibleForTesting
+    protected long pushStatsConfig(ITestDevice device, List<Integer> eventAtomIds)
+            throws IOException, DeviceNotAvailableException {
+        return ConfigUtil.pushStatsConfig(device, eventAtomIds);
+    }
+
+    /**
+     * Forwarding logic to {@link ConfigUtil} for testing as it is a static utility class.
+     *
+     * @hide
+     */
+    @VisibleForTesting
+    protected void removeConfig(ITestDevice device, long configId)
+            throws DeviceNotAvailableException {
+        ConfigUtil.removeConfig(device, configId);
+    }
+
+    /**
+     * Forwarding logic to {@link MetricUtil} for testing as it is a static utility class.
+     *
+     * @hide
+     */
+    @VisibleForTesting
+    protected StatsdStatsReport getStatsdMetadata(ITestDevice device)
+            throws DeviceNotAvailableException, InvalidProtocolBufferException {
+        return MetricUtil.getStatsdMetadata(device);
+    }
+
+    /**
+     * Forwarding logic to {@link MetricUtil} for testing as it is a static utility class.
+     *
+     * @hide
+     */
+    @VisibleForTesting
+    protected List<EventMetricData> getEventMetricData(ITestDevice device, long configId)
+            throws DeviceNotAvailableException {
+        return MetricUtil.getEventMetricData(device, configId);
+    }
+
+    /**
+     * Helper method to convert nanoseconds to HH:mm:ss. {@link SimpleDateFormat} is not used here
+     * as the input is not a real epoch-based timestamp.
+     */
+    private String nanosToHoursMinutesSeconds(long nanos) {
+        long hours = TimeUnit.NANOSECONDS.toHours(nanos);
+        nanos -= TimeUnit.HOURS.toNanos(hours);
+        long minutes = TimeUnit.NANOSECONDS.toMinutes(nanos);
+        nanos -= TimeUnit.MINUTES.toNanos(minutes);
+        long seconds = TimeUnit.NANOSECONDS.toSeconds(nanos);
+        return String.format("%02d:%02d:%02d", hours, minutes, seconds);
+    }
+
+    /** Helper method to format an epoch-based timestamp in seconds to HH:mm:ss. */
+    private String timestampToHoursMinutesSeconds(int seconds) {
+        return TIME_FORMATTER.format(new Date(TimeUnit.SECONDS.toMillis(seconds)));
+    }
+
+    /** Helper method to get all values in a list that appear after all occurrences of target. */
+    private List<Integer> getAllValuesAfter(int target, List<Integer> l) {
+        return l.subList(l.lastIndexOf(target) + 1, l.size());
+    }
+}
diff --git a/src/com/android/tradefed/util/statsd/MetricUtil.java b/src/com/android/tradefed/util/statsd/MetricUtil.java
index f7220a7..b0464d0 100644
--- a/src/com/android/tradefed/util/statsd/MetricUtil.java
+++ b/src/com/android/tradefed/util/statsd/MetricUtil.java
@@ -18,6 +18,7 @@
 import com.android.os.StatsLog.ConfigMetricsReport;
 import com.android.os.StatsLog.ConfigMetricsReportList;
 import com.android.os.StatsLog.EventMetricData;
+import com.android.os.StatsLog.StatsdStatsReport;
 import com.android.os.StatsLog.StatsLogReport;
 import com.android.tradefed.device.CollectingByteOutputReceiver;
 import com.android.tradefed.device.DeviceNotAvailableException;
@@ -26,6 +27,7 @@
 import com.android.tradefed.result.ByteArrayInputStreamSource;
 import com.android.tradefed.result.InputStreamSource;
 
+import com.google.common.annotations.VisibleForTesting;
 import com.google.protobuf.InvalidProtocolBufferException;
 
 import java.util.ArrayList;
@@ -34,8 +36,12 @@
 
 /** Utility class for pulling metrics from pushed statsd configurations. */
 public class MetricUtil {
+    @VisibleForTesting
     static final String DUMP_REPORT_CMD_TEMPLATE =
             "cmd stats dump-report %s --include_current_bucket --proto";
+    // The command is documented in frameworks/base/cmds/statsd/src/StatsService.cpp.
+    @VisibleForTesting
+    static final String DUMP_STATSD_METADATA_CMD = "dumpsys stats --metadata --proto";
 
     /** Get statsd event metrics data from the device using the statsd config id. */
     public static List<EventMetricData> getEventMetricData(ITestDevice device, long configId)
@@ -45,13 +51,16 @@
             CLog.d("No stats report collected.");
             return new ArrayList<EventMetricData>();
         }
-        ConfigMetricsReport report = reports.getReports(0);
         List<EventMetricData> data = new ArrayList<>();
-        for (StatsLogReport metric : report.getMetricsList()) {
-            data.addAll(metric.getEventMetrics().getDataList());
+        // Usually, there is only one report. However, a runtime restart will generate a new report
+        // for the same config, resulting in multiple reports. Their data is independent and can
+        // simply be concatenated together.
+        for (ConfigMetricsReport report : reports.getReportsList()) {
+            for (StatsLogReport metric : report.getMetricsList()) {
+                data.addAll(metric.getEventMetrics().getDataList());
+            }
         }
         data.sort(Comparator.comparing(EventMetricData::getElapsedTimestampNanos));
-
         CLog.d("Received EventMetricDataList as following:\n");
         for (EventMetricData d : data) {
             CLog.d("Atom at %d:\n%s", d.getElapsedTimestampNanos(), d.getAtom().toString());
@@ -65,6 +74,14 @@
         return new ByteArrayInputStreamSource(getReportByteArray(device, configId));
     }
 
+    /** Get statsd metadata which also contains system server crash information. */
+    public static StatsdStatsReport getStatsdMetadata(ITestDevice device)
+            throws DeviceNotAvailableException, InvalidProtocolBufferException {
+        final CollectingByteOutputReceiver receiver = new CollectingByteOutputReceiver();
+        device.executeShellCommand(DUMP_STATSD_METADATA_CMD, receiver);
+        return StatsdStatsReport.parser().parseFrom(receiver.getOutput());
+    }
+
     /** Get the report list proto from the device for the given {@code configId}. */
     private static ConfigMetricsReportList getReportList(ITestDevice device, long configId)
             throws DeviceNotAvailableException {
@@ -86,6 +103,4 @@
                 String.format(DUMP_REPORT_CMD_TEMPLATE, String.valueOf(configId)), receiver);
         return receiver.getOutput();
     }
-
-
 }
diff --git a/tests/src/com/android/tradefed/UnitTests.java b/tests/src/com/android/tradefed/UnitTests.java
index bedcfbb..1f130b3 100644
--- a/tests/src/com/android/tradefed/UnitTests.java
+++ b/tests/src/com/android/tradefed/UnitTests.java
@@ -99,6 +99,7 @@
 import com.android.tradefed.device.metric.PerfettoPullerMetricCollectorTest;
 import com.android.tradefed.device.metric.ProcessMaxMemoryCollectorTest;
 import com.android.tradefed.device.metric.RebootReasonCollectorTest;
+import com.android.tradefed.device.metric.RuntimeRestartCollectorTest;
 import com.android.tradefed.device.metric.ScheduleMultipleDeviceMetricCollectorTest;
 import com.android.tradefed.device.metric.ScheduledDeviceMetricCollectorTest;
 import com.android.tradefed.device.metric.ScreenshotOnFailureCollectorTest;
@@ -469,6 +470,7 @@
     PerfettoPullerMetricCollectorTest.class,
     ProcessMaxMemoryCollectorTest.class,
     RebootReasonCollectorTest.class,
+    RuntimeRestartCollectorTest.class,
     ScheduledDeviceMetricCollectorTest.class,
     ScheduleMultipleDeviceMetricCollectorTest.class,
     ScreenshotOnFailureCollectorTest.class,
diff --git a/tests/src/com/android/tradefed/device/metric/RuntimeRestartCollectorTest.java b/tests/src/com/android/tradefed/device/metric/RuntimeRestartCollectorTest.java
new file mode 100644
index 0000000..c626eca
--- /dev/null
+++ b/tests/src/com/android/tradefed/device/metric/RuntimeRestartCollectorTest.java
@@ -0,0 +1,614 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.tradefed.device.metric;
+
+import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.anyLong;
+import static org.mockito.Mockito.argThat;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.MockitoAnnotations.initMocks;
+
+import com.android.os.AtomsProto.Atom;
+import com.android.os.AtomsProto.AppCrashOccurred;
+import com.android.os.StatsLog.EventMetricData;
+import com.android.os.StatsLog.StatsdStatsReport;
+import com.android.tradefed.device.ITestDevice;
+import com.android.tradefed.invoker.IInvocationContext;
+import com.android.tradefed.metrics.proto.MetricMeasurement.Metric;
+import com.android.tradefed.result.ITestInvocationListener;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
+
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.mockito.Mock;
+import org.mockito.Spy;
+
+/** Unit tests for {@link RuntimeRestartCollector}. */
+@RunWith(JUnit4.class)
+public class RuntimeRestartCollectorTest {
+    private static final String DEVICE_SERIAL_1 = "device_serial_1";
+    private static final String DEVICE_SERIAL_2 = "device_serial_2";
+
+    private static final long CONFIG_ID_1 = 1;
+    private static final long CONFIG_ID_2 = 2;
+
+    private static final int TIMESTAMP_1_SECS = 1554764010;
+    private static final String TIMESTAMP_1_STR =
+            RuntimeRestartCollector.TIME_FORMATTER.format(
+                    new Date(TimeUnit.SECONDS.toMillis(TIMESTAMP_1_SECS)));
+    private static final int TIMESTAMP_2_SECS = 1554764135;
+    private static final String TIMESTAMP_2_STR =
+            RuntimeRestartCollector.TIME_FORMATTER.format(
+                    new Date(TimeUnit.SECONDS.toMillis(TIMESTAMP_2_SECS)));
+
+    private static final long UPTIME_1_NANOS =
+            TimeUnit.HOURS.toNanos(1) + TimeUnit.MINUTES.toNanos(2) + TimeUnit.SECONDS.toNanos(3);
+    private static final String UPTIME_1_STR = "01:02:03";
+    private static final long UPTIME_2_NANOS =
+            TimeUnit.HOURS.toNanos(1) + TimeUnit.MINUTES.toNanos(4) + TimeUnit.SECONDS.toNanos(8);
+    private static final String UPTIME_2_STR = "01:04:08";
+
+    private static final EventMetricData RUNTIME_RESTART_DATA_1 =
+            EventMetricData.newBuilder()
+                    .setElapsedTimestampNanos(UPTIME_1_NANOS)
+                    .setAtom(
+                            Atom.newBuilder()
+                                    .setAppCrashOccurred(
+                                            AppCrashOccurred.newBuilder()
+                                                    .setProcessName(
+                                                            RuntimeRestartCollector
+                                                                    .SYSTEM_SERVER_KEYWORD)))
+                    .build();
+    private static final EventMetricData RUNTIME_RESTART_DATA_2 =
+            RUNTIME_RESTART_DATA_1.toBuilder().setElapsedTimestampNanos(UPTIME_2_NANOS).build();
+    private static final EventMetricData NOT_RUNTIME_RESTART_DATA =
+            EventMetricData.newBuilder()
+                    .setElapsedTimestampNanos(111)
+                    .setAtom(
+                            Atom.newBuilder()
+                                    .setAppCrashOccurred(
+                                            AppCrashOccurred.newBuilder()
+                                                    .setProcessName("not_system_server")))
+                    .build();
+
+    @Spy private RuntimeRestartCollector mCollector;
+    @Mock private IInvocationContext mContext;
+    @Mock private ITestInvocationListener mListener;
+
+    @Before
+    public void setUp() throws Exception {
+        initMocks(this);
+    }
+
+    /**
+     * Test that the collector makes the correct calls to the statsd utilities for a single device.
+     *
+     * <p>During testRunStarted() it should push the config and pull the statsd metadata. During
+     * testRunEnded() it should pull the event metric data, remove the statsd config, and pull the
+     * statsd metadata again.
+     */
+    @Test
+    public void testStatsdInteractions_singleDevice() throws Exception {
+        ITestDevice testDevice = mockTestDevice(DEVICE_SERIAL_1);
+        doReturn(Arrays.asList(testDevice)).when(mContext).getDevices();
+        doReturn(CONFIG_ID_1)
+                .when(mCollector)
+                .pushStatsConfig(any(ITestDevice.class), any(List.class));
+        doReturn(new ArrayList<EventMetricData>())
+                .when(mCollector)
+                .getEventMetricData(any(ITestDevice.class), anyLong());
+        doReturn(StatsdStatsReport.newBuilder().build())
+                .when(mCollector)
+                .getStatsdMetadata(any(ITestDevice.class));
+
+        mCollector.init(mContext, mListener);
+
+        mCollector.testRunStarted("test run", 1);
+        verify(mCollector, times(1))
+                .pushStatsConfig(
+                        eq(testDevice),
+                        argThat(l -> l.contains(Atom.APP_CRASH_OCCURRED_FIELD_NUMBER)));
+        verify(mCollector, times(1)).getStatsdMetadata(eq(testDevice));
+
+        mCollector.testRunEnded(0, new HashMap<String, Metric>());
+        verify(mCollector, times(1)).getEventMetricData(eq(testDevice), eq(CONFIG_ID_1));
+        verify(mCollector, times(1)).removeConfig(eq(testDevice), eq(CONFIG_ID_1));
+        verify(mCollector, times(2)).getStatsdMetadata(eq(testDevice));
+    }
+
+    /**
+     * Test that the collector makes the correct calls to the statsd utilities for multiple devices.
+     *
+     * <p>During testRunStarted() it should push the config and pull the statsd metadata for each
+     * device. During testRunEnded() it should pull the event metric data, remove the statsd config,
+     * and pull the statsd metadata again for each device.
+     */
+    @Test
+    public void testStatsdInteractions_multiDevice() throws Exception {
+        ITestDevice testDevice1 = mockTestDevice(DEVICE_SERIAL_1);
+        ITestDevice testDevice2 = mockTestDevice(DEVICE_SERIAL_2);
+        doReturn(Arrays.asList(testDevice1, testDevice2)).when(mContext).getDevices();
+        doReturn(CONFIG_ID_1).when(mCollector).pushStatsConfig(eq(testDevice1), any(List.class));
+        doReturn(CONFIG_ID_2).when(mCollector).pushStatsConfig(eq(testDevice2), any(List.class));
+        doReturn(new ArrayList<EventMetricData>())
+                .when(mCollector)
+                .getEventMetricData(any(ITestDevice.class), anyLong());
+        doReturn(StatsdStatsReport.newBuilder().build())
+                .when(mCollector)
+                .getStatsdMetadata(any(ITestDevice.class));
+
+        mCollector.init(mContext, mListener);
+
+        mCollector.testRunStarted("test run", 1);
+        verify(mCollector, times(1))
+                .pushStatsConfig(
+                        eq(testDevice1),
+                        argThat(l -> l.contains(Atom.APP_CRASH_OCCURRED_FIELD_NUMBER)));
+        verify(mCollector, times(1))
+                .pushStatsConfig(
+                        eq(testDevice2),
+                        argThat(l -> l.contains(Atom.APP_CRASH_OCCURRED_FIELD_NUMBER)));
+        verify(mCollector, times(1)).getStatsdMetadata(eq(testDevice1));
+        verify(mCollector, times(1)).getStatsdMetadata(eq(testDevice2));
+
+        mCollector.testRunEnded(0, new HashMap<String, Metric>());
+        verify(mCollector, times(1)).getEventMetricData(eq(testDevice1), eq(CONFIG_ID_1));
+        verify(mCollector, times(1)).getEventMetricData(eq(testDevice2), eq(CONFIG_ID_2));
+        verify(mCollector, times(1)).removeConfig(eq(testDevice1), eq(CONFIG_ID_1));
+        verify(mCollector, times(1)).removeConfig(eq(testDevice2), eq(CONFIG_ID_2));
+        verify(mCollector, times(2)).getStatsdMetadata(eq(testDevice1));
+        verify(mCollector, times(2)).getStatsdMetadata(eq(testDevice2));
+    }
+
+    /**
+     * Test that the collector collects a count of zero and no timestamps when no runtime restarts
+     * occur during the test run and there were no prior runtime restarts.
+     */
+    @Test
+    public void testAddingMetrics_noRuntimeRestart_noPriorRuntimeRestart() throws Exception {
+        ITestDevice testDevice = mockTestDevice(DEVICE_SERIAL_1);
+        doReturn(Arrays.asList(testDevice)).when(mContext).getDevices();
+        doReturn(CONFIG_ID_1)
+                .when(mCollector)
+                .pushStatsConfig(any(ITestDevice.class), any(List.class));
+        doReturn(new ArrayList<EventMetricData>())
+                .when(mCollector)
+                .getEventMetricData(any(ITestDevice.class), anyLong());
+        doReturn(StatsdStatsReport.newBuilder().build(), StatsdStatsReport.newBuilder().build())
+                .when(mCollector)
+                .getStatsdMetadata(any(ITestDevice.class));
+
+        HashMap<String, Metric> runMetrics = new HashMap<>();
+        mCollector.init(mContext, mListener);
+        mCollector.testRunStarted("test run", 1);
+        mCollector.testRunEnded(0, runMetrics);
+
+        // A count should always be present, and should be zero in this case.
+        // If a count is not present, .findFirst().get() throws and the test will fail.
+        int count = getCount(runMetrics);
+        Assert.assertEquals(0, count);
+        // No other metric keys should be present as there is no data.
+        ensureNoMetricWithKeySuffix(
+                runMetrics, RuntimeRestartCollector.METRIC_SUFFIX_SYSTEM_TIMESTAMP_SECS);
+        ensureNoMetricWithKeySuffix(
+                runMetrics, RuntimeRestartCollector.METRIC_SUFFIX_SYSTEM_TIMESTAMP_FORMATTED);
+        ensureNoMetricWithKeySuffix(runMetrics, RuntimeRestartCollector.METRIC_SUFFIX_UPTIME_NANOS);
+        ensureNoMetricWithKeySuffix(
+                runMetrics, RuntimeRestartCollector.METRIC_SUFFIX_UPTIME_FORMATTED);
+    }
+
+    /**
+     * Test that the collector collects a count of zero and no timestamps when no runtime restarts
+     * and there were prior runtime restarts.
+     */
+    @Test
+    public void testAddingMetrics_noRuntimeRestart_withPriorRuntimeRestart() throws Exception {
+        ITestDevice testDevice = mockTestDevice(DEVICE_SERIAL_1);
+        doReturn(Arrays.asList(testDevice)).when(mContext).getDevices();
+        doReturn(CONFIG_ID_1)
+                .when(mCollector)
+                .pushStatsConfig(any(ITestDevice.class), any(List.class));
+        doReturn(new ArrayList<EventMetricData>())
+                .when(mCollector)
+                .getEventMetricData(any(ITestDevice.class), anyLong());
+        doReturn(
+                        StatsdStatsReport.newBuilder()
+                                .addAllSystemRestartSec(Arrays.asList(1, 2))
+                                .build(),
+                        StatsdStatsReport.newBuilder()
+                                .addAllSystemRestartSec(Arrays.asList(1, 2))
+                                .build())
+                .when(mCollector)
+                .getStatsdMetadata(any(ITestDevice.class));
+
+        HashMap<String, Metric> runMetrics = new HashMap<>();
+        mCollector.init(mContext, mListener);
+        mCollector.testRunStarted("test run", 1);
+        mCollector.testRunEnded(0, runMetrics);
+
+        // A count should always be present, and should be zero in this case.
+        // If a count is not present, .findFirst().get() throws and the test will fail.
+        int count = getCount(runMetrics);
+        Assert.assertEquals(0, count);
+        // No other metric keys should be present as there is no data.
+        ensureNoMetricWithKeySuffix(
+                runMetrics, RuntimeRestartCollector.METRIC_SUFFIX_SYSTEM_TIMESTAMP_SECS);
+        ensureNoMetricWithKeySuffix(
+                runMetrics, RuntimeRestartCollector.METRIC_SUFFIX_SYSTEM_TIMESTAMP_FORMATTED);
+        ensureNoMetricWithKeySuffix(runMetrics, RuntimeRestartCollector.METRIC_SUFFIX_UPTIME_NANOS);
+        ensureNoMetricWithKeySuffix(
+                runMetrics, RuntimeRestartCollector.METRIC_SUFFIX_UPTIME_FORMATTED);
+    }
+
+    /**
+     * Test that the collector collects counts and timestamps correctly when there are runtime
+     * restarts and there were no prior runtime restarts.
+     */
+    @Test
+    public void testAddingMetrics_withRuntimeRestart_noPriorRuntimeRestart() throws Exception {
+        ITestDevice testDevice = mockTestDevice(DEVICE_SERIAL_1);
+        doReturn(Arrays.asList(testDevice)).when(mContext).getDevices();
+        doReturn(CONFIG_ID_1)
+                .when(mCollector)
+                .pushStatsConfig(any(ITestDevice.class), any(List.class));
+        doReturn(Arrays.asList(RUNTIME_RESTART_DATA_1, RUNTIME_RESTART_DATA_2))
+                .when(mCollector)
+                .getEventMetricData(any(ITestDevice.class), anyLong());
+        doReturn(
+                        StatsdStatsReport.newBuilder().build(),
+                        StatsdStatsReport.newBuilder()
+                                .addAllSystemRestartSec(
+                                        Arrays.asList(TIMESTAMP_1_SECS, TIMESTAMP_2_SECS))
+                                .build())
+                .when(mCollector)
+                .getStatsdMetadata(any(ITestDevice.class));
+
+        HashMap<String, Metric> runMetrics = new HashMap<>();
+        mCollector.init(mContext, mListener);
+        mCollector.testRunStarted("test run", 1);
+        mCollector.testRunEnded(0, runMetrics);
+
+        // Count should be two.
+        int count = getCount(runMetrics);
+        Assert.assertEquals(2, count);
+        // There should be two timestamps that match TIMESTAMP_1_SECS and TIMESTAMP_2_SECS
+        // respectively.
+        List<Integer> timestampSecs =
+                getIntMetricValuesByKeySuffix(
+                        runMetrics, RuntimeRestartCollector.METRIC_SUFFIX_SYSTEM_TIMESTAMP_SECS);
+        Assert.assertEquals(Arrays.asList(TIMESTAMP_1_SECS, TIMESTAMP_2_SECS), timestampSecs);
+        // There should be two timestamp strings that match TIMESTAMP_1_STR and TIMESTAMP_2_STR
+        // respectively.
+        List<String> timestampStrs =
+                getStringMetricValuesByKeySuffix(
+                        runMetrics,
+                        RuntimeRestartCollector.METRIC_SUFFIX_SYSTEM_TIMESTAMP_FORMATTED);
+        Assert.assertEquals(Arrays.asList(TIMESTAMP_1_STR, TIMESTAMP_2_STR), timestampStrs);
+        // There should be two uptime timestsmps that match UPTIME_1_NANOS and UPTIME_2_NANOS
+        // respectively.
+        List<Long> uptimeNanos =
+                getLongMetricValuesByKeySuffix(
+                        runMetrics, RuntimeRestartCollector.METRIC_SUFFIX_UPTIME_NANOS);
+        Assert.assertEquals(Arrays.asList(UPTIME_1_NANOS, UPTIME_2_NANOS), uptimeNanos);
+        // There should be two uptime timestamp strings that match UPTIME_1_STR and UPTIME_2_STR
+        // respectively.
+        List<String> uptimeStrs =
+                getStringMetricValuesByKeySuffix(
+                        runMetrics, RuntimeRestartCollector.METRIC_SUFFIX_UPTIME_FORMATTED);
+        Assert.assertEquals(Arrays.asList(UPTIME_1_STR, UPTIME_2_STR), uptimeStrs);
+    }
+
+    /**
+     * Test that the collector collects counts and metrics correctly when there are runtime restarts
+     * and there were prior runtime restarts.
+     */
+    @Test
+    public void testAddingMetrics_withRuntimeRestart_withPriorRuntimeRestart() throws Exception {
+        ITestDevice testDevice = mockTestDevice(DEVICE_SERIAL_1);
+        doReturn(Arrays.asList(testDevice)).when(mContext).getDevices();
+        doReturn(CONFIG_ID_1)
+                .when(mCollector)
+                .pushStatsConfig(any(ITestDevice.class), any(List.class));
+        doReturn(Arrays.asList(RUNTIME_RESTART_DATA_1, RUNTIME_RESTART_DATA_2))
+                .when(mCollector)
+                .getEventMetricData(any(ITestDevice.class), anyLong());
+        doReturn(
+                        StatsdStatsReport.newBuilder()
+                                .addAllSystemRestartSec(Arrays.asList(1, 2, 3))
+                                .build(),
+                        StatsdStatsReport.newBuilder()
+                                .addAllSystemRestartSec(
+                                        Arrays.asList(2, 3, TIMESTAMP_1_SECS, TIMESTAMP_2_SECS))
+                                .build())
+                .when(mCollector)
+                .getStatsdMetadata(any(ITestDevice.class));
+
+        HashMap<String, Metric> runMetrics = new HashMap<>();
+        mCollector.init(mContext, mListener);
+        mCollector.testRunStarted("test run", 1);
+        mCollector.testRunEnded(0, runMetrics);
+
+        // Count should be two.
+        int count = getCount(runMetrics);
+        Assert.assertEquals(2, count);
+        // There should be two timestamps that match TIMESTAMP_1_SECS and TIMESTAMP_2_SECS
+        // respectively.
+        List<Integer> timestampSecs =
+                getIntMetricValuesByKeySuffix(
+                        runMetrics, RuntimeRestartCollector.METRIC_SUFFIX_SYSTEM_TIMESTAMP_SECS);
+        Assert.assertEquals(Arrays.asList(TIMESTAMP_1_SECS, TIMESTAMP_2_SECS), timestampSecs);
+        // There should be two timestamp strings that match TIMESTAMP_1_STR and TIMESTAMP_2_STR
+        // respectively.
+        List<String> timestampStrs =
+                getStringMetricValuesByKeySuffix(
+                        runMetrics,
+                        RuntimeRestartCollector.METRIC_SUFFIX_SYSTEM_TIMESTAMP_FORMATTED);
+        Assert.assertEquals(Arrays.asList(TIMESTAMP_1_STR, TIMESTAMP_2_STR), timestampStrs);
+        // There should be two uptime timestsmps that match UPTIME_1_NANOS and UPTIME_2_NANOS
+        // respectively.
+        List<Long> uptimeNanos =
+                getLongMetricValuesByKeySuffix(
+                        runMetrics, RuntimeRestartCollector.METRIC_SUFFIX_UPTIME_NANOS);
+        Assert.assertEquals(Arrays.asList(UPTIME_1_NANOS, UPTIME_2_NANOS), uptimeNanos);
+        // There should be two uptime timestamp strings that match UPTIME_1_STR and UPTIME_2_STR
+        // respectively.
+        List<String> uptimeStrs =
+                getStringMetricValuesByKeySuffix(
+                        runMetrics, RuntimeRestartCollector.METRIC_SUFFIX_UPTIME_FORMATTED);
+        Assert.assertEquals(Arrays.asList(UPTIME_1_STR, UPTIME_2_STR), uptimeStrs);
+    }
+
+    /**
+     * Test that the {@link AppCrashOccurred}-based collection only collects runtime restarts (i.e.
+     * system server crashes) and not other crashes.
+     */
+    @Test
+    public void testAddingMetrics_withRuntimeRestart_reportsSystemServerCrashesOnly()
+            throws Exception {
+        ITestDevice testDevice = mockTestDevice(DEVICE_SERIAL_1);
+        doReturn(Arrays.asList(testDevice)).when(mContext).getDevices();
+        doReturn(CONFIG_ID_1)
+                .when(mCollector)
+                .pushStatsConfig(any(ITestDevice.class), any(List.class));
+        doReturn(
+                        Arrays.asList(
+                                RUNTIME_RESTART_DATA_1,
+                                NOT_RUNTIME_RESTART_DATA,
+                                RUNTIME_RESTART_DATA_2))
+                .when(mCollector)
+                .getEventMetricData(any(ITestDevice.class), anyLong());
+        doReturn(
+                        StatsdStatsReport.newBuilder()
+                                .addAllSystemRestartSec(Arrays.asList(1, 2, 3))
+                                .build(),
+                        StatsdStatsReport.newBuilder()
+                                .addAllSystemRestartSec(
+                                        Arrays.asList(2, 3, TIMESTAMP_1_SECS, TIMESTAMP_2_SECS))
+                                .build())
+                .when(mCollector)
+                .getStatsdMetadata(any(ITestDevice.class));
+
+        HashMap<String, Metric> runMetrics = new HashMap<>();
+        mCollector.init(mContext, mListener);
+        mCollector.testRunStarted("test run", 1);
+        mCollector.testRunEnded(0, runMetrics);
+
+        // We only check for the uptime timestamps coming from the AppCrashOccurred atoms.
+
+        // There should be two uptime timestamps that match UPTIME_1_NANOS and UPTIME_2_NANOS
+        // respectively.
+        List<Long> uptimeNanos =
+                getLongMetricValuesByKeySuffix(
+                        runMetrics, RuntimeRestartCollector.METRIC_SUFFIX_UPTIME_NANOS);
+        Assert.assertEquals(Arrays.asList(UPTIME_1_NANOS, UPTIME_2_NANOS), uptimeNanos);
+        // There should be two uptime timestamp strings that match UPTIME_1_STR and UPTIME_2_STR
+        // respectively.
+        List<String> uptimeStrs =
+                getStringMetricValuesByKeySuffix(
+                        runMetrics, RuntimeRestartCollector.METRIC_SUFFIX_UPTIME_FORMATTED);
+        Assert.assertEquals(Arrays.asList(UPTIME_1_STR, UPTIME_2_STR), uptimeStrs);
+    }
+
+    /**
+     * Test that the collector reports counts based on the {@link AppCrashOccurred} results when it
+     * disagrees with info from statsd metadata.
+     */
+    @Test
+    public void testAddingMetrics_withRuntimeRestart_useAtomResultsForCount() throws Exception {
+        ITestDevice testDevice = mockTestDevice(DEVICE_SERIAL_1);
+        doReturn(Arrays.asList(testDevice)).when(mContext).getDevices();
+        // Two data points from the AppCrashOccurred data.
+        doReturn(CONFIG_ID_1)
+                .when(mCollector)
+                .pushStatsConfig(any(ITestDevice.class), any(List.class));
+        doReturn(Arrays.asList(RUNTIME_RESTART_DATA_1, RUNTIME_RESTART_DATA_2))
+                .when(mCollector)
+                .getEventMetricData(any(ITestDevice.class), anyLong());
+        // Data from statsd metadata only has one timestamp.
+        doReturn(
+                        StatsdStatsReport.newBuilder()
+                                .addAllSystemRestartSec(Arrays.asList())
+                                .build(),
+                        StatsdStatsReport.newBuilder()
+                                .addAllSystemRestartSec(Arrays.asList(TIMESTAMP_1_SECS))
+                                .build())
+                .when(mCollector)
+                .getStatsdMetadata(any(ITestDevice.class));
+
+        HashMap<String, Metric> runMetrics = new HashMap<>();
+        mCollector.init(mContext, mListener);
+        mCollector.testRunStarted("test run", 1);
+        mCollector.testRunEnded(0, runMetrics);
+
+        // Count should be two as in the stubbed EventMetricDataResults, even though statsd metadata
+        // only reported one timestamp.
+        int count = getCount(runMetrics);
+        Assert.assertEquals(2, count);
+    }
+
+    /**
+     * Test that the device serial number is included in the metric key when multiple devices are
+     * under test.
+     */
+    @Test
+    public void testAddingMetrics_includesSerialForMultipleDevices() throws Exception {
+        ITestDevice testDevice1 = mockTestDevice(DEVICE_SERIAL_1);
+        ITestDevice testDevice2 = mockTestDevice(DEVICE_SERIAL_2);
+        doReturn(Arrays.asList(testDevice1, testDevice2)).when(mContext).getDevices();
+        doReturn(CONFIG_ID_1).when(mCollector).pushStatsConfig(eq(testDevice1), any(List.class));
+        doReturn(CONFIG_ID_2).when(mCollector).pushStatsConfig(eq(testDevice2), any(List.class));
+        doReturn(new ArrayList<EventMetricData>())
+                .when(mCollector)
+                .getEventMetricData(any(ITestDevice.class), anyLong());
+        doReturn(StatsdStatsReport.newBuilder().build())
+                .when(mCollector)
+                .getStatsdMetadata(any(ITestDevice.class));
+
+        HashMap<String, Metric> runMetrics = new HashMap<>();
+        mCollector.init(mContext, mListener);
+        mCollector.testRunStarted("test run", 1);
+        mCollector.testRunEnded(0, runMetrics);
+
+        // Check that the count metrics contain the serial numbers for the two devices,
+        // respectively.
+        List<String> countMetricKeys =
+                runMetrics
+                        .keySet()
+                        .stream()
+                        .filter(
+                                key ->
+                                        hasPrefixAndSuffix(
+                                                key,
+                                                RuntimeRestartCollector.METRIC_PREFIX,
+                                                RuntimeRestartCollector.METRIC_SUFFIX_COUNT))
+                        .collect(Collectors.toList());
+        // One key for each device.
+        Assert.assertEquals(2, countMetricKeys.size());
+        Assert.assertTrue(
+                countMetricKeys
+                        .stream()
+                        .anyMatch(
+                                key ->
+                                        (key.contains(DEVICE_SERIAL_1)
+                                                && (!key.contains(DEVICE_SERIAL_2)))));
+        Assert.assertTrue(
+                countMetricKeys
+                        .stream()
+                        .anyMatch(
+                                key ->
+                                        (key.contains(DEVICE_SERIAL_2)
+                                                && (!key.contains(DEVICE_SERIAL_1)))));
+    }
+
+    /** Helper method to get count from metrics. */
+    private static int getCount(Map<String, Metric> metrics) {
+        return Integer.parseInt(
+                metrics.entrySet()
+                        .stream()
+                        .filter(
+                                entry ->
+                                        hasPrefixAndSuffix(
+                                                entry.getKey(),
+                                                RuntimeRestartCollector.METRIC_PREFIX,
+                                                RuntimeRestartCollector.METRIC_SUFFIX_COUNT))
+                        .map(entry -> entry.getValue())
+                        .findFirst()
+                        .get()
+                        .getMeasurements()
+                        .getSingleString());
+    }
+
+    /** Helper method to check that no metric key with the given suffix is in the metrics. */
+    private static void ensureNoMetricWithKeySuffix(Map<String, Metric> metrics, String suffix) {
+        Assert.assertTrue(
+                metrics.keySet()
+                        .stream()
+                        .noneMatch(
+                                key ->
+                                        hasPrefixAndSuffix(
+                                                key,
+                                                RuntimeRestartCollector.METRIC_PREFIX,
+                                                suffix)));
+    }
+
+    /**
+     * Helper method to find a comma-separated String metric value by its key suffix, and return its
+     * values as a list.
+     */
+    private static List<String> getStringMetricValuesByKeySuffix(
+            Map<String, Metric> metrics, String suffix) {
+        return metrics.entrySet()
+                .stream()
+                .filter(
+                        entry ->
+                                hasPrefixAndSuffix(
+                                        entry.getKey(),
+                                        RuntimeRestartCollector.METRIC_PREFIX,
+                                        suffix))
+                .map(entry -> entry.getValue().getMeasurements().getSingleString().split(","))
+                .flatMap(arr -> Arrays.stream(arr))
+                .collect(Collectors.toList());
+    }
+
+    /**
+     * Helper method to find a comma-separated int metric value by its key suffix, and return its
+     * values as a list.
+     */
+    private static List<Integer> getIntMetricValuesByKeySuffix(
+            Map<String, Metric> metrics, String suffix) {
+        return getStringMetricValuesByKeySuffix(metrics, suffix)
+                .stream()
+                .map(val -> Integer.valueOf(val))
+                .collect(Collectors.toList());
+    }
+
+    /**
+     * Helper method to find a comma-separated long metric value by its key suffix, and return its
+     * values as a list.
+     */
+    private static List<Long> getLongMetricValuesByKeySuffix(
+            Map<String, Metric> metrics, String suffix) {
+        return getStringMetricValuesByKeySuffix(metrics, suffix)
+                .stream()
+                .map(val -> Long.valueOf(val))
+                .collect(Collectors.toList());
+    }
+
+    private static boolean hasPrefixAndSuffix(String target, String prefix, String suffix) {
+        return target.startsWith(prefix) && target.endsWith(suffix);
+    }
+
+    private ITestDevice mockTestDevice(String serial) {
+        ITestDevice device = mock(ITestDevice.class);
+        doReturn(serial).when(device).getSerialNumber();
+        return device;
+    }
+}
diff --git a/tests/src/com/android/tradefed/util/statsd/MetricUtilTest.java b/tests/src/com/android/tradefed/util/statsd/MetricUtilTest.java
index 53ccadd..9182e6f 100644
--- a/tests/src/com/android/tradefed/util/statsd/MetricUtilTest.java
+++ b/tests/src/com/android/tradefed/util/statsd/MetricUtilTest.java
@@ -17,9 +17,10 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static org.junit.Assert.fail;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.ArgumentMatchers.matches;
+import static org.mockito.Mockito.any;
 import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.eq;
+import static org.mockito.Mockito.matches;
 
 import com.android.os.AtomsProto.Atom;
 import com.android.os.AtomsProto.BleScanStateChanged;
@@ -27,11 +28,14 @@
 import com.android.os.StatsLog.ConfigMetricsReport;
 import com.android.os.StatsLog.ConfigMetricsReportList;
 import com.android.os.StatsLog.EventMetricData;
+import com.android.os.StatsLog.StatsdStatsReport;
 import com.android.os.StatsLog.StatsLogReport;
 import com.android.tradefed.device.CollectingByteOutputReceiver;
 import com.android.tradefed.device.DeviceNotAvailableException;
 import com.android.tradefed.device.ITestDevice;
 
+import com.google.protobuf.InvalidProtocolBufferException;
+
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
@@ -41,7 +45,6 @@
 import org.mockito.invocation.InvocationOnMock;
 import org.mockito.stubbing.Answer;
 
-import java.io.IOException;
 import java.util.List;
 
 /** Unit tests for {@link MetricUtil} */
@@ -49,7 +52,7 @@
 public class MetricUtilTest {
     // A config id used for testing.
     private static final long CONFIG_ID = 11;
-    // Two metrics for a sample report.
+    // Three metrics for a sample report.
     private static final long METRIC_1_NANOS = 222;
     private static final Atom METRIC_1_ATOM =
             Atom.newBuilder().setBleScanStateChanged(BleScanStateChanged.newBuilder()).build();
@@ -66,8 +69,16 @@
                     .setElapsedTimestampNanos(METRIC_2_NANOS)
                     .setAtom(METRIC_2_ATOM)
                     .build();
+    // The third metric has the same atom as the first one for the "two reports on the same atom"
+    // test case.
+    private static final long METRIC_3_NANOS = 333;
+    private static final EventMetricData METRIC_3_DATA =
+            EventMetricData.newBuilder()
+                    .setElapsedTimestampNanos(METRIC_3_NANOS)
+                    .setAtom(METRIC_1_ATOM)
+                    .build();
     // A sample metrics report.
-    private static final ConfigMetricsReportList SAMPLE_REPORT_LIST_PROTO =
+    private static final ConfigMetricsReportList SINGLE_REPORT_LIST_PROTO =
             ConfigMetricsReportList.newBuilder()
                     .addReports(
                             ConfigMetricsReport.newBuilder()
@@ -79,6 +90,46 @@
                                                                     .addData(METRIC_1_DATA)
                                                                     .addData(METRIC_2_DATA))))
                     .build();
+    // A sample metrics report list with multiple reports on different atoms.
+    private static final ConfigMetricsReportList MULTIPLE_REPORT_LIST_PROTO_DIFFERENT_ATOMS =
+            ConfigMetricsReportList.newBuilder()
+                    .addReports(
+                            ConfigMetricsReport.newBuilder()
+                                    .addMetrics(
+                                            StatsLogReport.newBuilder()
+                                                    .setEventMetrics(
+                                                            StatsLogReport.EventMetricDataWrapper
+                                                                    .newBuilder()
+                                                                    .addData(METRIC_1_DATA))))
+                    .addReports(
+                            ConfigMetricsReport.newBuilder()
+                                    .addMetrics(
+                                            StatsLogReport.newBuilder()
+                                                    .setEventMetrics(
+                                                            StatsLogReport.EventMetricDataWrapper
+                                                                    .newBuilder()
+                                                                    .addData(METRIC_2_DATA))))
+                    .build();
+    // A sample metrics report list with multiple reports on the same atom.
+    private static final ConfigMetricsReportList MULTIPLE_REPORT_LIST_PROTO_SAME_ATOM =
+            ConfigMetricsReportList.newBuilder()
+                    .addReports(
+                            ConfigMetricsReport.newBuilder()
+                                    .addMetrics(
+                                            StatsLogReport.newBuilder()
+                                                    .setEventMetrics(
+                                                            StatsLogReport.EventMetricDataWrapper
+                                                                    .newBuilder()
+                                                                    .addData(METRIC_1_DATA))))
+                    .addReports(
+                            ConfigMetricsReport.newBuilder()
+                                    .addMetrics(
+                                            StatsLogReport.newBuilder()
+                                                    .setEventMetrics(
+                                                            StatsLogReport.EventMetricDataWrapper
+                                                                    .newBuilder()
+                                                                    .addData(METRIC_3_DATA))))
+                    .build();
 
     // Implementation of Mockito's {@link Answer<T>} interface to write bytes to the
     // {@link CollectingByteOutputReceiver} when mocking {@link ITestDevice#executeShellCommand}.
@@ -106,10 +157,10 @@
 
     /** Test that a non-empty ConfigMetricsReportList is parsed correctly. */
     @Test
-    public void testNonEmptyMetricReportList() throws DeviceNotAvailableException, IOException {
-        byte[] sampleReportBytes = SAMPLE_REPORT_LIST_PROTO.toByteArray();
+    public void testNonEmptyMetricReportList() throws DeviceNotAvailableException {
+        byte[] sampleReportBytes = SINGLE_REPORT_LIST_PROTO.toByteArray();
         // Mock executeShellCommand to supply the passed-in CollectingByteOutputReceiver with
-        // SAMPLE_REPORT_LIST_PROTO.
+        // SINGLE_REPORT_LIST_PROTO.
         doAnswer(new ExecuteShellCommandWithOutputAnswer(sampleReportBytes))
                 .when(mTestDevice)
                 .executeShellCommand(
@@ -131,7 +182,7 @@
 
     /** Test that an empty list is returned for an empty ConfigMetricsReportList proto. */
     @Test
-    public void testEmptyMetricReportList() throws DeviceNotAvailableException, IOException {
+    public void testEmptyMetricReportList() throws DeviceNotAvailableException {
         byte[] emptyReportBytes = ConfigMetricsReport.newBuilder().build().toByteArray();
         // Mock executeShellCommand to supply the passed-in CollectingByteOutputReceiver with the
         // empty report list proto.
@@ -148,9 +199,66 @@
         assertThat(data.size()).comparesEqualTo(0);
     }
 
+    /**
+     * Test that the utility can process multiple {@link ConfigMetricsReport} in the {@link
+     * ConfigMetricsReportList} that contain metrics from different atoms.
+     */
+    @Test
+    public void testMultipleReportsInReportList_differentAtoms()
+            throws DeviceNotAvailableException {
+        byte[] sampleReportBytes = MULTIPLE_REPORT_LIST_PROTO_DIFFERENT_ATOMS.toByteArray();
+        // Mock executeShellCommand to supply the passed-in CollectingByteOutputReceiver with
+        // SINGLE_REPORT_LIST_PROTO.
+        doAnswer(new ExecuteShellCommandWithOutputAnswer(sampleReportBytes))
+                .when(mTestDevice)
+                .executeShellCommand(
+                        matches(
+                                String.format(
+                                        MetricUtil.DUMP_REPORT_CMD_TEMPLATE,
+                                        String.valueOf(CONFIG_ID))),
+                        any(CollectingByteOutputReceiver.class));
+        List<EventMetricData> data = MetricUtil.getEventMetricData(mTestDevice, CONFIG_ID);
+        // Resulting list should have two metrics.
+        assertThat(data.size()).comparesEqualTo(2);
+        // The first metric should correspond to METRIC_2_* as its timestamp is earlier.
+        assertThat(data.get(0).getElapsedTimestampNanos()).comparesEqualTo(METRIC_2_NANOS);
+        assertThat(data.get(0).getAtom().hasBleScanResultReceived()).isTrue();
+        // The second metric should correspond to METRIC_1_*.
+        assertThat(data.get(1).getElapsedTimestampNanos()).comparesEqualTo(METRIC_1_NANOS);
+        assertThat(data.get(1).getAtom().hasBleScanStateChanged()).isTrue();
+    }
+
+    /**
+     * Test that the utility can process multiple {@link ConfigMetricsReport} in the {@link
+     * ConfigMetricsReportList} that contain metrics from the same atom.
+     */
+    @Test
+    public void testMultipleReportsInReportList_sameAtom() throws DeviceNotAvailableException {
+        byte[] sampleReportBytes = MULTIPLE_REPORT_LIST_PROTO_SAME_ATOM.toByteArray();
+        // Mock executeShellCommand to supply the passed-in CollectingByteOutputReceiver with
+        // SINGLE_REPORT_LIST_PROTO.
+        doAnswer(new ExecuteShellCommandWithOutputAnswer(sampleReportBytes))
+                .when(mTestDevice)
+                .executeShellCommand(
+                        matches(
+                                String.format(
+                                        MetricUtil.DUMP_REPORT_CMD_TEMPLATE,
+                                        String.valueOf(CONFIG_ID))),
+                        any(CollectingByteOutputReceiver.class));
+        List<EventMetricData> data = MetricUtil.getEventMetricData(mTestDevice, CONFIG_ID);
+        // Resulting list should have two metrics.
+        assertThat(data.size()).comparesEqualTo(2);
+        // The first metric should correspond to METRIC_1_* as its timestamp is earlier.
+        assertThat(data.get(0).getElapsedTimestampNanos()).comparesEqualTo(METRIC_1_NANOS);
+        assertThat(data.get(0).getAtom().hasBleScanStateChanged()).isTrue();
+        // The second metric should correspond to METRIC_3_*.
+        assertThat(data.get(1).getElapsedTimestampNanos()).comparesEqualTo(METRIC_3_NANOS);
+        assertThat(data.get(1).getAtom().hasBleScanStateChanged()).isTrue();
+    }
+
     /** Test that invalid proto from the dump report command causes an exception. */
     @Test
-    public void testInvalidDumpedReportThrows() throws DeviceNotAvailableException, IOException {
+    public void testInvalidDumpedReportThrows() throws DeviceNotAvailableException {
         byte[] invalidProtoBytes = "not a proto".getBytes();
         // Mock executeShellCommand to supply the passed-in CollectingByteOutputReceiver with the
         // invalid proto bytes.
@@ -169,4 +277,34 @@
             // Expected behavior, no action required.
         }
     }
+
+    /** Test that the utility can correctly retrieve statsd metadata. */
+    @Test
+    public void testGettingStatsdStats()
+            throws DeviceNotAvailableException, InvalidProtocolBufferException {
+        doAnswer(
+                        new ExecuteShellCommandWithOutputAnswer(
+                                StatsdStatsReport.newBuilder().build().toByteArray()))
+                .when(mTestDevice)
+                .executeShellCommand(
+                        eq(MetricUtil.DUMP_STATSD_METADATA_CMD),
+                        any(CollectingByteOutputReceiver.class));
+        assertThat(MetricUtil.getStatsdMetadata(mTestDevice)).isNotNull();
+    }
+
+    /** Test that the utility throws when the retrieved statsd metatdata is not valid. */
+    @Test
+    public void testInvalidStatsdStatsThrows() throws DeviceNotAvailableException {
+        doAnswer(new ExecuteShellCommandWithOutputAnswer("not a proto".getBytes()))
+                .when(mTestDevice)
+                .executeShellCommand(
+                        eq(MetricUtil.DUMP_STATSD_METADATA_CMD),
+                        any(CollectingByteOutputReceiver.class));
+        try {
+            MetricUtil.getStatsdMetadata(mTestDevice);
+            fail("An exception should be thrown for the invalid proto data.");
+        } catch (InvalidProtocolBufferException e) {
+            // Expected behavior, no action required.
+        }
+    }
 }