• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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.app.StatsManager;
19 import android.app.StatsManager.StatsUnavailableException;
20 import android.content.Context;
21 import android.content.res.AssetManager;
22 import android.os.Bundle;
23 import android.os.Environment;
24 import android.os.SystemClock;
25 import android.util.Log;
26 import android.util.StatsLog;
27 
28 import androidx.annotation.VisibleForTesting;
29 import androidx.test.InstrumentationRegistry;
30 
31 import com.android.internal.os.nano.StatsdConfigProto;
32 import com.android.os.nano.AtomsProto;
33 
34 import com.google.protobuf.nano.CodedOutputByteBufferNano;
35 import com.google.protobuf.nano.ExtendableMessageNano;
36 import com.google.protobuf.nano.InvalidProtocolBufferNanoException;
37 
38 import org.junit.runner.Description;
39 import org.junit.runner.Result;
40 
41 import java.io.ByteArrayOutputStream;
42 import java.io.File;
43 import java.io.IOException;
44 import java.io.InputStream;
45 import java.nio.file.Files;
46 import java.nio.file.Path;
47 import java.nio.file.Paths;
48 import java.util.Arrays;
49 import java.util.HashMap;
50 import java.util.List;
51 import java.util.Map;
52 import java.util.UUID;
53 import java.util.concurrent.TimeUnit;
54 import java.util.function.Function;
55 import java.util.stream.Collectors;
56 
57 /** A device-side metric listener that collects statsd-based metrics using bundled config files. */
58 public class StatsdListener extends BaseMetricListener {
59     private static final String LOG_TAG = StatsdListener.class.getSimpleName();
60 
61     static final String OPTION_CONFIGS_RUN_LEVEL = "statsd-configs-run-level";
62     static final String OPTION_CONFIGS_TEST_LEVEL = "statsd-configs-test-level";
63 
64     // Sub-directory within the test APK's assets/ directory to look for configs.
65     static final String CONFIG_SUB_DIRECTORY = "statsd-configs";
66     // File extension for all statsd configs.
67     static final String PROTO_EXTENSION = ".pb";
68 
69     // Parent directory for all statsd reports.
70     static final String REPORT_PATH_ROOT = "statsd-reports";
71     // Sub-directory for test run reports.
72     static final String REPORT_PATH_RUN_LEVEL = "run-level";
73     // Sub-directory for test-level reports.
74     static final String REPORT_PATH_TEST_LEVEL = "test-level";
75     // Suffix template for test-level metric report files.
76     static final String TEST_SUFFIX_TEMPLATE = "_%s-%d";
77 
78     // Common prefix for the metric key pointing to the report path.
79     static final String REPORT_KEY_PREFIX = "statsd-";
80     // Common prefix for the metric file.
81     static final String REPORT_FILENAME_PREFIX = "statsd-";
82 
83     // Labels used to signify test events to statsd with the AppBreadcrumbReported atom.
84     static final int RUN_EVENT_LABEL = 7;
85     static final int TEST_EVENT_LABEL = 11;
86     // A short delay after pushing the AppBreadcrumbReported event so that metrics can be dumped.
87     static final long METRIC_PULL_DELAY = TimeUnit.SECONDS.toMillis(1);
88 
89     // Configs used for the test run and each test, respectively.
90     private Map<String, StatsdConfigProto.StatsdConfig> mRunLevelConfigs =
91             new HashMap<String, StatsdConfigProto.StatsdConfig>();
92     private Map<String, StatsdConfigProto.StatsdConfig> mTestLevelConfigs =
93             new HashMap<String, StatsdConfigProto.StatsdConfig>();
94 
95     // Map to associate config names with their config Ids.
96     private Map<String, Long> mRunLevelConfigIds = new HashMap<String, Long>();
97     private Map<String, Long> mTestLevelConfigIds = new HashMap<String, Long>();
98 
99     // "Counter" for test iterations, keyed by the display name of each test's description.
100     private Map<String, Integer> mTestIterations = new HashMap<String, Integer>();
101 
102     // Cached stats manager instance.
103     private StatsManager mStatsManager;
104 
105     /** Register the test run configs with {@link StatsManager} before the test run starts. */
106     @Override
onTestRunStart(DataRecord runData, Description description)107     public void onTestRunStart(DataRecord runData, Description description) {
108         // The argument parsing has to be performed here as the instrumentation has not yet been
109         // registered when the constructor of this class is called.
110         mRunLevelConfigs.putAll(getConfigsFromOption(OPTION_CONFIGS_RUN_LEVEL));
111         mTestLevelConfigs.putAll(getConfigsFromOption(OPTION_CONFIGS_TEST_LEVEL));
112 
113         mRunLevelConfigIds = registerConfigsWithStatsManager(mRunLevelConfigs);
114 
115         if (!logStart(RUN_EVENT_LABEL)) {
116             Log.w(LOG_TAG, "Failed to log a test run start event. Metrics might be incomplete.");
117         }
118     }
119 
120     /**
121      * Dump the test run stats reports to the test run subdirectory after the test run ends.
122      *
123      * <p>Dumps the stats regardless of whether all the tests pass.
124      */
125     @Override
onTestRunEnd(DataRecord runData, Result result)126     public void onTestRunEnd(DataRecord runData, Result result) {
127         if (!logStop(RUN_EVENT_LABEL)) {
128             Log.w(LOG_TAG, "Failed to log a test run end event. Metrics might be incomplete.");
129         }
130         SystemClock.sleep(METRIC_PULL_DELAY);
131 
132         Map<String, File> configReports =
133                 pullReportsAndRemoveConfigs(
134                         mRunLevelConfigIds, Paths.get(REPORT_PATH_ROOT, REPORT_PATH_RUN_LEVEL), "");
135         for (String configName : configReports.keySet()) {
136             runData.addFileMetric(REPORT_KEY_PREFIX + configName, configReports.get(configName));
137         }
138     }
139 
140     /** Register the test-level configs with {@link StatsManager} before each test starts. */
141     @Override
onTestStart(DataRecord testData, Description description)142     public void onTestStart(DataRecord testData, Description description) {
143         mTestIterations.computeIfPresent(description.getDisplayName(), (name, count) -> count + 1);
144         mTestIterations.computeIfAbsent(description.getDisplayName(), name -> 1);
145         mTestLevelConfigIds = registerConfigsWithStatsManager(mTestLevelConfigs);
146 
147         if (!logStart(TEST_EVENT_LABEL)) {
148             Log.w(LOG_TAG, "Failed to log a test start event. Metrics might be incomplete.");
149         }
150     }
151 
152     /**
153      * Dump the test-level stats reports to the test-specific subdirectory after the test ends.
154      *
155      * <p>Dumps the stats regardless of whether the test passes.
156      */
157     @Override
onTestEnd(DataRecord testData, Description description)158     public void onTestEnd(DataRecord testData, Description description) {
159         if (!logStop(TEST_EVENT_LABEL)) {
160             Log.w(LOG_TAG, "Failed to log a test end event. Metrics might be incomplete.");
161         }
162         SystemClock.sleep(METRIC_PULL_DELAY);
163 
164         Map<String, File> configReports =
165                 pullReportsAndRemoveConfigs(
166                         mTestLevelConfigIds,
167                         Paths.get(REPORT_PATH_ROOT, REPORT_PATH_TEST_LEVEL),
168                         getTestSuffix(description));
169         for (String configName : configReports.keySet()) {
170             testData.addFileMetric(REPORT_KEY_PREFIX + configName, configReports.get(configName));
171         }
172     }
173 
174     /**
175      * Register a set of statsd configs and return their config IDs in a {@link Map}.
176      *
177      * @param configs Map of (config name, config proto message)
178      * @return Map of (config name, config id)
179      */
registerConfigsWithStatsManager( final Map<String, StatsdConfigProto.StatsdConfig> configs)180     private Map<String, Long> registerConfigsWithStatsManager(
181             final Map<String, StatsdConfigProto.StatsdConfig> configs) {
182         Map<String, Long> configIds = new HashMap<String, Long>();
183         adoptShellPermissionIdentity();
184         for (String configName : configs.keySet()) {
185             try {
186                 long configId = getUniqueIdForConfig(configs.get(configName));
187                 StatsdConfigProto.StatsdConfig newConfig = clone(configs.get(configName));
188                 newConfig.id = configId;
189                 Log.i(LOG_TAG, String.format("Adding config %s with ID %d.", configName, configId));
190                 addStatsConfig(configId, serialize(newConfig));
191                 configIds.put(configName, configId);
192             } catch (IOException | StatsUnavailableException e) {
193                 Log.e(
194                         LOG_TAG,
195                         String.format(
196                                 "Failed to add statsd config %s due to %s.",
197                                 configName, e.toString()));
198             }
199         }
200         dropShellPermissionIdentity();
201         return configIds;
202     }
203 
204     /**
205      * For a set of statsd config ids, retrieve the config reports from {@link StatsManager}, remove
206      * the config and dump the reports into the designated directory on the device's external
207      * storage.
208      *
209      * @param configIds Map of (config name, config Id)
210      * @param directory relative directory on external storage to dump the report in. Each report
211      *     will be named after its config.
212      * @param suffix a suffix to append to the metric report file name, used to differentiate
213      *     between tests and left empty for the test run.
214      * @return Map of (config name, config report file)
215      */
pullReportsAndRemoveConfigs( final Map<String, Long> configIds, Path directory, String suffix)216     private Map<String, File> pullReportsAndRemoveConfigs(
217             final Map<String, Long> configIds, Path directory, String suffix) {
218         File externalStorage = Environment.getExternalStorageDirectory();
219         File saveDirectory = new File(externalStorage, directory.toString());
220         if (!saveDirectory.isDirectory()) {
221             saveDirectory.mkdirs();
222         }
223         Map<String, File> savedConfigFiles = new HashMap<String, File>();
224         adoptShellPermissionIdentity();
225         for (String configName : configIds.keySet()) {
226             // Dump the metric report to external storage.
227             com.android.os.nano.StatsLog.ConfigMetricsReportList reportList;
228             try {
229                 Log.i(
230                         LOG_TAG,
231                         String.format(
232                                 "Pulling metrics for config %s with ID %d.",
233                                 configName, configIds.get(configName)));
234                 reportList =
235                         com.android.os.nano.StatsLog.ConfigMetricsReportList.parseFrom(
236                                 getStatsReports(configIds.get(configName)));
237                 Log.i(
238                         LOG_TAG,
239                         String.format(
240                                 "Found %d metric %s from config %s.",
241                                 reportList.reports.length,
242                                 reportList.reports.length == 1 ? "report" : "reports",
243                                 configName));
244                 File reportFile =
245                         new File(
246                                 saveDirectory,
247                                 REPORT_FILENAME_PREFIX + configName + suffix + PROTO_EXTENSION);
248                 writeToFile(reportFile, serialize(reportList));
249                 savedConfigFiles.put(configName, reportFile);
250             } catch (StatsUnavailableException e) {
251                 Log.e(
252                         LOG_TAG,
253                         String.format(
254                                 "Failed to retrieve metrics for config %s due to %s.",
255                                 configName, e.toString()));
256             } catch (InvalidProtocolBufferNanoException e) {
257                 Log.e(
258                         LOG_TAG,
259                         String.format(
260                                 "Unable to parse report for config %s. Details: %s.",
261                                 configName, e.toString()));
262             } catch (IOException e) {
263                 Log.e(
264                         LOG_TAG,
265                         String.format(
266                                 "Failed to write metric report for config %s to device. "
267                                         + "Details: %s.",
268                                 configName, e.toString()));
269             }
270 
271             // Remove the statsd config.
272             try {
273                 Log.i(
274                         LOG_TAG,
275                         String.format(
276                                 "Removing config %s with ID %d.",
277                                 configName, configIds.get(configName)));
278                 removeStatsConfig(configIds.get(configName));
279             } catch (StatsUnavailableException e) {
280                 Log.e(
281                         LOG_TAG,
282                         String.format(
283                                 "Unable to remove config %s due to %s.", configName, e.toString()));
284             }
285         }
286         dropShellPermissionIdentity();
287         return savedConfigFiles;
288     }
289 
290     /**
291      * Adopt shell permission identity to communicate with {@link StatsManager}.
292      *
293      * @hide
294      */
295     @VisibleForTesting
adoptShellPermissionIdentity()296     protected void adoptShellPermissionIdentity() {
297         InstrumentationRegistry.getInstrumentation()
298                 .getUiAutomation()
299                 .adoptShellPermissionIdentity();
300     }
301 
302     /**
303      * Drop shell permission identity once communication with {@link StatsManager} is done.
304      *
305      * @hide
306      */
307     @VisibleForTesting
dropShellPermissionIdentity()308     protected void dropShellPermissionIdentity() {
309         InstrumentationRegistry.getInstrumentation()
310                 .getUiAutomation()
311                 .dropShellPermissionIdentity();
312     }
313 
314     /** Returns the cached {@link StatsManager} instance; if none exists, request and cache it. */
getStatsManager()315     private StatsManager getStatsManager() {
316         if (mStatsManager == null) {
317             mStatsManager =
318                     (StatsManager)
319                             InstrumentationRegistry.getTargetContext()
320                                     .getSystemService(Context.STATS_MANAGER);
321         }
322         return mStatsManager;
323     }
324 
325     /** Get the suffix for a test + iteration combination to differentiate it from other files. */
326     @VisibleForTesting
getTestSuffix(Description description)327     String getTestSuffix(Description description) {
328         return String.format(
329                 TEST_SUFFIX_TEMPLATE,
330                 formatDescription(description),
331                 mTestIterations.get(description.getDisplayName()));
332     }
333 
334     /** Format a JUnit {@link Description} to a desired string format. */
335     @VisibleForTesting
formatDescription(Description description)336     String formatDescription(Description description) {
337         // Use String.valueOf() to guard agaist a null class name. This normally should not happen
338         // but the Description class does not explicitly guarantee it.
339         String className = String.valueOf(description.getClassName());
340         String methodName = description.getMethodName();
341         return methodName == null ? className : String.join("#", className, methodName);
342     }
343 
344     /**
345      * Forwarding logic for {@link StatsManager} as it is final and cannot be mocked.
346      *
347      * @hide
348      */
349     @VisibleForTesting
addStatsConfig(long configKey, byte[] config)350     protected void addStatsConfig(long configKey, byte[] config) throws StatsUnavailableException {
351         getStatsManager().addConfig(configKey, config);
352     }
353 
354     /**
355      * Forwarding logic for {@link StatsManager} as it is final and cannot be mocked.
356      *
357      * @hide
358      */
359     @VisibleForTesting
removeStatsConfig(long configKey)360     protected void removeStatsConfig(long configKey) throws StatsUnavailableException {
361         mStatsManager.removeConfig(configKey);
362     }
363 
364     /**
365      * Forwarding logic for {@link StatsManager} as it is final and cannot be mocked.
366      *
367      * @hide
368      */
369     @VisibleForTesting
getStatsReports(long configKey)370     protected byte[] getStatsReports(long configKey) throws StatsUnavailableException {
371         return mStatsManager.getReports(configKey);
372     }
373 
374     /**
375      * Allow tests to stub out getting instrumentation arguments.
376      *
377      * @hide
378      */
379     @VisibleForTesting
getArguments()380     protected Bundle getArguments() {
381         return InstrumentationRegistry.getArguments();
382     }
383 
384     /**
385      * Allow tests to stub out file I/O.
386      *
387      * @hide
388      */
389     @VisibleForTesting
writeToFile(File f, byte[] content)390     protected File writeToFile(File f, byte[] content) throws IOException {
391         Files.write(f.toPath(), content);
392         return f;
393     }
394 
395     /**
396      * Allow tests to override the random ID generation. The config is passed in to allow a specific
397      * ID to be associated with a config in the test.
398      *
399      * @hide
400      */
401     @VisibleForTesting
getUniqueIdForConfig(StatsdConfigProto.StatsdConfig config)402     protected long getUniqueIdForConfig(StatsdConfigProto.StatsdConfig config) {
403         return (long) UUID.randomUUID().hashCode();
404     }
405 
406     /**
407      * Allow tests to stub out {@link AssetManager} interactions as that class is final and cannot .
408      * be mocked.
409      *
410      * @hide
411      */
412     @VisibleForTesting
openConfigWithAssetManager(AssetManager manager, String configName)413     protected InputStream openConfigWithAssetManager(AssetManager manager, String configName)
414             throws IOException {
415         String configFilePath =
416                 Paths.get(CONFIG_SUB_DIRECTORY, configName + PROTO_EXTENSION).toString();
417         return manager.open(configFilePath);
418     }
419 
420     /**
421      * Parse a config from its name using {@link AssetManager}.
422      *
423      * <p>The option name is passed in for better error messaging.
424      */
parseConfigFromName( final AssetManager manager, String optionName, String configName)425     private StatsdConfigProto.StatsdConfig parseConfigFromName(
426             final AssetManager manager, String optionName, String configName) {
427         try (InputStream configStream = openConfigWithAssetManager(manager, configName)) {
428             try {
429                 byte[] serializedConfig = readInputStream(configStream);
430                 return fixPermissions(StatsdConfigProto.StatsdConfig.parseFrom(serializedConfig));
431             } catch (IOException e) {
432                 throw new RuntimeException(
433                         String.format(
434                                 "Cannot parse config %s in option %s.", configName, optionName),
435                         e);
436             }
437         } catch (IOException e) {
438             throw new IllegalArgumentException(
439                     String.format(
440                             "Config name %s in option %s does not exist", configName, optionName));
441         }
442     }
443 
444     /**
445      * Parse the suppplied option to get a set of statsd configs keyed by their names.
446      *
447      * @hide
448      */
449     @VisibleForTesting
getConfigsFromOption(String optionName)450     protected Map<String, StatsdConfigProto.StatsdConfig> getConfigsFromOption(String optionName) {
451         List<String> configNames =
452                 Arrays.asList(getArguments().getString(optionName, "").split(","))
453                         .stream()
454                         .map(s -> s.trim())
455                         .filter(s -> !s.isEmpty())
456                         .distinct()
457                         .collect(Collectors.toList());
458         // Look inside the APK assets for the configuration file.
459         final AssetManager manager = InstrumentationRegistry.getContext().getAssets();
460         return configNames
461                 .stream()
462                 .collect(
463                         Collectors.toMap(
464                                 Function.identity(),
465                                 configName ->
466                                         parseConfigFromName(manager, optionName, configName)));
467     }
468 
469     /**
470      * Log a "start" AppBreadcrumbReported event to statsd. Wraps a static method for testing.
471      *
472      * @hide
473      */
474     @VisibleForTesting
logStart(int label)475     protected boolean logStart(int label) {
476         return StatsLog.logStart(label);
477     }
478 
479     /**
480      * Log a "stop" AppBreadcrumbReported event to statsd. Wraps a static method for testing.
481      *
482      * @hide
483      */
484     @VisibleForTesting
logStop(int label)485     protected boolean logStop(int label) {
486         return StatsLog.logStop(label);
487     }
488 
489     /**
490      * Add a few permission-related options to the statsd config.
491      *
492      * <p>This is related to some new permission restrictions in RVC.
493      */
fixPermissions(StatsdConfigProto.StatsdConfig config)494     private StatsdConfigProto.StatsdConfig fixPermissions(StatsdConfigProto.StatsdConfig config)
495             throws IOException {
496         StatsdConfigProto.StatsdConfig newConfig = clone(config);
497         newConfig.defaultPullPackages =
498                 concat(config.defaultPullPackages, new String[] {"AID_SYSTEM"});
499         newConfig.whitelistedAtomIds =
500                 concat(
501                         config.whitelistedAtomIds,
502                         new int[] {AtomsProto.Atom.APP_BREADCRUMB_REPORTED_FIELD_NUMBER});
503         return newConfig;
504     }
505 
506     // Some utilities for Nano protos.
507 
serialize( ExtendableMessageNano<T> message)508     private static <T extends ExtendableMessageNano<T>> byte[] serialize(
509             ExtendableMessageNano<T> message) throws IOException {
510         byte[] serialized = new byte[message.getSerializedSize()];
511         CodedOutputByteBufferNano buffer = CodedOutputByteBufferNano.newInstance(serialized);
512         message.writeTo(buffer);
513         return serialized;
514     }
515 
clone(StatsdConfigProto.StatsdConfig config)516     private static StatsdConfigProto.StatsdConfig clone(StatsdConfigProto.StatsdConfig config)
517             throws IOException {
518         byte[] output = serialize(config);
519         return StatsdConfigProto.StatsdConfig.parseFrom(output);
520     }
521 
readInputStream(InputStream in)522     private static byte[] readInputStream(InputStream in) throws IOException {
523         ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
524         try {
525             byte[] buffer = new byte[1024];
526             int size = in.read(buffer);
527             while (size > 0) {
528                 outputStream.write(buffer, 0, size);
529                 size = in.read(buffer);
530             }
531             return outputStream.toByteArray();
532         } finally {
533             outputStream.close();
534         }
535     }
536 
537     // Array Concatenation
538 
concat(int[] source, int[] items)539     private static int[] concat(int[] source, int[] items) {
540         int[] concatenated = Arrays.copyOf(source, source.length + items.length);
541         System.arraycopy(items, 0, concatenated, source.length, items.length);
542         return concatenated;
543     }
544 
concat(T[] source, T[] items)545     private static <T> T[] concat(T[] source, T[] items) {
546         T[] concatenated = Arrays.copyOf(source, source.length + items.length);
547         System.arraycopy(items, 0, concatenated, source.length, items.length);
548         return concatenated;
549     }
550 }
551