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