Added Profiler control with basic UI

This commit is contained in:
2026-03-22 08:32:40 +01:00
parent 72d6cce6ff
commit b8fd4e5ed1
14 changed files with 3440 additions and 1105 deletions

View File

@@ -13,13 +13,14 @@ from myfasthtml.controls.FileUpload import FileUpload
from myfasthtml.controls.InstancesDebugger import InstancesDebugger
from myfasthtml.controls.Keyboard import Keyboard
from myfasthtml.controls.Layout import Layout
from myfasthtml.controls.Profiler import Profiler
from myfasthtml.controls.TabsManager import TabsManager
from myfasthtml.controls.helpers import Ids, mk
from myfasthtml.core.dbengine_utils import DataFrameHandler
from myfasthtml.core.instances import UniqueInstance
from myfasthtml.icons.carbon import volume_object_storage
from myfasthtml.icons.fluent_p2 import key_command16_regular
from myfasthtml.icons.fluent_p3 import folder_open20_regular, text_edit_style20_regular
from myfasthtml.icons.fluent_p3 import folder_open20_regular, text_edit_style20_regular, timer20_regular
from myfasthtml.myfastapp import create_app
with open('logging.yaml', 'r') as f:
@@ -55,13 +56,19 @@ def index(session):
btn_show_instances_debugger = mk.label("Instances",
icon=volume_object_storage,
command=add_tab("Instances", instances_debugger),
id=instances_debugger.get_id())
id=f"l_{instances_debugger.get_id()}")
commands_debugger = CommandsDebugger(layout)
btn_show_commands_debugger = mk.label("Commands",
icon=key_command16_regular,
command=add_tab("Commands", commands_debugger),
id=commands_debugger.get_id())
id=f"l_{commands_debugger.get_id()}")
profiler = Profiler(layout)
btn_show_profiler = mk.label("Profiler",
icon=timer20_regular,
command=add_tab("Profiler", profiler),
id=f"l_{profiler.get_id()}")
btn_file_upload = mk.label("Upload",
icon=folder_open20_regular,
@@ -75,12 +82,14 @@ def index(session):
layout.header_right.add(btn_show_right_drawer)
layout.left_drawer.add(btn_show_instances_debugger, "Debugger")
layout.left_drawer.add(btn_show_commands_debugger, "Debugger")
layout.left_drawer.add(btn_show_profiler, "Debugger")
# Parameters
formatting_manager = DataGridFormattingManager(layout)
btn_show_formatting_manager = mk.label("Formatting",
icon=text_edit_style20_regular,
command=add_tab("Formatting", formatting_manager))
command=add_tab("Formatting", formatting_manager),
id=f"l_{formatting_manager.get_id()}")
layout.left_drawer.add(btn_show_formatting_manager, "Parameters")
layout.left_drawer.add(btn_file_upload, "Test")

View File

@@ -0,0 +1,177 @@
/* ================================================================== */
/* Profiler Control */
/* ================================================================== */
/* ------------------------------------------------------------------
Root wrapper — fills parent, stacks toolbar above panel
------------------------------------------------------------------ */
.mf-profiler {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
overflow: hidden;
font-family: var(--font-sans);
font-size: var(--text-sm);
}
/* ------------------------------------------------------------------
Toolbar
------------------------------------------------------------------ */
.mf-profiler-toolbar {
display: flex;
align-items: center;
gap: 2px;
padding: 4px 8px;
background: var(--color-base-200);
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
/* Danger variant for clear button */
.mf-profiler-btn-danger {
color: var(--color-error) !important;
}
.mf-profiler-btn-danger:hover {
background: color-mix(in oklab, var(--color-error) 15%, transparent) !important;
}
/* Overhead metrics — right-aligned text */
.mf-profiler-overhead {
margin-left: auto;
font-family: var(--font-sans);
font-size: var(--text-xs);
color: color-mix(in oklab, var(--color-base-content) 50%, transparent);
white-space: nowrap;
}
/* ------------------------------------------------------------------
Trace list — left panel content
------------------------------------------------------------------ */
.mf-profiler-list {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
overflow: hidden;
}
.mf-profiler-list-header {
display: grid;
grid-template-columns: 1fr 80px 96px;
padding: 4px 10px;
background: var(--color-base-200);
border-bottom: 1px solid var(--color-border);
font-size: var(--text-xs);
color: color-mix(in oklab, var(--color-base-content) 50%, transparent);
text-transform: uppercase;
letter-spacing: 0.06em;
flex-shrink: 0;
}
.mf-profiler-col-header {
font-family: var(--font-sans);
}
.mf-profiler-col-right {
text-align: right;
}
.mf-profiler-list-body {
overflow-y: auto;
flex: 1;
}
/* ------------------------------------------------------------------
Trace row
------------------------------------------------------------------ */
.mf-profiler-row {
display: grid;
grid-template-columns: 1fr 80px 96px;
align-items: center;
padding: 5px 10px;
border-bottom: 1px solid color-mix(in oklab, var(--color-border) 60%, transparent);
cursor: pointer;
transition: background 0.1s;
}
.mf-profiler-row:hover {
background: var(--color-base-200);
}
.mf-profiler-row-selected {
background: color-mix(in oklab, var(--color-primary) 12%, transparent);
border-left: 2px solid var(--color-primary);
padding-left: 8px;
}
.mf-profiler-row-selected:hover {
background: color-mix(in oklab, var(--color-primary) 18%, transparent);
}
/* Command name + description cell */
.mf-profiler-cmd-cell {
display: flex;
align-items: baseline;
gap: 6px;
overflow: hidden;
}
.mf-profiler-cmd {
font-family: var(--font-sans);
font-size: var(--text-sm);
white-space: nowrap;
flex-shrink: 0;
}
.mf-profiler-cmd-description {
font-family: var(--font-sans);
font-size: var(--text-xs);
font-style: italic;
color: color-mix(in oklab, var(--color-base-content) 45%, transparent);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Duration cell — monospace, color-coded */
.mf-profiler-duration {
font-family: var(--font-mono);
font-size: var(--text-xs);
text-align: right;
}
.mf-profiler-fast {
color: var(--color-success);
}
.mf-profiler-medium {
color: var(--color-warning);
}
.mf-profiler-slow {
color: var(--color-error);
}
/* Timestamp cell */
.mf-profiler-ts {
font-family: var(--font-mono);
font-size: var(--text-xs);
text-align: right;
color: color-mix(in oklab, var(--color-base-content) 45%, transparent);
}
/* ------------------------------------------------------------------
Empty state
------------------------------------------------------------------ */
.mf-profiler-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
padding: 24px;
font-family: var(--font-sans);
font-size: var(--text-sm);
color: color-mix(in oklab, var(--color-base-content) 40%, transparent);
}

View File

@@ -272,7 +272,7 @@ class DataGrid(MultipleInstance):
self._datagrid_filter.bind_command("ChangeFilterType", self.commands.filter())
self._state.filtered[FILTER_INPUT_CID] = self._datagrid_filter.get_query()
# add Selection Selector
# add Selection Selector (cell, row, column)
selection_types = {
"cell": mk.icon(grid, tooltip="Cell selection"), # default
"row": mk.icon(row, tooltip="Row selection"),

View File

@@ -1,13 +1,14 @@
from fastcore.basics import NotStr
from myfasthtml.core.constants import ColumnType
from myfasthtml.core.constants import ColumnType, MediaActions
from myfasthtml.core.utils import pascal_to_snake
from myfasthtml.icons.fluent import question20_regular, brain_circuit20_regular, number_symbol20_regular, \
number_row20_regular
from myfasthtml.icons.fluent_p1 import checkbox_checked20_regular, checkbox_unchecked20_regular, \
checkbox_checked20_filled, math_formula16_regular, folder20_regular, document20_regular
from myfasthtml.icons.fluent_p2 import text_field20_regular, text_bullet_list_square20_regular
from myfasthtml.icons.fluent_p3 import calendar_ltr20_regular
checkbox_checked20_filled, math_formula16_regular, folder20_regular, document20_regular, pause_circle20_regular
from myfasthtml.icons.fluent_p2 import text_field20_regular, text_bullet_list_square20_regular, play_circle20_regular, \
dismiss_circle20_regular
from myfasthtml.icons.fluent_p3 import calendar_ltr20_regular, record_stop20_regular
default_icons = {
# default type icons
@@ -22,6 +23,12 @@ default_icons = {
"TreeViewFolder": folder20_regular,
"TreeViewFile": document20_regular,
# Media
MediaActions.Play: play_circle20_regular,
MediaActions.Pause: pause_circle20_regular,
MediaActions.Stop: record_stop20_regular,
MediaActions.Cancel: dismiss_circle20_regular,
# Datagrid column icons
ColumnType.RowIndex: number_symbol20_regular,
ColumnType.Text: text_field20_regular,

View File

@@ -104,7 +104,7 @@ class Panel(MultipleInstance):
the panel with appropriate HTML elements and JavaScript for interactivity.
"""
def __init__(self, parent, conf: Optional[PanelConf] = None, _id=None):
def __init__(self, parent, conf: Optional[PanelConf] = None, _id="-panel"):
super().__init__(parent, _id=_id)
self.conf = conf or PanelConf()
self.commands = Commands(self)

View File

@@ -0,0 +1,202 @@
import logging
from fasthtml.components import Div, Span
from myfasthtml.controls.BaseCommands import BaseCommands
from myfasthtml.controls.IconsHelper import IconsHelper
from myfasthtml.controls.Panel import Panel, PanelConf
from myfasthtml.controls.helpers import mk
from myfasthtml.core.commands import Command
from myfasthtml.core.constants import PROFILER_MAX_TRACES, MediaActions
from myfasthtml.core.instances import SingleInstance
from myfasthtml.core.profiler import profiler
from myfasthtml.icons.fluent import arrow_clockwise20_regular
logger = logging.getLogger("Profiler")
class Commands(BaseCommands):
def toggle_enable(self):
return Command(
"ProfilerToggleEnable",
"Enable / Disable profiler",
self._owner,
self._owner.handle_toggle_enable,
).htmx(target=f"#{self._id}")
def clear_traces(self):
return Command(
"ProfilerClearTraces",
"Clear all recorded traces",
self._owner,
self._owner.handle_clear_traces,
icon=IconsHelper.get(MediaActions.Cancel),
).htmx(target=f"#{self._id}")
def refresh(self):
return Command(
"ProfilerRefresh",
"Refresh traces",
self._owner,
self._owner.handle_refresh,
icon=arrow_clockwise20_regular,
).htmx(target=f"#{self._id}")
def select_trace(self, trace_id: str):
return Command(
"ProfilerSelectTrace",
"Display trace details",
self._owner,
self._owner.handle_select_trace,
kwargs={"trace_id": trace_id},
).htmx(target=f"#{self._id}")
class Profiler(SingleInstance):
"""In-application profiler UI.
Displays all recorded traces in a scrollable list (left) and trace
details in a resizable panel (right). The toolbar provides enable /
disable toggle and clear actions via icon-only buttons.
Attributes:
commands: Commands exposed by this control.
"""
def __init__(self, parent, _id=None):
super().__init__(parent, _id=_id)
self._panel = Panel(self, conf=PanelConf(show_right_title=False, show_display_right=False))
self._selected_id: str | None = None
self.commands = Commands(self)
logger.debug(f"Profiler created with id={self._id}")
# ------------------------------------------------------------------
# Command handlers
# ------------------------------------------------------------------
def handle_toggle_enable(self):
"""Toggle profiler.enabled and re-render."""
profiler.enabled = not profiler.enabled
logger.debug(f"Profiler enabled set to {profiler.enabled}")
return self
def handle_clear_traces(self):
"""Clear the trace buffer and re-render."""
profiler.clear()
logger.debug("Profiler traces cleared from UI")
return self
def handle_select_trace(self, trace_id: str):
"""Select a trace row and re-render to show it highlighted."""
self._selected_id = trace_id
return self
def handle_refresh(self):
"""Select a trace row and re-render to show it highlighted."""
return self
# ------------------------------------------------------------------
# Private rendering helpers
# ------------------------------------------------------------------
def _duration_cls(self, duration_ms: float) -> str:
"""Return the CSS modifier class for a given duration."""
if duration_ms < 20:
return "mf-profiler-fast"
if duration_ms < 100:
return "mf-profiler-medium"
return "mf-profiler-slow"
def _mk_toolbar(self):
"""Build the icon toolbar with enable/disable, clear and overhead metrics."""
enable_icon = (
IconsHelper.get(MediaActions.Pause)
if profiler.enabled
else IconsHelper.get(MediaActions.Play)
)
enable_tooltip = "Disable profiler" if profiler.enabled else "Enable profiler"
overhead = (
f"Overhead/span: {profiler.overhead_per_span_us:.1f} µs "
f"Total: {profiler.total_overhead_ms:.3f} ms "
f"Traces: {len(profiler.traces)} / {PROFILER_MAX_TRACES}"
)
return Div(
mk.icon(
enable_icon,
command=self.commands.toggle_enable(),
tooltip=enable_tooltip,
),
mk.icon(
command=self.commands.clear_traces(),
tooltip="Clear traces",
cls="mf-profiler-btn-danger",
),
mk.icon(
command=self.commands.refresh(),
),
Span(overhead, cls="mf-profiler-overhead"),
cls="mf-profiler-toolbar",
id=f"tb_{self._id}",
)
def _mk_trace_list(self):
"""Build the trace list with one clickable row per recorded trace."""
traces = profiler.traces
if not traces:
return Div("No traces recorded.", cls="mf-profiler-empty")
rows = []
for trace in traces:
ts = trace.timestamp.strftime("%H:%M:%S.") + f"{trace.timestamp.microsecond // 1000:03d}"
duration_cls = self._duration_cls(trace.total_duration_ms)
row_cls = "mf-profiler-row mf-profiler-row-selected" if trace.trace_id == self._selected_id else "mf-profiler-row"
row = mk.mk(
Div(
Div(
Span(trace.command_name, cls="mf-profiler-cmd"),
Span(trace.command_description, cls="mf-profiler-cmd-description"),
cls="mf-profiler-cmd-cell",
),
Span(f"{trace.total_duration_ms:.1f} ms", cls=f"mf-profiler-duration {duration_cls}"),
Span(ts, cls="mf-profiler-ts"),
cls=row_cls,
),
command=self.commands.select_trace(trace.trace_id),
)
rows.append(row)
return Div(
Div(
Span("Command", cls="mf-profiler-col-header"),
Span("Duration", cls="mf-profiler-col-header mf-profiler-col-right"),
Span("Time", cls="mf-profiler-col-header mf-profiler-col-right"),
cls="mf-profiler-list-header",
),
Div(*rows, cls="mf-profiler-list-body"),
cls="mf-profiler-list",
)
def _mk_detail_placeholder(self):
"""Placeholder shown in the right panel before a trace is selected."""
return Div("Select a trace to view details.", cls="mf-profiler-empty")
# ------------------------------------------------------------------
# Render
# ------------------------------------------------------------------
def render(self):
self._panel.set_main(self._mk_trace_list())
self._panel.set_right(self._mk_detail_placeholder())
return Div(
self._mk_toolbar(),
self._panel,
id=self._id,
cls="mf-profiler",
)
def __ft__(self):
return self.render()

View File

@@ -33,6 +33,13 @@ class ColumnType(Enum):
Formula = "Formula"
class MediaActions(Enum):
Play = "Play"
Pause = "Pause"
Stop = "Stop"
Cancel = "Cancel"
def get_columns_types() -> list[ColumnType]:
return [c for c in ColumnType if not c.value.endswith("_")]

View File

@@ -8,6 +8,7 @@ from contextvars import ContextVar
from dataclasses import dataclass, field
from datetime import datetime
from typing import Optional
from uuid import uuid4
logger = logging.getLogger("Profiler")
@@ -17,19 +18,19 @@ logger = logging.getLogger("Profiler")
# ---------------------------------------------------------------------------
class _NullSpan:
"""No-op span returned when profiler is disabled."""
def set(self, key: str, value) -> '_NullSpan':
return self
def __enter__(self) -> '_NullSpan':
return self
def __exit__(self, *args):
pass
def __call__(self, fn):
return fn
"""No-op span returned when profiler is disabled."""
def set(self, key: str, value) -> '_NullSpan':
return self
def __enter__(self) -> '_NullSpan':
return self
def __exit__(self, *args):
pass
def __call__(self, fn):
return fn
# ---------------------------------------------------------------------------
@@ -38,95 +39,98 @@ class _NullSpan:
@dataclass
class CumulativeSpan:
"""Aggregated span for loops — one entry regardless of iteration count.
"""Aggregated span for loops — one entry regardless of iteration count.
Attributes:
name: Span name.
count: Number of iterations recorded.
total_ms: Cumulative duration in milliseconds.
min_ms: Shortest recorded iteration in milliseconds.
max_ms: Longest recorded iteration in milliseconds.
Attributes:
name: Span name.
count: Number of iterations recorded.
total_ms: Cumulative duration in milliseconds.
min_ms: Shortest recorded iteration in milliseconds.
max_ms: Longest recorded iteration in milliseconds.
"""
name: str
count: int = 0
total_ms: float = 0.0
min_ms: float = float('inf')
max_ms: float = 0.0
@property
def avg_ms(self) -> float:
"""Average duration per iteration in milliseconds."""
return self.total_ms / self.count if self.count > 0 else 0.0
def record(self, duration_ms: float):
"""Record one iteration.
Args:
duration_ms: Duration of this iteration in milliseconds.
"""
name: str
count: int = 0
total_ms: float = 0.0
min_ms: float = float('inf')
max_ms: float = 0.0
@property
def avg_ms(self) -> float:
"""Average duration per iteration in milliseconds."""
return self.total_ms / self.count if self.count > 0 else 0.0
def record(self, duration_ms: float):
"""Record one iteration.
Args:
duration_ms: Duration of this iteration in milliseconds.
"""
self.count += 1
self.total_ms += duration_ms
if duration_ms < self.min_ms:
self.min_ms = duration_ms
if duration_ms > self.max_ms:
self.max_ms = duration_ms
self.count += 1
self.total_ms += duration_ms
if duration_ms < self.min_ms:
self.min_ms = duration_ms
if duration_ms > self.max_ms:
self.max_ms = duration_ms
@dataclass
class ProfilingSpan:
"""A named timing segment.
"""A named timing segment.
Attributes:
name: Span name.
data: Arbitrary metadata attached via span.set().
children: Nested spans and cumulative spans.
duration_ms: Duration of this span in milliseconds (set on finish).
Attributes:
name: Span name.
data: Arbitrary metadata attached via span.set().
children: Nested spans and cumulative spans.
duration_ms: Duration of this span in milliseconds (set on finish).
"""
name: str
data: dict = field(default_factory=dict)
children: list = field(default_factory=list)
_start: float = field(default_factory=time.perf_counter, repr=False)
duration_ms: float = field(default=0.0)
def set(self, key: str, value) -> 'ProfilingSpan':
"""Attach metadata to this span.
Args:
key: Metadata key.
value: Metadata value.
Returns:
Self, for chaining.
"""
name: str
data: dict = field(default_factory=dict)
children: list = field(default_factory=list)
_start: float = field(default_factory=time.perf_counter, repr=False)
duration_ms: float = field(default=0.0)
def set(self, key: str, value) -> 'ProfilingSpan':
"""Attach metadata to this span.
Args:
key: Metadata key.
value: Metadata value.
Returns:
Self, for chaining.
"""
self.data[key] = value
return self
def finish(self):
"""Stop timing and record the duration."""
self.duration_ms = (time.perf_counter() - self._start) * 1000
self.data[key] = value
return self
def finish(self):
"""Stop timing and record the duration."""
self.duration_ms = (time.perf_counter() - self._start) * 1000
@dataclass
class ProfilingTrace:
"""One complete command execution, from route handler to response.
"""One complete command execution, from route handler to response.
Attributes:
command_name: Name of the executed command.
command_id: UUID of the command.
kwargs: Arguments received from the client.
timestamp: When the command was received.
root_span: Top-level span wrapping the full execution.
total_duration_ms: Total server-side duration in milliseconds.
"""
command_name: str
command_id: str
kwargs: dict
timestamp: datetime
root_span: Optional[ProfilingSpan] = None
total_duration_ms: float = 0.0
Attributes:
command_name: Name of the executed command.
command_description: Human-readable description of the command.
command_id: UUID of the command.
kwargs: Arguments received from the client.
timestamp: When the command was received.
root_span: Top-level span wrapping the full execution.
total_duration_ms: Total server-side duration in milliseconds.
"""
command_name: str
command_description: str
command_id: str
kwargs: dict
timestamp: datetime
root_span: Optional[ProfilingSpan] = None
total_duration_ms: float = 0.0
trace_id: str = field(default_factory=lambda: str(uuid4()))
# ---------------------------------------------------------------------------
@@ -134,134 +138,140 @@ class ProfilingTrace:
# ---------------------------------------------------------------------------
class _ActiveSpan:
"""Context manager and decorator for a single named span.
"""Context manager and decorator for a single named span.
When used as a context manager, returns the ProfilingSpan so callers can
attach metadata via ``span.set()``. When used as a decorator, captures
function arguments automatically.
"""
def __init__(self, manager: 'ProfilingManager', name: str, args: dict = None):
self._manager = manager
self._name = name
self._args = args
self._span: Optional[ProfilingSpan] = None
self._token = None
def __enter__(self) -> ProfilingSpan:
if not self._manager.enabled:
return _NullSpan()
overhead_start = time.perf_counter()
self._span = ProfilingSpan(name=self._name)
if self._args:
self._span.data.update(self._args)
self._token = self._manager.push_span(self._span)
self._manager.record_overhead(time.perf_counter() - overhead_start)
return self._span
def __exit__(self, *args):
if self._span is not None:
overhead_start = time.perf_counter()
self._manager.pop_span(self._span, self._token)
self._manager.record_overhead(time.perf_counter() - overhead_start)
def __call__(self, fn):
"""Use as decorator — enabled check deferred to call time."""
manager = self._manager
name = self._name
@functools.wraps(fn)
def wrapper(*args, **kwargs):
if not manager.enabled:
return fn(*args, **kwargs)
captured = manager.capture_args(fn, args, kwargs)
with _ActiveSpan(manager, name, captured):
return fn(*args, **kwargs)
return wrapper
When used as a context manager, returns the ProfilingSpan so callers can
attach metadata via ``span.set()``. When used as a decorator, captures
function arguments automatically.
"""
def __init__(self, manager: 'ProfilingManager', name: str, args: dict = None):
self._manager = manager
self._name = name
self._args = args
self._span: Optional[ProfilingSpan] = None
self._token = None
def __enter__(self) -> ProfilingSpan:
if not self._manager.enabled:
return _NullSpan()
overhead_start = time.perf_counter()
self._span = ProfilingSpan(name=self._name)
if self._args:
self._span.data.update(self._args)
self._token = self._manager.push_span(self._span)
self._manager.record_overhead(time.perf_counter() - overhead_start)
return self._span
def __exit__(self, *args):
if self._span is not None:
overhead_start = time.perf_counter()
self._manager.pop_span(self._span, self._token)
self._manager.record_overhead(time.perf_counter() - overhead_start)
def __call__(self, fn):
"""Use as decorator — enabled check deferred to call time."""
manager = self._manager
name = self._name
@functools.wraps(fn)
def wrapper(*args, **kwargs):
if not manager.enabled:
return fn(*args, **kwargs)
captured = manager.capture_args(fn, args, kwargs)
with _ActiveSpan(manager, name, captured):
return fn(*args, **kwargs)
return wrapper
class _CumulativeActiveSpan:
"""Context manager and decorator for cumulative spans.
"""Context manager and decorator for cumulative spans.
Finds or creates a CumulativeSpan in the current parent and records
each iteration without adding a new child entry.
"""
def __init__(self, manager: 'ProfilingManager', name: str):
self._manager = manager
self._name = name
self._cumulative_span: Optional[CumulativeSpan] = None
self._iter_start: float = 0.0
def _get_or_create(self) -> CumulativeSpan:
parent = self._manager.current_span()
if isinstance(parent, ProfilingSpan):
for child in parent.children:
if isinstance(child, CumulativeSpan) and child.name == self._name:
return child
cumulative_span = CumulativeSpan(name=self._name)
parent.children.append(cumulative_span)
return cumulative_span
return CumulativeSpan(name=self._name)
def __enter__(self) -> CumulativeSpan:
if not self._manager.enabled:
return _NullSpan()
self._cumulative_span = self._get_or_create()
self._iter_start = time.perf_counter()
return self._cumulative_span
def __exit__(self, *args):
if self._cumulative_span is not None:
duration_ms = (time.perf_counter() - self._iter_start) * 1000
self._cumulative_span.record(duration_ms)
def __call__(self, fn):
manager = self._manager
name = self._name
@functools.wraps(fn)
def wrapper(*args, **kwargs):
with _CumulativeActiveSpan(manager, name):
return fn(*args, **kwargs)
return wrapper
Finds or creates a CumulativeSpan in the current parent and records
each iteration without adding a new child entry.
"""
def __init__(self, manager: 'ProfilingManager', name: str):
self._manager = manager
self._name = name
self._cumulative_span: Optional[CumulativeSpan] = None
self._iter_start: float = 0.0
def _get_or_create(self) -> CumulativeSpan:
parent = self._manager.current_span()
if isinstance(parent, ProfilingSpan):
for child in parent.children:
if isinstance(child, CumulativeSpan) and child.name == self._name:
return child
cumulative_span = CumulativeSpan(name=self._name)
parent.children.append(cumulative_span)
return cumulative_span
return CumulativeSpan(name=self._name)
def __enter__(self) -> CumulativeSpan:
if not self._manager.enabled:
return _NullSpan()
self._cumulative_span = self._get_or_create()
self._iter_start = time.perf_counter()
return self._cumulative_span
def __exit__(self, *args):
if self._cumulative_span is not None:
duration_ms = (time.perf_counter() - self._iter_start) * 1000
self._cumulative_span.record(duration_ms)
def __call__(self, fn):
manager = self._manager
name = self._name
@functools.wraps(fn)
def wrapper(*args, **kwargs):
with _CumulativeActiveSpan(manager, name):
return fn(*args, **kwargs)
return wrapper
class _CommandSpan:
"""Context manager that creates both a ProfilingTrace and its root span.
"""Context manager that creates both a ProfilingTrace and its root span.
Used exclusively by the route handler to wrap the full command execution.
"""
def __init__(self, manager: 'ProfilingManager', command_name: str,
command_id: str, kwargs: dict):
self._manager = manager
self._command_name = command_name
self._command_id = command_id
self._kwargs = kwargs
self._trace: Optional[ProfilingTrace] = None
self._span: Optional[ProfilingSpan] = None
self._token = None
def __enter__(self) -> ProfilingSpan:
self._trace = ProfilingTrace(
command_name=self._command_name,
command_id=self._command_id,
kwargs=dict(self._kwargs) if self._kwargs else {},
timestamp=datetime.now(),
)
self._span = ProfilingSpan(name=self._command_name)
self._token = self._manager.push_span(self._span)
return self._span
def __exit__(self, *args):
self._manager.pop_span(self._span, self._token)
self._trace.root_span = self._span
self._trace.total_duration_ms = self._span.duration_ms
self._manager.add_trace(self._trace)
Used exclusively by the route handler to wrap the full command execution.
"""
def __init__(self,
manager: 'ProfilingManager',
command_name: str,
command_description: str,
command_id: str,
kwargs: dict):
self._manager = manager
self._command_name = command_name
self._command_description = command_description
self._command_id = command_id
self._kwargs = kwargs
self._trace: Optional[ProfilingTrace] = None
self._span: Optional[ProfilingSpan] = None
self._token = None
def __enter__(self) -> ProfilingSpan:
self._trace = ProfilingTrace(
command_name=self._command_name,
command_description=self._command_description,
command_id=self._command_id,
kwargs=dict(self._kwargs) if self._kwargs else {},
timestamp=datetime.now(),
)
self._span = ProfilingSpan(name=self._command_name)
self._token = self._manager.push_span(self._span)
return self._span
def __exit__(self, *args):
self._manager.pop_span(self._span, self._token)
self._trace.root_span = self._span
self._trace.total_duration_ms = self._span.duration_ms
self._manager.add_trace(self._trace)
# ---------------------------------------------------------------------------
@@ -269,274 +279,278 @@ class _CommandSpan:
# ---------------------------------------------------------------------------
class ProfilingManager:
"""Global in-memory profiling manager.
"""Global in-memory profiling manager.
All probe mechanisms check ``enabled`` at call time, so the profiler can
be toggled without restarting the server. Use the module-level ``profiler``
singleton rather than instantiating this class directly.
All probe mechanisms check ``enabled`` at call time, so the profiler can
be toggled without restarting the server. Use the module-level ``profiler``
singleton rather than instantiating this class directly.
"""
def __init__(self, max_traces: int = None):
from myfasthtml.core.constants import PROFILER_MAX_TRACES
self.enabled: bool = False
self._traces: deque = deque(maxlen=max_traces or PROFILER_MAX_TRACES)
self._current_span: ContextVar[Optional[ProfilingSpan]] = ContextVar(
'profiler_current_span', default=None
)
self._overhead_samples: list = []
# --- Span lifecycle ---
def push_span(self, span: ProfilingSpan) -> object:
"""Register a span as the current context and attach it to the parent.
Args:
span: The span to activate.
Returns:
A reset token to pass to pop_span().
"""
parent = self._current_span.get()
if isinstance(parent, ProfilingSpan):
parent.children.append(span)
return self._current_span.set(span)
def pop_span(self, span: ProfilingSpan, token: object) -> None:
"""Finish a span and restore the previous context.
def __init__(self, max_traces: int = None):
from myfasthtml.core.constants import PROFILER_MAX_TRACES
self.enabled: bool = False
self._traces: deque = deque(maxlen=max_traces or PROFILER_MAX_TRACES)
self._current_span: ContextVar[Optional[ProfilingSpan]] = ContextVar(
'profiler_current_span', default=None
)
self._overhead_samples: list = []
Args:
span: The span to finish.
token: The reset token returned by push_span().
"""
span.finish()
self._current_span.reset(token)
def add_trace(self, trace: ProfilingTrace) -> None:
"""Add a completed trace to the buffer.
# --- Span lifecycle ---
Args:
trace: The trace to store.
"""
self._traces.appendleft(trace)
def record_overhead(self, duration_s: float) -> None:
"""Record a span boundary overhead sample.
def push_span(self, span: ProfilingSpan) -> object:
"""Register a span as the current context and attach it to the parent.
Args:
duration_s: Duration in seconds of the profiler's own housekeeping.
"""
self._overhead_samples.append(duration_s * 1e6)
if len(self._overhead_samples) > 1000:
self._overhead_samples = self._overhead_samples[-1000:]
@staticmethod
def capture_args(fn, args, kwargs) -> dict:
"""Capture function arguments as a truncated string dict.
Args:
span: The span to activate.
Args:
fn: The function whose signature is inspected.
args: Positional arguments passed to the function.
kwargs: Keyword arguments passed to the function.
Returns:
A reset token to pass to pop_span().
"""
parent = self._current_span.get()
if isinstance(parent, ProfilingSpan):
parent.children.append(span)
return self._current_span.set(span)
Returns:
Dict of parameter names to string-truncated values.
"""
try:
sig = inspect.signature(fn)
bound = sig.bind(*args, **kwargs)
bound.apply_defaults()
params = dict(bound.arguments)
params.pop('self', None)
params.pop('cls', None)
return {k: str(v)[:200] for k, v in params.items()}
except Exception:
return {}
# --- Public interface ---
@property
def traces(self) -> list[ProfilingTrace]:
"""All recorded traces, most recent first."""
return list(self._traces)
def current_span(self) -> Optional[ProfilingSpan]:
"""Return the active span in the current async context.
def pop_span(self, span: ProfilingSpan, token: object) -> None:
"""Finish a span and restore the previous context.
Can be called from anywhere within a span to attach metadata::
Args:
span: The span to finish.
token: The reset token returned by push_span().
"""
span.finish()
self._current_span.reset(token)
profiler.current_span().set("row_count", len(rows))
"""
return self._current_span.get()
def clear(self):
"""Empty the trace buffer."""
self._traces.clear()
logger.debug("Profiler traces cleared.")
# --- Probe mechanisms ---
def span(self, name: str, args: dict = None) -> _ActiveSpan:
"""Context manager and decorator for a named span.
def add_trace(self, trace: ProfilingTrace) -> None:
"""Add a completed trace to the buffer.
The enabled check is deferred to call/enter time, so this can be used
as a static decorator without concern for startup order.
Args:
trace: The trace to store.
"""
self._traces.appendleft(trace)
Args:
name: Span name.
args: Optional metadata to attach immediately.
def record_overhead(self, duration_s: float) -> None:
"""Record a span boundary overhead sample.
Returns:
An object usable as a context manager or decorator.
"""
return _ActiveSpan(self, name, args)
def cumulative_span(self, name: str) -> _CumulativeActiveSpan:
"""Context manager and decorator for loop spans.
Args:
duration_s: Duration in seconds of the profiler's own housekeeping.
"""
self._overhead_samples.append(duration_s * 1e6)
if len(self._overhead_samples) > 1000:
self._overhead_samples = self._overhead_samples[-1000:]
Aggregates all iterations into a single entry (count, total, min, max, avg).
@staticmethod
def capture_args(fn, args, kwargs) -> dict:
"""Capture function arguments as a truncated string dict.
Args:
name: Span name.
Args:
fn: The function whose signature is inspected.
args: Positional arguments passed to the function.
kwargs: Keyword arguments passed to the function.
Returns:
An object usable as a context manager or decorator.
"""
return _CumulativeActiveSpan(self, name)
def command_span(self,
command_name: str,
command_description: str,
command_id: str,
kwargs: dict) -> '_CommandSpan | _NullSpan':
"""Context manager for the route handler.
Returns:
Dict of parameter names to string-truncated values.
"""
try:
sig = inspect.signature(fn)
bound = sig.bind(*args, **kwargs)
bound.apply_defaults()
params = dict(bound.arguments)
params.pop('self', None)
params.pop('cls', None)
return {k: str(v)[:200] for k, v in params.items()}
except Exception:
return {}
Creates a ProfilingTrace and its root span together. When the context
exits, the trace is added to the buffer.
# --- Public interface ---
Args:
command_name: Human-readable command name.
command_description: Human-readable description of the command.
command_id: UUID string of the command.
kwargs: Client arguments received by the route handler.
@property
def traces(self) -> list[ProfilingTrace]:
"""All recorded traces, most recent first."""
return list(self._traces)
Returns:
An object usable as a context manager.
"""
if not self.enabled:
return _NullSpan()
return _CommandSpan(self, command_name, command_description, command_id, kwargs)
def trace_all(self, cls=None, *, exclude: list = None):
"""Class decorator — statically wraps all non-dunder methods with spans.
def current_span(self) -> Optional[ProfilingSpan]:
"""Return the active span in the current async context.
Wrapping happens at class definition time; the enabled check is deferred
to call time via _ActiveSpan.__call__.
Can be called from anywhere within a span to attach metadata::
Args:
cls: The class to instrument (when used without parentheses).
exclude: List of method names to skip.
profiler.current_span().set("row_count", len(rows))
"""
return self._current_span.get()
Usage::
def clear(self):
"""Empty the trace buffer."""
self._traces.clear()
logger.debug("Profiler traces cleared.")
@profiler.trace_all
class MyClass: ...
# --- Probe mechanisms ---
@profiler.trace_all(exclude=["render"])
class MyClass: ...
"""
_exclude = set(exclude or [])
def decorator(klass):
for attr_name, method in inspect.getmembers(klass, predicate=inspect.isfunction):
if attr_name in _exclude:
continue
if attr_name.startswith('__') and attr_name.endswith('__'):
continue
setattr(klass, attr_name, _ActiveSpan(self, attr_name)(method))
return klass
if cls is not None:
return decorator(cls)
return decorator
def trace_calls(self, fn):
"""Function decorator — traces all sub-calls via sys.setprofile().
def span(self, name: str, args: dict = None) -> _ActiveSpan:
"""Context manager and decorator for a named span.
Use for exploration when the bottleneck location is unknown.
sys.setprofile() is scoped to this function's execution only;
the global profiler is restored on exit.
The enabled check is deferred to call/enter time, so this can be used
as a static decorator without concern for startup order.
The root span for ``fn`` itself is created before setprofile is
activated so that profiler internals are not captured as children.
Args:
name: Span name.
args: Optional metadata to attach immediately.
Returns:
An object usable as a context manager or decorator.
"""
return _ActiveSpan(self, name, args)
def cumulative_span(self, name: str) -> _CumulativeActiveSpan:
"""Context manager and decorator for loop spans.
Aggregates all iterations into a single entry (count, total, min, max, avg).
Args:
name: Span name.
Returns:
An object usable as a context manager or decorator.
"""
return _CumulativeActiveSpan(self, name)
def command_span(self, command_name: str, command_id: str,
kwargs: dict) -> '_CommandSpan | _NullSpan':
"""Context manager for the route handler.
Creates a ProfilingTrace and its root span together. When the context
exits, the trace is added to the buffer.
Args:
command_name: Human-readable command name.
command_id: UUID string of the command.
kwargs: Client arguments received by the route handler.
Returns:
An object usable as a context manager.
"""
if not self.enabled:
return _NullSpan()
return _CommandSpan(self, command_name, command_id, kwargs)
def trace_all(self, cls=None, *, exclude: list = None):
"""Class decorator — statically wraps all non-dunder methods with spans.
Wrapping happens at class definition time; the enabled check is deferred
to call time via _ActiveSpan.__call__.
Args:
cls: The class to instrument (when used without parentheses).
exclude: List of method names to skip.
Usage::
@profiler.trace_all
class MyClass: ...
@profiler.trace_all(exclude=["render"])
class MyClass: ...
"""
_exclude = set(exclude or [])
def decorator(klass):
for attr_name, method in inspect.getmembers(klass, predicate=inspect.isfunction):
if attr_name in _exclude:
continue
if attr_name.startswith('__') and attr_name.endswith('__'):
continue
setattr(klass, attr_name, _ActiveSpan(self, attr_name)(method))
return klass
if cls is not None:
return decorator(cls)
return decorator
def trace_calls(self, fn):
"""Function decorator — traces all sub-calls via sys.setprofile().
Use for exploration when the bottleneck location is unknown.
sys.setprofile() is scoped to this function's execution only;
the global profiler is restored on exit.
The root span for ``fn`` itself is created before setprofile is
activated so that profiler internals are not captured as children.
Args:
fn: The function to instrument.
"""
manager = self
@functools.wraps(fn)
def wrapper(*args, **kwargs):
if not manager.enabled:
return fn(*args, **kwargs)
call_stack: list[tuple[ProfilingSpan, object]] = []
# Skip the first call event (fn itself — already represented by root_span)
skip_first = [True]
def _profile(frame, event, arg):
if event == 'call':
if skip_first[0]:
skip_first[0] = False
return
span = ProfilingSpan(name=frame.f_code.co_name)
token = manager.push_span(span)
call_stack.append((span, token))
elif event in ('return', 'exception'):
if call_stack:
span, token = call_stack.pop()
manager.pop_span(span, token)
# Build root span BEFORE activating setprofile so that profiler
# internals (capture_args, ProfilingSpan.__init__, etc.) are not
# captured as children.
captured = manager.capture_args(fn, args, kwargs)
root_span = ProfilingSpan(name=fn.__name__)
root_span.data.update(captured)
root_token = manager.push_span(root_span)
old_profile = sys.getprofile()
sys.setprofile(_profile)
try:
result = fn(*args, **kwargs)
finally:
sys.setprofile(old_profile)
manager.pop_span(root_span, root_token)
return result
return wrapper
# --- Overhead measurement ---
@property
def overhead_per_span_us(self) -> float:
"""Average overhead per span boundary in microseconds."""
if not self._overhead_samples:
return 0.0
return sum(self._overhead_samples) / len(self._overhead_samples)
@property
def total_overhead_ms(self) -> float:
"""Estimated total overhead across all recorded traces."""
total_spans = sum(
self._count_spans(t.root_span) for t in self._traces if t.root_span
)
return (total_spans * self.overhead_per_span_us * 2) / 1000
def _count_spans(self, span: ProfilingSpan) -> int:
if span is None:
return 0
count = 1
for child in span.children:
if isinstance(child, ProfilingSpan):
count += self._count_spans(child)
return count
Args:
fn: The function to instrument.
"""
manager = self
@functools.wraps(fn)
def wrapper(*args, **kwargs):
if not manager.enabled:
return fn(*args, **kwargs)
call_stack: list[tuple[ProfilingSpan, object]] = []
# Skip the first call event (fn itself — already represented by root_span)
skip_first = [True]
def _profile(frame, event, arg):
if event == 'call':
if skip_first[0]:
skip_first[0] = False
return
span = ProfilingSpan(name=frame.f_code.co_name)
token = manager.push_span(span)
call_stack.append((span, token))
elif event in ('return', 'exception'):
if call_stack:
span, token = call_stack.pop()
manager.pop_span(span, token)
# Build root span BEFORE activating setprofile so that profiler
# internals (capture_args, ProfilingSpan.__init__, etc.) are not
# captured as children.
captured = manager.capture_args(fn, args, kwargs)
root_span = ProfilingSpan(name=fn.__name__)
root_span.data.update(captured)
root_token = manager.push_span(root_span)
old_profile = sys.getprofile()
sys.setprofile(_profile)
try:
result = fn(*args, **kwargs)
finally:
sys.setprofile(old_profile)
manager.pop_span(root_span, root_token)
return result
return wrapper
# --- Overhead measurement ---
@property
def overhead_per_span_us(self) -> float:
"""Average overhead per span boundary in microseconds."""
if not self._overhead_samples:
return 0.0
return sum(self._overhead_samples) / len(self._overhead_samples)
@property
def total_overhead_ms(self) -> float:
"""Estimated total overhead across all recorded traces."""
total_spans = sum(
self._count_spans(t.root_span) for t in self._traces if t.root_span
)
return (total_spans * self.overhead_per_span_us * 2) / 1000
def _count_spans(self, span: ProfilingSpan) -> int:
if span is None:
return 0
count = 1
for child in span.children:
if isinstance(child, ProfilingSpan):
count += self._count_spans(child)
return count
# ---------------------------------------------------------------------------

View File

@@ -11,6 +11,7 @@ from rich.table import Table
from starlette.routing import Mount
from myfasthtml.core.constants import Routes, ROUTE_ROOT
from myfasthtml.core.profiler import profiler
from myfasthtml.core.dsl.exceptions import DSLSyntaxError
from myfasthtml.core.dsl.types import Position
from myfasthtml.core.dsls import DslsManager
@@ -379,8 +380,9 @@ def post(session, c_id: str, client_response: dict = None):
command = CommandsManager.get_command(c_id)
if command:
logger.debug(f"Executing command {command.name}.")
return command.execute(client_response)
with profiler.command_span(command.name, command.description, c_id, client_response or {}):
return command.execute(client_response)
raise ValueError(f"Command with ID '{c_id}' not found.")