1 /*
2  * Copyright (C) 2021 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 com.android.tradefed.util;
17 
18 import com.android.annotations.VisibleForTesting;
19 import com.android.tradefed.log.LogUtil.CLog;
20 import com.android.tradefed.metrics.proto.MetricMeasurement.Measurements;
21 import com.android.tradefed.metrics.proto.MetricMeasurement.Metric;
22 import com.android.tradefed.result.TestDescription;
23 import com.android.tradefed.util.proto.TfMetricProtoUtil;
24 
25 import com.google.common.base.Joiner;
26 import com.google.common.collect.ArrayListMultimap;
27 import com.google.common.math.Quantiles;
28 
29 import java.io.File;
30 import java.io.FileOutputStream;
31 import java.io.IOException;
32 import java.util.Arrays;
33 import java.util.ArrayList;
34 import java.util.Collection;
35 import java.util.Collections;
36 import java.util.HashMap;
37 import java.util.LinkedHashMap;
38 import java.util.LinkedHashSet;
39 import java.util.List;
40 import java.util.Map;
41 import java.util.Map.Entry;
42 import java.util.regex.Pattern;
43 import java.util.Set;
44 import java.util.stream.Collectors;
45 
46 /**
47  * Contains common utility methods for storing the test metrics, aggregating the metrics in similar
48  * tests and writing the metrics to a file.
49  */
50 public class MetricUtility {
51 
52     private static final String TEST_HEADER_SEPARATOR = "\n\n";
53     private static final String METRIC_SEPARATOR = "\n";
54     private static final String METRIC_KEY_VALUE_SEPARATOR = ":";
55     private static final String STATS_KEY_MIN = "min";
56     private static final String STATS_KEY_MAX = "max";
57     private static final String STATS_KEY_MEAN = "mean";
58     private static final String STATS_KEY_VAR = "var";
59     private static final String STATS_KEY_STDEV = "stdev";
60     private static final String STATS_KEY_MEDIAN = "median";
61     private static final String STATS_KEY_TOTAL = "total";
62     private static final String STATS_KEY_COUNT = "metric-count";
63     private static final String STATS_KEY_DIFF = "last-first-diff";
64     private static final String STATS_KEY_PERCENTILE_PREFIX = "p";
65     private static final String STATS_KEY_SEPARATOR = "-";
66     private static final Joiner CLASS_METHOD_JOINER = Joiner.on("#").skipNulls();
67 
68     // Used to separate the package name from the iteration number. Default is set to "$".
69     private String mTestIterationSeparator = "$";
70 
71     // Percentiles to include when calculating the aggregates.
72     private Set<Integer> mActualPercentiles = new LinkedHashSet<>();
73 
74     // Store the test metrics for aggregation at the end of test run.
75     // Outer map key is the test id and inner map key is the metric key name.
76     private Map<String, ArrayListMultimap<String, Metric>> mStoredTestMetrics =
77             new LinkedHashMap<String, ArrayListMultimap<String, Metric>>();
78 
79     private List<Pattern> mMetricPatterns = new ArrayList<>();
80 
81     /**
82      * Used for storing the individual test metrics and use it for aggregation.
83      *
84      * @param testDescription contains the test details like class name and test name.
85      * @param testMetrics metrics collected for the test.
86      */
storeTestMetrics(TestDescription testDescription, Map<String, Metric> testMetrics)87     public void storeTestMetrics(TestDescription testDescription,
88             Map<String, Metric> testMetrics) {
89 
90         if (testMetrics == null) {
91             return;
92         }
93 
94         // Group test cases which differs only by the iteration separator or test the same name.
95         // Removes iteration numbers like "$17".
96         String newTestId =
97                 CLASS_METHOD_JOINER
98                         .join(testDescription.getClassName(), testDescription.getTestName())
99                         .replaceFirst(Pattern.quote(mTestIterationSeparator) + "\\d+", "");
100 
101         if (!mStoredTestMetrics.containsKey(newTestId)) {
102             mStoredTestMetrics.put(newTestId, ArrayListMultimap.create());
103         }
104         ArrayListMultimap<String, Metric> storedMetricsForThisTest = mStoredTestMetrics
105                 .get(newTestId);
106         for (Map.Entry<String, Metric> entry : testMetrics.entrySet()) {
107             storedMetricsForThisTest.put(entry.getKey(), entry.getValue());
108         }
109     }
110 
111     /**
112      *
113      * Write metrics to a file.
114      *
115      * @param testFileSuffix is used as suffix in the test metric file name.
116      * @param testHeaderName metrics will be written under the test header name.
117      * @param metrics to write in the file.
118      * @param resultsFile if null create a new file and write the metrics otherwise append the
119      *        test header name and metric to the file.
120      * @return file with the metric.
121      */
writeResultsToFile(String testFileSuffix, String testHeaderName, Map<String, String> metrics, File resultsFile)122     public File writeResultsToFile(String testFileSuffix, String testHeaderName,
123             Map<String, String> metrics, File resultsFile) {
124 
125         if (resultsFile == null) {
126             try {
127                 resultsFile = FileUtil.createTempFile(String.format("test_results_%s_",
128                         testFileSuffix), "");
129             } catch (IOException e) {
130                 CLog.e(e);
131                 return resultsFile;
132             }
133         }
134 
135         try (FileOutputStream outputStream = new FileOutputStream(resultsFile, true)) {
136             // Write the header description name.
137             outputStream.write(String.format("%s%s", testHeaderName, TEST_HEADER_SEPARATOR)
138                     .getBytes());
139             for (Map.Entry<String, String> entry : metrics.entrySet()) {
140                 String test_metric = String.format("%s%s%s", entry.getKey(),
141                         METRIC_KEY_VALUE_SEPARATOR, entry.getValue());
142                 outputStream.write(String.format("%s%s", test_metric, METRIC_SEPARATOR).getBytes());
143             }
144             outputStream.write(TEST_HEADER_SEPARATOR.getBytes());
145         } catch (IOException ioe) {
146             CLog.e(ioe);
147         }
148         return resultsFile;
149     }
150 
151     /**
152      * Aggregate comma separated metrics.
153      *
154      * @param rawMetrics metrics collected during the test run.
155      * @return aggregated metrics.
156      */
aggregateMetrics(Map<String, Metric> rawMetrics)157     public Map<String, Metric> aggregateMetrics(Map<String, Metric> rawMetrics) {
158         Map<String, Metric> aggregateMetrics = new LinkedHashMap<String, Metric>();
159         for (Map.Entry<String, Metric> entry : rawMetrics.entrySet()) {
160             String values = entry.getValue().getMeasurements().getSingleString();
161             List<String> splitVals = Arrays.asList(values.split(",", 0));
162             // Build stats for keys with any values, even only one.
163             if (isAllDoubleValues(splitVals)) {
164                 buildStats(entry.getKey(), splitVals, aggregateMetrics);
165             }
166         }
167         return aggregateMetrics;
168     }
169 
170     /**
171      * Aggregate the metrics collected from multiple iterations of the test and
172      * write the aggregated metrics to a test result file.
173      *
174      * @param fileName file name to use while writing the metrics.
175      */
aggregateStoredTestMetricsAndWriteToFile(String fileName)176     public File aggregateStoredTestMetricsAndWriteToFile(String fileName) {
177         File resultsFile = null;
178         for (String testName : mStoredTestMetrics.keySet()) {
179             ArrayListMultimap<String, Metric> currentTest = mStoredTestMetrics.get(testName);
180 
181             Map<String, Metric> aggregateMetrics = new LinkedHashMap<String, Metric>();
182             for (String metricKey : currentTest.keySet()) {
183                 List<Metric> metrics = currentTest.get(metricKey);
184                 List<Measurements> measures = metrics.stream().map(Metric::getMeasurements)
185                         .collect(Collectors.toList());
186                 // Parse metrics into a list of SingleString values, concating lists in the process
187                 List<String> rawValues = measures.stream()
188                         .map(Measurements::getSingleString)
189                         .map(
190                                 m -> {
191                                     // Split results; also deals with the case of empty results
192                                     // in a certain run
193                                     List<String> splitVals = Arrays.asList(m.split(",", 0));
194                                     if (splitVals.size() == 1 && splitVals.get(0).isEmpty()) {
195                                         return Collections.<String> emptyList();
196                                     }
197                                     return splitVals;
198                                 })
199                         .flatMap(Collection::stream)
200                         .map(String::trim)
201                         .collect(Collectors.toList());
202                 // Do not report empty metrics
203                 if (rawValues.isEmpty()) {
204                     continue;
205                 }
206                 if (isAllDoubleValues(rawValues)) {
207                     buildStats(metricKey, rawValues, aggregateMetrics);
208                 }
209             }
210 
211             aggregateMetrics =
212                     mMetricPatterns.size() > 0 ? filterMetrics(aggregateMetrics) : aggregateMetrics;
213             Map<String, String> compatibleTestMetrics = TfMetricProtoUtil
214                     .compatibleConvert(aggregateMetrics);
215 
216             resultsFile = writeResultsToFile(fileName, testName,
217                     compatibleTestMetrics, resultsFile);
218         }
219         return resultsFile;
220     }
221 
setPercentiles(Set<Integer> percentiles)222     public void setPercentiles(Set<Integer> percentiles) {
223         mActualPercentiles = percentiles;
224     }
225 
setIterationSeparator(String separator)226     public void setIterationSeparator(String separator) {
227         mTestIterationSeparator = separator;
228     }
229 
230     @VisibleForTesting
getStoredTestMetric()231     public Map<String, ArrayListMultimap<String, Metric>> getStoredTestMetric() {
232         return mStoredTestMetrics;
233     }
234 
235     /**
236      * Return true is all the values can be parsed to double value.
237      * Otherwise return false.
238      *
239      * @param rawValues list whose values are validated.
240      */
isAllDoubleValues(List<String> rawValues)241     public static boolean isAllDoubleValues(List<String> rawValues) {
242         return rawValues
243                 .stream()
244                 .allMatch(
245                         val -> {
246                             try {
247                                 Double.parseDouble(val);
248                                 return true;
249                             } catch (NumberFormatException e) {
250                                 return false;
251                             }
252                         });
253     }
254 
255     /**
256      * Compute the stats from the give list of values.
257      *
258      * @param values raw values to compute the aggregation.
259      * @param percentiles stats to include in the final metrics.
260      * @return aggregated values.
261      */
262     public static Map<String, Double> getStats(List<Double> values,
263             Set<Integer> percentiles) {
264         Map<String, Double> stats = new LinkedHashMap<>();
265         double sum = values.stream().mapToDouble(Double::doubleValue).sum();
266         double count = values.size();
267         // The orElse situation should never happen.
268         double mean = values.stream()
269                 .mapToDouble(Double::doubleValue)
270                 .average()
271                 .orElseThrow(IllegalStateException::new);
272         double variance = values.stream().reduce(0.0, (a, b) -> a + Math.pow(b - mean, 2) / count);
273         // Calculate percentiles. 50 th percentile will be used as median.
274         Set<Integer> updatedPercentile = new LinkedHashSet<>(percentiles);
275         updatedPercentile.add(50);
276         Map<Integer, Double> percentileStat = Quantiles.percentiles().indexes(updatedPercentile)
277                 .compute(values);
278         double median = percentileStat.get(50);
279         // Diff of last and first value from the list. Return the first value if only one value is
280         // present.
281         double diff = (values.size() > 1) ? (values.get(values.size() - 1) - values.get(0))
282                 : values.get(0);
283 
284         stats.put(STATS_KEY_MIN, Collections.min(values));
285         stats.put(STATS_KEY_MAX, Collections.max(values));
286         stats.put(STATS_KEY_MEAN, mean);
287         stats.put(STATS_KEY_VAR, variance);
288         stats.put(STATS_KEY_STDEV, Math.sqrt(variance));
289         stats.put(STATS_KEY_MEDIAN, median);
290         stats.put(STATS_KEY_TOTAL, sum);
291         stats.put(STATS_KEY_COUNT, count);
292         stats.put(STATS_KEY_DIFF, diff);
293         percentileStat
294                 .entrySet()
295                 .stream()
296                 .forEach(
297                         e -> {
298                             // If the percentile is 50, only include it if the user asks for it
299                             // explicitly.
300                             if (e.getKey() != 50 || percentiles.contains(50)) {
301                                 stats.put(
302                                         STATS_KEY_PERCENTILE_PREFIX + e.getKey().toString(),
303                                         e.getValue());
304                             }
305                         });
306         return stats;
307     }
308 
309     /**
310      * Build stats for the given set of values and build the metrics using the metric key
311      * and stats name and update the results in aggregated metrics.
312      *
313      * @param metricKey key to which the values correspond to.
314      * @param values list of raw values.
315      * @param aggregateMetrics where final metrics will be stored.
316      */
317     private void buildStats(String metricKey, List<String> values,
318             Map<String, Metric> aggregateMetrics) {
319         List<Double> doubleValues = values.stream().map(Double::parseDouble)
320                 .collect(Collectors.toList());
321         Map<String, Double> stats = getStats(doubleValues, mActualPercentiles);
322         for (String statKey : stats.keySet()) {
323             Metric.Builder metricBuilder = Metric.newBuilder();
324             metricBuilder
325                     .getMeasurementsBuilder()
326                     .setSingleString(String.format("%2.2f", stats.get(statKey)));
327             aggregateMetrics.put(
328                     String.join(STATS_KEY_SEPARATOR, metricKey, statKey),
329                     metricBuilder.build());
330         }
331     }
332 
333     /** Build regular expression patterns to filter the metrics. */
334     public void buildMetricFilterPatterns(Set<String> strictIncludeRegEx) {
335         if (!strictIncludeRegEx.isEmpty() && mMetricPatterns.isEmpty()) {
336             for (String regEx : strictIncludeRegEx) {
337                 mMetricPatterns.add(Pattern.compile(regEx));
338             }
339         }
340     }
341 
342     /** Filter the metrics that matches the pattern. */
343     public Map<String, Metric> filterMetrics(Map<String, Metric> parsedMetrics) {
344         Map<String, Metric> filteredMetrics = new HashMap<>();
345         for (Entry<String, Metric> metricEntry : parsedMetrics.entrySet()) {
346             for (Pattern pattern : mMetricPatterns) {
347                 if (pattern.matcher(metricEntry.getKey()).matches()) {
348                     filteredMetrics.put(metricEntry.getKey(), metricEntry.getValue());
349                     break;
350                 }
351             }
352         }
353         return filteredMetrics;
354     }
355 }
356