Added Profiler control with basic UI
This commit is contained in:
17
src/app.py
17
src/app.py
@@ -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")
|
||||
|
||||
177
src/myfasthtml/assets/core/profiler.css
Normal file
177
src/myfasthtml/assets/core/profiler.css
Normal 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);
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
202
src/myfasthtml/controls/Profiler.py
Normal file
202
src/myfasthtml/controls/Profiler.py
Normal 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()
|
||||
@@ -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("_")]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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.")
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user