1 /*
2  * Copyright (C) 2017 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.annotations.MetricOption;
19 import android.device.collectors.annotations.OptionClass;
20 import android.device.collectors.util.SendToInstrumentation;
21 import android.os.Bundle;
22 import android.os.Environment;
23 import android.os.ParcelFileDescriptor;
24 import android.os.Trace;
25 import androidx.annotation.VisibleForTesting;
26 import android.util.Log;
27 
28 import androidx.test.InstrumentationRegistry;
29 import androidx.test.internal.runner.listener.InstrumentationRunListener;
30 
31 import org.junit.runner.Description;
32 import org.junit.runner.Result;
33 import org.junit.runner.notification.Failure;
34 
35 import java.io.ByteArrayOutputStream;
36 import java.io.File;
37 import java.io.IOException;
38 import java.io.InputStream;
39 import java.io.PrintStream;
40 import java.util.ArrayList;
41 import java.util.Arrays;
42 import java.util.HashMap;
43 import java.util.HashSet;
44 import java.util.Map;
45 import java.util.List;
46 import java.util.Set;
47 
48 /**
49  * Base implementation of a device metric listener that will capture and output metrics for each
50  * test run or test cases. Collectors will have access to {@link DataRecord} objects where they
51  * can put results and the base class ensure these results will be send to the instrumentation.
52  *
53  * Any subclass that calls {@link #createAndEmptyDirectory(String)} needs external storage
54  * permission. So to use this class at runtime, your test need to
55  * <a href="{@docRoot}training/basics/data-storage/files.html#GetWritePermission">have storage
56  * permission enabled</a>, and preferably granted at install time (to avoid interrupting the test).
57  * For testing at desk, run adb install -r -g testpackage.apk
58  * "-g" grants all required permission at install time.
59  *
60  * Filtering:
61  * You can annotate any test method (@Test) with {@link MetricOption} and specify an arbitrary
62  * group name that the test will be part of. It is possible to trigger the collection only against
63  * test part of a group using '--include-filter-group [group name]' or to exclude a particular
64  * group using '--exclude-filter-group [group name]'.
65  * Several group name can be passed using a comma separated argument.
66  *
67  */
68 public class BaseMetricListener extends InstrumentationRunListener {
69 
70     public static final int BUFFER_SIZE = 1024;
71     // Default collect iteration interval.
72     private static final int DEFAULT_COLLECT_INTERVAL = 1;
73 
74     // Default skip metric until iteration count.
75     private static final int SKIP_UNTIL_DEFAULT_ITERATION = 0;
76 
77     /** Options keys that the collector can receive. */
78     // Filter groups, comma separated list of group name to be included or excluded
79     public static final String INCLUDE_FILTER_GROUP_KEY = "include-filter-group";
80     public static final String EXCLUDE_FILTER_GROUP_KEY = "exclude-filter-group";
81     // Argument passed to AndroidJUnitRunner to make it log-only, we shouldn't collect on log only.
82     public static final String ARGUMENT_LOG_ONLY = "log";
83     // Collect metric every nth iteration of a test with the same name.
84     public static final String COLLECT_ITERATION_INTERVAL = "collect_iteration_interval";
85 
86     // Skip metric collection until given n iteration. Uses 1 indexing here.
87     // For example if overall iteration is 10 and skip until iteration is set
88     // to 3. Metric will not be collected for 1st,2nd and 3rd iteration.
89     public static final String SKIP_METRIC_UNTIL_ITERATION = "skip_metric_until_iteration";
90 
91     private static final String NAMESPACE_SEPARATOR = ":";
92 
93     private DataRecord mRunData;
94     private DataRecord mTestData;
95 
96     private Bundle mArgsBundle = null;
97     private final List<String> mIncludeFilters;
98     private final List<String> mExcludeFilters;
99     private boolean mLogOnly = false;
100     // Store the method name and invocation count.
101     private Map<String, Integer> mTestIdInvocationCount = new HashMap<>();
102     private int mCollectIterationInterval = 1;
103     private int mSkipMetricUntilIteration = 0;
104 
105     // Whether to report the results as instrumentation results. Used by metric collector rules,
106     // which do not have the information to invoke InstrumentationRunFinished() to report metrics.
107     private boolean mReportAsInstrumentationResults = false;
108 
BaseMetricListener()109     public BaseMetricListener() {
110         mIncludeFilters = new ArrayList<>();
111         mExcludeFilters = new ArrayList<>();
112     }
113 
114     /**
115      * Constructor to simulate receiving the instrumentation arguments. Should not be used except
116      * for testing.
117      */
118     @VisibleForTesting
BaseMetricListener(Bundle argsBundle)119     protected BaseMetricListener(Bundle argsBundle) {
120         this();
121         mArgsBundle = argsBundle;
122     }
123 
124     @Override
testRunStarted(Description description)125     public final void testRunStarted(Description description) throws Exception {
126         Trace.beginSection(this.getClass().getSimpleName() + ":testRunStarted");
127         setUp();
128         if (!mLogOnly) {
129             try {
130                 mRunData = createDataRecord();
131                 onTestRunStart(mRunData, description);
132             } catch (RuntimeException e) {
133                 // Prevent exception from reporting events.
134                 Log.e(getTag(), "Exception during onTestRunStart.", e);
135             }
136         }
137         super.testRunStarted(description);
138         Trace.endSection();
139     }
140 
141     @Override
testRunFinished(Result result)142     public final void testRunFinished(Result result) throws Exception {
143         Trace.beginSection(this.getClass().getSimpleName() + ":testRunFinished");
144         if (!mLogOnly) {
145             try {
146                 onTestRunEnd(mRunData, result);
147             } catch (RuntimeException e) {
148                 // Prevent exception from reporting events.
149                 Log.e(getTag(), "Exception during onTestRunEnd.", e);
150             }
151         }
152         cleanUp();
153         super.testRunFinished(result);
154         Trace.endSection();
155     }
156 
157     @Override
testStarted(Description description)158     public final void testStarted(Description description) throws Exception {
159         Trace.beginSection(this.getClass().getSimpleName() + ":testStarted");
160         // Update the current invocation before proceeding with metric collection.
161         // mTestIdInvocationCount uses 1 indexing.
162         mTestIdInvocationCount.compute(description.toString(),
163                 (key, value) -> (value == null) ? 1 : value + 1);
164 
165         if (shouldRun(description)) {
166             try {
167                 mTestData = createDataRecord();
168                 onTestStart(mTestData, description);
169             } catch (RuntimeException e) {
170                 // Prevent exception from reporting events.
171                 Log.e(getTag(), "Exception during onTestStart.", e);
172             }
173         }
174         super.testStarted(description);
175         Trace.endSection();
176     }
177 
178     @Override
testFailure(Failure failure)179     public final void testFailure(Failure failure) throws Exception {
180         Description description = failure.getDescription();
181         if (shouldRun(description)) {
182             try {
183                 onTestFail(mTestData, description, failure);
184             } catch (RuntimeException e) {
185                 // Prevent exception from reporting events.
186                 Log.e(getTag(), "Exception during onTestFail.", e);
187             }
188         }
189         super.testFailure(failure);
190     }
191 
192     @Override
testFinished(Description description)193     public final void testFinished(Description description) throws Exception {
194         Trace.beginSection(this.getClass().getSimpleName() + ":testFinished");
195         if (shouldRun(description)) {
196             try {
197                 onTestEnd(mTestData, description);
198             } catch (RuntimeException e) {
199                 // Prevent exception from reporting events.
200                 Log.e(getTag(), "Exception during onTestEnd.", e);
201             }
202             if (mTestData.hasMetrics()) {
203                 // Only send the status progress if there are metrics
204                 if (mReportAsInstrumentationResults) {
205                     getInstrumentation().addResults(mTestData.createBundleFromMetrics());
206                 } else {
207                 SendToInstrumentation.sendBundle(getInstrumentation(),
208                         mTestData.createBundleFromMetrics());
209             }
210             }
211         }
212         super.testFinished(description);
213         Trace.endSection();
214     }
215 
216     @Override
instrumentationRunFinished( PrintStream streamResult, Bundle resultBundle, Result junitResults)217     public void instrumentationRunFinished(
218             PrintStream streamResult, Bundle resultBundle, Result junitResults) {
219         // Test Run data goes into the INSTRUMENTATION_RESULT
220         if (mRunData != null) {
221             resultBundle.putAll(mRunData.createBundleFromMetrics());
222         }
223     }
224 
225     /**
226      * Set up the metric collector.
227      *
228      * <p>If another class is invoking the metric collector's callbacks directly, it should call
229      * this method to make sure that the metric collector is set up properly.
230      */
setUp()231     public final void setUp() {
232         parseArguments();
233         setupAdditionalArgs();
234         onSetUp();
235     }
236 
237     /**
238      * Clean up the metric collector.
239      *
240      * <p>If another class is invoking the metric collector's callbacks directly, it should call
241      * this method to make sure that the metric collector is cleaned up properly after collection.
242      */
cleanUp()243     public final void cleanUp() {
244         onCleanUp();
245     }
246 
247     /**
248      * Create a {@link DataRecord}. Exposed for testing.
249      */
250     @VisibleForTesting
createDataRecord()251     DataRecord createDataRecord() {
252         return new DataRecord();
253     }
254 
255     // ---------- Interfaces that can be implemented to set up and clean up metric collection.
256 
257     /** Called if custom set-up is needed for this metric collector. */
onSetUp()258     protected void onSetUp() {
259         // Does nothing by default.
260     }
261 
onCleanUp()262     protected void onCleanUp() {
263         // Does nothing by default.
264     }
265 
266     // ---------- Interfaces that can be implemented to take action on each test state.
267 
268     /**
269      * Called when {@link #testRunStarted(Description)} is called.
270      *
271      * @param runData structure where metrics can be put.
272      * @param description the {@link Description} for the run about to start.
273      */
onTestRunStart(DataRecord runData, Description description)274     public void onTestRunStart(DataRecord runData, Description description) {
275         // Does nothing
276     }
277 
278     /**
279      * Called when {@link #testRunFinished(Result result)} is called.
280      *
281      * @param runData structure where metrics can be put.
282      * @param result the {@link Result} for the run coming from the runner.
283      */
onTestRunEnd(DataRecord runData, Result result)284     public void onTestRunEnd(DataRecord runData, Result result) {
285         // Does nothing
286     }
287 
288     /**
289      * Called when {@link #testStarted(Description)} is called.
290      *
291      * @param testData structure where metrics can be put.
292      * @param description the {@link Description} for the test case about to start.
293      */
onTestStart(DataRecord testData, Description description)294     public void onTestStart(DataRecord testData, Description description) {
295         // Does nothing
296     }
297 
298     /**
299      * Called when {@link #testFailure(Failure)} is called.
300      *
301      * @param testData structure where metrics can be put.
302      * @param description the {@link Description} for the test case that just failed.
303      * @param failure the {@link Failure} describing the failure.
304      */
onTestFail(DataRecord testData, Description description, Failure failure)305     public void onTestFail(DataRecord testData, Description description, Failure failure) {
306         // Does nothing
307     }
308 
309     /**
310      * Called when {@link #testFinished(Description)} is called.
311      *
312      * @param testData structure where metrics can be put.
313      * @param description the {@link Description} of the test coming from the runner.
314      */
onTestEnd(DataRecord testData, Description description)315     public void onTestEnd(DataRecord testData, Description description) {
316         // Does nothing
317     }
318 
319     /**
320      * To add listener-specific extra args, implement this method in the sub class and add the
321      * listener specific args.
322      */
setupAdditionalArgs()323     public void setupAdditionalArgs() {
324         // NO-OP by default
325     }
326 
327     /**
328      * Turn executeShellCommand into a blocking operation.
329      *
330      * @param command shell command to be executed.
331      * @return byte array of execution result
332      */
executeCommandBlocking(String command)333     public byte[] executeCommandBlocking(String command) {
334         try (
335                 InputStream is = new ParcelFileDescriptor.AutoCloseInputStream(
336                         getInstrumentation().getUiAutomation().executeShellCommand(command));
337                 ByteArrayOutputStream out = new ByteArrayOutputStream()
338         ) {
339             byte[] buf = new byte[BUFFER_SIZE];
340             int length;
341             while ((length = is.read(buf)) >= 0) {
342                 out.write(buf, 0, length);
343             }
344             return out.toByteArray();
345         } catch (IOException e) {
346             Log.e(getTag(), "Error executing: " + command, e);
347             return null;
348         }
349     }
350 
351     /**
352      * Create a directory inside external storage, and optionally empty it.
353      *
354      * @param dir full path to the dir to be created.
355      * @param empty whether to empty the new dirctory.
356      * @return directory file created
357      */
createDirectory(String dir, boolean empty)358     public File createDirectory(String dir, boolean empty) {
359         File rootDir = Environment.getExternalStorageDirectory();
360         File destDir = new File(rootDir, dir);
361         if (empty) {
362             executeCommandBlocking("rm -rf " + destDir.getAbsolutePath());
363         }
364         if (!destDir.exists() && !destDir.mkdirs()) {
365             Log.e(getTag(), "Unable to create dir: " + destDir.getAbsolutePath());
366             return null;
367         }
368         return destDir;
369     }
370 
371     /**
372      * Create a directory inside external storage, and empty it.
373      *
374      * @param dir full path to the dir to be created.
375      * @return directory file created
376      */
createAndEmptyDirectory(String dir)377     public File createAndEmptyDirectory(String dir) {
378         return createDirectory(dir, true);
379     }
380 
381     /**
382      * Delete a directory and all the file inside.
383      *
384      * @param rootDir the {@link File} directory to delete.
385      */
recursiveDelete(File rootDir)386     public void recursiveDelete(File rootDir) {
387         if (rootDir != null) {
388             if (rootDir.isDirectory()) {
389                 File[] childFiles = rootDir.listFiles();
390                 if (childFiles != null) {
391                     for (File child : childFiles) {
392                         recursiveDelete(child);
393                     }
394                 }
395             }
396             rootDir.delete();
397         }
398     }
399 
400     /** Sets whether metrics should be reported directly to instrumentation results. */
setReportAsInstrumentationResults(boolean enabled)401     public final void setReportAsInstrumentationResults(boolean enabled) {
402         mReportAsInstrumentationResults = enabled;
403     }
404 
405     /**
406      * Returns the name of the current class to be used as a logging tag.
407      */
getTag()408     String getTag() {
409         return this.getClass().getName();
410     }
411 
412     /**
413      * Returns the bundle containing the instrumentation arguments.
414      */
getArgsBundle()415     protected final Bundle getArgsBundle() {
416         if (mArgsBundle == null) {
417             mArgsBundle = InstrumentationRegistry.getArguments();
418         }
419         return mArgsBundle;
420     }
421 
parseArguments()422     protected void parseArguments() {
423         Bundle args = getArgsBundle();
424         // First filter the arguments with the alias
425         filterAlias(args);
426         // Handle filtering
427         String includeGroup = args.getString(INCLUDE_FILTER_GROUP_KEY);
428         String excludeGroup = args.getString(EXCLUDE_FILTER_GROUP_KEY);
429         if (includeGroup != null) {
430             mIncludeFilters.addAll(Arrays.asList(includeGroup.split(",")));
431         }
432         if (excludeGroup != null) {
433             mExcludeFilters.addAll(Arrays.asList(excludeGroup.split(",")));
434         }
435         mCollectIterationInterval = Integer.parseInt(args.getString(
436                 COLLECT_ITERATION_INTERVAL, String.valueOf(DEFAULT_COLLECT_INTERVAL)));
437         mSkipMetricUntilIteration = Integer.parseInt(args.getString(
438                 SKIP_METRIC_UNTIL_ITERATION, String.valueOf(SKIP_UNTIL_DEFAULT_ITERATION)));
439 
440         if (mCollectIterationInterval < 1) {
441             Log.i(getTag(), "Metric collection iteration interval cannot be less than 1."
442                     + "Switching to collect for all the iterations.");
443             // Reset to collect for all the iterations.
444             mCollectIterationInterval = 1;
445         }
446         String logOnly = args.getString(ARGUMENT_LOG_ONLY);
447         if (logOnly != null) {
448             mLogOnly = Boolean.parseBoolean(logOnly);
449         }
450     }
451 
452     /**
453      * Filter the alias-ed options from the bundle, each implementation of BaseMetricListener will
454      * have its own list of arguments.
455      * TODO: Split the filtering logic outside the collector class in a utility/helper.
456      */
filterAlias(Bundle bundle)457     private void filterAlias(Bundle bundle) {
458         Set<String> keySet = new HashSet<>(bundle.keySet());
459         OptionClass optionClass = this.getClass().getAnnotation(OptionClass.class);
460         if (optionClass == null) {
461             // No @OptionClass was specified, remove all alias-ed options.
462             for (String key : keySet) {
463                 if (key.indexOf(NAMESPACE_SEPARATOR) != -1) {
464                     bundle.remove(key);
465                 }
466             }
467             return;
468         }
469         // Alias is a required field so if OptionClass is set, alias is set.
470         String alias = optionClass.alias();
471         for (String key : keySet) {
472             if (key.indexOf(NAMESPACE_SEPARATOR) == -1) {
473                 continue;
474             }
475             String optionAlias = key.split(NAMESPACE_SEPARATOR)[0];
476             if (alias.equals(optionAlias)) {
477                 // Place the option again, without alias.
478                 String optionName = key.split(NAMESPACE_SEPARATOR)[1];
479                 bundle.putString(optionName, bundle.getString(key));
480                 bundle.remove(key);
481             } else {
482                 // Remove other aliases.
483                 bundle.remove(key);
484             }
485         }
486     }
487 
488     /**
489      * Helper to decide whether the collector should run or not against the test case.
490      *
491      * @param desc The {@link Description} of the method.
492      * @return True if the collector should run.
493      */
shouldRun(Description desc)494     private boolean shouldRun(Description desc) {
495         if (mLogOnly) {
496             return false;
497         }
498 
499         MetricOption annotation = desc.getAnnotation(MetricOption.class);
500         List<String> groups = new ArrayList<>();
501         if (annotation != null) {
502             String group = annotation.group();
503             groups.addAll(Arrays.asList(group.split(",")));
504         }
505         if (!mExcludeFilters.isEmpty()) {
506             for (String group : groups) {
507                 // Exclude filters has priority, if any of the group is excluded, exclude the method
508                 if (mExcludeFilters.contains(group)) {
509                     return false;
510                 }
511             }
512         }
513         // If we have include filters, we can only run what's part of them.
514         if (!mIncludeFilters.isEmpty()) {
515             for (String group : groups) {
516                 if (mIncludeFilters.contains(group)) {
517                     return true;
518                 }
519             }
520             // We have include filter and did not match them.
521             return false;
522         }
523 
524         // Skip metric collection if current iteration is lesser than or equal to
525         // given skip until iteration count.
526         // mTestIdInvocationCount uses 1 indexing.
527         if (mTestIdInvocationCount.containsKey(desc.toString())
528                 && mTestIdInvocationCount.get(desc.toString()) <= mSkipMetricUntilIteration) {
529             Log.i(getTag(), String.format("Skipping metric collection. Current iteration is %d."
530                     + "Requested to skip metric until %d",
531                     mTestIdInvocationCount.get(desc.toString()),
532                     mSkipMetricUntilIteration));
533             return false;
534         }
535 
536         // Check for iteration interval metric collection criteria.
537         if (mTestIdInvocationCount.containsKey(desc.toString())
538                 && (mTestIdInvocationCount.get(desc.toString()) % mCollectIterationInterval != 0)) {
539             return false;
540         }
541         return true;
542     }
543 }
544