1 /*
2  * Copyright (C) 2019 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 package android.device.collectors;
17 
18 import android.device.collectors.util.SendToInstrumentation;
19 import android.os.Bundle;
20 import android.os.SystemClock;
21 import android.util.Log;
22 import androidx.annotation.VisibleForTesting;
23 
24 import com.android.helpers.ICollectorHelper;
25 
26 import java.io.BufferedWriter;
27 import java.io.File;
28 import java.io.FileWriter;
29 import java.io.IOException;
30 import java.nio.file.Path;
31 import java.nio.file.Paths;
32 import java.util.HashMap;
33 import java.util.Map;
34 import java.util.UUID;
35 
36 import org.junit.runner.Description;
37 import org.junit.runner.Result;
38 
39 /**
40  * Extend this class for a periodic metric collection which relies on ICollectorHelper to collect
41  * metrics and dump the time-series in csv format. In case of system crashes, the time series up to
42  * the point where the crash happened will still be stored.
43  *
44  * In case of running tests with Tradefed file pulller, use the option
45  * {@link file-puller-log-collector:directory-keys} from {{@link FilePullerLogCollector} to
46  * specify the directory path under which the output file should be pulled from (i.e.
47  * <external_storage>/test_results, where <external_storage> is /sdcard for Android phones and
48  * /storage/emulated/10 for Android Auto), instead of using
49  * {@link file-puller-log-collector:pull-pattern-keys}.
50  */
51 public class ScheduledRunCollectionListener<T extends Number> extends ScheduledRunMetricListener {
52     private static final String LOG_TAG = ScheduledRunCollectionListener.class.getSimpleName();
53     private static final String TIME_SERIES_PREFIX = "time_series_";
54     @VisibleForTesting public static final String OUTPUT_ROOT = "test_results";
55     @VisibleForTesting public static final String OUTPUT_FILE_PATH = "%s_time_series_path";
56 
57     @VisibleForTesting
58     public static final String TIME_SERIES_HEADER =
59             String.format("%-20s,%-100s,%-20s", "time", "metric_key", "value");
60 
61     private static final String TIME_SERIES_BODY = "%-20d,%-100s,%-20s";
62     @VisibleForTesting public static final String MEAN_SUFFIX = "-mean";
63     @VisibleForTesting public static final String MAX_SUFFIX = "-max";
64     @VisibleForTesting public static final String MIN_SUFFIX = "-min";
65 
66     protected ICollectorHelper<T> mHelper;
67     private TimeSeriesCsvWriter mTimeSeriesCsvWriter;
68     private TimeSeriesStatistics mTimeSeriesStatistics;
69     private long mStartTime;
70 
ScheduledRunCollectionListener()71     public ScheduledRunCollectionListener() {}
72 
73     @VisibleForTesting
ScheduledRunCollectionListener(Bundle argsBundle, ICollectorHelper helper)74     ScheduledRunCollectionListener(Bundle argsBundle, ICollectorHelper helper) {
75         super(argsBundle);
76         mHelper = helper;
77     }
78 
79     /**
80      * Write a time-series in csv format to the given destination under external storage as an
81      * unpivoted table like:
82      *
83      * time  ,metric_key ,value
84      * 0     ,metric1    ,5
85      * 0     ,metric2    ,10
86      * 0     ,metric3    ,15
87      * 1000  ,metric1    ,6
88      * 1000  ,metric2    ,11
89      * 1000  ,metric3    ,16
90      */
91     private class TimeSeriesCsvWriter {
92         private File mDestFile;
93         private boolean mIsHeaderWritten = false;
94 
TimeSeriesCsvWriter(Path destination)95         private TimeSeriesCsvWriter(Path destination) {
96             // Create parent directory if it doesn't exist.
97             File destDir = createAndEmptyDirectory(destination.getParent().toString());
98             mDestFile = new File(destDir, destination.getFileName().toString());
99         }
100 
write(Map<String, T> dataPoint, long timeStamp)101         private void write(Map<String, T> dataPoint, long timeStamp) {
102             try (BufferedWriter writer = new BufferedWriter(new FileWriter(mDestFile, true))) {
103                 if (!mIsHeaderWritten) {
104                     writer.append(TIME_SERIES_HEADER);
105                     writer.append("\n");
106                     mIsHeaderWritten = true;
107                 }
108 
109                 for (String key : dataPoint.keySet()) {
110                     writer.append(
111                             String.format(TIME_SERIES_BODY, timeStamp, key, dataPoint.get(key)));
112                     writer.append("\n");
113                 }
114             } catch (IOException e) {
115                 Log.e(
116                         LOG_TAG,
117                         String.format("Fail to output time series due to : %s.", e.getMessage()));
118             }
119         }
120     }
121 
122     private class TimeSeriesStatistics {
123         Map<String, T> minMap = new HashMap<>();
124         Map<String, T> maxMap = new HashMap<>();
125         Map<String, Double> sumMap = new HashMap<>();
126         Map<String, Long> countMap = new HashMap<>();
127 
update(Map<String, T> dataPoint)128         private void update(Map<String, T> dataPoint) {
129             for (String key : dataPoint.keySet()) {
130                 T value = dataPoint.get(key);
131                 // Add / replace min.
132                 minMap.computeIfPresent(key, (k, v) -> compareAsDouble(value, v) == -1 ? value : v);
133                 minMap.computeIfAbsent(key, k -> value);
134                 // Add / replace max.
135                 maxMap.computeIfPresent(key, (k, v) -> compareAsDouble(value, v) == 1 ? value : v);
136                 maxMap.computeIfAbsent(key, k -> value);
137                 // Add / update sum.
138                 sumMap.put(key, value.doubleValue() + sumMap.getOrDefault(key, 0.));
139                 // Add / update count.
140                 countMap.put(key, 1 + countMap.getOrDefault(key, 0L));
141             }
142         }
143 
getStatistics()144         private Map<String, String> getStatistics() {
145             Map<String, String> res = new HashMap<>();
146             for (String key : minMap.keySet()) {
147                 res.put(key + MIN_SUFFIX, minMap.get(key).toString());
148             }
149             for (String key : maxMap.keySet()) {
150                 res.put(key + MAX_SUFFIX, maxMap.get(key).toString());
151             }
152             for (String key : sumMap.keySet()) {
153                 if (countMap.containsKey(key)) {
154                     double mean = sumMap.get(key) / countMap.get(key);
155                     res.put(key + MEAN_SUFFIX, Double.toString(mean));
156                 }
157             }
158             return res;
159         }
160 
161         /** Compare to Number objects. Return -1 if the n1 < n2; 0 if n1 == n2; 1 if n1 > n2. */
compareAsDouble(Number n1, Number n2)162         private int compareAsDouble(Number n1, Number n2) {
163             Double d1 = Double.valueOf(n1.doubleValue());
164             Double d2 = Double.valueOf(n2.doubleValue());
165             return d1.compareTo(d2);
166         }
167     }
168 
169     /** {@inheritDoc} */
170     @Override
onStart(DataRecord runData, Description description)171     void onStart(DataRecord runData, Description description) {
172         setupAdditionalArgs();
173         Path path =
174                 Paths.get(
175                         OUTPUT_ROOT,
176                         getClass().getSimpleName(),
177                         String.format(
178                                 "%s%s-%d.csv",
179                                 TIME_SERIES_PREFIX,
180                                 getClass().getSimpleName(),
181                                 UUID.randomUUID().hashCode()));
182         mTimeSeriesCsvWriter = new TimeSeriesCsvWriter(path);
183         mTimeSeriesStatistics = new TimeSeriesStatistics();
184         mStartTime = SystemClock.uptimeMillis();
185         mHelper.startCollecting();
186         // Send to stdout the path where the time-series files will be stored.
187         Bundle filePathBundle = new Bundle();
188         filePathBundle.putString(
189                 String.format(OUTPUT_FILE_PATH, getClass().getSimpleName()),
190                 mTimeSeriesCsvWriter.mDestFile.toString());
191         SendToInstrumentation.sendBundle(getInstrumentation(), filePathBundle);
192     }
193 
194     /** {@inheritDoc} */
195     @Override
onEnd(DataRecord runData, Result result)196     void onEnd(DataRecord runData, Result result) {
197         mHelper.stopCollecting();
198         for (Map.Entry<String, String> entry : mTimeSeriesStatistics.getStatistics().entrySet()) {
199             runData.addStringMetric(entry.getKey(), entry.getValue());
200         }
201     }
202 
203     /** {@inheritDoc} */
204     @Override
collect(DataRecord runData, Description description)205     public void collect(DataRecord runData, Description description) throws InterruptedException {
206         long timeStamp = SystemClock.uptimeMillis() - mStartTime;
207         Map<String, T> dataPoint = mHelper.getMetrics();
208         mTimeSeriesCsvWriter.write(dataPoint, timeStamp);
209         mTimeSeriesStatistics.update(dataPoint);
210     }
211 
212     /**
213      * To add listener specific extra args implement this method in the sub class and add the
214      * listener specific args.
215      */
setupAdditionalArgs()216     public void setupAdditionalArgs() {
217         // NO-OP by default
218     }
219 
createHelperInstance(ICollectorHelper helper)220     protected void createHelperInstance(ICollectorHelper helper) {
221         mHelper = helper;
222     }
223 }
224 
225