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