blob: a9799f5f114c361ab8648cd965ec5071eb5d1b29 [file] [log] [blame]
/*
* Copyright (C) 2019 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package android.device.collectors;
import android.app.StatsManager;
import android.app.StatsManager.StatsUnavailableException;
import android.content.Context;
import android.content.res.AssetManager;
import android.os.Bundle;
import android.os.Environment;
import android.util.Log;
import androidx.annotation.VisibleForTesting;
import androidx.test.InstrumentationRegistry;
import com.android.internal.os.StatsdConfigProto.StatsdConfig;
import com.android.os.StatsLog.ConfigMetricsReportList;
import com.google.protobuf.InvalidProtocolBufferException;
import org.junit.runner.Description;
import org.junit.runner.Result;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.function.Function;
import java.util.stream.Collectors;
/** A device-side metric listener that collects statsd-based metrics using bundled config files. */
public class StatsdListener extends BaseMetricListener {
private static final String LOG_TAG = StatsdListener.class.getSimpleName();
static final String OPTION_CONFIGS_RUN_LEVEL = "statsd-configs-run-level";
static final String OPTION_CONFIGS_TEST_LEVEL = "statsd-configs-test-level";
// Sub-directory within the test APK's assets/ directory to look for configs.
static final String CONFIG_SUB_DIRECTORY = "statsd-configs";
// File extension for all statsd configs.
static final String PROTO_EXTENSION = ".pb";
// Parent directory for all statsd reports.
static final String REPORT_PATH_ROOT = "statsd-reports";
// Sub-directory for test run reports.
static final String REPORT_PATH_RUN_LEVEL = "run-level";
// Sub-directory for test-level reports.
static final String REPORT_PATH_TEST_LEVEL = "test-level";
// Prefix template for test-level metric report files.
static final String TEST_PREFIX_TEMPLATE = "%s-%d_";
// Common prefix for the metric key pointing to the report path.
static final String REPORT_KEY_PREFIX = "statsd-";
// Configs used for the test run and each test, respectively.
private Map<String, StatsdConfig> mRunLevelConfigs = new HashMap<String, StatsdConfig>();
private Map<String, StatsdConfig> mTestLevelConfigs = new HashMap<String, StatsdConfig>();
// Map to associate config names with their config Ids.
private Map<String, Long> mRunLevelConfigIds = new HashMap<String, Long>();
private Map<String, Long> mTestLevelConfigIds = new HashMap<String, Long>();
// "Counter" for test iterations, keyed by the display name of each test's description.
private Map<String, Integer> mTestIterations = new HashMap<String, Integer>();
// Cached stats manager instance.
private StatsManager mStatsManager;
/** Register the test run configs with {@link StatsManager} before the test run starts. */
@Override
public void onTestRunStart(DataRecord runData, Description description) {
// The argument parsing has to be performed here as the instrumentation has not yet been
// registered when the constructor of this class is called.
mRunLevelConfigs.putAll(getConfigsFromOption(OPTION_CONFIGS_RUN_LEVEL));
mTestLevelConfigs.putAll(getConfigsFromOption(OPTION_CONFIGS_TEST_LEVEL));
mRunLevelConfigIds = registerConfigsWithStatsManager(mRunLevelConfigs);
}
/**
* Dump the test run stats reports to the test run subdirectory after the test run ends.
*
* <p>Dumps the stats regardless of whether all the tests pass.
*/
@Override
public void onTestRunEnd(DataRecord runData, Result result) {
Map<String, File> configReports =
pullReportsAndRemoveConfigs(
mRunLevelConfigIds, Paths.get(REPORT_PATH_ROOT, REPORT_PATH_RUN_LEVEL), "");
for (String configName : configReports.keySet()) {
runData.addFileMetric(REPORT_KEY_PREFIX + configName, configReports.get(configName));
}
}
/** Register the test-level configs with {@link StatsManager} before each test starts. */
@Override
public void onTestStart(DataRecord testData, Description description) {
mTestIterations.computeIfPresent(description.getDisplayName(), (name, count) -> count + 1);
mTestIterations.computeIfAbsent(description.getDisplayName(), name -> 1);
mTestLevelConfigIds = registerConfigsWithStatsManager(mTestLevelConfigs);
}
/**
* Dump the test-level stats reports to the test-specific subdirectory after the test ends.
*
* <p>Dumps the stats regardless of whether the test passes.
*/
@Override
public void onTestEnd(DataRecord testData, Description description) {
Map<String, File> configReports =
pullReportsAndRemoveConfigs(
mTestLevelConfigIds,
Paths.get(REPORT_PATH_ROOT, REPORT_PATH_TEST_LEVEL),
getTestPrefix(description));
for (String configName : configReports.keySet()) {
testData.addFileMetric(REPORT_KEY_PREFIX + configName, configReports.get(configName));
}
}
/**
* Register a set of statsd configs and return their config IDs in a {@link Map}.
*
* @param configs Map of (config name, config proto message)
* @return Map of (config name, config id)
*/
private Map<String, Long> registerConfigsWithStatsManager(
final Map<String, StatsdConfig> configs) {
Map<String, Long> configIds = new HashMap<String, Long>();
adoptShellPermissionIdentity();
for (String configName : configs.keySet()) {
long configId = getUniqueIdForConfig(configs.get(configName));
StatsdConfig newConfig = configs.get(configName).toBuilder().setId(configId).build();
try {
addStatsConfig(configId, newConfig.toByteArray());
configIds.put(configName, configId);
} catch (StatsUnavailableException e) {
Log.e(
LOG_TAG,
String.format(
"Failed to add statsd config %s due to %s.",
configName, e.toString()));
}
}
dropShellPermissionIdentity();
return configIds;
}
/**
* For a set of statsd config ids, retrieve the config reports from {@link StatsManager}, remove
* the config and dump the reports into the designated directory on the device's external
* storage.
*
* @param configIds Map of (config name, config Id)
* @param directory relative directory on external storage to dump the report in. Each report
* will be named after its config.
* @param prefix a prefix to prepend to the metric report file name, used to differentiate
* between tests and left empty for the test run.
* @return Map of (config name, config report file)
*/
private Map<String, File> pullReportsAndRemoveConfigs(
final Map<String, Long> configIds, Path directory, String prefix) {
File externalStorage = Environment.getExternalStorageDirectory();
File saveDirectory = new File(externalStorage, directory.toString());
if (!saveDirectory.isDirectory()) {
saveDirectory.mkdirs();
}
Map<String, File> savedConfigFiles = new HashMap<String, File>();
adoptShellPermissionIdentity();
for (String configName : configIds.keySet()) {
// Dump the metric report to external storage.
ConfigMetricsReportList reportList;
try {
reportList =
ConfigMetricsReportList.parseFrom(
getStatsReports(configIds.get(configName)));
File reportFile = new File(saveDirectory, prefix + configName + PROTO_EXTENSION);
writeToFile(reportFile, reportList.toByteArray());
savedConfigFiles.put(configName, reportFile);
} catch (StatsUnavailableException e) {
Log.e(
LOG_TAG,
String.format(
"Failed to retrieve metrics for config %s due to %s.",
configName, e.toString()));
} catch (InvalidProtocolBufferException e) {
Log.e(
LOG_TAG,
String.format(
"Unable to parse report for config %s. Details: %s.",
configName, e.toString()));
} catch (IOException e) {
Log.e(
LOG_TAG,
String.format(
"Failed to write metric report for config %s to device. "
+ "Details: %s.",
configName, e.toString()));
}
// Remove the statsd config.
try {
removeStatsConfig(configIds.get(configName));
} catch (StatsUnavailableException e) {
Log.e(
LOG_TAG,
String.format(
"Unable to remove config %s due to %s.", configName, e.toString()));
}
}
dropShellPermissionIdentity();
return savedConfigFiles;
}
/**
* Adopt shell permission identity to communicate with {@link StatsManager}.
*
* @hide
*/
@VisibleForTesting
protected void adoptShellPermissionIdentity() {
InstrumentationRegistry.getInstrumentation()
.getUiAutomation()
.adoptShellPermissionIdentity();
}
/**
* Drop shell permission identity once communication with {@link StatsManager} is done.
*
* @hide
*/
@VisibleForTesting
protected void dropShellPermissionIdentity() {
InstrumentationRegistry.getInstrumentation()
.getUiAutomation()
.dropShellPermissionIdentity();
}
/** Returns the cached {@link StatsManager} instance; if none exists, request and cache it. */
private StatsManager getStatsManager() {
if (mStatsManager == null) {
mStatsManager =
(StatsManager)
InstrumentationRegistry.getTargetContext()
.getSystemService(Context.STATS_MANAGER);
}
return mStatsManager;
}
/** Get the prefix for a test + iteration combination to differentiate it from other files. */
@VisibleForTesting
String getTestPrefix(Description description) {
return String.format(
TEST_PREFIX_TEMPLATE,
formatDescription(description),
mTestIterations.get(description.getDisplayName()));
}
/** Format a JUnit {@link Description} to a desired string format. */
@VisibleForTesting
String formatDescription(Description description) {
return String.join(
"#", description.getTestClass().getCanonicalName(), description.getMethodName());
}
/**
* Forwarding logic for {@link StatsManager} as it is final and cannot be mocked.
*
* @hide
*/
@VisibleForTesting
protected void addStatsConfig(long configKey, byte[] config) throws StatsUnavailableException {
getStatsManager().addConfig(configKey, config);
}
/**
* Forwarding logic for {@link StatsManager} as it is final and cannot be mocked.
*
* @hide
*/
@VisibleForTesting
protected void removeStatsConfig(long configKey) throws StatsUnavailableException {
mStatsManager.removeConfig(configKey);
}
/**
* Forwarding logic for {@link StatsManager} as it is final and cannot be mocked.
*
* @hide
*/
@VisibleForTesting
protected byte[] getStatsReports(long configKey) throws StatsUnavailableException {
return mStatsManager.getReports(configKey);
}
/**
* Allow tests to stub out getting instrumentation arguments.
*
* @hide
*/
@VisibleForTesting
protected Bundle getArguments() {
return InstrumentationRegistry.getArguments();
}
/**
* Allow tests to stub out file I/O.
*
* @hide
*/
@VisibleForTesting
protected File writeToFile(File f, byte[] content) throws IOException {
Files.write(f.toPath(), content);
return f;
}
/**
* Allow tests to override the random ID generation. The config is passed in to allow a specific
* ID to be associated with a config in the test.
*
* @hide
*/
@VisibleForTesting
protected long getUniqueIdForConfig(StatsdConfig config) {
return (long) UUID.randomUUID().hashCode();
}
/**
* Allow tests to stub out {@link AssetManager} interactions as that class is final and cannot .
* be mocked.
*
* @hide
*/
@VisibleForTesting
protected InputStream openConfigWithAssetManager(AssetManager manager, String configName)
throws IOException {
String configFilePath =
Paths.get(CONFIG_SUB_DIRECTORY, configName + PROTO_EXTENSION).toString();
return manager.open(configFilePath);
}
/**
* Parse a config from its name using {@link AssetManager}.
*
* <p>The option name is passed in for better error messaging.
*/
private StatsdConfig parseConfigFromName(
final AssetManager manager, String optionName, String configName) {
try (InputStream configStream = openConfigWithAssetManager(manager, configName)) {
try {
return StatsdConfig.parseFrom(configStream);
} catch (IOException e) {
throw new RuntimeException(
String.format(
"Cannot parse profile %s in option %s.", configName, optionName),
e);
}
} catch (IOException e) {
throw new IllegalArgumentException(
String.format(
"Config name %s in option %s does not exist", configName, optionName));
}
}
/**
* Parse the suppplied option to get a set of statsd configs keyed by their names.
*
* @hide
*/
@VisibleForTesting
protected Map<String, StatsdConfig> getConfigsFromOption(String optionName) {
List<String> configNames =
Arrays.asList(getArguments().getString(optionName, "").split(","))
.stream()
.map(s -> s.trim())
.filter(s -> !s.isEmpty())
.distinct()
.collect(Collectors.toList());
// Look inside the APK assets for the configuration file.
final AssetManager manager = InstrumentationRegistry.getContext().getAssets();
return configNames
.stream()
.collect(
Collectors.toMap(
Function.identity(),
configName ->
parseConfigFromName(manager, optionName, configName)));
}
}