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