blob: c9258cc25b648efbbc6dbd7663ddfc6cfcf5b8a7 [file]
#!/usr/bin/env python3
# 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.
"""Extracts APC (AnnotatedPageContent) and screenshots from an Actor trace.
This script parses a Perfetto trace recorded by Chrome during Actor tasks and
extracts:
- PageContext event (contains serialized AnnotatedPageContent protobuf)
-> step{N}_apc.textproto (or step{N}_apc.pb if protoc is unavailable)
- Screenshot event (contains JPEG or PNG screenshot)
-> step{N}_screenshot.jpg (or .png)
Recording Actor Traces:
Pass the `--actor-trace-path=<path>` switch to Chrome to record traces to a
file or directory.
Android:
1. Set command-line flags:
out_android/Release/bin/chrome_apk argv set \
--args="--actor-trace-path=/sdcard/Download/actor_trace.pb"
(or via adb shell "echo '_ \
--actor-trace-path=/sdcard/Download/actor_trace.pb' \
> /data/local/tmp/chrome-command-line")
2. Launch Chrome and run Actor tasks:
out_android/Release/bin/chrome_apk launch
3. Pull the trace file from device:
adb pull /sdcard/Download/actor_trace.pb /tmp/actor_trace.pb
Desktop (Linux / Mac / Windows):
Run Chrome with the flag:
chrome --actor-trace-path=/tmp/actor_trace.pb
Parsing and Extracting Steps:
# Automatically decodes to .textproto when protoc or -b is provided:
python3 components/actor/tools/parse_actor_trace.py \
/tmp/actor_trace.pb -b out_linux/Release
# Custom output directory is optional (defaults to <trace>_extracted):
python3 components/actor/tools/parse_actor_trace.py \
/tmp/actor_trace.pb -o /tmp/extracted_steps -b out_linux/Release
Manual Protoc Inspection (if raw .pb files were saved):
protoc --decode=optimization_guide.proto.AnnotatedPageContent \
components/optimization_guide/proto/features/common_quality_data.proto \
< /tmp/actor_trace_extracted/step1_apc.pb
"""
import argparse
import base64
import binascii
import os
import pathlib
import shutil
import subprocess
import sys
from typing import Any, Dict, List, Optional, Tuple, Union
# Add third_party/perfetto/python and third_party/protobuf/python to sys.path
_SCRIPT_DIR = pathlib.Path(__file__).resolve().parent
_SRC_DIR = _SCRIPT_DIR.parents[2]
for p in [
_SRC_DIR / "third_party" / "perfetto" / "python",
_SRC_DIR / "third_party" / "protobuf" / "python",
]:
if p.is_dir() and str(p) not in sys.path:
sys.path.insert(0, str(p))
from perfetto.trace_processor import TraceProcessor # pylint: disable=wrong-import-position
# Standard file signature magic bytes:
# - JPEG begins with the Start of Image (SOI) marker (0xFF, 0xD8) followed by
# a marker prefix byte (0xFF).
# - PNG begins with the 4-byte signature: 0x89, 'P', 'N', 'G'
# (0x89, 0x50, 0x4E, 0x47).
JPEG_MAGIC = bytes([0xFF, 0xD8, 0xFF])
PNG_MAGIC = bytes([0x89, 0x50, 0x4E, 0x47])
def extract_raw_payload(data: Union[bytes, str]) -> bytes:
"""Returns binary data, decoding base64 if needed."""
if not data:
return b"" if isinstance(data, (bytes, str)) else data
# Only attempt base64 decoding if the input is a string (e.g. from
# TraceProcessor query results).
if isinstance(data, str):
try:
return base64.b64decode(data, validate=True)
except (binascii.Error, ValueError):
return data.encode("utf-8")
return data
def parse_actor_trace(trace_path: str) -> List[Dict[str, Any]]:
"""Parses trace using Perfetto TraceProcessor."""
trace_file = pathlib.Path(trace_path)
if not trace_file.is_file():
raise FileNotFoundError(f"Trace file not found: {trace_path}")
tp = TraceProcessor(trace=str(trace_file.resolve()))
query = """
SELECT
slice.parent_id AS parent_id,
slice.name AS name,
slice.ts AS ts,
args.string_value AS data
FROM slice
JOIN args ON slice.arg_set_id = args.arg_set_id
WHERE slice.name IN ('PageContext', 'Screenshot')
AND args.key = 'screenshot.jpg_image'
ORDER BY slice.ts ASC
"""
qr = tp.query(query)
events = []
for row in qr:
if row.data:
events.append({
"parent_id": row.parent_id,
"timestamp": row.ts,
"name": row.name,
"data": extract_raw_payload(row.data),
})
tp.close()
return events
def find_or_build_protoc(
build_dir: Optional[str] = None,
) -> Optional[pathlib.Path]:
"""Locates or builds the protoc binary from the specified build directory."""
if not build_dir:
return None
protoc_name = "protoc.exe" if sys.platform == "win32" else "protoc"
build_path = pathlib.Path(build_dir).resolve()
protoc_path = build_path / protoc_name
if protoc_path.is_file() and os.access(protoc_path, os.X_OK):
return protoc_path
# Try building protoc using autoninja/ninja if not found
print(f"protoc not found in {build_path}, attempting to build...")
build_cmd = None
if shutil.which("autoninja"):
build_cmd = ["autoninja", "-C", str(build_path), "protoc"]
elif shutil.which("ninja"):
build_cmd = ["ninja", "-C", str(build_path), "protoc"]
if build_cmd:
try:
subprocess.run(build_cmd, check=True, cwd=str(_SRC_DIR))
if protoc_path.is_file():
print(f"Successfully built protoc at: {protoc_path}")
return protoc_path
except Exception as e:
print(f"Failed to build protoc: {e}")
return None
def decode_apc_with_protoc(
protoc_bin: pathlib.Path, pb_bytes: bytes
) -> Optional[str]:
"""Decodes AnnotatedPageContent proto bytes into textproto using protoc."""
proto_file = (
_SRC_DIR
/ "components"
/ "optimization_guide"
/ "proto"
/ "features"
/ "common_quality_data.proto"
)
if not proto_file.is_file():
return None
cmd = [
str(protoc_bin),
f"--proto_path={_SRC_DIR}",
"--decode=optimization_guide.proto.AnnotatedPageContent",
str(proto_file),
]
try:
res = subprocess.run(
cmd,
input=pb_bytes,
capture_output=True,
cwd=str(_SRC_DIR),
timeout=10,
)
if res.returncode == 0:
return res.stdout.decode("utf-8", errors="replace")
except Exception:
pass
return None
def extract_trace_to_dir(trace_path: str,
output_dir: Optional[str] = None,
build_dir: Optional[str] = None) -> str:
"""Extracts step APC and Screenshot files from trace into output_dir."""
trace_file = pathlib.Path(trace_path)
if not trace_file.is_file():
raise FileNotFoundError(f"Trace file not found: {trace_path}")
if not output_dir:
output_dir = str(trace_file.parent / f"{trace_file.stem}_extracted")
out_path = pathlib.Path(output_dir)
out_path.mkdir(parents=True, exist_ok=True)
events = parse_actor_trace(str(trace_file.resolve()))
# Sort events by timestamp
events.sort(key=lambda x: x["timestamp"])
protoc_bin = find_or_build_protoc(build_dir)
if protoc_bin:
print(f"Using protoc binary: {protoc_bin}")
# Pair PageContext and Screenshot events that share the same parent slice
# (e.g. RequestTabObservation). Fallback to sequential pairing if unparented.
steps: List[Dict[str, bytes]] = []
events_by_parent: Dict[Any, Dict[str, bytes]] = {}
unparented_apc: List[bytes] = []
unparented_screenshot: List[bytes] = []
for e in events:
pid = e.get("parent_id")
if pid is not None:
if pid not in events_by_parent:
events_by_parent[pid] = {}
steps.append(events_by_parent[pid])
events_by_parent[pid][e["name"]] = e["data"]
elif e["name"] == "PageContext":
unparented_apc.append(e["data"])
elif e["name"] == "Screenshot":
unparented_screenshot.append(e["data"])
if unparented_apc or unparented_screenshot:
if steps:
print(
"Warning: Found unparented PageContext/Screenshot events alongside"
" parented events. Appending them as additional steps."
)
total = max(len(unparented_apc), len(unparented_screenshot))
for i in range(total):
step: Dict[str, bytes] = {}
if i < len(unparented_apc):
step["PageContext"] = unparented_apc[i]
if i < len(unparented_screenshot):
step["Screenshot"] = unparented_screenshot[i]
steps.append(step)
print(f"Extracting {len(steps)} step(s) to: {out_path.resolve()}\n")
for i, step in enumerate(steps, 1):
step_prefix = f"step{i}"
# Extract APC
if "PageContext" in step:
apc_data = step["PageContext"]
text_content = None
if protoc_bin:
text_content = decode_apc_with_protoc(protoc_bin, apc_data)
if text_content:
apc_txt_path = out_path / f"{step_prefix}_apc.textproto"
with open(apc_txt_path, "w", encoding="utf-8") as f:
f.write(text_content)
print(f" [+] Wrote APC textproto: {apc_txt_path.name}")
else:
apc_pb_path = out_path / f"{step_prefix}_apc.pb"
with open(apc_pb_path, "wb") as f:
f.write(apc_data)
print(
f" [+] Wrote APC proto: {apc_pb_path.name} "
f"({len(apc_data)} bytes)"
)
# Extract Screenshot
if "Screenshot" in step:
screenshot_data = step["Screenshot"]
ext = ".png" if screenshot_data.startswith(PNG_MAGIC) else ".jpg"
screenshot_path = out_path / f"{step_prefix}_screenshot{ext}"
with open(screenshot_path, "wb") as f:
f.write(screenshot_data)
print(f" [+] Wrote Screenshot: {screenshot_path.name} "
f"({len(screenshot_data)} bytes)")
print(f"\nExtraction complete! Files saved in: {out_path.resolve()}\n")
if not protoc_bin:
print("=" * 70)
print(
"To inspect and format the AnnotatedPageContent .pb file using protoc:"
)
print("=" * 70)
sample_pb = out_path / "step1_apc.pb"
print(
" protoc --decode=optimization_guide.proto.AnnotatedPageContent \\\n"
" components/optimization_guide/proto/features/"
"common_quality_data.proto \\\n"
f" < {sample_pb}"
)
print("=" * 70)
return str(out_path.resolve())
def main():
parser = argparse.ArgumentParser(description=(
"Extract PageContext APC and Screenshots from Actor Perfetto Traces."))
parser.add_argument("trace_path",
help="Path to actor trace file (.pb or .trace)")
parser.add_argument(
"-o",
"--output_dir",
default=None,
help=("Directory to save extracted files (default:"
" <trace_name>_extracted)"),
)
parser.add_argument(
"-b",
"--build_dir",
default=None,
help=(
"Path to local OS build directory (e.g. out_linux/Release) "
"containing or to build protoc for textproto extraction"
),
)
args = parser.parse_args()
extract_trace_to_dir(args.trace_path, args.output_dir, args.build_dir)
if __name__ == "__main__":
main()