blob: f3a7551baf27ba35a51ca3f413bc9846446a3dbd [file] [edit]
# 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 importlib.resources
import logging
import os
import ssl
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 of 2 minutes (120 seconds) 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,
) -> 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.
"""
if trace_path.startswith("http://") or trace_path.startswith(
"https://"
):
self.trace_path = trace_path
else:
self.trace_path = os.path.abspath(trace_path)
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)
self.debug = debug
if not (
self.trace_path.startswith("http://")
or self.trace_path.startswith("https://")
) and not os.path.exists(self.trace_path):
raise FileNotFoundError(f"Trace file not found: {self.trace_path}")
if not os.path.exists(self.tp_shell_path):
raise FileNotFoundError(
f"Trace processor shell not found: {self.tp_shell_path}"
)
# Override Perfetto's PlatformDelegate to point directly to our prebuilt shell binary
perfetto.trace_processor.api.PLATFORM_DELEGATE = (
lambda: FuchsiaPlatformDelegate(self.tp_shell_path)
)
# 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}"
)
self._tp = PerfettoTP(trace=self.trace_path, config=config)
# Register a finalizer to ensure the subprocess is cleaned up even if close() isn't called.
# This cleanup is handled either in the except block on failure or by the weakref
# finalizer via _cleanup.
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:
print("--- DEBUG SQL QUERY ---")
print(query.strip())
print("-----------------------")
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 __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()