1#!/usr/bin/python3
2
3# Copyright (C) 2023 The Android Open Source Project
4#
5# Licensed under the Apache License, Version 2.0 (the "License");
6# you may not use this file except in compliance with the License.
7# You may obtain a copy of the License at
8#
9#      http://www.apache.org/licenses/LICENSE-2.0
10#
11# Unless required by applicable law or agreed to in writing, software
12# distributed under the License is distributed on an "AS IS" BASIS,
13# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14# See the License for the specific language governing permissions and
15# limitations under the License.
16
17"""Parser library to parse CarWatchdog dumpsys string."""
18
19import datetime
20import json
21import re
22import sys
23from typing import Any, Dict, List, Optional, Tuple
24from . import performancestats_pb2
25
26
27BOOT_TIME_REPORT_HEADER_PATTERN = (
28    r"Boot-time (?:performance|collection) report:"
29)
30TOP_N_STORAGE_IO_READS_HEADER_PATTERN = r"Top N (?:Storage I/O )?Reads:"
31TOP_N_STORAGE_IO_WRITES_HEADER_PATTERN = r"Top N (?:Storage I/O )?Writes:"
32STATS_COLLECTION_PATTERN = r"Collection (?P<id>\d+): <(?P<date>.+)>"
33PACKAGE_STORAGE_IO_STATS_PATTERN = (
34    r"(?P<userId>\d+), (?P<packageName>.+), (?P<fgBytes>\d+),"
35    r" (?P<fgBytesPercent>\d+.\d+)%, "
36    r"(?P<fgFsync>\d+), (?P<fgFsyncPercent>\d+.\d+)%, (?P<bgBytes>\d+), "
37    r"(?P<bgBytesPercent>\d+.\d+)%, (?P<bgFsync>\d+),"
38    r" (?P<bgFsyncPercent>\d+.\d+)%"
39)
40PACKAGE_CPU_STATS_PATTERN = (
41    r"(?P<userId>\d+), (?P<packageName>.+), (?P<cpuTimeMs>\d+),"
42    r" (?P<cpuTimePercent>\d+\.\d+)%"
43    r"(, (?P<cpuCycles>\d+))?"
44)
45PROCESS_CPU_STATS_PATTERN = (
46    r"\s+(?P<command>.+), (?P<cpuTimeMs>\d+), (?P<uidCpuPercent>\d+.\d+)%"
47    r"(, (?P<cpuCycles>\d+))?"
48)
49TOTAL_CPU_TIME_PATTERN = r"Total CPU time \\(ms\\): (?P<totalCpuTimeMs>\d+)"
50TOTAL_CPU_CYCLES_PATTERN = r"Total CPU cycles: (?P<totalCpuCycles>\d+)"
51TOTAL_IDLE_CPU_TIME_PATTERN = (
52    r"Total idle CPU time \\(ms\\)/percent: (?P<idleCpuTimeMs>\d+) / .+"
53)
54CPU_IO_WAIT_TIME_PATTERN = (
55    r"CPU I/O wait time(?: \\(ms\\))?/percent: (?P<iowaitCpuTimeMs>\d+) / .+"
56)
57CONTEXT_SWITCHES_PATTERN = (
58    r"Number of context switches: (?P<totalCtxtSwitches>\d+)"
59)
60IO_BLOCKED_PROCESSES_PATTERN = (
61    r"Number of I/O blocked processes/percent: (?P<totalIoBlkProc>\d+)" r" / .+"
62)
63MAJOR_PAGE_FAULTS_PATTERN = (
64    r"Number of major page faults since last collection:"
65    r" (?P<totalMajPgFaults>\d+)"
66)
67
68COLLECTION_END_LINE_MIN_LEN = 50
69PERIODIC_COLLECTION_HEADER = "Periodic collection report:"
70LAST_N_MINS_COLLECTION_HEADER = "Last N minutes performance report:"
71CUSTOM_COLLECTION_REPORT_HEADER = "Custom performance data report:"
72TOP_N_CPU_TIME_HEADER = "Top N CPU Times:"
73
74DUMP_DATETIME_FORMAT = "%a %b %d %H:%M:%S %Y %Z"
75DATETIME_FORMAT = "%Y-%m-%d %H:%M:%S"
76
77
78class BuildInformation:
79  """Contains Android build information."""
80
81  def __init__(self):
82    self.fingerprint = None
83    self.brand = None
84    self.product = None
85    self.device = None
86    self.version_release = None
87    self.id = None
88    self.version_incremental = None
89    self.type = None
90    self.tags = None
91    self.sdk = None
92    self.platform_minor = None
93    self.codename = None
94
95  def __repr__(self) -> str:
96    return (
97        "BuildInformation (fingerprint={}, brand={}, product={}, device={}, "
98        "version_release={}, id={}, version_incremental={}, type={}, tags={}, "
99        "sdk={}, platform_minor={}, codename={})".format(
100            self.fingerprint,
101            self.brand,
102            self.product,
103            self.device,
104            self.version_release,
105            self.id,
106            self.version_incremental,
107            self.type,
108            self.tags,
109            self.sdk,
110            self.platform_minor,
111            self.codename,
112        )
113    )
114
115
116class ProcessCpuStats:
117  """Contains the CPU stats for a top process in a package."""
118
119  def __init__(
120      self, command, cpu_time_ms, package_cpu_time_percent, cpu_cycles
121  ):
122    self.command = command
123    self.cpu_time_ms = cpu_time_ms
124    self.package_cpu_time_percent = package_cpu_time_percent
125    self.cpu_cycles = cpu_cycles
126
127  @classmethod
128  def from_proto(
129      cls, stats_pb: performancestats_pb2.ProcessCpuStats
130  ) -> "ProcessCpuStats":
131    """Generates ProcessCpuStats instance from the proto object."""
132    return cls(
133        stats_pb.command,
134        stats_pb.cpu_time_ms,
135        round(stats_pb.package_cpu_time_percent, 2),
136        stats_pb.cpu_cycles,
137    )
138
139  def __repr__(self) -> str:
140    return (
141        "ProcessCpuStats (command={}, CPU time={}ms, percent of "
142        "package's CPU time={}%, CPU cycles={})".format(
143            self.command,
144            self.cpu_time_ms,
145            self.package_cpu_time_percent,
146            self.cpu_cycles,
147        )
148    )
149
150
151class PackageCpuStats:
152  """Contains the CPU stats for a top package."""
153
154  def __init__(
155      self,
156      user_id,
157      package_name,
158      cpu_time_ms,
159      total_cpu_time_percent,
160      cpu_cycles,
161  ):
162    self.user_id = user_id
163    self.package_name = package_name
164    self.cpu_time_ms = cpu_time_ms
165    self.total_cpu_time_percent = total_cpu_time_percent
166    self.cpu_cycles = cpu_cycles
167    self.process_cpu_stats = []
168
169  @classmethod
170  def from_proto(
171      cls, stats_pb: performancestats_pb2.PackageCpuStats
172  ) -> "PackageCpuStats":
173    """Generates PackageCpuStats instance from the proto object."""
174    return cls(
175        stats_pb.user_id,
176        stats_pb.package_name,
177        stats_pb.cpu_time_ms,
178        round(stats_pb.total_cpu_time_percent, 2),
179        stats_pb.cpu_cycles,
180    )
181
182  def to_dict(self) -> Dict[str, Any]:
183    """Generates a dictionary equivalent where the field names are the keys."""
184    return {
185        "user_id": self.user_id,
186        "package_name": self.package_name,
187        "cpu_time_ms": self.cpu_time_ms,
188        "total_cpu_time_percent": self.total_cpu_time_percent,
189        "cpu_cycles": self.cpu_cycles,
190        "process_cpu_stats": [vars(p) for p in self.process_cpu_stats],
191    }
192
193  def __repr__(self) -> str:
194    process_cpu_stats_str = "[])"
195    if self.process_cpu_stats:
196      process_list_str = "\n      ".join(
197          list(map(repr, self.process_cpu_stats))
198      )
199      process_cpu_stats_str = "\n      {}\n    )".format(process_list_str)
200    return (
201        "PackageCpuStats (user id={}, package name={}, CPU time={}ms, "
202        "percent of total CPU time={}%, CPU cycles={}, process CPU stats={})"
203        .format(
204            self.user_id,
205            self.package_name,
206            self.cpu_time_ms,
207            self.total_cpu_time_percent,
208            self.cpu_cycles,
209            process_cpu_stats_str,
210        )
211    )
212
213
214class PackageStorageIoStats:
215  """Contains a package's storage I/O read/write stats."""
216
217  def __init__(
218      self,
219      user_id,
220      package_name,
221      fg_bytes,
222      fg_bytes_percent,
223      fg_fsync,
224      fg_fsync_percent,
225      bg_bytes,
226      bg_bytes_percent,
227      bg_fsync,
228      bg_fsync_percent,
229  ):
230    self.user_id = user_id
231    self.package_name = package_name
232    self.fg_bytes = fg_bytes
233    self.fg_bytes_percent = fg_bytes_percent
234    self.fg_fsync = fg_fsync
235    self.fg_fsync_percent = fg_fsync_percent
236    self.bg_bytes = bg_bytes
237    self.bg_bytes_percent = bg_bytes_percent
238    self.bg_fsync = bg_fsync
239    self.bg_fsync_percent = bg_fsync_percent
240
241  @classmethod
242  def from_proto(
243      cls, stats_pb: performancestats_pb2.PackageStorageIoStats
244  ) -> "PackageStorageIoStats":
245    """Generates PackageStorageIoStats instance from the proto object."""
246    return cls(
247        stats_pb.user_id,
248        stats_pb.package_name,
249        stats_pb.fg_bytes,
250        round(stats_pb.fg_bytes_percent, 2),
251        stats_pb.fg_fsync,
252        round(stats_pb.fg_fsync_percent, 2),
253        stats_pb.bg_bytes,
254        round(stats_pb.bg_bytes_percent, 2),
255        stats_pb.bg_fsync,
256        round(stats_pb.bg_fsync_percent, 2),
257    )
258
259  def to_dict(self) -> Dict[str, Any]:
260    """Generates a dictionary equivalent where the field names are the keys."""
261    return {
262        "user_id": self.user_id,
263        "package_name": self.package_name,
264        "fg_bytes": self.fg_bytes,
265        "fg_bytes_percent": self.fg_bytes_percent,
266        "fg_fsync": self.fg_fsync,
267        "fg_fsync_percent": self.fg_fsync_percent,
268        "bg_bytes": self.bg_bytes,
269        "bg_bytes_percent": self.bg_bytes_percent,
270        "bg_fsync": self.bg_fsync,
271        "bg_fsync_percent": self.bg_fsync_percent,
272    }
273
274  def __repr__(self) -> str:
275    return (
276        "PackageStorageIoStats (user id={}, package name={}, foreground"
277        " bytes={}, foreground bytes percent={}, foreground fsync={},"
278        " foreground fsync percent={}, background bytes={}, background bytes"
279        " percent={}, background fsync={}, background fsync percent={}) "
280        .format(
281            self.user_id,
282            self.package_name,
283            self.fg_bytes,
284            self.fg_bytes_percent,
285            self.fg_fsync,
286            self.fg_fsync_percent,
287            self.bg_bytes,
288            self.bg_bytes_percent,
289            self.bg_fsync,
290            self.bg_fsync_percent,
291        )
292    )
293
294
295class StatsCollection:
296  """Contains stats recorded during a single collection polling."""
297
298  def __init__(self):
299    self.id = -1
300    self.date = None
301    self.total_cpu_time_ms = 0
302    self.total_cpu_cycles = 0
303    self.idle_cpu_time_ms = 0
304    self.io_wait_time_ms = 0
305    self.context_switches = 0
306    self.io_blocked_processes = 0
307    self.major_page_faults = 0
308    self.package_cpu_stats = []
309    self.package_storage_io_read_stats = []
310    self.package_storage_io_write_stats = []
311
312  def is_empty(self) -> bool:
313    """Returns true when the object is empty."""
314    val = (
315        self.total_cpu_time_ms
316        + self.total_cpu_cycles
317        + self.idle_cpu_time_ms
318        + self.io_wait_time_ms
319        + self.context_switches
320        + self.io_blocked_processes
321        + self.major_page_faults
322    )
323    return (
324        self.id == -1
325        and not self.date
326        and val == 0
327        and not self.package_cpu_stats
328        and not self.package_storage_io_read_stats
329        and not self.package_storage_io_write_stats
330    )
331
332  def to_dict(self) -> Dict[str, Any]:
333    """Generates a dictionary equivalent where the field names are the keys."""
334    return {
335        "id": self.id,
336        "date": self.date.strftime(DATETIME_FORMAT) if self.date else "",
337        "total_cpu_time_ms": self.total_cpu_time_ms,
338        "total_cpu_cycles": self.total_cpu_cycles,
339        "idle_cpu_time_ms": self.idle_cpu_time_ms,
340        "io_wait_time_ms": self.io_wait_time_ms,
341        "context_switches": self.context_switches,
342        "io_blocked_processes": self.io_blocked_processes,
343        "major_page_faults": self.major_page_faults,
344        "package_cpu_stats": [p.to_dict() for p in self.package_cpu_stats],
345        "package_storage_io_read_stats": [
346            p.to_dict() for p in self.package_storage_io_read_stats
347        ],
348        "package_storage_io_write_stats": [
349            p.to_dict() for p in self.package_storage_io_write_stats
350        ],
351    }
352
353  def __repr__(self) -> str:
354    date = self.date.strftime(DATETIME_FORMAT) if self.date else ""
355    package_cpu_stats_dump = ""
356    package_storage_io_read_stats_dump = ""
357    package_storage_io_write_stats_dump = ""
358
359    if self.package_cpu_stats:
360      package_cpu_stats_str = "\n    ".join(
361          list(map(repr, self.package_cpu_stats))
362      )
363      package_cpu_stats_dump = ", package CPU stats=\n    {}\n".format(
364          package_cpu_stats_str
365      )
366
367    if self.package_storage_io_read_stats:
368      package_storage_io_read_stats_str = "\n    ".join(
369          list(map(repr, self.package_storage_io_read_stats))
370      )
371      package_storage_io_read_stats_dump = (
372          ", package storage I/O read stats=\n    {}\n".format(
373              package_storage_io_read_stats_str
374          )
375      )
376
377    if self.package_storage_io_write_stats:
378      package_storage_io_write_stats_str = "\n    ".join(
379          list(map(repr, self.package_storage_io_write_stats))
380      )
381      package_storage_io_write_stats_dump = (
382          ", package storage I/O write stats=\n    {}\n".format(
383              package_storage_io_write_stats_str
384          )
385      )
386
387    return (
388        "StatsCollection (id={}, date={}, total CPU time={}ms, total CPU"
389        " cycles={}, idle CPU time={}ms, I/O wait time={}ms, total context"
390        " switches={}, total I/O blocked processes={}, major page"
391        " faults={}{}{}{})\n".format(
392            self.id,
393            date,
394            self.total_cpu_time_ms,
395            self.total_cpu_cycles,
396            self.idle_cpu_time_ms,
397            self.io_wait_time_ms,
398            self.context_switches,
399            self.io_blocked_processes,
400            self.major_page_faults,
401            package_cpu_stats_dump,
402            package_storage_io_read_stats_dump,
403            package_storage_io_write_stats_dump,
404        )
405    )
406
407
408class SystemEventStats:
409  """Contains stats recorded from all pollings during a system event."""
410
411  def __init__(self):
412    self.collections = []
413
414  def add(self, collection: StatsCollection) -> None:
415    """Adds the collection stats to the system event."""
416    self.collections.append(collection)
417
418  def is_empty(self) -> bool:
419    """Returns true when the object is empty."""
420    return not any(map(lambda c: not c.is_empty(), self.collections))
421
422  def to_list(self) -> List[Dict[str, Any]]:
423    """Generates a list equivalent of the object."""
424    return [c.to_dict() for c in self.collections]
425
426  def __repr__(self) -> str:
427    collections_str = "\n  ".join(list(map(repr, self.collections)))
428    return "SystemEventStats (\n  {}\n)".format(collections_str)
429
430
431class PerformanceStats:
432  """Contains CarWatchdog stats captured in a dumpsys output."""
433
434  def __init__(self):
435    self._boot_time_stats = None
436    self._last_n_minutes_stats = None
437    self._user_switch_stats = []
438    self._custom_collection_stats = None
439
440  def _has_boot_time_stats(self) -> bool:
441    """Returns true when boot_time_stats are available."""
442    return self._boot_time_stats and not self._boot_time_stats.is_empty()
443
444  def _has_last_n_minutes_stats(self) -> bool:
445    """Returns true when last_n_minutes_stats are available."""
446    return (
447        self._last_n_minutes_stats and not self._last_n_minutes_stats.is_empty()
448    )
449
450  def _has_custom_collection_stats(self) -> bool:
451    """Returns true when custom_collection_stats are available."""
452    return (
453        self._custom_collection_stats
454        and not self._custom_collection_stats.is_empty()
455    )
456
457  def set_boot_time_stats(self, stats: SystemEventStats) -> None:
458    """Sets the boot time stats."""
459    self._boot_time_stats = stats
460
461  def set_last_n_minutes_stats(self, stats: SystemEventStats) -> None:
462    """Sets the last n minutes stats."""
463    self._last_n_minutes_stats = stats
464
465  def set_custom_collection_stats(self, stats: SystemEventStats) -> None:
466    """Sets the custom collection stats."""
467    self._custom_collection_stats = stats.collections
468
469  def get_boot_time_stats(self) -> Optional[SystemEventStats]:
470    """Returns the boot time stats."""
471    if self._has_boot_time_stats():
472      return self._boot_time_stats
473    return None
474
475  def get_last_n_minutes_stats(self) -> Optional[SystemEventStats]:
476    """Returns the last n minutes stats."""
477    if self._has_last_n_minutes_stats():
478      return self._last_n_minutes_stats
479    return None
480
481  def get_custom_collection_stats(self) -> Optional[SystemEventStats]:
482    """Returns the custom collection stats."""
483    if self._has_custom_collection_stats():
484      return self._custom_collection_stats
485    return None
486
487  def is_empty(self) -> bool:
488    """Return true when the object is empty."""
489    return (
490        not self._has_boot_time_stats()
491        and not self._has_last_n_minutes_stats()
492        and not self._has_custom_collection_stats()
493        and not any(map(lambda u: not u.is_empty(), self._user_switch_stats))
494    )
495
496  def to_dict(self) -> Optional[Dict[str, Any]]:
497    """Generates a dictionary equivalent where the field names are the keys."""
498    return {
499        "boot_time_stats": (
500            self._boot_time_stats.to_list() if self._boot_time_stats else None
501        ),
502        "last_n_minutes_stats": (
503            self._last_n_minutes_stats.to_list()
504            if self._last_n_minutes_stats
505            else None
506        ),
507        "user_switch_stats": [u.to_list() for u in self._user_switch_stats],
508        "custom_collection_stats": (
509            self._custom_collection_stats.to_list()
510            if self._custom_collection_stats
511            else None
512        ),
513    }
514
515  def __repr__(self) -> str:
516    return (
517        "PerformanceStats (\n"
518        "boot-time stats={}\n"
519        "\nlast n minutes stats={}\n"
520        "\nuser-switch stats={}\n"
521        "\ncustom-collection stats={}\n)".format(
522            self._boot_time_stats,
523            self._last_n_minutes_stats,
524            self._user_switch_stats,
525            self._custom_collection_stats,
526        )
527    )
528
529
530class DevicePerformanceStats:
531  """Contains the build information and the CarWatchdog stats."""
532
533  def __init__(self):
534    self.build_info = None
535    self.perf_stats = []
536
537  def to_dict(self) -> Dict[str, Any]:
538    """Generates a dictionary equivalent where the field names are the keys."""
539    return {
540        "build_info": vars(self.build_info),
541        "perf_stats": [s.to_dict() for s in self.perf_stats],
542    }
543
544  def __repr__(self) -> str:
545    return "DevicePerformanceStats (\nbuild_info={}\n\nperf_stats={}\n)".format(
546        self.build_info, self.perf_stats
547    )
548
549
550def parse_build_info(build_info_file: str) -> BuildInformation:
551  """Parses and returns the BuildInformation from the build info file."""
552  build_info = BuildInformation()
553
554  def get_value(line):
555    if ":" not in line:
556      return ""
557    return line.split(":")[1].strip()
558
559  with open(build_info_file, "r") as f:
560    for line in f:
561      value = get_value(line)
562      if line.startswith("fingerprint"):
563        build_info.fingerprint = value
564      elif line.startswith("brand"):
565        build_info.brand = value
566      elif line.startswith("product"):
567        build_info.product = value
568      elif line.startswith("device"):
569        build_info.device = value
570      elif line.startswith("version.release"):
571        build_info.version_release = value
572      elif line.startswith("id"):
573        build_info.id = value
574      elif line.startswith("version.incremental"):
575        build_info.version_incremental = value
576      elif line.startswith("type"):
577        build_info.type = value
578      elif line.startswith("tags"):
579        build_info.tags = value
580      elif line.startswith("sdk"):
581        build_info.sdk = value
582      elif line.startswith("platform minor version"):
583        build_info.platform_minor = value
584      elif line.startswith("codename"):
585        build_info.codename = value
586
587    return build_info
588
589
590def _is_stats_section_end(line: str) -> bool:
591  return (
592      line.startswith("Top N")
593      or re.fullmatch(STATS_COLLECTION_PATTERN, line) is not None
594      or line.startswith("-" * COLLECTION_END_LINE_MIN_LEN)
595  )
596
597
598def _parse_cpu_stats(
599    lines: List[str], idx: int
600) -> Tuple[List[PackageCpuStats], int]:
601  """Parses the CPU stats from the lines."""
602  package_cpu_stats = []
603  package_cpu_stat = None
604
605  while not _is_stats_section_end(line := lines[idx].rstrip()):
606    if match := re.fullmatch(PACKAGE_CPU_STATS_PATTERN, line):
607      cpu_cycles_str = match.group("cpuCycles")
608
609      package_cpu_stat = PackageCpuStats(
610          int(match.group("userId")),
611          match.group("packageName"),
612          int(match.group("cpuTimeMs")),
613          float(match.group("cpuTimePercent")),
614          int(cpu_cycles_str) if cpu_cycles_str is not None else -1,
615      )
616      package_cpu_stats.append(package_cpu_stat)
617    elif match := re.fullmatch(PROCESS_CPU_STATS_PATTERN, line):
618      command = match.group("command")
619      cpu_cycles_str = match.group("cpuCycles")
620      if package_cpu_stat:
621        package_cpu_stat.process_cpu_stats.append(
622            ProcessCpuStats(
623                command,
624                int(match.group("cpuTimeMs")),
625                float(match.group("uidCpuPercent")),
626                int(cpu_cycles_str) if cpu_cycles_str is not None else -1,
627            )
628        )
629      else:
630        print(
631            "No package CPU stats parsed for process:", command, file=sys.stderr
632        )
633
634    idx += 1
635
636  return package_cpu_stats, idx
637
638
639def _parse_storage_io_stats(
640    lines: List[str], idx: int
641) -> Tuple[List[PackageStorageIoStats], int]:
642  """Parses the storage I/O stats from the lines."""
643  package_storage_io_stats = []
644
645  while not _is_stats_section_end(line := lines[idx].rstrip()):
646    if match := re.fullmatch(PACKAGE_STORAGE_IO_STATS_PATTERN, line):
647      package_storage_io_stats.append(
648          PackageStorageIoStats(
649              int(match.group("userId")),
650              match.group("packageName"),
651              int(match.group("fgBytes")),
652              float(match.group("fgBytesPercent")),
653              int(match.group("fgFsync")),
654              float(match.group("fgFsyncPercent")),
655              int(match.group("bgBytes")),
656              float(match.group("bgBytesPercent")),
657              int(match.group("bgFsync")),
658              float(match.group("bgFsyncPercent")),
659          )
660      )
661
662    idx += 1
663
664  return package_storage_io_stats, idx
665
666
667def _parse_collection(
668    lines: List[str], idx: int, match: re.Match[str]
669) -> Tuple[StatsCollection, int]:
670  """Parses the stats recorded for a single polling."""
671  collection = StatsCollection()
672  collection.id = int(match.group("id"))
673  collection.date = datetime.datetime.strptime(
674      match.group("date"), DUMP_DATETIME_FORMAT
675  )
676
677  while not (
678      re.fullmatch(STATS_COLLECTION_PATTERN, (line := lines[idx].strip()))
679      or line.startswith("-" * COLLECTION_END_LINE_MIN_LEN)
680  ):
681    if match := re.fullmatch(TOTAL_CPU_TIME_PATTERN, line):
682      collection.total_cpu_time_ms = int(match.group("totalCpuTimeMs"))
683    if match := re.fullmatch(TOTAL_CPU_CYCLES_PATTERN, line):
684      collection.total_cycles = int(match.group("totalCpuCycles"))
685    elif match := re.fullmatch(TOTAL_IDLE_CPU_TIME_PATTERN, line):
686      collection.idle_cpu_time_ms = int(match.group("idleCpuTimeMs"))
687    elif match := re.fullmatch(CPU_IO_WAIT_TIME_PATTERN, line):
688      collection.io_wait_time_ms = int(match.group("iowaitCpuTimeMs"))
689    elif match := re.fullmatch(CONTEXT_SWITCHES_PATTERN, line):
690      collection.context_switches = int(match.group("totalCtxtSwitches"))
691    elif match := re.fullmatch(IO_BLOCKED_PROCESSES_PATTERN, line):
692      collection.io_blocked_processes = int(match.group("totalIoBlkProc"))
693    elif match := re.fullmatch(MAJOR_PAGE_FAULTS_PATTERN, line):
694      collection.major_page_faults = int(match.group("totalMajPgFaults"))
695    elif line == TOP_N_CPU_TIME_HEADER:
696      idx += 1  # Skip subsection header
697      package_cpu_stats, idx = _parse_cpu_stats(lines, idx)
698      collection.package_cpu_stats = package_cpu_stats
699      continue
700    elif re.fullmatch(TOP_N_STORAGE_IO_READS_HEADER_PATTERN, line):
701      idx += 1
702      package_storage_io_stats, idx = _parse_storage_io_stats(lines, idx)
703      collection.package_storage_io_read_stats = package_storage_io_stats
704      continue
705    elif re.fullmatch(TOP_N_STORAGE_IO_WRITES_HEADER_PATTERN, line):
706      idx += 1
707      package_storage_io_stats, idx = _parse_storage_io_stats(lines, idx)
708      collection.package_storage_io_write_stats = package_storage_io_stats
709      continue
710    idx += 1
711
712  return collection, idx
713
714
715def _parse_stats_collections(
716    lines: List[str], idx: int
717) -> Tuple[SystemEventStats, int]:
718  """Parses the stats recorded for a system event."""
719  system_event_stats = SystemEventStats()
720  while not (line := lines[idx].strip()).startswith("-" * 50):
721    if match := re.fullmatch(STATS_COLLECTION_PATTERN, line):
722      idx += 1  # Skip the collection header
723      collection, idx = _parse_collection(lines, idx, match)
724      if not collection.is_empty():
725        system_event_stats.add(collection)
726    else:
727      idx += 1
728  return system_event_stats, idx
729
730
731def parse_dump(dump: str) -> PerformanceStats:
732  """Parses/returns a PerformanceStats object from CarWatchdog dump string."""
733  lines = dump.split("\n")
734  performance_stats = PerformanceStats()
735  idx = 0
736  while idx < len(lines):
737    line = lines[idx].strip()
738    if re.fullmatch(BOOT_TIME_REPORT_HEADER_PATTERN, line):
739      boot_time_stats, idx = _parse_stats_collections(lines, idx)
740      if not boot_time_stats.is_empty():
741        performance_stats.set_boot_time_stats(boot_time_stats)
742    if (
743        line == PERIODIC_COLLECTION_HEADER
744        or line == LAST_N_MINS_COLLECTION_HEADER
745    ):
746      last_n_minutes_stats, idx = _parse_stats_collections(lines, idx)
747      if not last_n_minutes_stats.is_empty():
748        performance_stats.set_last_n_minutes_stats(last_n_minutes_stats)
749    if line == CUSTOM_COLLECTION_REPORT_HEADER:
750      idx += 2  # Skip the dashed-line after the custom collection header
751      custom_collection_stats, idx = _parse_stats_collections(lines, idx)
752      if not custom_collection_stats.is_empty():
753        performance_stats.set_custom_collection_stats(custom_collection_stats)
754    else:
755      idx += 1
756
757  return performance_stats
758
759
760def parse_dump_to_json(dump: str) -> Any:
761  """Parses and returns a json object from the CarWatchdog dump string."""
762  return json.loads(json.dumps(parse_dump(dump).to_dict()))
763