Restart timing: bbctrl.Trace, /api/diag/timing, UI marks

Add a lightweight, self-contained phase tracer for measuring end-to-end
bbctrl restart and Pi boot time. Disabled by setting BBCTRL_TRACE=0.

- src/py/bbctrl/Trace.py: monotonic-anchored event log + sd_notify helper.
- bbctrl/__init__.py: marks for imports, args parsed, ioloop, web init,
  listen, and an sd_notify READY=1 once HTTP is bound.
- bbctrl/Ctrl.py: spans around each subsystem (avr, i2c, lcd, mach,
  preplanner, jog, pwr, hooks, aux, mach.connect).
- bbctrl/Comm.py: avr.firmware_rebooted mark.
- bbctrl/Web.py: TimingHandler (GET /api/diag/timing) and
  UITimingHandler (PUT /api/diag/timing/ui), plus a ws.first_open mark.
- src/js/restart-timing.js + app.js: UI-side performance.now() marks
  (script.load, ws.open, ws.first_msg, ui.first_state, window.load),
  posted once to the controller.
- scripts/bbctrl.service: stdout/stderr -> journal so TRACE lines are
  visible via journalctl -u bbctrl. (Was StandardOutput=null.)

Revert: git revert this commit. To disable at runtime without
reverting, set BBCTRL_TRACE=0 in the bbctrl service environment.
This commit is contained in:
2026-05-01 09:48:10 +02:00
parent 73c6a4f160
commit 561d2fd7ea
8 changed files with 328 additions and 14 deletions

140
src/py/bbctrl/Trace.py Normal file
View File

@@ -0,0 +1,140 @@
################################################################################
# #
# Lightweight phase tracing for bbctrl restart / boot timing. #
# #
# Anchored at module import time. All timestamps are seconds since the #
# process anchor (monotonic). A wall-clock anchor is captured once so the #
# timeline can be aligned with journalctl / systemd-analyze. #
# #
# Set BBCTRL_TRACE=0 in the environment to disable all marks (no-op). #
# #
# Exposed by /api/diag/timing as JSON. #
# #
################################################################################
"""Bbctrl restart / startup tracing.
Usage:
import bbctrl.Trace as T
T.mark('proc.start')
with T.span('ctrl.avr.init'):
...
The timeline is also dumped on demand via /api/diag/timing.
"""
import os
import time
import json
import threading
_ENABLED = os.environ.get('BBCTRL_TRACE', '1') != '0'
_t0_monotonic = time.monotonic()
_t0_wall = time.time()
_lock = threading.Lock()
_events = [] # list of dicts: {t, name, fields}
_ui_timing = None # last timeline POSTed by the browser
def now():
return time.monotonic() - _t0_monotonic
def mark(name, **fields):
"""Record a single named event at the current monotonic time."""
if not _ENABLED:
return
t = now()
ev = {'t': round(t, 4), 'name': name}
if fields:
ev['fields'] = fields
with _lock:
_events.append(ev)
# Also surface in the regular log stream so journalctl shows it.
try:
extras = ''
if fields:
extras = ' ' + ' '.join('%s=%s' % (k, v) for k, v in fields.items())
print('TRACE +%.3fs %s%s' % (t, name, extras), flush=True)
except Exception:
pass
class span(object):
"""Context manager that emits <name>.start / <name>.end with duration."""
def __init__(self, name, **fields):
self.name = name
self.fields = fields
self._t = None
def __enter__(self):
if _ENABLED:
self._t = time.monotonic()
mark(self.name + '.start', **self.fields)
return self
def __exit__(self, exc_type, exc, tb):
if _ENABLED and self._t is not None:
dur_ms = int((time.monotonic() - self._t) * 1000)
extra = dict(self.fields)
extra['dur_ms'] = dur_ms
if exc_type is not None:
extra['error'] = exc_type.__name__
mark(self.name + '.end', **extra)
return False
def set_ui_timing(data):
global _ui_timing
_ui_timing = data
def timeline():
with _lock:
events = list(_events)
return {
'enabled': _ENABLED,
't0_wall': _t0_wall,
't0_iso': time.strftime('%Y-%m-%dT%H:%M:%S', time.localtime(_t0_wall)),
'now': now(),
'pid': os.getpid(),
'events': events,
'ui': _ui_timing,
}
def dump(path):
try:
with open(path, 'w') as f:
json.dump(timeline(), f, indent=2)
except Exception:
pass
# Sd_notify helper -------------------------------------------------------------
#
# Allows bbctrl to tell systemd "I am ready" / "current status is X" so
# `systemctl status bbctrl` and `systemd-analyze critical-chain` reflect the
# actual application state instead of just exec start.
def sd_notify(state):
"""Send a status line to systemd. Safe no-op when not under systemd."""
addr = os.environ.get('NOTIFY_SOCKET')
if not addr:
return
try:
import socket
sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
try:
# Abstract socket if it starts with '@'
target = '\0' + addr[1:] if addr.startswith('@') else addr
sock.sendto(state.encode('utf-8'), target)
finally:
sock.close()
except Exception:
pass
# Mark module-import time so even importing bbctrl shows up.
mark('trace.import')