1 /* 2 * Copyright (C) 2022 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.helpers; 18 19 import android.app.UiAutomation; 20 import android.os.ParcelFileDescriptor; 21 import android.util.Log; 22 23 import androidx.annotation.VisibleForTesting; 24 import androidx.test.platform.app.InstrumentationRegistry; 25 import androidx.test.uiautomator.UiDevice; 26 27 import java.io.BufferedReader; 28 import java.io.IOException; 29 import java.io.InputStream; 30 import java.io.InputStreamReader; 31 import java.nio.file.Path; 32 import java.nio.file.Paths; 33 import java.util.ArrayList; 34 import java.util.Collections; 35 import java.util.HashMap; 36 import java.util.HashSet; 37 import java.util.Map; 38 import java.util.regex.Matcher; 39 import java.util.regex.Pattern; 40 41 /** 42 * BugReportDurationHelper is used to collect the durations of a bug report's component sections 43 * during a test. 44 */ 45 public class BugReportDurationHelper implements ICollectorHelper<Double> { 46 47 private static final String TAG = BugReportDurationHelper.class.getSimpleName(); 48 49 // Commands that will run on the test device. 50 private static final String LS_CMD = "ls %s"; 51 private static final String UNZIP_EXTRACT_CMD = "unzip -p %s %s"; 52 private static final String UNZIP_CONTENTS_CMD = "unzip -l %s"; 53 54 // Filters for selecting or omitting lines from the raw bug report. 55 private static final String DUMPSTATE_DURATION_FILTER = "was the duration of \'"; 56 private static final String DUMPSYS_DURATION_FILTER = "was the duration of dumpsys"; 57 private static final String DUMPSTATE_TIMEOUT_FILTER = "timed out after"; 58 private static final String DUMPSTATE_LOG_TAG_FILTER = "dumpstate:"; 59 private static final String SHOWMAP_FILTER = "SHOW MAP"; 60 61 // Filters for selecting lines from dumpstate_board.txt. Unlike raw bug reports, dumpstate_board 62 // sections and durations are contained on separate lines. 63 private static final String DUMPSTATE_BOARD_SECTION_FILTER = "------ Section end:"; 64 private static final String DUMPSTATE_BOARD_DURATION_FILTER = "Elapsed msec:"; 65 66 // This pattern will match a group of characters representing a number with a decimal point. 67 private Pattern decimalDurationPattern = Pattern.compile("[0-9]+\\.[0-9]+"); 68 // This pattern will match a group of characters representing a number without a decimal point. 69 private Pattern integerDurationPattern = Pattern.compile("[0-9]+"); 70 71 // This pattern will match a group of characters enclosed by \'. 72 private Pattern dumpstateKeyPattern = Pattern.compile("'(.+)'"); 73 // This pattern will match a group of characters surrounded by "dumpsys " and ','. 74 private Pattern dumpsysKeyPattern = Pattern.compile("dumpsys (.+),"); 75 // This pattern will match a group of characters surrounded by ": " and " -". 76 private Pattern dumpstateBoardKeyPattern = Pattern.compile(": (.+) -"); 77 78 private String bugReportDir; 79 80 private UiDevice device; 81 BugReportDurationHelper(String dir)82 public BugReportDurationHelper(String dir) { 83 super(); 84 // The helper methods assume that the directory path is terminated by '/'. 85 if (dir.charAt(dir.length() - 1) != '/') { 86 dir += '/'; 87 } 88 bugReportDir = dir; 89 } 90 91 @Override startCollecting()92 public boolean startCollecting() { 93 device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()); 94 Log.d(TAG, "Started collecting for BugReportDuration."); 95 return true; 96 } 97 98 /** Convenience class for returning separate lists of lines from the raw bug report. */ 99 @VisibleForTesting 100 public class BugReportDurationLines { 101 public ArrayList<String> dumpstateLines; 102 public ArrayList<String> dumpsysLines; 103 public ArrayList<String> timeoutLines; 104 BugReportDurationLines()105 public BugReportDurationLines() { 106 dumpstateLines = new ArrayList<>(); 107 dumpsysLines = new ArrayList<>(); 108 timeoutLines = new ArrayList<>(); 109 } 110 isEmpty()111 public boolean isEmpty() { 112 return dumpstateLines.isEmpty() && dumpsysLines.isEmpty() && timeoutLines.isEmpty(); 113 } 114 115 // Only used in testing. contains(String s)116 public boolean contains(String s) { 117 return dumpstateLines.contains(s) 118 || dumpsysLines.contains(s) 119 || timeoutLines.contains(s); 120 } 121 } 122 123 /** Convenience class for returning section name/duration lines from dumpstate_board.txt. */ 124 @VisibleForTesting 125 public class DumpstateBoardLines { 126 public ArrayList<String> sectionNameLines; 127 public ArrayList<String> durationLines; 128 DumpstateBoardLines()129 public DumpstateBoardLines() { 130 sectionNameLines = new ArrayList<>(); 131 durationLines = new ArrayList<>(); 132 } 133 isValid()134 public boolean isValid() { 135 return sectionNameLines.size() == durationLines.size(); 136 } 137 isEmpty()138 public boolean isEmpty() { 139 return isValid() && sectionNameLines.isEmpty(); 140 } 141 size()142 public int size() { 143 return sectionNameLines.size(); 144 } 145 146 // Only used in testing. contains(String s)147 public boolean contains(String s) { 148 return sectionNameLines.contains(s) || durationLines.contains(s); 149 } 150 } 151 152 @Override getMetrics()153 public Map<String, Double> getMetrics() { 154 Log.d(TAG, "Grabbing metrics for BugReportDuration."); 155 Map<String, Double> metrics = new HashMap<>(); 156 String archive = getLatestBugReport(); 157 // No bug report was found, so there are no metrics to return. 158 if (archive == null) { 159 Log.w(TAG, "No bug report was found in directory: " + bugReportDir); 160 return metrics; 161 } 162 163 BugReportDurationLines bugReportDurationLines = extractAndFilterBugReport(archive); 164 // No lines relevant to bug report durations were found, so there are no metrics to return. 165 if (bugReportDurationLines == null || bugReportDurationLines.isEmpty()) { 166 Log.w(TAG, "No lines relevant to bug report durations were found."); 167 return metrics; 168 } 169 170 /* 171 * Some examples of section duration-relevant lines are: 172 * ------ 44.619s was the duration of 'dumpstate_board()' ------ 173 * ------ 21.397s was the duration of 'DUMPSYS' ------ 174 */ 175 for (String line : bugReportDurationLines.dumpstateLines) { 176 String dumpstateSection = parseDumpstateSection(line); 177 double duration = parseDecimalDuration(line); 178 // The line doesn't contain the expected dumpstate section name or duration value. 179 if (dumpstateSection == null || duration == -1) { 180 Log.e(TAG, "Dumpstate section name or duration could not be parsed from: " + line); 181 continue; 182 } 183 String key = convertDumpstateSectionToKey(dumpstateSection); 184 // Some sections are collected multiple times (e.g. trusty version, system log). 185 metrics.put(key, duration + metrics.getOrDefault(key, 0.0)); 186 } 187 188 /* 189 * Some examples of dumpsys duration-relevant lines are: 190 * --------- 20.865s was the duration of dumpsys meminfo, ending at: 2023-04-24 19:53:46 191 * --------- 0.316s was the duration of dumpsys gfxinfo, ending at: 2023-04-24 19:54:06 192 */ 193 for (String line : bugReportDurationLines.dumpsysLines) { 194 String dumpsysSection = parseDumpsysSection(line); 195 double duration = parseDecimalDuration(line); 196 // The line doesn't contain the expected dumpsys section name or duration value. 197 if (dumpsysSection == null || duration == -1) { 198 Log.e(TAG, "Dumpstate section name or duration could not be parsed from: " + line); 199 continue; 200 } 201 metrics.put(convertDumpsysSectionToKey(dumpsysSection), duration); 202 } 203 204 metrics.put( 205 "dumpstate-section-timeout-count", 206 (double) bugReportDurationLines.timeoutLines.size()); 207 208 DumpstateBoardLines dumpstateBoardLines = extractAndFilterDumpstateBoard(archive); 209 // No lines relevant to dumpstate board durations were found, but we should return the 210 // previously-inserted metrics anyways. 211 if (dumpstateBoardLines == null || dumpstateBoardLines.isEmpty()) { 212 Log.w(TAG, "No lines relevant to dumpstate board durations were found."); 213 return metrics; 214 } else if (!dumpstateBoardLines.isValid()) { 215 Log.w(TAG, "Mismatch in the number of dumpstate_board section names and durations."); 216 return metrics; 217 } 218 219 /* 220 * Some examples of dumpstate_board section lines are: 221 * ------ Section end: dump_display ------ 222 * ------ Section end: dump_modem.sh ------ 223 * ------ Section end: dump_modemlog ------ 224 * 225 * Some examples of dumpstate_board duration lines are: 226 * Elapsed msec: 103 227 * Elapsed msec: 89 228 * Elapsed msec: 12532 229 */ 230 for (int i = 0; i < dumpstateBoardLines.size(); i++) { 231 String dumpstateBoardSection = 232 parseDumpstateBoardSection(dumpstateBoardLines.sectionNameLines.get(i)); 233 double duration = parseIntegerDuration(dumpstateBoardLines.durationLines.get(i)); 234 if (dumpstateBoardSection == null || duration == -1) { 235 Log.e( 236 TAG, 237 String.format( 238 "Section name or duration could not be parsed from (%s, %s)", 239 dumpstateBoardLines.sectionNameLines.get(i), 240 dumpstateBoardLines.durationLines.get(i))); 241 continue; 242 } 243 metrics.put(convertDumpstateBoardSectionToKey(dumpstateBoardSection), duration); 244 } 245 return metrics; 246 } 247 248 @Override stopCollecting()249 public boolean stopCollecting() { 250 Log.d(TAG, "Stopped collecting for BugReportDuration."); 251 return true; 252 } 253 254 // Returns the name of the most recent bug report .zip in bugReportDir. 255 @VisibleForTesting getLatestBugReport()256 public String getLatestBugReport() { 257 try { 258 // executeShellCommand will return files (separated by '\n') in a single String. 259 String[] files = 260 device.executeShellCommand(String.format(LS_CMD, bugReportDir)).split("\n"); 261 HashSet<String> bugreports = new HashSet<>(); 262 for (String file : files) { 263 if (file.contains("bugreport") && file.endsWith("zip")) { 264 // We don't want to keep track of wifi or telephony bug reports because they 265 // break the assumption that lexicographically-greater bug reports are also 266 // more-recent. 267 if (file.contains("wifi") || file.contains("telephony")) { 268 Log.w(TAG, "Wifi or telephony bug report found and skipped: " + file); 269 } else { 270 bugreports.add(file); 271 } 272 } 273 } 274 if (bugreports.size() == 0) { 275 Log.e(TAG, "Failed to find a bug report in " + bugReportDir); 276 return null; 277 } else if (bugreports.size() > 1) { 278 Log.w(TAG, "There are multiple bug reports in " + bugReportDir + ":"); 279 for (String bugreport : bugreports) { 280 Log.w(TAG, " " + bugreport); 281 } 282 } 283 // Returns the newest bug report. Bug report names contain a timestamp, so the 284 // lexicographically-greatest name will correspond to the most recent bug report. 285 return Collections.max(bugreports); 286 } catch (IOException e) { 287 Log.e(TAG, "Failed to find a bug report in " + bugReportDir + ": " + e.getMessage()); 288 return null; 289 } 290 } 291 292 // Extracts a bug report .txt to stdout and returns a BugReportDurationLines object containing 293 // lines with dumpstate/dumpsys sections and durations. 294 @VisibleForTesting extractAndFilterBugReport(String archive)295 public BugReportDurationLines extractAndFilterBugReport(String archive) { 296 Path archivePath = Paths.get(bugReportDir, archive); 297 String entry = archive.replace("zip", "txt"); 298 UiAutomation automation = InstrumentationRegistry.getInstrumentation().getUiAutomation(); 299 String cmd = String.format(UNZIP_EXTRACT_CMD, archivePath.toString(), entry); 300 Log.d(TAG, "The unzip command that will be run is: " + cmd); 301 302 // We keep track of whether the buffered reader was empty because this probably indicates an 303 // issue in unzipping (e.g. a mismatch in the bugreport's .zip name and .txt entry name). 304 boolean bufferedReaderNotEmpty = false; 305 BugReportDurationLines bugReportDurationLines = new BugReportDurationLines(); 306 try (InputStream is = 307 new ParcelFileDescriptor.AutoCloseInputStream( 308 automation.executeShellCommand(cmd)); 309 BufferedReader br = new BufferedReader(new InputStreamReader(is)); ) { 310 String line; 311 while ((line = br.readLine()) != null) { 312 bufferedReaderNotEmpty = true; 313 if (line.contains(DUMPSTATE_DURATION_FILTER) && !line.contains(SHOWMAP_FILTER)) { 314 bugReportDurationLines.dumpstateLines.add(line); 315 } else if (line.contains(DUMPSYS_DURATION_FILTER)) { 316 bugReportDurationLines.dumpsysLines.add(line); 317 } else if (line.contains(DUMPSTATE_LOG_TAG_FILTER) 318 && line.contains(DUMPSTATE_TIMEOUT_FILTER)) { 319 bugReportDurationLines.timeoutLines.add(line); 320 } 321 } 322 } catch (IOException e) { 323 Log.e(TAG, "Failed to extract and parse the raw bug report: " + e.getMessage()); 324 return null; 325 } 326 if (!bufferedReaderNotEmpty) { 327 Log.e( 328 TAG, 329 String.format( 330 "The buffered reader for file %s in archive %s was empty.", 331 entry, archivePath.toString())); 332 dumpBugReportEntries(archivePath); 333 } 334 return bugReportDurationLines; 335 } 336 337 // Extracts a dumpstate_board.txt file to stdout and returns a DumpstateBoardLines object 338 // containing lines with dumpstate_board sections and lines with durations. 339 @VisibleForTesting extractAndFilterDumpstateBoard(String archive)340 public DumpstateBoardLines extractAndFilterDumpstateBoard(String archive) { 341 Path archivePath = Paths.get(bugReportDir, archive); 342 String entry = "dumpstate_board.txt"; 343 UiAutomation automation = InstrumentationRegistry.getInstrumentation().getUiAutomation(); 344 String cmd = String.format(UNZIP_EXTRACT_CMD, archivePath.toString(), entry); 345 Log.d(TAG, "The unzip command that will be run is: " + cmd); 346 347 // We keep track of whether the buffered reader was empty because this may indicate an issue 348 // in unzipping (e.g. dumpstate_board.txt doesn't exist for some reason). 349 boolean bufferedReaderNotEmpty = false; 350 DumpstateBoardLines dumpstateBoardLines = new DumpstateBoardLines(); 351 try (InputStream is = 352 new ParcelFileDescriptor.AutoCloseInputStream( 353 automation.executeShellCommand(cmd)); 354 BufferedReader br = new BufferedReader(new InputStreamReader(is)); ) { 355 String line; 356 while ((line = br.readLine()) != null) { 357 bufferedReaderNotEmpty = true; 358 if (line.contains(DUMPSTATE_BOARD_SECTION_FILTER)) { 359 dumpstateBoardLines.sectionNameLines.add(line); 360 } else if (line.contains(DUMPSTATE_BOARD_DURATION_FILTER)) { 361 dumpstateBoardLines.durationLines.add(line); 362 } 363 } 364 } catch (IOException e) { 365 Log.e(TAG, "Failed to extract and parse dumpstate_board.txt: " + e.getMessage()); 366 return null; 367 } 368 if (!bufferedReaderNotEmpty) { 369 Log.e( 370 TAG, 371 String.format( 372 "The buffered reader for file %s in archive %s was empty.", 373 entry, archivePath.toString())); 374 dumpBugReportEntries(archivePath); 375 } 376 return dumpstateBoardLines; 377 } 378 379 // Prints out every entry contained in the zip archive at archivePath. dumpBugReportEntries(Path archivePath)380 private void dumpBugReportEntries(Path archivePath) { 381 UiAutomation automation = InstrumentationRegistry.getInstrumentation().getUiAutomation(); 382 String cmd = String.format(UNZIP_CONTENTS_CMD, archivePath.toString()); 383 Log.d(TAG, "The list-contents command that will be run is: " + cmd); 384 try (InputStream is = 385 new ParcelFileDescriptor.AutoCloseInputStream( 386 automation.executeShellCommand(cmd)); 387 BufferedReader br = new BufferedReader(new InputStreamReader(is)); ) { 388 String line; 389 Log.d(TAG, "Dumping list of entries in " + archivePath); 390 while ((line = br.readLine()) != null) { 391 Log.d(TAG, " " + line); 392 } 393 } catch (IOException e) { 394 Log.e(TAG, "Failed to list the contents of the bug report: " + e.getMessage()); 395 } 396 } 397 398 // Parses a decimal duration from the input duration-relevant log line. This should only be 399 // called for dumpstate/dumpsys lines. 400 @VisibleForTesting parseDecimalDuration(String line)401 public double parseDecimalDuration(String line) { 402 Matcher m = decimalDurationPattern.matcher(line); 403 if (m.find()) { 404 return Double.parseDouble(m.group()); 405 } else { 406 return -1; 407 } 408 } 409 410 // Parses an integer duration from the input duration-relevant line. This should only be called 411 // for dumpstate_board duration-specific lines. 412 @VisibleForTesting parseIntegerDuration(String line)413 public double parseIntegerDuration(String line) { 414 Matcher m = integerDurationPattern.matcher(line); 415 if (m.find()) { 416 return Double.parseDouble(m.group()); 417 } else { 418 return -1; 419 } 420 } 421 parseSection(String line, Pattern p)422 private String parseSection(String line, Pattern p) { 423 Matcher m = p.matcher(line); 424 if (m.find()) { 425 // m.group(0) corresponds to the entire match; m.group(1) is the substring within the 426 // first set of parentheses. 427 return m.group(1); 428 } else { 429 return null; 430 } 431 } 432 433 @VisibleForTesting parseDumpstateSection(String line)434 public String parseDumpstateSection(String line) { 435 return parseSection(line, dumpstateKeyPattern); 436 } 437 438 @VisibleForTesting parseDumpsysSection(String line)439 public String parseDumpsysSection(String line) { 440 return parseSection(line, dumpsysKeyPattern); 441 } 442 443 @VisibleForTesting parseDumpstateBoardSection(String line)444 public String parseDumpstateBoardSection(String line) { 445 return parseSection(line, dumpstateBoardKeyPattern); 446 } 447 448 // Converts a dumpstate section to a key by replacing spaces with '-', lowercasing, and 449 // prepending "bugreport-duration-". 450 @VisibleForTesting convertDumpstateSectionToKey(String dumpstateSection)451 public String convertDumpstateSectionToKey(String dumpstateSection) { 452 return "bugreport-duration-" + dumpstateSection.replace(" ", "-").toLowerCase(); 453 } 454 455 // Converts a dumpsys section to a key by prepending "bugreport-dumpsys-duration-". 456 // 457 // Spaces aren't replaced because dumpsys sections shouldn't contain spaces. Lowercasing is not 458 // done either because dumpsys section names are case-sensitive, and can be run on-device as-is 459 // (e.g. "dumpsys SurfaceFlinger"). 460 @VisibleForTesting convertDumpsysSectionToKey(String dumpsysSection)461 public String convertDumpsysSectionToKey(String dumpsysSection) { 462 return "bugreport-dumpsys-duration-" + dumpsysSection; 463 } 464 465 // Converts a dumpstate_board section name to a key by prepending 466 // "bugreport-dumpstateboard-duration-". 467 // 468 // Spaces aren't replaced and lowercasing is not done because dumpstate_board section names are 469 // binary/script names. 470 @VisibleForTesting convertDumpstateBoardSectionToKey(String dumpstateBoardSection)471 public String convertDumpstateBoardSectionToKey(String dumpstateBoardSection) { 472 return "bugreport-dumpstate_board-duration-" + dumpstateBoardSection; 473 } 474 } 475