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