blob: e88afabb1c05d5f548328b646fb6d60de597bb55 [file] [edit]
# Copyright 2026 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Lightweight Chrome Trace Event format tracer for depot_tools.
Produces JSON trace files compatible with Perfetto UI (https://ui.perfetto.dev)
and chrome://tracing with zero external dependencies.
"""
import contextlib
import functools
import json
import os
import sys
import tempfile
import threading
import time
from typing import Any, Callable, Dict, Iterator, List, Optional
_ENV_TRACE_VAR = "GCLIENT_TRACE"
_DEFAULT_TRACE_FILE = os.path.join(tempfile.gettempdir(), "gclient_trace.json")
class TraceCollector:
"""Collects trace events and writes them to a JSON file."""
def __init__(self) -> None:
self._lock = threading.Lock()
self._events: List[Dict[str, Any]] = []
self._threads: Dict[int, str] = {}
self._enabled = False
self._filepath: Optional[str] = None
self._pid = os.getpid()
# Check environment variable on startup.
env_val = os.environ.get(_ENV_TRACE_VAR)
if env_val:
val_lower = env_val.strip().lower()
if not val_lower or val_lower in ("0", "false", "no", "off"):
pass
elif val_lower in ("1", "true", "yes", "on"):
self.start(_DEFAULT_TRACE_FILE)
else:
self.start(env_val)
@property
def enabled(self) -> bool:
return self._enabled
@property
def filepath(self) -> Optional[str]:
return self._filepath
def start(self, filepath: Optional[str] = None) -> None:
"""Starts trace collection to the given file path."""
with self._lock:
if self._enabled:
# Tracing is already enabled (e.g. initialized early via environment
# variable and called again during CLI argument parsing).
# Do not wipe already collected events; update filepath if specified.
if filepath:
self._filepath = filepath
return
self._enabled = True
self._filepath = filepath or _DEFAULT_TRACE_FILE
self._events = []
self._threads = {}
# Record main process/thread metadata.
self._set_process_name_locked("gclient")
self._set_thread_name_locked(threading.get_ident(), "MainThread")
def set_thread_name(self, name: str, tid: Optional[int] = None) -> None:
"""Sets the display name for a thread."""
if not self._enabled:
return
if tid is None:
tid = threading.get_ident()
with self._lock:
self._set_thread_name_locked(tid, name)
def _set_process_name_locked(self, name: str) -> None:
self._events.append(
{
"name": "process_name",
"ph": "M",
"pid": self._pid,
"tid": threading.get_ident(),
"args": {"name": name},
}
)
def _set_thread_name_locked(self, tid: int, name: str) -> None:
self._threads[tid] = name
self._events.append(
{
"name": "thread_name",
"ph": "M",
"pid": self._pid,
"tid": tid,
"args": {"name": name},
}
)
def add_complete_event(
self,
name: str,
cat: Optional[str],
start_us: int,
dur_us: int,
tid: Optional[int] = None,
args: Optional[Dict[str, Any]] = None,
) -> None:
"""Adds a Complete ('X') trace event."""
if not self._enabled:
return
if tid is None:
tid = threading.get_ident()
event: Dict[str, Any] = {
"name": name,
"cat": cat or "gclient",
"ph": "X",
"ts": start_us,
"dur": dur_us,
"pid": self._pid,
"tid": tid,
}
if args:
# Ensure args values are JSON serializable.
clean_args: Dict[str, Any] = {}
for k, v in args.items():
if (
isinstance(v, (str, int, float, bool, list, dict))
or v is None
):
clean_args[k] = v
else:
clean_args[k] = str(v)
event["args"] = clean_args
with self._lock:
self._events.append(event)
def close(self) -> None:
"""Flushes collected trace events to the JSON file and disables tracing."""
if not self._enabled:
return
with self._lock:
self._enabled = False
events_to_write = list(self._events)
filepath = self._filepath
if not filepath:
return
try:
with open(filepath, "w", encoding="utf-8") as f:
json.dump({"traceEvents": events_to_write}, f, indent=2)
print(
f"Trace written to {os.path.abspath(filepath)} (open in https://ui.perfetto.dev)",
file=sys.stderr,
)
except IOError as e:
print(
f"Failed to write trace file {filepath}: {e}", file=sys.stderr
)
collector = TraceCollector()
@contextlib.contextmanager
def trace(
name: str,
cat: str = "gclient",
args: Optional[Dict[str, Any]] = None,
) -> Iterator[None]:
"""Context manager to trace execution time of a block."""
if not collector.enabled:
yield
return
tid = threading.get_ident()
start_time = time.time()
try:
yield
finally:
end_time = time.time()
start_us = int(start_time * 1_000_000)
dur_us = int((end_time - start_time) * 1_000_000)
collector.add_complete_event(
name, cat, start_us, dur_us, tid=tid, args=args
)
def trace_function(
name: Optional[str] = None,
cat: str = "gclient",
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
"""Decorator to trace a function call."""
def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
event_name = name or fn.__qualname__
@functools.wraps(fn)
def wrapper(*args: Any, **kwargs: Any) -> Any:
if not collector.enabled:
return fn(*args, **kwargs)
with trace(event_name, cat=cat):
return fn(*args, **kwargs)
return wrapper
return decorator