1 /* 2 * Copyright (C) 2018 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 17 package com.android.build.tests; 18 19 import com.android.ddmlib.Log; 20 import com.android.tradefed.build.IBuildInfo; 21 import com.android.tradefed.config.Option; 22 import com.android.tradefed.config.OptionClass; 23 import com.android.tradefed.device.DeviceNotAvailableException; 24 import com.android.tradefed.log.LogUtil.CLog; 25 import com.android.tradefed.metrics.proto.MetricMeasurement.Metric; 26 import com.android.tradefed.result.ITestInvocationListener; 27 import com.android.tradefed.result.TestDescription; 28 import com.android.tradefed.testtype.IBuildReceiver; 29 import com.android.tradefed.testtype.IRemoteTest; 30 import com.android.tradefed.util.FileUtil; 31 import com.android.tradefed.util.proto.TfMetricProtoUtil; 32 33 import org.json.JSONArray; 34 import org.json.JSONException; 35 import org.json.JSONObject; 36 37 import java.io.File; 38 import java.io.IOException; 39 import java.util.HashMap; 40 import java.util.HashSet; 41 import java.util.Map; 42 import java.util.Map.Entry; 43 import java.util.Set; 44 import java.util.regex.Matcher; 45 import java.util.regex.Pattern; 46 47 /** 48 * A device-less test that parses standard Android build image stats file and performs aggregation 49 */ 50 @OptionClass(alias = "image-stats") 51 public class ImageStats implements IRemoteTest, IBuildReceiver { 52 53 // built-in aggregation labels 54 private static final String LABEL_TOTAL = "total"; 55 private static final String LABEL_CATEGORIZED = "categorized"; 56 private static final String LABEL_UNCATEGORIZED = "uncategorized"; 57 58 private static final String FILE_SIZES = "fileSizes"; 59 60 @Option( 61 name = "size-stats-file", 62 description = 63 "Specify the name of the file containing image " 64 + "stats; when \"file-from-build-info\" is set to true, the name refers to a file that " 65 + "can be found in build info (note that build provider must be properly configured to " 66 + "download it), otherwise it refers to a local file, typically used for debugging " 67 + "purposes", 68 mandatory = true) 69 private Set<String> mStatsFileNames = new HashSet<>(); 70 71 @Option( 72 name = "file-from-build-info", 73 description = 74 "If the \"size-stats-file\" references a " 75 + "file from build info, or local; use local file for debugging purposes.") 76 private boolean mFileFromBuildInfo = false; 77 78 @Option( 79 name = "aggregation-pattern", 80 description = 81 "A key value pair consists of a regex as " 82 + "key and a string label as value. The regex is used to scan and group file size " 83 + "entries together for aggregation; that is, all files with names matching the " 84 + "pattern are grouped together for summing; this also means that a file could be " 85 + "counted multiple times; note that the regex must be a full match, not substring. " 86 + "The string label is used for identifying the summed group of file sizes when " 87 + "reporting; the regex may contain unnamed capturing groups, and values may contain " 88 + "numerical \"back references\" as place holders to be replaced with content of " 89 + "corresponding capturing group, example: ^.+\\.(.+)$ -> group-by-extension-\\1; back " 90 + "references are 1-indexed and there maybe up to 9 capturing groups; no strict checks " 91 + "are performed to ensure that capturing groups and place holders are 1:1 mapping. " 92 + "There are 3 built-in aggregations: total, categorized and uncategorized.") 93 private Map<String, String> mAggregationPattern = new HashMap<>(); 94 95 @Option( 96 name = "min-report-size", 97 description = 98 "Minimum size in bytes that an aggregated " 99 + "category needs to reach before being included in reported metrics. 0 for no limit. " 100 + "Note that built-in categories are always reported.") 101 private long mMinReportSize = 0; 102 103 private IBuildInfo mBuildInfo; 104 105 @Override setBuild(IBuildInfo buildInfo)106 public void setBuild(IBuildInfo buildInfo) { 107 mBuildInfo = buildInfo; 108 } 109 110 /** {@inheritDoc} */ 111 @Override run(ITestInvocationListener listener)112 public void run(ITestInvocationListener listener) throws DeviceNotAvailableException { 113 File statsFile; 114 // fixed run name, 1 test to run 115 long start = System.currentTimeMillis(); 116 listener.testRunStarted("image-stats-run", 1); 117 for (String statsFileName : mStatsFileNames) { 118 if (mFileFromBuildInfo) { 119 statsFile = mBuildInfo.getFile(statsFileName); 120 } else { 121 statsFile = new File(statsFileName); 122 } 123 Map<String, String> finalFileSizeMetrics = new HashMap<>(); 124 if (statsFile == null || !statsFile.exists()) { 125 throw new RuntimeException( 126 "Invalid image stats file (<null>) specified or it does not exist."); 127 } 128 // Use stats file name to uniquely identify the test and post only the metrics 129 // collected from that file under the test. 130 TestDescription td = new TestDescription(FileUtil.getBaseName(statsFileName), 131 FILE_SIZES); 132 listener.testStarted(td); 133 try { 134 parseFinalMetrics(statsFile, finalFileSizeMetrics); 135 } catch (IOException ioe) { 136 String message = String.format( 137 "Failed to parse image stats file: %s", 138 statsFile.getAbsolutePath()); 139 CLog.e(message); 140 CLog.e(ioe); 141 listener.testFailed(td, ioe.toString()); 142 listener.testEnded(td, new HashMap<String, Metric>()); 143 listener.testRunFailed(message); 144 listener.testRunEnded( 145 System.currentTimeMillis() - start, new HashMap<String, Metric>()); 146 throw new RuntimeException(message, ioe); 147 } 148 String logOutput = String.format("File sizes: %s", finalFileSizeMetrics.toString()); 149 if (mFileFromBuildInfo) { 150 CLog.v(logOutput); 151 } else { 152 // assume local debug, print outloud 153 CLog.logAndDisplay(Log.LogLevel.VERBOSE, logOutput); 154 } 155 listener.testEnded(td, TfMetricProtoUtil.upgradeConvert(finalFileSizeMetrics)); 156 157 } 158 listener.testRunEnded(System.currentTimeMillis() - start, new HashMap<String, Metric>()); 159 } 160 161 /** 162 * Parse the aggregated metrics that matches the patterns and all individual file size metrics. 163 * 164 * @param statsFile {@link File} which contains the file name and the corresponding size 165 * details. 166 * @param finalFileSizeMetrics final map that will have all the metrics of aggregated and 167 * individual file names and their corresponding values. 168 * @throws IOException 169 */ parseFinalMetrics(File statsFile, Map<String, String> finalFileSizeMetrics)170 protected void parseFinalMetrics(File statsFile, 171 Map<String, String> finalFileSizeMetrics) throws IOException { 172 Map<String, Long> individualFileSizes = parseFileSizes(statsFile); 173 // Add aggregated metrics. 174 finalFileSizeMetrics.putAll(performAggregation(individualFileSizes, 175 processAggregationPatterns(mAggregationPattern))); 176 // Add individual file size metrics. 177 finalFileSizeMetrics.putAll(convertMestricsToString(individualFileSizes)); 178 } 179 180 /** 181 * Processes json files like 'installed-files.json' (as built by standard Android build rules 182 * for device targets) into a map of file path to file sizes 183 * 184 * @param statsFile {@link File} which contains the file name and the corresponding size 185 * details. 186 * @return 187 */ parseFileSizes(File statsFile)188 protected Map<String, Long> parseFileSizes(File statsFile) throws IOException { 189 Map<String, Long> ret = new HashMap<>(); 190 try { 191 JSONArray jsonArray = new JSONArray(FileUtil.readStringFromFile(statsFile)); 192 for (int i = 0; i < jsonArray.length(); i++) { 193 JSONObject jsonObject = jsonArray.getJSONObject(i); 194 String fileName = jsonObject.getString("Name"); 195 Long fileSize = Long.parseLong(jsonObject.getString("Size")); 196 ret.put(fileName, fileSize); 197 } 198 } catch (JSONException e) { 199 CLog.w("JSONException: %s", e.getMessage()); 200 } 201 return ret; 202 } 203 204 /** Compiles the supplied aggregation regex's */ processAggregationPatterns(Map<String, String> rawPatterns)205 protected Map<Pattern, String> processAggregationPatterns(Map<String, String> rawPatterns) { 206 Map<Pattern, String> ret = new HashMap<>(); 207 for (Map.Entry<String, String> e : rawPatterns.entrySet()) { 208 Pattern p = Pattern.compile(e.getKey()); 209 ret.put(p, e.getValue()); 210 } 211 return ret; 212 } 213 214 /** 215 * Converts a matched file entry to the final aggregation label name. 216 * 217 * <p>The main thing being converted here is that capturing groups in the regex (used to match 218 * the filenames) are extracted, and used to replace the corresponding placeholders in raw 219 * label. For each 1-indexed capturing group, the captured content is used to replace the "\x" 220 * placeholders in raw label, with x being a number between 1-9, corresponding to the index of 221 * the capturing group. 222 * 223 * @param matcher the {@link Matcher} representing the matched result from the regex and input 224 * @param rawLabel the corresponding aggregation label 225 * @return 226 */ getAggregationLabel(Matcher matcher, String rawLabel)227 protected String getAggregationLabel(Matcher matcher, String rawLabel) { 228 if (matcher.groupCount() == 0) { 229 // no capturing groups, return label as is 230 return rawLabel; 231 } 232 if (matcher.groupCount() > 9) { 233 // since we are doing replacement of back references to capturing groups manually, 234 // artificially limiting this to avoid overly complex code to handle \1 vs \10 235 // in other words, "9 capturing groups ought to be enough for anybody" 236 throw new RuntimeException("too many capturing groups"); 237 } 238 String label = rawLabel; 239 for (int i = 1; i <= matcher.groupCount(); i++) { 240 String marker = String.format("\\%d", i); // e.g. "\1" 241 if (label.indexOf(marker) == -1) { 242 CLog.w( 243 "Capturing groups were defined in regex '%s', but corresponding " 244 + "back-reference placeholder '%s' not found in label '%s'", 245 matcher.pattern(), marker, rawLabel); 246 continue; 247 } 248 label = label.replace(marker, matcher.group(i)); 249 } 250 // ensure that the resulting label is not the same as the fixed "uncategorized" label 251 if (LABEL_UNCATEGORIZED.equals(label)) { 252 throw new IllegalArgumentException( 253 String.format( 254 "Use of aggregation label '%s' " + "conflicts with built-in default.", 255 LABEL_UNCATEGORIZED)); 256 } 257 return label; 258 } 259 260 /** 261 * Performs aggregation by adding raw file size entries together based on the regex's the full 262 * path names are matched. Note that this means a file entry could get aggregated multiple 263 * times. The returned map will also include a fixed entry called "uncategorized" that adds the 264 * sizes of all file entries that were never matched together. 265 * 266 * @param stats the map of raw stats: full path name -> file size 267 * @param patterns the map of aggregation patterns: a regex that could match file names -> the 268 * name of the aggregated result category (e.g. all apks) 269 * @return 270 */ performAggregation( Map<String, Long> stats, Map<Pattern, String> patterns)271 protected Map<String, String> performAggregation( 272 Map<String, Long> stats, Map<Pattern, String> patterns) { 273 Set<String> uncategorizedFiles = new HashSet<>(stats.keySet()); 274 Map<String, Long> result = new HashMap<>(); 275 long total = 0; 276 277 for (Map.Entry<String, Long> stat : stats.entrySet()) { 278 // aggregate for total first 279 total += stat.getValue(); 280 for (Map.Entry<Pattern, String> pattern : patterns.entrySet()) { 281 Matcher m = pattern.getKey().matcher(stat.getKey()); 282 if (m.matches()) { 283 // the file entry being looked at matches one of the preconfigured rules 284 String label = getAggregationLabel(m, pattern.getValue()); 285 Long size = result.get(label); 286 if (size == null) { 287 size = 0L; 288 } 289 size += stat.getValue(); 290 result.put(label, size); 291 // keep track of files that we've already aggregated at least once 292 if (uncategorizedFiles.contains(stat.getKey())) { 293 uncategorizedFiles.remove(stat.getKey()); 294 } 295 } 296 } 297 } 298 // final pass for uncategorized files 299 long uncategorized = 0; 300 for (String file : uncategorizedFiles) { 301 uncategorized += stats.get(file); 302 } 303 Map<String, String> ret = new HashMap<>(); 304 for (Map.Entry<String, Long> e : result.entrySet()) { 305 if (mMinReportSize > 0 && e.getValue() < mMinReportSize) { 306 // has a min report size requirement and current category does not meet it 307 CLog.v( 308 "Skipped reporting for %s (value %d): it's below threshold %d", 309 e.getKey(), e.getValue(), mMinReportSize); 310 continue; 311 } 312 ret.put(e.getKey(), Long.toString(e.getValue())); 313 } 314 ret.put(LABEL_UNCATEGORIZED, Long.toString(uncategorized)); 315 ret.put(LABEL_TOTAL, Long.toString(total)); 316 ret.put(LABEL_CATEGORIZED, Long.toString(total - uncategorized)); 317 return ret; 318 } 319 320 /** 321 * Convert the metric type to String which is compatible for posting the results. 322 * 323 * @param allIndividualFileSizeMetrics 324 * @return 325 */ convertMestricsToString( Map<String, Long> allIndividualFileSizeMetrics)326 private Map<String, String> convertMestricsToString( 327 Map<String, Long> allIndividualFileSizeMetrics) { 328 Map<String, String> compatibleMetrics = new HashMap<>(); 329 for (Entry<String, Long> fileSizeEntry : allIndividualFileSizeMetrics.entrySet()) { 330 compatibleMetrics.put(fileSizeEntry.getKey(), String.valueOf(fileSizeEntry.getValue())); 331 } 332 return compatibleMetrics; 333 } 334 } 335