blob: 5c3a94cba90b3c1b3d2a95eeb33d582d321add85 [file]
# Copyright 2026 The Fuchsia Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Trace Processor wrapper utilities for Perfetto trace analysis."""
import hashlib
import importlib.resources
import logging
import os
import ssl
import tempfile
import urllib.request
import weakref
from types import TracebackType
from typing import Any
import perfetto.trace_processor.api
from perfetto.tools.download_trace import resolve_trace_url
from perfetto.trace_processor.api import TraceProcessor as PerfettoTP
from perfetto.trace_processor.api import TraceProcessorConfig
from perfetto.trace_processor.platform import PlatformDelegate
from perfetto.trace_uri_resolver import util as resolver_util
from perfetto.trace_uri_resolver.path import PathUriResolver
from perfetto.trace_uri_resolver.registry import ResolverRegistry
from perfetto.trace_uri_resolver.resolver import TraceUriResolver
_LOGGER = logging.getLogger(__name__)
class HttpUriResolver(TraceUriResolver):
"""URI Resolver that streams trace data from HTTP/HTTPS endpoints and permalinks."""
PREFIX = "http"
def __init__(self, uri: str) -> None:
self.uri = uri
@classmethod
def from_trace_uri(cls, uri: str) -> "HttpUriResolver":
return cls(uri)
def resolve(self) -> list[TraceUriResolver.Result]:
if "perfetto.dev" in self.uri:
url = resolve_trace_url(self.uri)
else:
url = self.uri
context = ssl._create_unverified_context()
# Add a default timeout as a backstop to avoid permanently blocking when loading
# the trace file.
response = urllib.request.urlopen(url, context=context, timeout=120)
return [
TraceUriResolver.Result(
trace=resolver_util.read_generator(response),
metadata={"_url": url},
)
]
class HttpsUriResolver(HttpUriResolver):
PREFIX = "https"
class FuchsiaPlatformDelegate(PlatformDelegate):
"""PlatformDelegate that points directly to the host's prebuilt trace_processor_shell binary.
This delegate is used to override the default PlatformDelegate in the
third_party Perfetto Python SDK, ensuring that it uses our prebuilt, in-tree
version of trace_processor_shell instead of trying to download or resolve it
from the network or default paths.
"""
host_tp_shell_path: str
def __init__(self, host_tp_shell_path: str) -> None:
super().__init__()
self.host_tp_shell_path = host_tp_shell_path
def get_shell_path(
self, bin_path: str | None = None, fetch_latest: bool = False
) -> str:
return self.host_tp_shell_path
def get_resource(self, file: str) -> bytes:
return (
importlib.resources.files("perfetto.trace_processor")
.joinpath(file)
.read_bytes()
)
def default_resolver_registry(self) -> ResolverRegistry:
return ResolverRegistry(
resolvers=[PathUriResolver, HttpUriResolver, HttpsUriResolver]
)
class PerfettoTraceProcessor:
"""A wrapper around Perfetto's official Python TraceProcessor API.
This class provides a way to interact with Perfetto's TraceProcessor API,
handling the setup and teardown of the trace_processor_shell backend.
The trace file is parsed only once, and backend process is torn down
when the processor is closed.
Attributes:
trace_path: Path to the trace file to ingest.
tp_shell_path: Path to the trace_processor_shell binary.
debug: If True, prints SQL queries.
"""
trace_path: str
tp_shell_path: str
debug: bool
_tp: PerfettoTP
_finalizer: weakref.finalize
_tp_shell_context: Any
def __init__(
self,
trace_path: str,
tp_shell_path: str | None = None,
debug: bool = False,
cache: bool = True,
) -> None:
"""Initializes PerfettoTraceProcessor.
Args:
trace_path: Path to the trace file to ingest.
tp_shell_path: Optional path to the trace_processor_shell binary.
debug: If True, prints SQL queries.
cache: If True and trace_path is a URL, caches trace locally (default: True).
"""
self._tp_shell_context = None
if tp_shell_path is None:
try:
# The logic here involving _tp_shell_context and its __enter__/__exit__
# methods is necessary to correctly manage the lifecycle of the extracted
# trace_processor_shell binary when loaded from package resources.
# importlib.resources.as_file returns a context manager that extracts
# the resource to a temporary location. We must call __enter__() to get
# the path and ensure __exit__() is called for cleanup.
resource = importlib.resources.files("tp_shell.bin").joinpath(
"trace_processor_shell"
)
self._tp_shell_context = importlib.resources.as_file(resource)
# Enter the extraction context to obtain a real filesystem path
extracted_path = self._tp_shell_context.__enter__()
tp_shell_path = str(extracted_path)
# Ensure the extracted file is executable (necessary in ZIP contexts)
os.chmod(tp_shell_path, 0o755)
except Exception as e:
if self._tp_shell_context is not None:
try:
self._tp_shell_context.__exit__(None, None, None)
except Exception:
pass
raise FileNotFoundError(
"trace_processor_shell was not found in the packaged resources. "
"The binary must either be explicitly specified or packaged as a data source dependency."
) from e
self.tp_shell_path = os.path.abspath(tp_shell_path)
if not os.path.exists(self.tp_shell_path):
raise FileNotFoundError(
f"Trace processor shell not found: {self.tp_shell_path}"
)
self.debug = debug
delegate = FuchsiaPlatformDelegate(self.tp_shell_path)
if trace_path.startswith("http://") or trace_path.startswith(
"https://"
):
if cache:
registry = delegate.default_resolver_registry()
self.trace_path = os.path.abspath(
self._resolve_and_cache_url(trace_path, registry)
)
else:
self.trace_path = trace_path
else:
self.trace_path = os.path.abspath(trace_path)
if not os.path.exists(self.trace_path):
raise FileNotFoundError(
f"Trace file not found: {self.trace_path}"
)
# Override Perfetto's PlatformDelegate to point directly to our prebuilt shell binary
perfetto.trace_processor.api.PLATFORM_DELEGATE = lambda: delegate
# Configure TraceProcessor to use a unique port to avoid conflicts
config = TraceProcessorConfig(unique_port=True)
_LOGGER.info(
f"Initializing Perfetto TraceProcessor for trace: {self.trace_path}"
)
try:
self._tp = PerfettoTP(trace=self.trace_path, config=config)
except Exception:
if self._tp_shell_context is not None:
try:
self._tp_shell_context.__exit__(None, None, None)
except Exception:
pass
raise
# Register a finalizer to ensure the subprocess is cleaned up even if close() isn't called.
self._finalizer = weakref.finalize(
self, self._cleanup, self._tp, self._tp_shell_context
)
@staticmethod
def _cleanup(tp: PerfettoTP, context: Any = None) -> None:
"""Safely tears down the Perfetto TraceProcessor shell process and deletes temporary extraction files."""
_LOGGER.info("Tearing down Perfetto TraceProcessor shell process...")
try:
tp.close()
except Exception as e:
_LOGGER.error(f"Error closing trace processor: {e}")
if context is not None:
_LOGGER.info(
"Cleaning up temporary trace_processor_shell extraction..."
)
try:
context.__exit__(None, None, None)
except Exception as e:
_LOGGER.error(f"Error cleaning up extraction context: {e}")
def run_query(self, query: str) -> list[dict[str, Any]]:
"""Runs a SQL query against the trace and returns the result as a list of dicts."""
if not hasattr(self, "_finalizer") or not self._finalizer.alive:
raise RuntimeError("Trace processor is closed.")
if self.debug:
_LOGGER.debug(
f"--- DEBUG SQL QUERY ---\n{query.strip()}\n-----------------------"
)
try:
result_iterator = self._tp.query(query)
# Row.__dict__ on instance contains exactly the dynamic attributes (columns)
return [row.__dict__ for row in result_iterator]
except Exception as e:
_LOGGER.error(f"Error running query: {e}")
raise
def get_tables(self) -> set[str]:
"""Returns the set of table and view names available in the trace database."""
rows = self.run_query(
"SELECT name FROM sqlite_master WHERE type IN ('table', 'view')"
)
return {row["name"] for row in rows}
def __enter__(self) -> "PerfettoTraceProcessor":
return self
def __exit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: TracebackType | None,
) -> None:
self.close()
def close(self) -> None:
"""Tears down the trace processor shell process."""
if hasattr(self, "_finalizer") and self._finalizer.alive:
self._finalizer()
@staticmethod
def _resolve_and_cache_url(url: str, registry: ResolverRegistry) -> str:
cache_key = hashlib.sha256(url.encode("utf-8")).hexdigest()
cache_dir = os.path.join(tempfile.gettempdir(), "perf_analyze_cache")
cached_file = os.path.join(cache_dir, f"{cache_key}.fxt")
if os.path.exists(cached_file) and os.path.getsize(cached_file) > 0:
_LOGGER.info("Using cached trace: %s", cached_file)
return cached_file
results = registry.resolve(url)
if not results:
raise ValueError(f"Could not resolve URL: {url}")
os.makedirs(cache_dir, exist_ok=True)
_LOGGER.info(
"Downloading trace URL %s to cache %s...", url, cached_file
)
temp_file = f"{cached_file}.tmp.{os.getpid()}"
try:
with open(temp_file, "wb") as out:
for chunk in results[0].generator:
out.write(chunk)
os.replace(temp_file, cached_file)
except Exception:
if os.path.exists(temp_file):
os.remove(temp_file)
raise
return cached_file