📦 deps(thirdparty): update snapshots

This commit is contained in:
ci[bot]
2026-07-01 16:02:41 +00:00
parent 8301f01888
commit c824ba9d7b
2449 changed files with 555104 additions and 9259 deletions
@@ -0,0 +1,301 @@
#!/usr/bin/env python3
"""Analyze an Xcode Instruments .trace file and emit JSON + markdown.
Primary modes:
(default) Full four-lane analysis + cross-lane correlations.
--list-logs Dump os_log entries (optionally filtered) as JSON so an
agent can locate a focus window by log content.
--list-signposts Dump os_signpost intervals + point events as JSON.
Windowing:
--window START_MS:END_MS restricts every lane to that slice of the trace.
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from instruments_parser import (
causes,
correlate,
events,
hangs,
hitches,
summary,
swiftui,
time_profiler,
xctrace,
)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="Analyze an Instruments .trace file.",
)
parser.add_argument("--trace", required=True, type=Path)
parser.add_argument(
"--output",
type=Path,
help="Base path; writes <output>.json and <output>.md",
)
parser.add_argument("--top", type=int, default=10, help="Top-N per lane")
parser.add_argument(
"--top-hitches",
type=int,
default=5,
help="Correlate only the N worst hitches (avoid flooding output).",
)
parser.add_argument(
"--window",
type=str,
default=None,
help="Restrict analysis to a time slice, e.g. --window 10400:11700 (ms).",
)
parser.add_argument(
"--run",
type=int,
default=None,
help="Which run to analyze (1-based). Required for traces with >1 run.",
)
parser.add_argument(
"--list-runs", action="store_true",
help="Emit per-run metadata as JSON (use this to discover available runs).",
)
# Mode flags (mutually exclusive with full analysis)
mode_group = parser.add_argument_group("Discovery modes")
mode_group.add_argument(
"--list-logs", action="store_true",
help="Emit os_log entries as JSON (use filter flags below).",
)
mode_group.add_argument(
"--list-signposts", action="store_true",
help="Emit os_signpost intervals + events as JSON.",
)
mode_group.add_argument("--log-subsystem", type=str, default=None)
mode_group.add_argument("--log-category", type=str, default=None)
mode_group.add_argument(
"--log-type", type=str, default=None,
help="e.g. Fault, Error, Default, Info, Debug",
)
mode_group.add_argument(
"--log-message-contains", type=str, default=None,
help="Case-insensitive substring match on the message / format string.",
)
mode_group.add_argument(
"--log-limit", type=int, default=None,
help="Cap number of log entries returned (applied after all filters).",
)
mode_group.add_argument(
"--signpost-name-contains", type=str, default=None,
help="Case-insensitive substring match on signpost name.",
)
mode_group.add_argument("--signpost-subsystem", type=str, default=None)
mode_group.add_argument("--signpost-category", type=str, default=None)
mode_group.add_argument(
"--fanin-for", type=str, default=None,
help="Emit incoming cause-graph sources for destinations whose fmt "
"contains this substring. Case-insensitive.",
)
fmt_group = parser.add_mutually_exclusive_group()
fmt_group.add_argument("--json-only", action="store_true")
fmt_group.add_argument("--markdown-only", action="store_true")
args = parser.parse_args(argv)
# The discovery modes aren't in a mutually_exclusive_group because they
# live alongside their sub-filters in the same argparse group; enforce the
# constraint by hand so an agent gets a clear error instead of silent
# precedence.
active_modes = sum([
args.list_runs,
args.list_logs,
args.list_signposts,
bool(args.fanin_for),
])
if active_modes > 1:
parser.error(
"--list-runs, --list-logs, --list-signposts, and --fanin-for are "
"mutually exclusive; pick one per invocation."
)
trace = args.trace
if not trace.exists():
print(f"error: trace not found: {trace}", file=sys.stderr)
return 2
info = xctrace.toc(trace)
window_ns = _parse_window(args.window)
if args.list_runs:
sys.stdout.write(json.dumps({
"xctrace_version": info.xctrace_version,
"runs": [
{
"number": r.number,
"template": r.template_name,
"duration_s": r.duration_s,
"start_date": r.start_date,
"end_date": r.end_date,
"schemas": sorted(r.schemas),
}
for r in info.runs
],
}, indent=2))
sys.stdout.write("\n")
return 0
run_info = _resolve_run(info, args.run)
if run_info is None:
return 2
run_number = run_info.number
if args.list_logs:
out = events.list_logs(
trace, run_info.schemas,
subsystem=args.log_subsystem,
category=args.log_category,
message_contains=args.log_message_contains,
message_type=args.log_type,
limit=args.log_limit,
window_ns=window_ns,
run=run_number,
)
sys.stdout.write(json.dumps({"logs": out, "count": len(out)}, indent=2))
sys.stdout.write("\n")
return 0
if args.list_signposts:
sp = events.list_signposts(
trace, run_info.schemas,
name_contains=args.signpost_name_contains,
subsystem=args.signpost_subsystem,
category=args.signpost_category,
window_ns=window_ns,
run=run_number,
)
sys.stdout.write(json.dumps(sp, indent=2))
sys.stdout.write("\n")
return 0
if args.fanin_for:
fanin = causes.fanin_for(
trace, run_info.schemas,
destination_contains=args.fanin_for,
top_k=args.top,
window=window_ns,
run=run_number,
)
sys.stdout.write(json.dumps(fanin, indent=2))
sys.stdout.write("\n")
return 0
# Full five-lane analysis
schemas = run_info.schemas
lanes_out = {
"time-profiler": time_profiler.analyze(trace, schemas, top_n=args.top, window=window_ns, run=run_number),
"hangs": hangs.analyze(trace, schemas, top_n=args.top, window=window_ns, run=run_number),
"hitches": hitches.analyze(trace, schemas, top_n=args.top, window=window_ns, run=run_number),
"swiftui": swiftui.analyze(trace, schemas, top_n=args.top, window=window_ns, run=run_number),
"swiftui-causes": causes.analyze(trace, schemas, top_n=args.top, window=window_ns, run=run_number),
}
correlations = correlate.build(
lanes_out, top_hitches=args.top_hitches, top_symbols=5
)
public_lanes = [_strip_internal(l) for l in lanes_out.values()]
result: dict = {
"trace": str(trace),
"xctrace_version": info.xctrace_version,
"run": run_number,
"runs_available": [r.number for r in info.runs],
"template": run_info.template_name,
"duration_s": run_info.duration_s,
"start_date": run_info.start_date,
"end_date": run_info.end_date,
"schemas_available": sorted(run_info.schemas),
"lanes": public_lanes,
"correlations": correlations,
}
if window_ns is not None:
result["window_ms"] = {
"start": window_ns[0] / 1_000_000,
"end": window_ns[1] / 1_000_000,
}
md = summary.render(result)
if args.output:
json_path = args.output.with_suffix(".json")
md_path = args.output.with_suffix(".md")
json_path.write_text(json.dumps(result, indent=2))
md_path.write_text(md)
print(f"wrote {json_path}")
print(f"wrote {md_path}")
return 0
if args.markdown_only:
sys.stdout.write(md)
elif args.json_only:
sys.stdout.write(json.dumps(result, indent=2))
sys.stdout.write("\n")
else:
sys.stdout.write(json.dumps(result, indent=2))
sys.stdout.write("\n---\n")
sys.stdout.write(md)
return 0
def _resolve_run(info, requested: int | None):
"""Pick a run from the trace.
If `requested` is given, return that run or None on miss (with a friendly
error). If unset and the trace has exactly one run, default to it. If
unset and there are multiple runs, error out so the agent picks
explicitly — silently picking run 1 lost data for the user.
"""
if not info.runs:
print("error: trace has no runs", file=sys.stderr)
return None
if requested is not None:
try:
return info.get_run(requested)
except KeyError as e:
print(f"error: {e}", file=sys.stderr)
return None
if len(info.runs) == 1:
return info.runs[0]
available = ", ".join(str(r.number) for r in info.runs)
print(
f"error: trace has {len(info.runs)} runs ({available}); pass --run N. "
f"Use --list-runs to see per-run metadata.",
file=sys.stderr,
)
return None
def _parse_window(spec: str | None) -> tuple[int, int] | None:
if not spec:
return None
if ":" not in spec:
raise SystemExit(f"--window expects START_MS:END_MS, got {spec!r}")
start_s, end_s = spec.split(":", 1)
try:
start_ms = float(start_s)
end_ms = float(end_s)
except ValueError as e:
raise SystemExit(f"--window: {e}")
if end_ms < start_ms:
raise SystemExit("--window: end_ms must be >= start_ms")
return (int(start_ms * 1_000_000), int(end_ms * 1_000_000))
def _strip_internal(lane: dict) -> dict:
return {k: v for k, v in lane.items() if not k.startswith("_")}
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1 @@
"""Parsers for Xcode Instruments .trace files via xctrace export."""
@@ -0,0 +1,187 @@
"""SwiftUI cause-graph lane (`swiftui-causes` schema).
Instruments emits one row per edge in SwiftUI's dependency graph: every time
a source node (a state change, user defaults observer, system event, etc.)
propagates to a destination node (a body evaluation, layout, creation), a
row is written with both endpoints as metadata values.
This lane aggregates those edges two ways:
- **By source node** — which attribute graph nodes are driving the most
updates overall. The canonical "why is my app thrashing?" view; a
`UserDefaultObserver.send()` showing up with 11k outgoing edges is a
feedback storm.
- **By destination node** — which views/modifiers receive the most
invalidations, and from whom. Use this to trace a hot view back to the
source that keeps poking it.
The analyzer's main lane (`swiftui`) tells you *what* updates are
expensive; this lane tells you *why* they keep happening.
"""
from __future__ import annotations
from collections import Counter, defaultdict
from pathlib import Path
from typing import Any
from . import xctrace, xml_utils
SCHEMA = "swiftui-causes"
# Metadata nodes render as space-separated field dumps ("A gray icon n/a n/a").
# We aggregate on the full fmt string so callers can spot specific edges like
# "@AppStorage TextStyleModifier.fontOption", but also expose the short head
# ("@AppStorage", "Creation of App", ...) for coarser grouping.
def analyze(
trace_path: Path,
toc_schemas: frozenset[str],
top_n: int = 10,
top_k_per_node: int = 5,
window: tuple[int, int] | None = None,
run: int = 1,
) -> dict[str, Any]:
if SCHEMA not in toc_schemas:
return {
"lane": "swiftui-causes",
"available": False,
"notes": [
"SwiftUI causes data not present (requires SwiftUI template on a real device).",
],
}
xml_bytes = xctrace.export_schema(trace_path, SCHEMA, run=run)
stream = xml_utils.RowStream(xml_bytes)
source_edges: Counter[str] = Counter()
destination_edges: Counter[str] = Counter()
fanout: dict[str, Counter[str]] = defaultdict(Counter)
fanin: dict[str, Counter[str]] = defaultdict(Counter)
label_counts: Counter[str] = Counter()
total_edges = 0
for row in stream:
time_el = xml_utils.first_present(row, "timestamp", "time")
if time_el is not None:
t_ns = xml_utils.int_text(stream.resolve(time_el))
if t_ns is not None and not xml_utils.in_window(t_ns, window):
continue
src = _fmt(row, stream, "source-node")
dst = _fmt(row, stream, "destination-node")
if not src or not dst:
continue
source_edges[src] += 1
destination_edges[dst] += 1
fanout[src][dst] += 1
fanin[dst][src] += 1
label = _fmt(row, stream, "label")
if label:
label_counts[label] += 1
total_edges += 1
top_sources = [
{
"source": src,
"edges": count,
"top_destinations": [
{"destination": d, "edges": c}
for d, c in fanout[src].most_common(top_k_per_node)
],
}
for src, count in source_edges.most_common(top_n)
]
top_destinations = [
{
"destination": dst,
"edges": count,
"top_sources": [
{"source": s, "edges": c}
for s, c in fanin[dst].most_common(top_k_per_node)
],
}
for dst, count in destination_edges.most_common(top_n)
]
return {
"lane": "swiftui-causes",
"available": True,
"schema_used": SCHEMA,
"metrics": {
"total_edges": total_edges,
"unique_sources": len(source_edges),
"unique_destinations": len(destination_edges),
"top_labels": dict(label_counts.most_common(top_n)),
},
"top_sources": top_sources,
"top_destinations": top_destinations,
"notes": [],
}
def fanin_for(
trace_path: Path,
toc_schemas: frozenset[str],
destination_contains: str,
top_k: int = 10,
window: tuple[int, int] | None = None,
run: int = 1,
) -> dict[str, Any]:
"""Return the top source nodes feeding any destination whose fmt string
contains `destination_contains` (case-insensitive substring).
Used when the agent has a suspect view from the `swiftui` lane and wants
to know *who keeps invalidating it*. Does a full pass over the causes
schema each time — cheap enough at typical trace sizes.
"""
if SCHEMA not in toc_schemas:
return {"available": False, "matches": []}
needle = destination_contains.lower()
xml_bytes = xctrace.export_schema(trace_path, SCHEMA, run=run)
stream = xml_utils.RowStream(xml_bytes)
matches: dict[str, Counter[str]] = defaultdict(Counter)
totals: Counter[str] = Counter()
for row in stream:
time_el = xml_utils.first_present(row, "timestamp", "time")
if time_el is not None:
t_ns = xml_utils.int_text(stream.resolve(time_el))
if t_ns is not None and not xml_utils.in_window(t_ns, window):
continue
dst = _fmt(row, stream, "destination-node")
if not dst or needle not in dst.lower():
continue
src = _fmt(row, stream, "source-node")
if not src:
continue
matches[dst][src] += 1
totals[dst] += 1
out = []
for dst, count in totals.most_common(top_k):
out.append({
"destination": dst,
"total_incoming_edges": count,
"top_sources": [
{"source": s, "edges": c}
for s, c in matches[dst].most_common(top_k)
],
})
return {"available": True, "matches": out}
def _fmt(row, stream, key: str) -> str | None:
el = row.get(key)
if el is None:
return None
resolved = stream.resolve(el)
return resolved.get("fmt") or xml_utils.str_text(resolved)
@@ -0,0 +1,179 @@
"""Cross-lane correlation: for each hang and top-N worst hitches, aggregate
Time Profiler samples and SwiftUI updates whose timestamps fall inside the
event window [start, start+duration]. Uses bisect so lookups stay O(log N)
per event.
"""
from __future__ import annotations
from bisect import bisect_left, bisect_right
from collections import defaultdict
from typing import Any
def build(lanes: dict[str, dict], top_hitches: int = 5, top_symbols: int = 5) -> list[dict]:
"""Produce a list of correlation entries.
`lanes` is a dict keyed by lane name (time-profiler, hangs, hitches,
swiftui) of their analyzer outputs.
"""
tp = lanes.get("time-profiler")
hangs = lanes.get("hangs")
hitches = lanes.get("hitches")
swiftui = lanes.get("swiftui")
tp_index = _build_time_profile_index(tp)
sui_events = (swiftui or {}).get("_events") if swiftui and swiftui.get("available") else None
correlations: list[dict] = []
if hangs and hangs.get("available"):
for h in hangs.get("_events", []):
correlations.append(
_correlate_event(
trigger_lane="hangs",
start_ns=h["start_ns"],
end_ns=h["end_ns"],
extra={"hang_type": h["hang_type"]},
tp_index=tp_index,
sui_events=sui_events,
top_symbols=top_symbols,
)
)
if hitches and hitches.get("available"):
worst_hitches = hitches.get("_events", [])[:top_hitches]
for hi in worst_hitches:
correlations.append(
_correlate_event(
trigger_lane="hitches",
start_ns=hi["start_ns"],
end_ns=hi["end_ns"],
extra={
"frame_duration_ms": hi["frame_duration_ms"],
"hitch_duration_ms": hi["hitch_duration_ms"],
},
tp_index=tp_index,
sui_events=sui_events,
top_symbols=top_symbols,
)
)
return correlations
# --- Internal -------------------------------------------------------------
def _build_time_profile_index(tp: dict | None):
if not tp or not tp.get("available"):
return None
samples = tp.get("_samples") or []
if not samples:
return None
# Samples are already sorted by time in time_profiler.analyze.
times = [s["time_ns"] for s in samples]
return {"times": times, "samples": samples}
def _correlate_event(
trigger_lane: str,
start_ns: int,
end_ns: int,
extra: dict,
tp_index: dict | None,
sui_events: list[dict] | None,
top_symbols: int,
) -> dict[str, Any]:
entry: dict[str, Any] = {
"trigger": {
"lane": trigger_lane,
"start_ms": round(start_ns / 1_000_000, 2),
"end_ms": round(end_ns / 1_000_000, 2),
"duration_ms": round((end_ns - start_ns) / 1_000_000, 2),
**extra,
},
}
if tp_index is not None:
tp = _time_profile_hot_symbols(
tp_index, start_ns, end_ns, top_symbols
)
duration_ns = end_ns - start_ns
# Sample rate is 1ms/sample on standard Time Profiler. If the window
# is N ms long we'd expect ~N main-thread samples if main was fully
# running; fewer means main was blocked (I/O, lock, etc.).
expected_if_running = max(1, duration_ns // 1_000_000)
coverage_pct = min(100.0, 100.0 * tp["samples_main"] / expected_if_running)
entry["time_profiler_main_thread"] = {
"samples_in_window": tp["samples_total"],
"samples_on_main": tp["samples_main"],
"main_running_coverage_pct": round(coverage_pct, 1),
"hot_symbols": tp["hot_symbols"],
}
if sui_events is not None:
sui_overlap = _swiftui_overlaps(sui_events, start_ns, end_ns)
entry["swiftui_overlapping_updates"] = sui_overlap
return entry
def _time_profile_hot_symbols(
tp_index: dict, start_ns: int, end_ns: int, top_n: int
) -> dict:
"""Return main-thread hot symbols in the given window.
Hang/hitch/SwiftUI correlations are all main-thread responsiveness
problems, so worker-thread symbols are noise. We also return a coverage
metric — when main was blocked on I/O or a lock, the window will have
far fewer samples than its duration would predict, and that signal is
what tells the agent "this was blocked, not CPU-bound".
"""
times = tp_index["times"]
samples = tp_index["samples"]
lo = bisect_left(times, start_ns)
hi = bisect_right(times, end_ns)
window = samples[lo:hi]
if not window:
return {"samples_total": 0, "samples_main": 0, "hot_symbols": []}
main_samples = [s for s in window if s["is_main"]]
weight_by_symbol: dict[str, int] = defaultdict(int)
count_by_symbol: dict[str, int] = defaultdict(int)
for s in main_samples:
weight_by_symbol[s["leaf_symbol"]] += s["weight_ns"]
count_by_symbol[s["leaf_symbol"]] += 1
total_weight = sum(weight_by_symbol.values()) or 1
ranked = sorted(weight_by_symbol.items(), key=lambda kv: kv[1], reverse=True)
hot = []
for symbol, weight in ranked[:top_n]:
hot.append({
"symbol": symbol,
"samples": count_by_symbol[symbol],
"weight_ms": round(weight / 1_000_000, 2),
"percent_of_main": round(100.0 * weight / total_weight, 2),
})
return {
"samples_total": len(window),
"samples_main": len(main_samples),
"hot_symbols": hot,
}
def _swiftui_overlaps(
events: list[dict], start_ns: int, end_ns: int
) -> list[dict]:
# Events aren't guaranteed sorted by start_ns here (we sort by duration in
# swiftui.analyze). Linear scan; SwiftUI event counts are typically small.
out: list[dict] = []
for e in events:
if e["end_ns"] < start_ns or e["start_ns"] > end_ns:
continue
out.append({
"view": e["view"],
"duration_ms": e["duration_ms"],
"start_ms": e["start_ms"],
})
# Worst first.
out.sort(key=lambda x: x["duration_ms"], reverse=True)
return out[:10]
@@ -0,0 +1,291 @@
"""Discovery helpers for os_log messages and os_signpost intervals.
These let an agent locate a focus window (e.g. "after the log saying X",
"during signpost Y") before running the main lane analysis.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
from . import xctrace, xml_utils
OS_LOG_SCHEMA = "os-log"
OS_SIGNPOST_SCHEMA = "os-signpost"
OS_SIGNPOST_INTERVAL_SCHEMA = "os-signpost-interval"
def list_logs(
trace_path: Path,
toc_schemas: frozenset[str],
subsystem: str | None = None,
category: str | None = None,
message_contains: str | None = None,
message_type: str | None = None,
limit: int | None = None,
window_ns: tuple[int, int] | None = None,
run: int = 1,
) -> list[dict[str, Any]]:
"""Return os_log entries, optionally filtered. Case-insensitive contains.
`limit` counts *post-filter* matches — including the window filter — so
the caller gets N matching logs inside the window rather than the first
N matching logs that might all fall outside it.
"""
if OS_LOG_SCHEMA not in toc_schemas:
return []
xml_bytes = xctrace.export_schema(trace_path, OS_LOG_SCHEMA, run=run)
stream = xml_utils.RowStream(xml_bytes)
needle = message_contains.lower() if message_contains else None
out: list[dict[str, Any]] = []
for row in stream:
time_el = row.get("time")
if time_el is None:
continue
time_ns = xml_utils.int_text(stream.resolve(time_el))
if time_ns is None:
continue
if not xml_utils.in_window(time_ns, window_ns):
continue
sub = _str_of(row, stream, "subsystem")
cat = _str_of(row, stream, "category")
typ = _str_of(row, stream, "message-type")
fmt = _str_of(row, stream, "format-string")
msg = _str_of(row, stream, "message") or fmt
if subsystem and (sub or "") != subsystem:
continue
if category and (cat or "") != category:
continue
if message_type and (typ or "") != message_type:
continue
if needle and needle not in (msg or "").lower() and needle not in (fmt or "").lower():
continue
process_el = row.get("process")
process = (
xml_utils.extract_process(process_el, stream).get("name")
if process_el is not None else None
)
out.append({
"time_ns": time_ns,
"time_ms": round(time_ns / 1_000_000, 3),
"type": typ,
"subsystem": sub,
"category": cat,
"process": process,
"message": msg,
"format_string": fmt,
})
if limit is not None and len(out) >= limit:
break
out.sort(key=lambda e: e["time_ns"])
return out
def list_signposts(
trace_path: Path,
toc_schemas: frozenset[str],
name_contains: str | None = None,
subsystem: str | None = None,
category: str | None = None,
window_ns: tuple[int, int] | None = None,
run: int = 1,
) -> dict[str, list[dict[str, Any]]]:
"""Return signpost intervals (paired begin/end) plus single-point events.
Shape: { "intervals": [...], "events": [...] }. Intervals have
start_ms/end_ms/duration_ms; events have a single time_ms.
Reads two complementary schemas:
* `os-signpost-interval`: already-paired intervals (this is where
user-emitted signposts like com.example.MyApp typically land).
* `os-signpost`: raw begin/end/event rows; we pair begins with ends
ourselves and fall back to point events for unpaired rows. Most
Apple-framework signposts (CloudKit, AppKit, …) live here.
Filters are AND-combined. `name_contains` is a case-insensitive substring
match. `window_ns` keeps intervals that overlap the window (not strict
containment) and point events whose timestamp falls inside it.
"""
# The two signpost schemas overlap: every paired begin/end in `os-signpost`
# also shows up as a row in `os-signpost-interval`. To avoid duplicates we
# prefer the pre-paired schema for intervals and only mine `os-signpost`
# for point events (and for begin/end pairing as a fallback when the
# interval schema is missing — older traces).
intervals: list[dict[str, Any]] = []
events: list[dict[str, Any]] = []
has_intervals = OS_SIGNPOST_INTERVAL_SCHEMA in toc_schemas
if has_intervals:
intervals.extend(_read_interval_schema(trace_path, run=run))
if OS_SIGNPOST_SCHEMA in toc_schemas:
more_intervals, more_events = _read_event_schema(trace_path, run=run)
if not has_intervals:
intervals.extend(more_intervals)
events.extend(more_events)
intervals.sort(key=lambda i: i["start_ns"])
events.sort(key=lambda e: e["time_ns"])
needle = name_contains.lower() if name_contains else None
def _matches(entry: dict) -> bool:
if subsystem and (entry.get("subsystem") or "") != subsystem:
return False
if category and (entry.get("category") or "") != category:
return False
if needle and needle not in (entry.get("name") or "").lower():
return False
return True
if subsystem or category or needle:
intervals = [i for i in intervals if _matches(i)]
events = [e for e in events if _matches(e)]
if window_ns is not None:
s, e = window_ns
intervals = [
i for i in intervals
if not (i["end_ns"] < s or i["start_ns"] > e)
]
events = [ev for ev in events if s <= ev["time_ns"] <= e]
return {"intervals": intervals, "events": events}
def _read_interval_schema(trace_path: Path, run: int = 1) -> list[dict[str, Any]]:
"""Read the os-signpost-interval schema (pre-paired intervals)."""
xml_bytes = xctrace.export_schema(trace_path, OS_SIGNPOST_INTERVAL_SCHEMA, run=run)
stream = xml_utils.RowStream(xml_bytes)
out: list[dict[str, Any]] = []
for row in stream:
start_el = xml_utils.first_present(row, "start", "time")
dur_el = row.get("duration")
if start_el is None or dur_el is None:
continue
start_ns = xml_utils.int_text(stream.resolve(start_el))
dur_ns = xml_utils.int_text(stream.resolve(dur_el))
if start_ns is None or dur_ns is None:
continue
end_ns = start_ns + dur_ns
name = _str_of(row, stream, "name")
sub = _str_of(row, stream, "subsystem")
cat = _str_of(row, stream, "category")
signpost_id = _str_of(row, stream, "identifier") or _str_of(row, stream, "signpost-id")
process_el = row.get("process")
process = (
xml_utils.extract_process(process_el, stream).get("name")
if process_el is not None else None
)
out.append({
"start_ns": start_ns,
"end_ns": end_ns,
"duration_ns": dur_ns,
"start_ms": round(start_ns / 1_000_000, 3),
"end_ms": round(end_ns / 1_000_000, 3),
"duration_ms": round(dur_ns / 1_000_000, 3),
"name": name,
"subsystem": sub,
"category": cat,
"process": process,
"signpost_id": signpost_id,
})
return out
def _read_event_schema(
trace_path: Path,
run: int = 1,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""Read the os-signpost schema and pair begin/end rows into intervals."""
xml_bytes = xctrace.export_schema(trace_path, OS_SIGNPOST_SCHEMA, run=run)
stream = xml_utils.RowStream(xml_bytes)
pending: dict[tuple, dict] = {}
intervals: list[dict[str, Any]] = []
events: list[dict[str, Any]] = []
for row in stream:
time_el = xml_utils.first_present(row, "time", "start")
if time_el is None:
continue
time_ns = xml_utils.int_text(stream.resolve(time_el))
if time_ns is None:
continue
name = _str_of(row, stream, "name")
sub = _str_of(row, stream, "subsystem")
cat = _str_of(row, stream, "category")
event_type = _str_of(row, stream, "event-type") or _str_of(row, stream, "message-type")
signpost_id = _str_of(row, stream, "signpost-id") or _str_of(row, stream, "identifier")
process_el = row.get("process")
process = (
xml_utils.extract_process(process_el, stream).get("name")
if process_el is not None else None
)
key = (process, sub, cat, name, signpost_id)
etype = (event_type or "").lower()
if etype in ("begin", "interval begin", "start"):
pending[key] = {"start_ns": time_ns, "name": name,
"subsystem": sub, "category": cat,
"process": process, "signpost_id": signpost_id}
elif etype in ("end", "interval end", "stop"):
start = pending.pop(key, None)
if start is not None:
dur_ns = time_ns - start["start_ns"]
intervals.append({
**start,
"end_ns": time_ns,
"duration_ns": dur_ns,
"start_ms": round(start["start_ns"] / 1_000_000, 3),
"end_ms": round(time_ns / 1_000_000, 3),
"duration_ms": round(dur_ns / 1_000_000, 3),
})
else:
events.append(_point_event(time_ns, name, sub, cat,
process, signpost_id, event_type))
else:
events.append(_point_event(time_ns, name, sub, cat,
process, signpost_id, event_type))
# Unclosed begins are surfaced as point events so nothing is silently dropped.
for info in pending.values():
events.append(_point_event(info["start_ns"], info["name"],
info["subsystem"], info["category"],
info["process"], info["signpost_id"],
"Begin (unclosed)"))
return intervals, events
def _point_event(time_ns, name, subsystem, category, process, signpost_id, event_type):
return {
"time_ns": time_ns,
"time_ms": round(time_ns / 1_000_000, 3),
"name": name,
"subsystem": subsystem,
"category": category,
"process": process,
"signpost_id": signpost_id,
"event_type": event_type,
}
def _str_of(row, stream, key):
el = row.get(key)
if el is None:
return None
resolved = stream.resolve(el)
txt = xml_utils.str_text(resolved) or resolved.get("fmt")
return txt
@@ -0,0 +1,108 @@
"""Hangs lane parser (schema `potential-hangs`).
The schema lacks inline backtraces — stacks come from Time Profiler samples
that overlap each hang's window. Correlation is done later in correlate.py.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
from . import xctrace, xml_utils
PREFERRED_SCHEMAS = ("potential-hangs",)
FALLBACK_SCHEMAS = ("main-thread-hang", "hang", "hangs")
def analyze(
trace_path: Path,
toc_schemas: frozenset[str],
top_n: int = 10,
window: tuple[int, int] | None = None,
run: int = 1,
) -> dict[str, Any]:
schema = _pick_schema(toc_schemas)
if schema is None:
return {
"lane": "hangs",
"available": False,
"notes": ["Hangs data not present in trace."],
}
xml_bytes = xctrace.export_schema(trace_path, schema, run=run)
stream = xml_utils.RowStream(xml_bytes)
hangs: list[dict] = []
for row in stream:
start_el = row.get("start")
dur_el = row.get("duration")
type_el = row.get("hang-type")
thread_el = row.get("thread")
if start_el is None or dur_el is None:
continue
start_ns = xml_utils.int_text(stream.resolve(start_el))
duration_ns = xml_utils.int_text(stream.resolve(dur_el))
if start_ns is None or duration_ns is None:
continue
if not xml_utils.event_overlaps_window(start_ns, start_ns + duration_ns, window):
continue
hang_type = xml_utils.str_text(stream.resolve(type_el)) if type_el is not None else None
thread = xml_utils.extract_thread(thread_el, stream) if thread_el is not None else None
hangs.append({
"start_ns": start_ns,
"duration_ns": duration_ns,
"end_ns": start_ns + duration_ns,
"duration_ms": round(duration_ns / 1_000_000, 2),
"start_ms": round(start_ns / 1_000_000, 2),
"hang_type": hang_type or "Hang",
"thread": thread,
})
hangs.sort(key=lambda h: h["duration_ns"], reverse=True)
total_ms = sum(h["duration_ms"] for h in hangs)
worst = hangs[0] if hangs else None
# Severity buckets per Apple docs (Microhang: 250ms500ms, Hang: ≥500ms).
# We bucket by raw duration so the agent can reason about it.
buckets = {"lt_250ms": 0, "250ms_1s": 0, "gt_1s": 0}
for h in hangs:
if h["duration_ms"] < 250:
buckets["lt_250ms"] += 1
elif h["duration_ms"] < 1000:
buckets["250ms_1s"] += 1
else:
buckets["gt_1s"] += 1
top_offenders = [
{
"start_ms": h["start_ms"],
"duration_ms": h["duration_ms"],
"hang_type": h["hang_type"],
"thread": (h["thread"] or {}).get("name", ""),
}
for h in hangs[:top_n]
]
return {
"lane": "hangs",
"available": True,
"schema_used": schema,
"metrics": {
"count": len(hangs),
"total_duration_ms": round(total_ms, 2),
"worst_duration_ms": worst["duration_ms"] if worst else 0,
"severity_buckets": buckets,
},
"top_offenders": top_offenders,
"notes": [],
"_events": hangs, # retained for correlation
}
def _pick_schema(available: frozenset[str]) -> str | None:
for s in PREFERRED_SCHEMAS + FALLBACK_SCHEMAS:
if s in available:
return s
return None
@@ -0,0 +1,145 @@
"""Animation hitches lane parser.
Xcode 26 schema `hitches` columns: start, duration (hitch time), process,
is-system, swap-id, label, display, narrative-description. The
narrative-description field carries Apple's own attribution (e.g.
"Potentially expensive app update(s)") which is the highest-signal column.
"""
from __future__ import annotations
from collections import Counter
from pathlib import Path
from typing import Any
from . import xctrace, xml_utils
CANDIDATE_SCHEMAS = ("hitches", "animation-hitch", "hitch")
START_KEYS = ("start", "time", "sample-time")
DURATION_KEYS = ("duration", "hitch-duration", "frame-duration")
def analyze(
trace_path: Path,
toc_schemas: frozenset[str],
top_n: int = 10,
window: tuple[int, int] | None = None,
run: int = 1,
) -> dict[str, Any]:
schema = _pick_schema(toc_schemas)
if schema is None:
return {
"lane": "hitches",
"available": False,
"notes": ["Animation hitches not present in trace."],
}
xml_bytes = xctrace.export_schema(trace_path, schema, run=run)
stream = xml_utils.RowStream(xml_bytes)
events: list[dict] = []
narrative_counts: Counter[str] = Counter()
system_count = 0
for row in stream:
start_ns = _first_int(row, stream, START_KEYS)
duration_ns = _first_int(row, stream, DURATION_KEYS)
if start_ns is None or duration_ns is None:
continue
if not xml_utils.event_overlaps_window(start_ns, start_ns + duration_ns, window):
continue
process_el = row.get("process")
process = (
xml_utils.extract_process(process_el, stream)
if process_el is not None else None
)
narrative_el = row.get("narrative-description")
narrative = xml_utils.str_text(stream.resolve(narrative_el)) if narrative_el is not None else None
if narrative:
narrative_counts[narrative] += 1
is_system_el = row.get("is-system")
is_system = _bool_text(stream.resolve(is_system_el)) if is_system_el is not None else None
if is_system:
system_count += 1
events.append({
"start_ns": start_ns,
"end_ns": start_ns + duration_ns,
"duration_ns": duration_ns,
"hitch_duration_ns": duration_ns, # Xcode 26 `duration` == hitch time
"frame_duration_ns": None,
"hitch_duration_ms": round(duration_ns / 1_000_000, 2),
"frame_duration_ms": None,
"start_ms": round(start_ns / 1_000_000, 2),
"process": (process or {}).get("name"),
"narrative": narrative,
"is_system": bool(is_system) if is_system is not None else None,
})
events.sort(key=lambda e: e["duration_ns"], reverse=True)
total_hitch_ms = sum(e["hitch_duration_ms"] for e in events)
worst = events[0] if events else None
per_process: dict[str, int] = {}
for e in events:
key = e["process"] or "unknown"
per_process[key] = per_process.get(key, 0) + 1
top_offenders = [
{
"start_ms": e["start_ms"],
"hitch_duration_ms": e["hitch_duration_ms"],
"frame_duration_ms": e["frame_duration_ms"],
"process": e["process"],
"narrative": e["narrative"],
"is_system": e["is_system"],
}
for e in events[:top_n]
]
return {
"lane": "hitches",
"available": True,
"schema_used": schema,
"metrics": {
"count": len(events),
"total_hitch_ms": round(total_hitch_ms, 2),
"worst_hitch_ms": worst["hitch_duration_ms"] if worst else 0,
"per_process": per_process,
"system_hitches": system_count,
"app_hitches": len(events) - system_count,
"narrative_breakdown": dict(narrative_counts.most_common()),
},
"top_offenders": top_offenders,
"notes": [],
"_events": events,
}
def _pick_schema(available: frozenset[str]) -> str | None:
for s in CANDIDATE_SCHEMAS:
if s in available:
return s
return None
def _first_int(row, stream, keys):
for key in keys:
el = row.get(key)
if el is None:
continue
val = xml_utils.int_text(stream.resolve(el))
if val is not None:
return val
return None
def _bool_text(elem) -> bool | None:
txt = xml_utils.str_text(elem)
if txt is None:
return None
return txt.strip() in ("1", "true", "True", "YES", "Yes")
@@ -0,0 +1,243 @@
"""Markdown summary renderer for the combined trace analysis."""
from __future__ import annotations
def render(result: dict) -> str:
lines: list[str] = []
trace = result.get("trace", "?")
header = result.get("xctrace_version") or ""
template = result.get("template") or ""
duration_s = result.get("duration_s")
lines.append(f"# Instruments Trace Analysis")
meta = [p for p in [f"Trace: `{trace}`", header, template] if p]
lines.append("".join(meta))
if duration_s is not None:
lines.append(f"Recording duration: {duration_s:.2f}s")
lines.append("")
lanes_by_name = {lane["lane"]: lane for lane in result.get("lanes", [])}
_render_time_profiler(lines, lanes_by_name.get("time-profiler"))
_render_hangs(lines, lanes_by_name.get("hangs"))
_render_hitches(lines, lanes_by_name.get("hitches"))
_render_swiftui(lines, lanes_by_name.get("swiftui"))
_render_causes(lines, lanes_by_name.get("swiftui-causes"))
_render_correlations(lines, result.get("correlations", []))
return "\n".join(lines).rstrip() + "\n"
def _skipped_block(title: str, lane: dict | None) -> list[str]:
if lane is None:
return [f"## {title} — skipped (lane module not run)", ""]
notes = lane.get("notes") or []
note_text = f"{notes[0]}" if notes else ""
return [f"## {title} — skipped{note_text}", ""]
def _render_time_profiler(lines: list[str], lane: dict | None) -> None:
if not lane or not lane.get("available"):
lines.extend(_skipped_block("Time Profiler", lane))
return
m = lane["metrics"]
lines.append(
f"## Time Profiler — {m['total_samples']:,} samples, "
f"{m['total_weight_ms']:.0f}ms CPU time"
)
if m.get("processes"):
lines.append(f"Processes: {', '.join(m['processes'])}")
lines.append("")
if lane["top_offenders"]:
lines.append("Top offenders:")
for i, o in enumerate(lane["top_offenders"], 1):
lines.append(
f"{i}. `{_truncate(o['symbol'], 90)}` — "
f"{o['percent']:.1f}% ({o['weight_ms']:.0f}ms, "
f"{o['samples']} samples, {_short_thread(o['thread'])})"
)
for note in lane.get("notes") or []:
lines.append(f"> {note}")
lines.append("")
def _render_hangs(lines: list[str], lane: dict | None) -> None:
if not lane or not lane.get("available"):
lines.extend(_skipped_block("Hangs", lane))
return
m = lane["metrics"]
buckets = m["severity_buckets"]
lines.append(
f"## Hangs — {m['count']} hangs, {m['total_duration_ms']:.0f}ms total, "
f"worst {m['worst_duration_ms']:.0f}ms"
)
lines.append(
f"Severity: <250ms={buckets['lt_250ms']}, "
f"250ms1s={buckets['250ms_1s']}, >1s={buckets['gt_1s']}"
)
lines.append("")
for i, h in enumerate(lane["top_offenders"], 1):
lines.append(
f"{i}. {h['duration_ms']:.0f}ms {h['hang_type']} at "
f"{h['start_ms']:.2f}ms on {_short_thread(h['thread'])}"
)
lines.append("")
def _render_hitches(lines: list[str], lane: dict | None) -> None:
if not lane or not lane.get("available"):
lines.extend(_skipped_block("Animation Hitches", lane))
return
m = lane["metrics"]
lines.append(
f"## Animation Hitches — {m['count']} hitches, "
f"{m['total_hitch_ms']:.0f}ms total, worst {m['worst_hitch_ms']:.0f}ms"
)
if m.get("per_process"):
pp = ", ".join(f"{k}={v}" for k, v in m["per_process"].items())
lines.append(f"By process: {pp}")
lines.append("")
if m.get("narrative_breakdown"):
nb = ", ".join(f"{k}={v}" for k, v in m["narrative_breakdown"].items() if k)
if nb:
lines.append(f"Apple attribution: {nb}")
if m.get("system_hitches") is not None:
lines.append(
f"System vs app: system={m['system_hitches']}, app={m['app_hitches']}"
)
lines.append("")
for i, h in enumerate(lane["top_offenders"], 1):
narrative = f"{h['narrative']}" if h.get("narrative") else ""
src = " [system]" if h.get("is_system") else ""
proc = f" ({h['process']})" if h.get("process") else ""
lines.append(
f"{i}. {h['hitch_duration_ms']:.0f}ms at {h['start_ms']:.2f}ms"
f"{proc}{src}{narrative}"
)
lines.append("")
def _render_swiftui(lines: list[str], lane: dict | None) -> None:
if not lane or not lane.get("available"):
lines.extend(_skipped_block("SwiftUI", lane))
return
m = lane["metrics"]
lines.append(
f"## SwiftUI — {m['total_events']:,} updates across "
f"{m['unique_views']} views, {m['total_duration_ms']:.0f}ms total"
)
if m.get("severity_breakdown"):
sb = ", ".join(f"{k}={v}" for k, v in m["severity_breakdown"].items())
lines.append(f"Severity: {sb}")
if m.get("update_type_breakdown"):
ub = ", ".join(f"{k}={v}" for k, v in m["update_type_breakdown"].items())
lines.append(f"Update types: {ub}")
lines.append("")
if lane["top_offenders"]:
lines.append("Heaviest views (by total body time):")
for i, v in enumerate(lane["top_offenders"], 1):
lines.append(
f"{i}. `{_truncate(v['view'], 80)}` — {v['total_ms']:.0f}ms total, "
f"{v['count']} updates (avg {v['avg_ms']:.2f}ms)"
)
if lane.get("high_severity_events"):
lines.append("")
lines.append("High-severity updates:")
for i, e in enumerate(lane["high_severity_events"][:5], 1):
cat = f" [{e['category']}]" if e.get("category") else ""
lines.append(
f"{i}. `{_truncate(e['view'], 60)}` — "
f"{e['severity']} ({e['duration_ms']:.2f}ms at {e['start_ms']:.2f}ms){cat}"
)
lines.append("")
def _render_causes(lines: list[str], lane: dict | None) -> None:
if not lane or not lane.get("available"):
lines.extend(_skipped_block("SwiftUI Cause Graph", lane))
return
m = lane["metrics"]
lines.append(
f"## SwiftUI Cause Graph — {m['total_edges']:,} edges, "
f"{m['unique_sources']} sources → {m['unique_destinations']} destinations"
)
lines.append("")
if lane.get("top_sources"):
lines.append("Top sources (who's driving the most updates):")
for i, s in enumerate(lane["top_sources"][:5], 1):
lines.append(f"{i}. `{_truncate(s['source'], 80)}` — {s['edges']:,} edges")
for d in s["top_destinations"][:3]:
lines.append(
f" → `{_truncate(d['destination'], 70)}` {d['edges']:,}"
)
if lane.get("top_destinations"):
lines.append("")
lines.append("Top destinations (who's being invalidated most):")
for i, d in enumerate(lane["top_destinations"][:5], 1):
lines.append(f"{i}. `{_truncate(d['destination'], 80)}` — {d['edges']:,} edges")
for s in d["top_sources"][:3]:
lines.append(
f" ← `{_truncate(s['source'], 70)}` {s['edges']:,}"
)
lines.append("")
def _render_correlations(lines: list[str], correlations: list[dict]) -> None:
if not correlations:
return
lines.append("## Correlations")
lines.append("")
for c in correlations:
t = c["trigger"]
head = (
f"- **{t['lane']}** at {t['start_ms']:.2f}ms "
f"({t['duration_ms']:.0f}ms)"
)
if t.get("hang_type"):
head += f"{t['hang_type']}"
lines.append(head)
tp = c.get("time_profiler_main_thread")
if tp is not None:
cov = tp["main_running_coverage_pct"]
lines.append(
f" - Main thread: {tp['samples_on_main']} running samples "
f"({cov:.0f}% coverage — "
f"{'blocked' if cov < 25 else 'mostly running'})"
)
for s in tp["hot_symbols"][:3]:
lines.append(
f" · `{_truncate(s['symbol'], 80)}` "
f"{s['percent_of_main']:.0f}% ({s['samples']} samples)"
)
if not tp["hot_symbols"]:
lines.append(" · no main-thread samples in window")
sui = c.get("swiftui_overlapping_updates")
if sui:
for s in sui[:3]:
lines.append(
f" - SwiftUI: `{s['view']}` {s['duration_ms']:.2f}ms "
f"(at {s['start_ms']:.2f}ms)"
)
lines.append("")
def _short_thread(name: str) -> str:
if not name:
return ""
if name.startswith("Main Thread") or name == "main":
return "main"
# "NowPlaying Gigs (0x251990d) (NowPlaying Gigs, pid: 28401)" -> "tid 0x251990d"
tid_start = name.find("(0x")
if tid_start != -1:
start = tid_start + 1
end = name.find(")", start)
if end != -1:
return f"tid {name[start:end]}"
return name[:40]
def _truncate(s: str, n: int) -> str:
if len(s) <= n:
return s
return s[: n - 1] + ""
@@ -0,0 +1,195 @@
"""SwiftUI lane parser (Xcode 26+).
Primary schema is `swiftui-updates` with columns: start, duration, id,
update-type, allocations, description, category, view-hierarchy, module,
view-name, process, thread, root-causes, severity, cause-graph-node,
full-cause-graph-node.
We aggregate by view-name across all SwiftUI schemas (future-proofing against
schema renames) and break severity out separately so the agent can focus on
the high-severity rows.
"""
from __future__ import annotations
from collections import Counter, defaultdict
from pathlib import Path
from typing import Any
from . import xctrace, xml_utils
START_KEYS = ("start", "time", "sample-time", "timestamp")
DURATION_KEYS = ("duration", "body-duration", "update-duration")
VIEW_KEYS = ("view-name", "view", "view-type", "name", "type")
MODULE_KEYS = ("module",)
CATEGORY_KEYS = ("category",)
UPDATE_TYPE_KEYS = ("update-type",)
SEVERITY_KEYS = ("severity",)
DESCRIPTION_KEYS = ("description",)
HIGH_SEVERITIES = {"High", "Very High", "Severe", "Critical"}
# Ongoing / unterminated updates carry a sentinel duration (≈ UINT64_MAX-ish).
# Any duration longer than an hour is almost certainly that sentinel and would
# break aggregates + the correlation overlap check.
_SENTINEL_DURATION_NS = 60 * 60 * 1_000_000_000 # 1 hour
def analyze(
trace_path: Path,
toc_schemas: frozenset[str],
top_n: int = 10,
window: tuple[int, int] | None = None,
run: int = 1,
) -> dict[str, Any]:
schemas = sorted(
s for s in toc_schemas
if s.startswith("swiftui") and not s.endswith("-causes")
)
if not schemas:
return {
"lane": "swiftui",
"available": False,
"notes": ["SwiftUI lane not in trace (Xcode 26+ SwiftUI template required)."],
}
events: list[dict] = []
per_view_total_ns: dict[str, int] = defaultdict(int)
per_view_count: dict[str, int] = defaultdict(int)
severity_counts: Counter[str] = Counter()
update_type_counts: Counter[str] = Counter()
category_counts: Counter[str] = Counter()
for schema in schemas:
xml_bytes = xctrace.export_schema(trace_path, schema, run=run)
stream = xml_utils.RowStream(xml_bytes)
for row in stream:
start_ns = _first_int(row, stream, START_KEYS)
dur_ns = _first_int(row, stream, DURATION_KEYS)
if start_ns is None or dur_ns is None:
continue
if dur_ns < 0 or dur_ns > _SENTINEL_DURATION_NS:
# Unterminated / ongoing update; skip so it doesn't poison
# totals and the correlation overlap check.
continue
if not xml_utils.event_overlaps_window(start_ns, start_ns + dur_ns, window):
continue
view = _first_str(row, stream, VIEW_KEYS)
module = _first_str(row, stream, MODULE_KEYS)
category = _first_str(row, stream, CATEGORY_KEYS)
update_type = _first_str(row, stream, UPDATE_TYPE_KEYS)
severity = _first_str(row, stream, SEVERITY_KEYS)
description = _first_str(row, stream, DESCRIPTION_KEYS)
# Fall back through description → category → update-type so the
# agent sees "EnvironmentWriter: RootEnvironment" instead of
# "<unknown>" when SwiftUI doesn't record a view type.
if not view:
view = description or category or update_type or "<unknown>"
per_view_total_ns[view] += dur_ns
per_view_count[view] += 1
if severity:
severity_counts[severity] += 1
if update_type:
update_type_counts[update_type] += 1
if category:
category_counts[category] += 1
events.append({
"schema": schema,
"start_ns": start_ns,
"end_ns": start_ns + dur_ns,
"duration_ns": dur_ns,
"duration_ms": round(dur_ns / 1_000_000, 2),
"start_ms": round(start_ns / 1_000_000, 2),
"view": view,
"module": module,
"category": category,
"update_type": update_type,
"severity": severity,
"description": description,
})
events.sort(key=lambda e: e["duration_ns"], reverse=True)
top_by_total = sorted(
per_view_total_ns.items(), key=lambda kv: kv[1], reverse=True
)[:top_n]
top_offenders = [
{
"view": view,
"total_ms": round(total_ns / 1_000_000, 2),
"count": per_view_count[view],
"avg_ms": round(total_ns / per_view_count[view] / 1_000_000, 2),
}
for view, total_ns in top_by_total
]
high_severity = [
{
"view": e["view"],
"severity": e["severity"],
"duration_ms": e["duration_ms"],
"start_ms": e["start_ms"],
"category": e["category"],
"update_type": e["update_type"],
"description": e["description"],
}
for e in events if e["severity"] in HIGH_SEVERITIES
][:top_n]
longest = [
{
"view": e["view"],
"duration_ms": e["duration_ms"],
"start_ms": e["start_ms"],
"category": e["category"],
"update_type": e["update_type"],
"severity": e["severity"],
}
for e in events[:top_n]
]
return {
"lane": "swiftui",
"available": True,
"schemas_used": schemas,
"metrics": {
"total_events": len(events),
"unique_views": len(per_view_total_ns),
"total_duration_ms": round(
sum(per_view_total_ns.values()) / 1_000_000, 2
),
"severity_breakdown": dict(severity_counts.most_common()),
"update_type_breakdown": dict(update_type_counts.most_common()),
"category_breakdown": dict(category_counts.most_common()),
},
"top_offenders": top_offenders,
"longest_single_events": longest,
"high_severity_events": high_severity,
"notes": [],
"_events": events,
}
def _first_int(row, stream, keys):
for key in keys:
el = row.get(key)
if el is None:
continue
val = xml_utils.int_text(stream.resolve(el))
if val is not None:
return val
return None
def _first_str(row, stream, keys):
for key in keys:
el = row.get(key)
if el is None:
continue
resolved = stream.resolve(el)
txt = xml_utils.str_text(resolved) or resolved.get("fmt")
if txt:
return txt
return None
@@ -0,0 +1,135 @@
"""Time Profiler lane parser (schema `time-profile`).
Aggregates CPU samples by leaf symbol, keeps per-sample rows so that other
lanes can correlate by timestamp window.
"""
from __future__ import annotations
from collections import defaultdict
from pathlib import Path
from typing import Any
from . import xctrace, xml_utils
PREFERRED_SCHEMAS = ("time-profile",)
FALLBACK_SCHEMAS = ("time-sample",) # no symbolication; used only if nothing else
def analyze(
trace_path: Path,
toc_schemas: frozenset[str],
top_n: int = 10,
window: tuple[int, int] | None = None,
run: int = 1,
) -> dict[str, Any]:
schema = _pick_schema(toc_schemas)
if schema is None:
return {
"lane": "time-profiler",
"available": False,
"notes": ["Time Profiler data not present in trace."],
}
xml_bytes = xctrace.export_schema(trace_path, schema, run=run)
stream = xml_utils.RowStream(xml_bytes)
samples: list[dict] = []
symbol_weight: dict[str, int] = defaultdict(int)
symbol_samples: dict[str, int] = defaultdict(int)
symbol_thread: dict[str, str] = {}
processes: set[str] = set()
total_weight = 0
min_time: int | None = None
max_time: int | None = None
for row in stream:
time_el = row.get("time")
weight_el = row.get("weight")
thread_el = row.get("thread")
stack_el = row.get("stack")
if stack_el is None or time_el is None or thread_el is None:
continue
sample_time_ns = xml_utils.int_text(stream.resolve(time_el))
if not xml_utils.in_window(sample_time_ns, window):
continue
weight_ns = xml_utils.int_text(stream.resolve(weight_el)) or 0
frames = xml_utils.extract_backtrace(stack_el, stream, max_frames=20)
if not frames:
continue
thread = xml_utils.extract_thread(thread_el, stream)
process_name = (thread.get("process") or {}).get("name")
if process_name:
processes.add(process_name)
leaf = xml_utils.top_symbol(frames)
symbol_weight[leaf] += weight_ns
symbol_samples[leaf] += 1
symbol_thread.setdefault(
leaf, "main" if thread["is_main"] else thread.get("name", "")
)
total_weight += weight_ns
if sample_time_ns is not None:
min_time = sample_time_ns if min_time is None else min(min_time, sample_time_ns)
max_time = sample_time_ns if max_time is None else max(max_time, sample_time_ns)
samples.append({
"time_ns": sample_time_ns,
"weight_ns": weight_ns,
"thread_name": thread["name"],
"is_main": thread["is_main"],
"process": process_name,
"leaf_symbol": leaf,
"frames": frames[:5],
})
samples.sort(key=lambda s: s["time_ns"])
top = sorted(
symbol_weight.items(), key=lambda kv: kv[1], reverse=True
)[:top_n]
top_offenders = [
{
"symbol": sym,
"weight_ns": w,
"weight_ms": round(w / 1_000_000, 2),
"samples": symbol_samples[sym],
"percent": round(100.0 * w / total_weight, 2) if total_weight else 0.0,
"thread": symbol_thread.get(sym, ""),
}
for sym, w in top
]
notes: list[str] = []
if schema in FALLBACK_SCHEMAS:
notes.append(
f"Using fallback schema `{schema}`; backtraces may be unsymbolicated."
)
return {
"lane": "time-profiler",
"available": True,
"schema_used": schema,
"metrics": {
"total_samples": len(samples),
"total_weight_ns": total_weight,
"total_weight_ms": round(total_weight / 1_000_000, 2),
"window_start_ns": min_time,
"window_end_ns": max_time,
"processes": sorted(processes),
},
"top_offenders": top_offenders,
"notes": notes,
# Internal: retained for correlation. Stripped before JSON emission
# if --slim is requested by the orchestrator.
"_samples": samples,
}
def _pick_schema(available: frozenset[str]) -> str | None:
for s in PREFERRED_SCHEMAS + FALLBACK_SCHEMAS:
if s in available:
return s
return None
@@ -0,0 +1,117 @@
"""Thin wrapper around the `xctrace` CLI."""
from __future__ import annotations
import subprocess
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from pathlib import Path
@dataclass(frozen=True)
class RunInfo:
"""Per-run metadata and schemas. Instruments traces can hold multiple runs."""
number: int
template_name: str | None
duration_s: float | None
start_date: str | None
end_date: str | None
schemas: frozenset[str]
@dataclass(frozen=True)
class TraceInfo:
xctrace_version: str
runs: tuple[RunInfo, ...]
def get_run(self, number: int) -> RunInfo:
for r in self.runs:
if r.number == number:
return r
available = ", ".join(str(r.number) for r in self.runs)
raise KeyError(f"run {number} not in trace (available: {available})")
def version() -> str:
out = subprocess.run(
["xctrace", "version"], capture_output=True, text=True, check=True
)
return out.stdout.strip()
def toc(trace_path: Path) -> TraceInfo:
"""Export the trace's table of contents and return per-run metadata.
The TOC is small (a few KB) so we load it fully rather than streaming.
"""
xml_bytes = _run_export(trace_path, ["--toc"])
root = ET.fromstring(xml_bytes)
instruments = _find_text(root, ".//instruments-version") or ""
runs: list[RunInfo] = []
for run_el in root.iterfind("./run"):
number_attr = run_el.get("number")
if not number_attr:
continue
try:
number = int(number_attr)
except ValueError:
continue
if number <= 0:
continue
schemas: set[str] = set()
for table in run_el.iterfind("./data/table"):
schema = table.get("schema")
if schema:
schemas.add(schema)
summary = run_el.find("./info/summary")
if summary is not None:
template = _find_text(summary, "./template-name")
duration = _find_text(summary, "./duration")
start = _find_text(summary, "./start-date")
end = _find_text(summary, "./end-date")
else:
template = duration = start = end = None
runs.append(RunInfo(
number=number,
template_name=template,
duration_s=float(duration) if duration else None,
start_date=start,
end_date=end,
schemas=frozenset(schemas),
))
runs.sort(key=lambda r: r.number)
return TraceInfo(
xctrace_version=instruments,
runs=tuple(runs),
)
def export_schema(trace_path: Path, schema: str, run: int = 1) -> bytes:
"""Export one schema's data as XML bytes from the given run.
Callers are expected to iterparse the result rather than build a full tree
for large schemas (time-profile can be tens of MB).
"""
xpath = f'/trace-toc/run[@number="{run}"]/data/table[@schema="{schema}"]'
return _run_export(trace_path, ["--xpath", xpath])
def _run_export(trace_path: Path, extra_args: list[str]) -> bytes:
cmd = ["xctrace", "export", "--input", str(trace_path), *extra_args]
proc = subprocess.run(cmd, capture_output=True, check=False)
if proc.returncode != 0:
raise RuntimeError(
f"xctrace export failed ({proc.returncode}): "
f"{proc.stderr.decode(errors='replace').strip()}"
)
return proc.stdout
def _find_text(root: ET.Element, path: str) -> str | None:
el = root.find(path)
return el.text if el is not None and el.text else None
@@ -0,0 +1,224 @@
"""Streaming XML helpers for xctrace export output.
Instruments XML deduplicates repeated values with `id`/`ref` attributes that
can span the whole document, so we stream rows with iterparse while keeping
a global id cache for later ref lookups.
"""
from __future__ import annotations
import xml.etree.ElementTree as ET
from collections.abc import Iterator
from dataclasses import dataclass
@dataclass(frozen=True)
class Column:
mnemonic: str # e.g. "time", "weight", "stack"
engineering_type: str # e.g. "sample-time", "weight", "tagged-backtrace"
class RowStream:
"""Iterate <row> elements of a single <table> schema export.
Yields `dict[str, Element]` keyed by column mnemonic. Elements inside a
yielded row are live ET elements (rooted in the id cache where applicable
so ref resolution via `resolve()` remains valid after the row is yielded).
"""
def __init__(self, xml_bytes: bytes):
self._xml = xml_bytes
self.columns: list[Column] = []
self._id_cache: dict[str, ET.Element] = {}
def resolve(self, element: ET.Element) -> ET.Element:
"""If the element is a ref, return the referenced element; else self."""
ref = element.get("ref")
if ref is None:
return element
target = self._id_cache.get(ref)
if target is None:
return element # unresolved; return the ref element itself
return target
def __iter__(self) -> Iterator[dict[str, ET.Element]]:
# iterparse fires `end` events once an element is fully parsed, so ids
# are visible to descendants via the cache. We only need `end` events;
# row bodies are reconstructed from the end element itself in _row_dict.
#
# NOTE: we intentionally don't call `elem.clear()` after yielding a row.
# Instruments' XML is a single shared doc where any row can `ref` an
# `id` defined earlier (threads, processes, stacks, metadata), and
# clearing would break those later lookups. The tradeoff is peak RAM
# ≈ document size. That's fine for typical traces up to a few hundred
# MB; very large exports may need a smarter pass that first indexes
# referenced ids and only retains those.
schema_seen = False
context = ET.iterparse(_bytes_to_file(self._xml), events=("end",))
for _event, elem in context:
eid = elem.get("id")
if eid is not None:
self._id_cache[eid] = elem
if elem.tag == "schema" and not schema_seen:
self.columns = _parse_columns(elem)
schema_seen = True
continue
if elem.tag == "row":
yield _row_dict(elem, self.columns)
# Do not clear elem — children referenced via id may still be needed.
def _parse_columns(schema_el: ET.Element) -> list[Column]:
cols: list[Column] = []
for col in schema_el.findall("col"):
mnemonic = (col.findtext("mnemonic") or "").strip()
etype = (col.findtext("engineering-type") or "").strip()
if mnemonic:
cols.append(Column(mnemonic=mnemonic, engineering_type=etype))
return cols
def _row_dict(row_el: ET.Element, cols: list[Column]) -> dict[str, ET.Element]:
# Row children map positionally to columns. <sentinel/> marks a missing
# optional value for that column.
result: dict[str, ET.Element] = {}
children = list(row_el)
for idx, child in enumerate(children):
if idx >= len(cols):
break
if child.tag == "sentinel":
continue
result[cols[idx].mnemonic] = child
return result
def _bytes_to_file(data: bytes):
import io
return io.BytesIO(data)
# --- Extraction helpers ---------------------------------------------------
def int_text(elem: ET.Element | None) -> int | None:
if elem is None or elem.text is None:
return None
try:
return int(elem.text)
except ValueError:
return None
def str_text(elem: ET.Element | None) -> str | None:
if elem is None or elem.text is None:
return None
return elem.text
def fmt_attr(elem: ET.Element | None) -> str | None:
"""Return the human-readable `fmt` attribute if present."""
if elem is None:
return None
return elem.get("fmt")
def extract_thread(thread_el: ET.Element, stream: RowStream) -> dict:
"""Parse a <thread> element into name, tid, process dict.
Handles ref-style threads by resolving through the stream's id cache.
"""
resolved = stream.resolve(thread_el)
name = resolved.get("fmt", "")
tid_el = resolved.find("tid")
process_el = resolved.find("process")
process = extract_process(process_el, stream) if process_el is not None else None
return {
"name": name,
"tid": int_text(tid_el),
"process": process,
"is_main": name.startswith("Main Thread") if name else False,
}
def extract_process(process_el: ET.Element, stream: RowStream) -> dict:
resolved = stream.resolve(process_el)
name = resolved.get("fmt", "")
pid_el = resolved.find("pid")
return {
"name": _clean_process_name(name),
"pid": int_text(pid_el),
}
def _clean_process_name(fmt: str) -> str:
# "NowPlaying Gigs (28401)" -> "NowPlaying Gigs"
if " (" in fmt and fmt.endswith(")"):
return fmt.rsplit(" (", 1)[0]
return fmt
def extract_backtrace(
bt_el: ET.Element, stream: RowStream, max_frames: int = 20
) -> list[dict]:
"""Return a list of frame dicts from a <tagged-backtrace> or <backtrace>.
Frames are ordered leaf-first (top of stack first), matching Instruments'
display order.
"""
resolved = stream.resolve(bt_el)
inner = resolved.find("backtrace")
if inner is None:
inner = resolved
frames: list[dict] = []
for frame_el in inner.findall("frame"):
f = stream.resolve(frame_el)
frames.append({
"name": f.get("name") or "",
"addr": f.get("addr") or "",
})
if len(frames) >= max_frames:
break
return frames
def top_symbol(frames: list[dict]) -> str:
"""Pick the leaf symbol, falling back to addr if unsymbolicated."""
if not frames:
return "<empty-stack>"
first = frames[0]
return first.get("name") or first.get("addr") or "<unknown>"
def first_present(row: dict, *keys: str) -> ET.Element | None:
"""Return the first row column whose key exists.
`row[key] or row[other_key]` is unsafe here: Element is falsy when it has
no children (a common case for leaf <event-time>, <start-time>, etc.), so
`or` short-circuits past valid leaf elements. This walks keys explicitly.
"""
for key in keys:
el = row.get(key)
if el is not None:
return el
return None
def in_window(time_ns: int | None, window: tuple[int, int] | None) -> bool:
"""Return True if time_ns is inside [start, end] (inclusive), or window is None."""
if window is None:
return True
if time_ns is None:
return False
start, end = window
return start <= time_ns <= end
def event_overlaps_window(
start_ns: int, end_ns: int, window: tuple[int, int] | None
) -> bool:
"""Return True if [start, end] overlaps [window.start, window.end]."""
if window is None:
return True
w_start, w_end = window
return not (end_ns < w_start or start_ns > w_end)
@@ -0,0 +1,252 @@
#!/usr/bin/env python3
"""Record an Xcode Instruments .trace file via `xctrace record`.
Three modes:
(default) Start a recording. Stops on Ctrl+C, stop-file, or time limit.
--list-devices Enumerate connected devices + simulators as JSON.
--list-templates Enumerate available Instruments templates as JSON.
Attach vs launch vs all-processes is mutually exclusive and passed straight
through to xctrace. The default template is "SwiftUI" (matches the
SwiftUI template in Xcode 26+ — change with --template).
Manual stop options, most to least automated:
* Send SIGINT (Ctrl+C) to this script — forwarded to xctrace, which
finalises the trace before exiting.
* Pass --stop-file PATH; when that file appears on disk, this script
sends SIGINT to xctrace. Useful for `Bash run_in_background`
workflows where there's no interactive terminal.
* Pass --time-limit 30s / 5m / etc. — xctrace stops itself.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import signal
import subprocess
import sys
import time
from datetime import datetime
from pathlib import Path
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Record an Instruments .trace file.")
list_mode = parser.add_mutually_exclusive_group()
list_mode.add_argument("--list-devices", action="store_true",
help="List devices and simulators as JSON, then exit.")
list_mode.add_argument("--list-templates", action="store_true",
help="List template names as JSON, then exit.")
parser.add_argument("--template", default="SwiftUI",
help="Template name (default: SwiftUI).")
parser.add_argument("--device", default=None,
help="Device name or UDID. Defaults to the host.")
parser.add_argument("--output", type=Path, default=None,
help="Output .trace path. Defaults to ./<template>-<timestamp>.trace.")
parser.add_argument("--time-limit", default=None,
help="Cap recording duration (e.g. 30s, 5m, 1h). Optional.")
parser.add_argument("--stop-file", type=Path, default=None,
help="When this path appears on disk, stop the recording.")
parser.add_argument("--env", action="append", default=[],
metavar="KEY=VALUE",
help="Env var for the launched process. Can repeat. Launch mode only.")
parser.add_argument("--instrument", action="append", default=[],
help="Extra --instrument flag passthrough (can repeat).")
parser.add_argument("--run-name", default=None)
target = parser.add_mutually_exclusive_group()
target.add_argument("--launch", metavar="APP",
help="Launch this .app path and record it.")
target.add_argument("--attach", metavar="PID_OR_NAME",
help="Attach to a running process by pid or name.")
target.add_argument("--all-processes", action="store_true",
help="Record every process (system-wide).")
args = parser.parse_args(argv)
if args.list_devices:
_print_devices()
return 0
if args.list_templates:
_print_templates()
return 0
if not (args.launch or args.attach or args.all_processes):
print("error: need one of --launch, --attach, or --all-processes.",
file=sys.stderr)
return 2
if args.env and not args.launch:
# xctrace silently ignores --env outside launch mode; surfacing this
# explicitly saves agents a confusing "why didn't my env var apply?".
print("error: --env only applies to --launch; remove it or switch target mode.",
file=sys.stderr)
return 2
output = args.output or Path.cwd() / _default_trace_name(args.template)
if output.exists():
print(f"error: output already exists: {output}", file=sys.stderr)
return 2
cmd = _build_xctrace_cmd(args, output)
# Tell the user (and an agent reading stdout) what's happening + how to stop.
print("[record] starting xctrace record", flush=True)
print(f"[record] template: {args.template}", flush=True)
print(f"[record] device: {args.device or '(host)'}", flush=True)
print(f"[record] target: {_describe_target(args)}", flush=True)
print(f"[record] output: {output}", flush=True)
stop_hints = ["Ctrl+C"]
if args.stop_file:
stop_hints.append(f"`touch {args.stop_file}`")
if args.time_limit:
stop_hints.append(f"after {args.time_limit}")
print(f"[record] stop via: {', '.join(stop_hints)}", flush=True)
print(f"[record] cmd: {' '.join(_shell_quote(c) for c in cmd)}", flush=True)
# Start xctrace in its own process group so we can signal cleanly.
proc = subprocess.Popen(cmd, start_new_session=True)
try:
_wait_with_stop(proc, args.stop_file)
except KeyboardInterrupt:
_forward_sigint(proc)
# Give xctrace up to 60s to finalise after SIGINT — large traces take time.
try:
rc = proc.wait(timeout=60)
except subprocess.TimeoutExpired:
print("[record] xctrace did not exit within 60s after stop; killing.",
file=sys.stderr)
proc.kill()
rc = proc.wait()
if output.exists():
print(f"[record] done. trace written: {output}", flush=True)
else:
print("[record] done but output file not found — did xctrace error?",
file=sys.stderr)
return rc or 1
return rc
def _build_xctrace_cmd(args, output: Path) -> list[str]:
cmd = ["xctrace", "record", "--template", args.template, "--output", str(output)]
if args.device:
cmd += ["--device", args.device]
if args.time_limit:
cmd += ["--time-limit", args.time_limit]
if args.run_name:
cmd += ["--run-name", args.run_name]
for inst in args.instrument:
cmd += ["--instrument", inst]
for env in args.env:
cmd += ["--env", env]
# Target must come last — --launch consumes the remainder.
if args.attach:
cmd += ["--attach", args.attach]
elif args.all_processes:
cmd += ["--all-processes"]
elif args.launch:
cmd += ["--launch", "--", args.launch]
return cmd
def _describe_target(args) -> str:
if args.launch:
return f"launch {args.launch}"
if args.attach:
return f"attach {args.attach}"
if args.all_processes:
return "all processes"
return "(none)"
def _default_trace_name(template: str) -> str:
safe = re.sub(r"[^A-Za-z0-9]+", "-", template).strip("-").lower() or "trace"
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
return f"{safe}-{ts}.trace"
def _wait_with_stop(proc: subprocess.Popen, stop_file: Path | None) -> None:
"""Poll until xctrace exits or stop_file appears; then send SIGINT."""
while True:
rc = proc.poll()
if rc is not None:
return
if stop_file and stop_file.exists():
print(f"[record] stop-file detected ({stop_file}); stopping xctrace.",
flush=True)
_forward_sigint(proc)
return
time.sleep(0.5)
def _forward_sigint(proc: subprocess.Popen) -> None:
try:
# Signal the whole group so child instruments tools also get SIGINT.
os.killpg(os.getpgid(proc.pid), signal.SIGINT)
except ProcessLookupError:
pass
def _print_devices() -> None:
out = subprocess.run(
["xctrace", "list", "devices"], capture_output=True, text=True, check=True
).stdout
devices: list[dict] = []
section = None
# Device lines end with "(UDID)"; real iOS devices also have "(OS version)"
# before the UDID. The host (macOS) line has only "(UDID)".
line_re = re.compile(r"^(.+?)(?:\s+\(([^()]+)\))?\s+\(([0-9A-Fa-f-]{20,})\)\s*$")
for line in out.splitlines():
stripped = line.strip()
if not stripped:
continue
if stripped.startswith("==") and stripped.endswith("=="):
section = stripped.strip("= ").strip().lower()
continue
m = line_re.match(stripped)
if not m:
continue
name, os_ver, udid = m.group(1).strip(), m.group(2), m.group(3)
devices.append({
"kind": section or "unknown",
"name": name,
"os": os_ver,
"udid": udid,
})
print(json.dumps({"devices": devices}, indent=2))
def _print_templates() -> None:
out = subprocess.run(
["xctrace", "list", "templates"], capture_output=True, text=True, check=True
).stdout
groups: dict[str, list[str]] = {}
section = "unknown"
for line in out.splitlines():
stripped = line.strip()
if not stripped:
continue
if stripped.startswith("==") and stripped.endswith("=="):
section = stripped.strip("= ").strip().lower()
groups.setdefault(section, [])
continue
groups.setdefault(section, []).append(stripped)
# Flat convenience list + structured by section.
flat = [name for items in groups.values() for name in items]
print(json.dumps({"templates": flat, "by_section": groups}, indent=2))
def _shell_quote(s: str) -> str:
if re.match(r"^[A-Za-z0-9_./:=@-]+$", s):
return s
return "'" + s.replace("'", "'\\''") + "'"
if __name__ == "__main__":
sys.exit(main())