Skip to content

Commit 73cb435

Browse files
committed
feat(trace) add rust-backend timing audit hooks
why: We need per-test timing breakdowns to locate slow paths. what: - emit JSONL spans for libtmux rust backend calls - surface trace summary in pytest terminal output - capture server is_alive/require_server timing
1 parent 93f658d commit 73cb435

4 files changed

Lines changed: 357 additions & 108 deletions

File tree

conftest.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import pytest
1717
from _pytest.doctest import DoctestItem
1818

19+
from libtmux._internal import trace as libtmux_trace
1920
from libtmux.pane import Pane
2021
from libtmux.pytest_plugin import USING_ZSH
2122
from libtmux.server import Server
@@ -28,6 +29,34 @@
2829
pytest_plugins = ["pytester"]
2930

3031

32+
@pytest.fixture(autouse=True, scope="session")
33+
def trace_session() -> None:
34+
"""Initialize trace collection when enabled."""
35+
if not libtmux_trace.TRACE_ENABLED:
36+
return
37+
if libtmux_trace.TRACE_RESET:
38+
libtmux_trace.reset_trace()
39+
40+
41+
def pytest_terminal_summary(terminalreporter: pytest.TerminalReporter) -> None:
42+
"""Print trace summary in pytest's terminal summary."""
43+
if not libtmux_trace.TRACE_ENABLED:
44+
return
45+
terminalreporter.section("libtmux trace")
46+
terminalreporter.write_line(libtmux_trace.summarize())
47+
48+
49+
@pytest.fixture(autouse=True)
50+
def trace_test_context(request: pytest.FixtureRequest) -> t.Iterator[None]:
51+
"""Attach the current pytest node id to trace events."""
52+
if not libtmux_trace.TRACE_ENABLED:
53+
yield
54+
return
55+
libtmux_trace.set_test_context(request.node.nodeid)
56+
yield
57+
libtmux_trace.set_test_context(None)
58+
59+
3160
@pytest.fixture(autouse=True)
3261
def add_doctest_fixtures(
3362
request: pytest.FixtureRequest,

src/libtmux/_internal/trace.py

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
"""Lightweight tracing for libtmux timing audits."""
2+
3+
from __future__ import annotations
4+
5+
import contextlib
6+
import contextvars
7+
import itertools
8+
import json
9+
import os
10+
import pathlib
11+
import threading
12+
import time
13+
import typing as t
14+
15+
TRACE_PATH = os.getenv("LIBTMUX_TRACE_PATH", "/tmp/libtmux-trace.jsonl")
16+
17+
18+
def _env_flag(name: str) -> bool:
19+
value = os.getenv(name)
20+
if value is None:
21+
return False
22+
return value not in {"", "0", "false", "False", "no", "NO"}
23+
24+
25+
TRACE_ENABLED = _env_flag("LIBTMUX_TRACE")
26+
TRACE_RESET = _env_flag("LIBTMUX_TRACE_RESET")
27+
28+
_TRACE_TEST: contextvars.ContextVar[str | None] = contextvars.ContextVar(
29+
"libtmux_trace_test", default=None
30+
)
31+
_TRACE_STACK: contextvars.ContextVar[tuple[int, ...]] = contextvars.ContextVar(
32+
"libtmux_trace_stack", default=()
33+
)
34+
_TRACE_COUNTER = itertools.count(1)
35+
36+
37+
def set_test_context(name: str | None) -> None:
38+
_TRACE_TEST.set(name)
39+
40+
41+
def reset_trace(path: str | None = None) -> None:
42+
if not TRACE_ENABLED:
43+
return
44+
target = path or TRACE_PATH
45+
with pathlib.Path(target).open("w", encoding="utf-8") as handle:
46+
handle.write("")
47+
48+
49+
def _write_event(event: dict[str, t.Any]) -> None:
50+
if not TRACE_ENABLED:
51+
return
52+
event["pid"] = os.getpid()
53+
event["thread"] = threading.get_ident()
54+
test_name = _TRACE_TEST.get()
55+
if test_name:
56+
event["test"] = test_name
57+
with pathlib.Path(TRACE_PATH).open("a", encoding="utf-8") as handle:
58+
handle.write(json.dumps(event, sort_keys=False))
59+
handle.write("\n")
60+
61+
62+
@contextlib.contextmanager
63+
def span(name: str, **fields: t.Any) -> t.Iterator[None]:
64+
if not TRACE_ENABLED:
65+
yield
66+
return
67+
span_id = next(_TRACE_COUNTER)
68+
stack = _TRACE_STACK.get()
69+
parent_id = stack[-1] if stack else None
70+
_TRACE_STACK.set((*stack, span_id))
71+
start_ns = time.perf_counter_ns()
72+
try:
73+
yield
74+
finally:
75+
duration_ns = time.perf_counter_ns() - start_ns
76+
_TRACE_STACK.set(stack)
77+
event = {
78+
"event": name,
79+
"span_id": span_id,
80+
"parent_id": parent_id,
81+
"depth": len(stack),
82+
"start_ns": start_ns,
83+
"duration_ns": duration_ns,
84+
}
85+
event.update(fields)
86+
_write_event(event)
87+
88+
89+
def point(name: str, **fields: t.Any) -> None:
90+
if not TRACE_ENABLED:
91+
return
92+
event = {"event": name, "point": True, "ts_ns": time.perf_counter_ns()}
93+
event.update(fields)
94+
_write_event(event)
95+
96+
97+
def summarize(path: str | None = None, limit: int = 20) -> str:
98+
target = path or TRACE_PATH
99+
if not pathlib.Path(target).exists():
100+
return "libtmux trace: no data collected"
101+
102+
totals: dict[str, dict[str, int]] = {}
103+
slowest: list[tuple[int, str]] = []
104+
105+
with pathlib.Path(target).open(encoding="utf-8") as handle:
106+
for line in handle:
107+
line = line.strip()
108+
if not line:
109+
continue
110+
try:
111+
event = json.loads(line)
112+
except json.JSONDecodeError:
113+
continue
114+
if event.get("point"):
115+
continue
116+
name = str(event.get("event", "unknown"))
117+
duration = int(event.get("duration_ns", 0))
118+
entry = totals.setdefault(name, {"count": 0, "total_ns": 0, "max_ns": 0})
119+
entry["count"] += 1
120+
entry["total_ns"] += duration
121+
if duration > entry["max_ns"]:
122+
entry["max_ns"] = duration
123+
slowest.append((duration, json.dumps(event, sort_keys=False)))
124+
125+
if not totals:
126+
return "libtmux trace: no span data collected"
127+
128+
sorted_totals = sorted(
129+
totals.items(), key=lambda item: item[1]["total_ns"], reverse=True
130+
)
131+
sorted_slowest = sorted(slowest, key=lambda item: item[0], reverse=True)[:limit]
132+
133+
lines = ["libtmux trace summary (ns):"]
134+
for name, stats in sorted_totals[:limit]:
135+
avg = stats["total_ns"] // max(stats["count"], 1)
136+
lines.append(
137+
f"- {name}: count={stats['count']} total={stats['total_ns']} avg={avg} "
138+
f"max={stats['max_ns']}"
139+
)
140+
lines.append("libtmux trace slowest spans:")
141+
for duration, payload in sorted_slowest:
142+
lines.append(f"- {duration} {payload}")
143+
return "\n".join(lines)
144+
145+
146+
if TRACE_ENABLED and TRACE_RESET:
147+
reset_trace()

0 commit comments

Comments
 (0)