blob: e89a2062bacc02378c19ec5306c1b14c65d66448 [file]
# Copyright 2023 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
from __future__ import annotations
import argparse
import dataclasses
import functools
import logging
from typing import TYPE_CHECKING, ClassVar, Final, Iterable, Self, Type
from google.protobuf import json_format
from typing_extensions import override
from crossbench import exception
from crossbench import path as pth
from crossbench.config import ConfigObject, config_dir
from crossbench.helper import fs_helper
from crossbench.helper.collection_helper import close_matches_message
from crossbench.parse import PROTOBUF_ALL_SUFFIX, ObjectParser, PathParser
from crossbench.probes.cb_perfetto.context.android import \
AndroidPerfettoProbeContext
from crossbench.probes.cb_perfetto.context.chromeos import \
ChromeOsPerfettoProbeContext
from crossbench.probes.cb_perfetto.context.desktop import \
DesktopPerfettoProbeContext
from crossbench.probes.cb_perfetto.context.windows import \
WindowsPerfettoProbeContext
from crossbench.probes.probe import Probe, ProbeConfigParser, ProbeKeyT
from crossbench.probes.result_location import ResultLocation
from crossbench.probes.trace_processor import profile_helper
from protoc import trace_config_pb2
if TYPE_CHECKING:
from crossbench.browsers.browser import Browser
from crossbench.probes.cb_perfetto.context.base import PerfettoProbeContext
from crossbench.runner.groups.browsers import BrowsersRunGroup
from crossbench.runner.run import Run
from crossbench.runner.runner import Runner
@dataclasses.dataclass(frozen=True)
class TraceConfig(ConfigObject):
""" See https://perfetto.dev/docs/reference/trace-config-proto for more
details."""
VALID_EXTENSIONS: ClassVar[tuple[str, ...]] = PROTOBUF_ALL_SUFFIX
trace_config: trace_config_pb2.TraceConfig = dataclasses.field(
default_factory=trace_config_pb2.TraceConfig)
@classmethod
@override
def parse_str(cls, value: str) -> Self:
if ":" in value:
return cls.parse_textproto(value)
presets = cls.presets()
if preset_file := presets.get(value):
return cls.parse_path(preset_file)
error_message, alternative = close_matches_message(value, presets.keys(),
"TraceConfig preset")
if not alternative:
raise ValueError(error_message)
logging.error(error_message)
preset_file = presets[alternative]
return cls.parse_path(preset_file)
@classmethod
def parse_textproto(cls, value: str) -> Self:
trace_config = trace_config_pb2.TraceConfig()
ObjectParser.parse_text_or_binary_proto(trace_config, value.encode("utf-8"))
return cls(trace_config)
@classmethod
@override
def parse_dict(cls, config: dict[str, object], **kwargs) -> Self:
cls.expect_no_extra_kwargs(kwargs)
return cls.parse_json(config)
@classmethod
def parse_json(cls, config: dict[str, object]) -> Self:
with exception.annotate_argparsing(
f"Parsing {cls.__name__} dict as json proto"):
trace_config = trace_config_pb2.TraceConfig()
json_format.ParseDict(config, trace_config)
return cls(trace_config=trace_config)
raise exception.UnreachableError
@classmethod
@override
def parse_path(cls, path: pth.LocalPath, **kwargs) -> Self:
trace_config = trace_config_pb2.TraceConfig()
ObjectParser.parse_text_or_binary_proto_file(trace_config, path)
return cls(trace_config, **kwargs)
@classmethod
def preset_dir(cls) -> pth.LocalPath:
return config_dir() / "probe/perfetto/trace_config"
@classmethod
@functools.cache
def presets(cls) -> dict[str, pth.LocalPath]:
result: dict[str, pth.LocalPath] = {}
for preset_config in cls.preset_dir().glob("*.txtpb"):
result[preset_config.stem] = preset_config
assert result, f"No trace_config presets found in {cls.preset_dir()}"
return result
@override
def to_argument_value(self) -> trace_config_pb2.TraceConfig:
return self.trace_config
@classmethod
@override
def help_text_items(cls) -> list[tuple[str, str]]:
help_items = super().help_text_items()
preset_help: list[str] = []
for name, path in cls.presets().items():
help_str = cls._trace_config_help(name, path)
preset_help.append(help_str)
help_items.append(("trace_config presets", "\n".join(preset_help)))
return help_items
@classmethod
def _trace_config_help(cls, name: str, path: pth.LocalPath) -> str:
comments: list[str] = []
with path.open(encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
if not line.startswith("#"):
break
comments.append(line[1:].strip())
if comments:
description = " ".join(comments)
return f"{name}\n {description}\n"
return f"{name}\n"
def has_v8_code_data_source(trace_config: trace_config_pb2.TraceConfig) -> bool:
return has_data_source(trace_config, "dev.v8.code")
def has_data_source(trace_config: trace_config_pb2.TraceConfig,
name: str) -> bool:
return any(data_source.config.name == name
for data_source in trace_config.data_sources)
class PerfettoProbe(Probe):
"""
A probe to collect Perfetto system traces that can be viewed on
https://ui.perfetto.dev/. The probe supports Android and ChromeOS targets.
Recommended way to use:
1. Go to https://ui.perfetto.dev/, click "Record new trace" and set up your
preferred tracing options.
2. Click "Recording command" and copy the textproto config part of the
command.
3. Paste it into the textproto field of the probe config. An example probe
config can be found at config/doc/probe/perfetto.config.hjson.
4. Specify the config via the --probe-config command-line flag.
After the run, the trace will be found among the results as
"perfetto.trace.pb.gz".
"""
NAME: ClassVar = "perfetto"
RESULT_LOCATION: ClassVar = ResultLocation.BROWSER
@classmethod
@override
def help_text_items(cls) -> list[tuple[str, str]]:
items = super().help_text_items()
items.extend(TraceConfig.help_text_items())
return items
@classmethod
@override
def config_parser(cls) -> ProbeConfigParser[Self]:
parser = super().config_parser()
parser.add_default_argument(
"trace_config",
aliases=("config", "textproto", "preset"),
type=TraceConfig,
help=("Serialized perfetto configuration. "
"See probe instructions for more details"))
parser.add_argument(
"perfetto_bin",
aliases=("perfetto",),
type=PathParser.any_path,
default=pth.AnyPath("perfetto"),
help="Perfetto binary on the browser device (android, chrome-os)")
parser.add_argument(
"tracebox_bin",
aliases=("tracebox",),
type=PathParser.any_path,
default=pth.AnyPath("tracebox"),
help="Tracebox binary on the browser device (linux, macos). "
"Auto downloaded on local devices.")
parser.add_argument(
"enabled_tags",
aliases=("tags",),
is_list=True,
type=str,
default=(),
help="Enabled tags, will be combined with the 'trace_config'.")
parser.add_argument(
"disabled_tags",
is_list=True,
type=str,
default=(),
help="Disabled tags, will be combined with the 'trace_config'.")
parser.add_argument(
"enabled_categories",
aliases=("categories",),
is_list=True,
type=str,
default=(),
help="Enabled categories, will be combined with the 'trace_config'.")
parser.add_argument(
"disabled_categories",
is_list=True,
type=str,
default=(),
help="Disabled categories, will be combined with the 'trace_config'.")
parser.add_argument(
"trace_browser_startup",
type=bool,
default=False,
help="Start perfetto tracing before launching the browser.")
parser.add_argument(
"config_via_stdin",
type=bool,
default=False,
help="Pass perfetto tracing config via stdin.")
return parser
@classmethod
def parse_str(cls: Type[Self], value: str) -> Self:
if ":" in value:
return super().parse_str(value)
if "," in value or value.startswith(("-", "+")):
return cls.parse_tags(value)
if not value:
raise argparse.ArgumentTypeError(
"Cannot create empty probe with empty trace config")
return super().parse_str(value)
@classmethod
def parse_tags(cls, value: str) -> Self:
enabled_tags: list[str] = []
disabled_tags: list[str] = []
for tag in value.split(","):
if tag.startswith("-"):
disabled_tags.append(tag[1:])
elif tag.startswith("+"):
enabled_tags.append(tag[1:])
else:
enabled_tags.append(tag)
return cls(
trace_config=trace_config_pb2.TraceConfig(),
enabled_tags=enabled_tags,
disabled_tags=disabled_tags)
def __init__(self,
trace_config: trace_config_pb2.TraceConfig | None = None,
perfetto_bin: pth.AnyPath = pth.AnyPath("perfetto"),
tracebox_bin: pth.AnyPath = pth.AnyPath("tracebox"),
enabled_tags: Iterable[str] = (),
disabled_tags: Iterable[str] = (),
enabled_categories: Iterable[str] = (),
disabled_categories: Iterable[str] = (),
trace_browser_startup: bool = False,
config_via_stdin: bool = False) -> None:
super().__init__()
if not trace_config:
trace_config = trace_config_pb2.TraceConfig()
self._trace_config_obj: Final[trace_config_pb2.TraceConfig] = trace_config
self._perfetto_bin: Final[pth.AnyPath] = perfetto_bin
self._tracebox_bin: Final[pth.AnyPath] = tracebox_bin
self._enabled_tags: Final[tuple[str, ...]] = tuple(enabled_tags)
self._disabled_tags: Final[tuple[str, ...]] = tuple(disabled_tags)
self._enabled_categories: Final[tuple[str, ...]] = tuple(enabled_categories)
self._disabled_categories: Final[tuple[str, ...]] = (
tuple(disabled_categories))
self._trace_browser_startup: Final[bool] = trace_browser_startup
self._config_via_stdin: Final[bool] = config_via_stdin
self._needs_v8_code_logger: Final[bool] = has_v8_code_data_source(
self.trace_config)
self._validate_trace_config()
def _validate_trace_config(self) -> None:
if self.trace_config.ByteSize() == 0:
raise argparse.ArgumentTypeError(
"Perfetto trace config cannot be empty."
"Either specify a trace_configs, tags or categories.")
@property
@override
def key(self) -> ProbeKeyT:
return super().key + (
("textproto", str(self._trace_config_obj)),
("perfetto_bin", str(self.perfetto_bin)),
("tracebox_bin", str(self.tracebox_bin)),
("trace_browser_startup", str(self.trace_browser_startup)),
("config_via_stdin", str(self.config_via_stdin)),
("enabled_tags", self.enabled_tags),
("disabled_tags", self.disabled_tags),
("enabled_categories", self.enabled_categories),
("disabled_categories", self.disabled_categories),
)
@property
def trace_config(self) -> trace_config_pb2.TraceConfig:
base_config = self._trace_config_obj
if (not self.enabled_tags and not self.disabled_tags and
not self.enabled_categories and not self.disabled_categories):
return base_config
trace_config = trace_config_pb2.TraceConfig()
trace_config.CopyFrom(base_config)
track_event_config: trace_config_pb2.TraceConfig.DataSource | None = None
for data_source in trace_config.data_sources:
if data_source.config.name == "track_event":
track_event_config = data_source
break
if not track_event_config:
track_event_config = trace_config.data_sources.add()
track_event_config.config.name = "track_event"
te_config = track_event_config.config.track_event_config
te_config.enabled_tags.extend(self.enabled_tags)
te_config.disabled_tags.extend(self.disabled_tags)
te_config.enabled_categories.extend(self.enabled_categories)
te_config.disabled_categories.extend(self.disabled_categories)
return trace_config
@property
def perfetto_bin(self) -> pth.AnyPath:
return self._perfetto_bin
@property
def tracebox_bin(self) -> pth.AnyPath:
return self._tracebox_bin
@property
def enabled_tags(self) -> tuple[str, ...]:
return self._enabled_tags
@property
def disabled_tags(self) -> tuple[str, ...]:
return self._disabled_tags
@property
def enabled_categories(self) -> tuple[str, ...]:
return self._enabled_categories
@property
def disabled_categories(self) -> tuple[str, ...]:
return self._disabled_categories
@property
def trace_browser_startup(self) -> bool:
return self._trace_browser_startup
@property
def config_via_stdin(self) -> bool:
return self._config_via_stdin
@property
@override
def result_path_name(self) -> str:
return "perfetto.trace.pb"
@override
def attach(self, browser: Browser) -> None:
assert browser.attributes().is_chromium_based
browser.features.enable("EnablePerfettoSystemTracing")
if self._needs_v8_code_logger:
logging.debug("Auto-enabling --perfetto-code-logger on %s", browser)
browser.js_flags.set("--perfetto-code-logger")
super().attach(browser)
@override
def log_run_result(self, run: Run) -> None:
self._log_results([run])
@override
def log_browsers_result(self, group: BrowsersRunGroup) -> None:
self._log_results(group.runs)
def _log_results(self, runs: Iterable[Run]) -> None:
logging.info("-" * 80)
logging.critical("Perfetto trace results:")
for run in runs:
result_file = run.results[self].file
logging.critical(" - %s : %s", result_file,
fs_helper.get_file_size(result_file))
@override
def create_context(self, run: Run) -> PerfettoProbeContext:
# TODO: support more platforms
if run.browser_platform.is_chromeos:
return ChromeOsPerfettoProbeContext(self, run)
if run.browser_platform.is_android:
return AndroidPerfettoProbeContext(self, run)
if run.browser_platform.is_win:
return WindowsPerfettoProbeContext(self, run)
return DesktopPerfettoProbeContext(self, run)
@override
def get_extra_probes(self, runner: Runner) -> Iterable[Probe]:
return profile_helper.get_extra_trace_processor(runner)