diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index ff956e9..ff8168d 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -44,4 +44,4 @@ jobs: run: python -m pip install ".[test]" - name: Run Python tests - run: python -m pytest -v tests/python_bindings_tests.py tests/profiler_loader_tests.py + run: python -m pytest -v tests/python_bindings_tests.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 246c13f..eef22e8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -77,7 +77,7 @@ jobs: CIBW_MANYLINUX_X86_64_IMAGE: manylinux_2_28 CIBW_MANYLINUX_AARCH64_IMAGE: manylinux_2_28 CIBW_TEST_COMMAND: >- - python -c "import calf, google.protobuf; from calf.protobuf import calf_trace_pb2; assert calf.__version__ == '${{ needs.release-version.outputs.version }}'; assert calf_trace_pb2.TraceFile() is not None" + python -c "import calf; assert calf.__version__ == '${{ needs.release-version.outputs.version }}'" MACOSX_DEPLOYMENT_TARGET: "15.0" run: python -m cibuildwheel --output-dir wheelhouse diff --git a/README.md b/README.md index b4721d6..478f58f 100644 --- a/README.md +++ b/README.md @@ -25,22 +25,12 @@ writes standard `.perfetto-trace` protobuf streams that open directly in the while `LOG` messages are instant TrackEvents with source locations and an `args` debug annotation. -## CALF Inspector - -CALF includes an interactive Inspector for exploring and analysing structured -trace logs. Its responsive web interface provides call-tree navigation, lazy -trace loading, search, timing details, and aggregated statistics. - -See the [CALF Inspector documentation](profiler/README.md) for installation, -usage and web-server options. - - ## Requirements - C++17 or later - CMake 3.16 or later - Linux for `SyscallLogger` backend -- Python 3.10 or later for the Python bindings and Inspector +- Python 3.10 or later for the Python bindings ## Testing @@ -185,8 +175,7 @@ cmake -S . -B build \ ### Python bindings -The Python package includes bindings for the STL file logger and stdout logger, -as well as the CALF Inspector. +The Python package includes bindings for the STL file logger and stdout logger. #### Installation @@ -263,17 +252,6 @@ calf.StdoutLogger.print("finished") Available colors are `CLI_LEVEL_RESET`, `CLI_LEVEL_STATUS`, `CLI_LEVEL_INFO`, `CLI_LEVEL_WARNING`, and `CLI_LEVEL_ERROR`. -#### Inspector - -Installing the package also installs the bundled CALF Inspector: - -```bash -calf calf_logs -``` - -It can also be launched with `python -m calf`. See the -[CALF Inspector documentation](profiler/README.md) for all options. - ### Server / non-interceptor code Include `StlLogger.h`. Logging is enabled by default on every thread. diff --git a/bindings/calf/__main__.py b/bindings/calf/__main__.py deleted file mode 100644 index ecd85b9..0000000 --- a/bindings/calf/__main__.py +++ /dev/null @@ -1,5 +0,0 @@ -from .profiler.__main__ import main - - -if __name__ == "__main__": - main() diff --git a/bindings/calf/protobuf/__init__.py b/bindings/calf/protobuf/__init__.py deleted file mode 100644 index 225163e..0000000 --- a/bindings/calf/protobuf/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Generated CALF protobuf messages.""" diff --git a/bindings/calf/protobuf/calf_trace_pb2.py b/bindings/calf/protobuf/calf_trace_pb2.py deleted file mode 100644 index 49e3fbc..0000000 --- a/bindings/calf/protobuf/calf_trace_pb2.py +++ /dev/null @@ -1,50 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by the protocol buffer compiler. DO NOT EDIT! -# NO CHECKED-IN PROTOBUF GENCODE -# source: calf/protobuf/calf_trace.proto -# Protobuf Python Version: 6.31.1 -"""Generated protocol buffer code.""" -from google.protobuf import descriptor as _descriptor -from google.protobuf import descriptor_pool as _descriptor_pool -from google.protobuf import runtime_version as _runtime_version -from google.protobuf import symbol_database as _symbol_database -from google.protobuf.internal import builder as _builder -_runtime_version.ValidateProtobufRuntimeVersion( - _runtime_version.Domain.PUBLIC, - 6, - 31, - 1, - '', - 'calf/protobuf/calf_trace.proto' -) -# @@protoc_insertion_point(imports) - -_sym_db = _symbol_database.Default() - - - - -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1e\x63\x61lf/protobuf/calf_trace.proto\x12\x0fperfetto.protos\"5\n\x05Trace\x12,\n\x06packet\x18\x01 \x03(\x0b\x32\x1c.perfetto.protos.TracePacket\"\xce\x01\n\x0bTracePacket\x12\x11\n\ttimestamp\x18\x08 \x01(\x04\x12\"\n\x1atrusted_packet_sequence_id\x18\n \x01(\r\x12\x30\n\x0btrack_event\x18\x0b \x01(\x0b\x32\x1b.perfetto.protos.TrackEvent\x12\x1a\n\x12timestamp_clock_id\x18: \x01(\r\x12:\n\x10track_descriptor\x18< \x01(\x0b\x32 .perfetto.protos.TrackDescriptor\"\xc3\x02\n\nTrackEvent\x12;\n\x11\x64\x65\x62ug_annotations\x18\x04 \x03(\x0b\x32 .perfetto.protos.DebugAnnotation\x12.\n\x04type\x18\t \x01(\x0e\x32 .perfetto.protos.TrackEvent.Type\x12\x12\n\ntrack_uuid\x18\x0b \x01(\x04\x12\x12\n\ncategories\x18\x16 \x03(\t\x12\x0c\n\x04name\x18\x17 \x01(\t\x12\x38\n\x0fsource_location\x18! \x01(\x0b\x32\x1f.perfetto.protos.SourceLocation\"X\n\x04Type\x12\x14\n\x10TYPE_UNSPECIFIED\x10\x00\x12\x14\n\x10TYPE_SLICE_BEGIN\x10\x01\x12\x12\n\x0eTYPE_SLICE_END\x10\x02\x12\x10\n\x0cTYPE_INSTANT\x10\x03\"5\n\x0f\x44\x65\x62ugAnnotation\x12\x14\n\x0cstring_value\x18\x06 \x01(\t\x12\x0c\n\x04name\x18\n \x01(\t\"O\n\x0eSourceLocation\x12\x11\n\tfile_name\x18\x02 \x01(\t\x12\x15\n\rfunction_name\x18\x03 \x01(\t\x12\x13\n\x0bline_number\x18\x04 \x01(\r\"`\n\x0fTrackDescriptor\x12\x0c\n\x04uuid\x18\x01 \x01(\x04\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x31\n\x06thread\x18\x04 \x01(\x0b\x32!.perfetto.protos.ThreadDescriptor\"A\n\x10ThreadDescriptor\x12\x0b\n\x03pid\x18\x01 \x01(\x05\x12\x0b\n\x03tid\x18\x02 \x01(\x03\x12\x13\n\x0bthread_name\x18\x05 \x01(\t') - -_globals = globals() -_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) -_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'calf.protobuf.calf_trace_pb2', _globals) -if not _descriptor._USE_C_DESCRIPTORS: - DESCRIPTOR._loaded_options = None - _globals['_TRACE']._serialized_start=51 - _globals['_TRACE']._serialized_end=104 - _globals['_TRACEPACKET']._serialized_start=107 - _globals['_TRACEPACKET']._serialized_end=313 - _globals['_TRACKEVENT']._serialized_start=316 - _globals['_TRACKEVENT']._serialized_end=639 - _globals['_TRACKEVENT_TYPE']._serialized_start=551 - _globals['_TRACKEVENT_TYPE']._serialized_end=639 - _globals['_DEBUGANNOTATION']._serialized_start=641 - _globals['_DEBUGANNOTATION']._serialized_end=694 - _globals['_SOURCELOCATION']._serialized_start=696 - _globals['_SOURCELOCATION']._serialized_end=775 - _globals['_TRACKDESCRIPTOR']._serialized_start=777 - _globals['_TRACKDESCRIPTOR']._serialized_end=873 - _globals['_THREADDESCRIPTOR']._serialized_start=875 - _globals['_THREADDESCRIPTOR']._serialized_end=940 -# @@protoc_insertion_point(module_scope) diff --git a/profiler/README.md b/profiler/README.md deleted file mode 100644 index 1557050..0000000 --- a/profiler/README.md +++ /dev/null @@ -1,128 +0,0 @@ -

- CALF - CAPIO Logging Facility -

- -# CALF Inspector - -CALF Inspector is an interactive viewer and analyser for structured trace logs -produced by [CALF](../README.md). It provides a responsive browser interface -backed by the CALF trace loader and statistics engine. - -## Features - -- Navigate logs by host, component, and thread. -- Explore nested scopes and events as a call tree. -- Inspect source locations, arguments, timestamps, and durations. -- Search invokers, arguments, and source paths. -- Search every visible web column at once. Serialized `req=` messages are - correlated by their complete normalized request and occurrence order, with a - shared-parameter fallback for older logs; other calls use invoker and time. -- Compare aggregated timing statistics by invoker. -- Review duration percentiles, slow scopes, frequent calls, and costly call paths. -- Export filtered web events as JSON, CSV, or a standalone HTML report. -- Repair and read logs truncated by an abrupt process exit. -- Load web traces one level at a time to limit browser memory usage. - -## Requirements - -- Python 3.10 or later -- A modern browser - -Python dependencies are installed automatically from `pyproject.toml`. - -## Installation - -From the CALF repository root: - -```bash -python -m pip install . -``` - -For development, use an editable installation: - -```bash -python -m pip install -e . -``` - -To install directly from GitHub: - -```bash -python -m pip install "capio-calf @ git+https://github.com/High-Performance-IO/CALF.git" -``` - -Installation provides the `calf` command. - -## Log directory - -Inspector expects the directory structure generated by CALF: - -```text -calf_logs/ -|-- / -| `-- _.perfetto-trace -|-- syscall/ -| `-- / -| `-- .log -`-- stl/ - `-- / - `-- .log -``` - -Each Perfetto file contains all component threads for one process. JSON thread -files are presented independently. Other files are grouped under an `unknown` -backend when possible. - -## Web interface - -Launch the browser interface with: - -```bash -calf calf_logs -``` - -If the directory is omitted, Inspector uses `calf_logs` in the current working -directory. - -Inspector listens on `127.0.0.1:8765` and opens the default browser. The page -starts with one trace column. Each column selects a log directory and provides -tabs for its thread logs, a scrollable call tree, a paginated event table, node -details, server-side search, and aggregated statistics. Use **Add column** to -compare directories or threads side by side. - -The browser initially receives only root trace nodes. Expanding a node requests -only its immediate children. The **Expand level** control advances one loaded -level at a time rather than downloading the complete trace tree. - -### Web options - -| Option | Default | Description | -|----------------|-------------|------------------------------------------| -| `--host HOST` | `127.0.0.1` | Address used by the web server | -| `--port PORT` | `8765` | Port used by the web server | -| `--no-browser` | Disabled | Do not open a browser automatically | - -For remote access, bind to all interfaces and disable automatic browser launch: - -```bash -calf calf_logs --host 0.0.0.0 --port 8765 --no-browser -``` - -The built-in server does not provide authentication or TLS. Expose it only on a -trusted network, or place it behind an authenticated reverse proxy. - -## Trace format - -CALF logs contain a JSON array. Entries are either scopes or events: - -- A scope contains `ts_enter`, `ts_exit`, and a nested `events` array. -- An event contains a single `ts` timestamp. -- Both forms include an `invoker`, source `file`, `line`, and formatted `args`. - -If a process exits while writing, Inspector attempts to close incomplete JSON -structures before parsing the file. Files that cannot be repaired are skipped -and reported during loading. - -## Related documentation - -See the [main CALF README](../README.md) for C++ integration, logger backends, -configuration options, macros, and environment variables. diff --git a/profiler/__init__.py b/profiler/__init__.py deleted file mode 100644 index 1286bd9..0000000 --- a/profiler/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Interactive viewer and analyzer for CALF JSON and Perfetto traces.""" - -from .. import __version__ diff --git a/profiler/__main__.py b/profiler/__main__.py deleted file mode 100644 index 1eb7a50..0000000 --- a/profiler/__main__.py +++ /dev/null @@ -1,58 +0,0 @@ -from __future__ import annotations - -import argparse -import sys - -from .loader import discover_tabs - - -def main() -> None: - parser = argparse.ArgumentParser( - prog="calf", - description=( - "Web inspector and profiler for CALF JSON and Perfetto traces.\n\n" - "Traces are read from a log directory laid out as:\n" - " /syscall//.log\n" - " /stl//.log\n" - " //_.perfetto-trace\n\n" - "Each trace file becomes a tab in the web inspector." - ), - formatter_class=argparse.RawDescriptionHelpFormatter, - ) - parser.add_argument( - "log_dir", - nargs="?", - default="calf_logs", - help="Root log directory (default: calf_logs)", - ) - parser.add_argument( - "--host", default="127.0.0.1", - help="Web server bind address (default: 127.0.0.1)", - ) - parser.add_argument( - "--port", type=int, default=8765, - help="Web server port (default: 8765)", - ) - - args = parser.parse_args() - - # Discover tabs (fast — no file parsing yet) - try: - tabs = discover_tabs(args.log_dir) - except (FileNotFoundError, RuntimeError) as exc: - print(f"error: {exc}", file=sys.stderr) - sys.exit(1) - - total_files = len(tabs) - print(f"Found {len(tabs)} tab(s) across {total_files} file(s):") - - for tab in tabs: - print(f" [READY: {tab.kind:8}] {tab.hostname} tid={tab.tid}") - - print("Trace data will be loaded when selected.") - from .web import run_web - run_web(tabs, args.host, args.port) - - -if __name__ == "__main__": - main() diff --git a/profiler/loader.py b/profiler/loader.py deleted file mode 100644 index d60cd84..0000000 --- a/profiler/loader.py +++ /dev/null @@ -1,369 +0,0 @@ -from __future__ import annotations - -import json -import os -import threading -from dataclasses import dataclass, field -from typing import BinaryIO, Iterable, Iterator, Optional - -from calf.protobuf import calf_trace_pb2 - - -def repair_and_load(path: str) -> list: - """ - Load a CALF JSON file, repairing truncation caused by an abrupt - process exit mid-write. - - Strategy: walk the raw text tracking bracket depth (skipping string - contents and escape sequences), then append the exact closing tokens - that are missing. Falls back to stripping after the last complete - root-level entry if the first attempt still fails to parse. - """ - with open(path, "r", encoding="utf-8", errors="replace") as f: - raw = f.read().rstrip() - - if not raw: - return [] - - depth_stack: list[str] = [] - in_string = False - escape_next = False - - for ch in raw: - if escape_next: - escape_next = False - continue - if ch == "\\" and in_string: - escape_next = True - continue - if ch == '"': - in_string = not in_string - continue - if in_string: - continue - if ch == "{": - depth_stack.append("}") - elif ch == "[": - depth_stack.append("]") - elif ch in ("}", "]"): - if depth_stack and depth_stack[-1] == ch: - depth_stack.pop() - - # If truncation happened inside a string, close it first. - if in_string: - depth_stack.append('"') - - repaired = raw + "".join(reversed(depth_stack)) - - try: - data = json.loads(repaired) - except json.JSONDecodeError as exc: - pos = repaired.rfind("\n }") - if pos != -1: - repaired = repaired[: pos + 4] + "\n]" - try: - data = json.loads(repaired) - except json.JSONDecodeError: - raise RuntimeError(f"Cannot parse {path}: {exc}") from exc - else: - raise RuntimeError(f"Cannot parse {path}: {exc}") from exc - - if isinstance(data, dict): - data = [data] - return data - - -def load_protobuf(path: str) -> list: - """Load a Perfetto trace stream, ignoring an incomplete final packet.""" - return _perfetto_packets_to_entries(_iter_perfetto_packets(path)) - - -def _iter_perfetto_packets(path: str) -> Iterator[calf_trace_pb2.TracePacket]: - with open(path, "rb", buffering=1024 * 1024) as trace_file: - offset = 0 - while field := trace_file.read(1): - if field != b"\x0a": # perfetto.protos.Trace.packet - raise RuntimeError( - f"Cannot parse {path}: invalid protobuf field at byte {offset}" - ) - offset += 1 - length, offset = _read_varint(trace_file, path, offset) - if length is None: - break - payload = trace_file.read(length) - if len(payload) < length: - break - offset += length - packet = calf_trace_pb2.TracePacket() - packet.ParseFromString(payload) - yield packet - - -def _read_varint( - trace_file: BinaryIO, path: str, offset: int -) -> tuple[Optional[int], int]: - value = 0 - shift = 0 - start = offset - while shift < 64: - raw = trace_file.read(1) - if not raw: - return None, offset - byte = raw[0] - offset += 1 - value |= (byte & 0x7F) << shift - if byte < 0x80: - return value, offset - shift += 7 - raise RuntimeError(f"Cannot parse {path}: invalid protobuf varint at byte {start}") - - -def _perfetto_packets_to_entries( - packets: Iterable[calf_trace_pb2.TracePacket], -) -> list: - roots = [] - stacks: dict[int, list[dict]] = {} - - for packet in packets: - if not packet.HasField("track_event"): - continue - event = packet.track_event - stack = stacks.setdefault(event.track_uuid, []) - timestamp_ms = packet.timestamp // 1_000_000 - source = event.source_location - args = next( - ( - annotation.string_value - for annotation in event.debug_annotations - if annotation.name == "args" - ), - "", - ) - - if event.type == calf_trace_pb2.TrackEvent.TYPE_SLICE_BEGIN: - entry = { - "invoker": event.name or source.function_name or "?", - "args": args, - "file": source.file_name, - "line": source.line_number, - "ts_enter": timestamp_ms, - "ts_exit": None, - "events": [], - } - (stack[-1]["events"] if stack else roots).append(entry) - stack.append(entry) - elif event.type == calf_trace_pb2.TrackEvent.TYPE_INSTANT: - entry = { - "invoker": event.name or source.function_name or "?", - "args": args, - "file": source.file_name, - "line": source.line_number, - "ts": timestamp_ms, - } - (stack[-1]["events"] if stack else roots).append(entry) - elif event.type == calf_trace_pb2.TrackEvent.TYPE_SLICE_END and stack: - stack.pop()["ts_exit"] = timestamp_ms - - return roots - - -def load_trace(path: str) -> list: - if path.lower().endswith(".perfetto-trace"): - return load_protobuf(path) - return repair_and_load(path) - - -@dataclass -class TraceNode: - """ - One node in the trace tree. - - Scope nodes — have ts_enter / ts_exit and a children list. - Leaf nodes — have ts only; they are LOG() events with no children. - """ - invoker: str - args: str - file: str - line: int - ts_enter: Optional[int] - ts_exit: Optional[int] - ts: Optional[int] - children: list[TraceNode] - is_leaf: bool - depth: int = 0 - parent: Optional[TraceNode] = field(default=None, repr=False) - - @property - def duration_ms(self) -> Optional[int]: - if self.ts_enter is not None and self.ts_exit is not None: - return self.ts_exit - self.ts_enter - return None - - @property - def timestamp(self) -> Optional[int]: - return self.ts if self.is_leaf else self.ts_enter - - @property - def short_file(self) -> str: - """Last two path components, e.g. server/client_manager.cpp""" - parts = self.file.replace("\\", "/").split("/") - return "/".join(parts[-2:]) if len(parts) >= 2 else self.file - - -def build_tree(entries: list, depth: int = 0, - parent: Optional[TraceNode] = None) -> list[TraceNode]: - nodes: list[TraceNode] = [] - for entry in entries: - is_scope = "ts_enter" in entry - children_raw = entry.get("events", []) - node = TraceNode( - invoker = entry.get("invoker", "?"), - args = entry.get("args", ""), - file = entry.get("file", ""), - line = entry.get("line", 0), - ts_enter = entry.get("ts_enter"), - ts_exit = entry.get("ts_exit"), - ts = entry.get("ts"), - children = [], - is_leaf = not is_scope, - depth = depth, - parent = parent, - ) - node.children = build_tree(children_raw, depth + 1, node) - nodes.append(node) - return nodes - - -def flatten(nodes: list[TraceNode]) -> list[TraceNode]: - """Return all nodes in the tree (depth-first), regardless of expansion.""" - result: list[TraceNode] = [] - for node in nodes: - result.append(node) - result.extend(flatten(node.children)) - return result - -def compute_stats(roots: list[TraceNode]) -> list[dict]: - """ - Walk the full tree and compute per-invoker timing statistics. - Returns a list of dicts sorted by total_ms descending. - """ - from collections import defaultdict - import numpy as np - - buckets: dict[str, list[int]] = defaultdict(list) - - def walk(nodes: list[TraceNode]) -> None: - for n in nodes: - if not n.is_leaf and n.duration_ms is not None: - buckets[n.invoker].append(n.duration_ms) - walk(n.children) - - walk(roots) - - rows = [] - for invoker, times in buckets.items(): - arr = np.array(times, dtype=float) - rows.append({ - "invoker": invoker, - "count": len(times), - "total_ms": int(arr.sum()), - "mean_ms": float(arr.mean()), - "median_ms": float(np.median(arr)), - "max_ms": int(arr.max()), - "min_ms": int(arr.min()), - "std_ms": float(arr.std()), - }) - - rows.sort(key=lambda r: r["total_ms"], reverse=True) - return rows - -@dataclass -class TraceTab: - """ - One tab = one log file. - - JSON tabs represent one thread. Perfetto tabs contain every thread for a - component on a host. - """ - hostname: str - kind: str # "syscall", "stl", or ... - path: str # path to the single .log or Perfetto file this tab represents - _roots: Optional[list[TraceNode]] = field(default=None, repr=False) - _load_lock: threading.Lock = field(default_factory=threading.Lock, repr=False) - - @property - def tid(self) -> str: - """Thread-id portion of the filename, e.g. '44623' from 'stl_44623.log'.""" - if self.path.lower().endswith(".perfetto-trace"): - stem = os.path.basename(self.path)[: -len(".perfetto-trace")] - _, separator, pid = stem.rpartition("_") - return f"process {pid} (all threads)" if separator and pid.isdigit() else "all threads" - stem = os.path.splitext(os.path.basename(self.path))[0] - # Strip the kind prefix if present (stl_44623 -> 44623) - if "_" in stem: - return stem.rsplit("_", 1)[-1] - return stem - - @property - def label(self) -> str: - return f"{self.kind} / {self.hostname} / {self.tid}" - - @property - def roots(self) -> list[TraceNode]: - if self._roots is None: - with self._load_lock: - if self._roots is None: - try: - data = load_trace(self.path) - self._roots = build_tree(data) - except Exception: - self._roots = [] - return self._roots - - @property - def total_nodes(self) -> int: - return len(flatten(self.roots)) - - -def discover_tabs(log_dir: str) -> list[TraceTab]: - """ - Walk log_dir and create one TraceTab per .log or .perfetto-trace file. - - Expected CALF layout: - /syscall//.log - /stl//.log - //_.perfetto-trace - """ - if not os.path.isdir(log_dir): - raise FileNotFoundError(f"Log directory not found: {log_dir!r}") - - tabs: list[TraceTab] = [] - - for root, dirs, files in os.walk(log_dir): - dirs.sort() - for fname in sorted(files): - if not fname.lower().endswith((".log", ".perfetto-trace")): - continue - full = os.path.join(root, fname) - rel = os.path.relpath(full, log_dir).replace("\\", "/") - parts = rel.split("/") - - if fname.lower().endswith(".perfetto-trace"): - hostname = os.path.basename(root) or "unknown" - stem = fname[: -len(".perfetto-trace")] - component, separator, pid = stem.rpartition("_") - kind = component if separator and pid.isdigit() else stem or "unknown" - elif len(parts) == 3: - kind, hostname = parts[0], parts[1] - else: - hostname = os.path.basename(root) or "unknown" - kind = "unknown" - - tabs.append(TraceTab(hostname=hostname, kind=kind, path=full)) - - if not tabs: - raise RuntimeError(f"No .log or .perfetto-trace files found under {log_dir!r}") - - order = {"syscall": 0, "stl": 1, "unknown": 2} - tabs.sort(key=lambda t: (order.get(t.kind, 9), t.hostname, t.tid)) - return tabs diff --git a/profiler/web.py b/profiler/web.py deleted file mode 100644 index 9874bdd..0000000 --- a/profiler/web.py +++ /dev/null @@ -1,639 +0,0 @@ -from __future__ import annotations - -import csv -import html -import io -import json -import mimetypes -import re -import threading -import webbrowser -from collections import Counter, defaultdict -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -from pathlib import Path -from typing import Optional -from urllib.parse import parse_qs, urlparse - -from .loader import TraceNode, TraceTab, compute_stats, flatten - - -_ASSET_DIR = Path(__file__).with_name("web") -_ASSETS = { - "/": "index.html", - "/index.html": "index.html", - "/app.js": "app.js", - "/style.css": "style.css", - "/calf.svg": "calf.svg", -} - - -def _time_range(nodes: list[TraceNode]) -> tuple[Optional[int], Optional[int]]: - timestamps = [ - value - for node in nodes - for value in (node.ts, node.ts_enter, node.ts_exit) - if value is not None - ] - if not timestamps: - return None, None - return min(timestamps), max(timestamps) - - -def _tab_summary(index: int, tab: TraceTab) -> dict: - nodes = flatten(tab.roots) - scopes = sum(not node.is_leaf for node in nodes) - start, end = _time_range(nodes) - return { - "id": index, - "hostname": tab.hostname, - "kind": tab.kind, - "tid": tab.tid, - "file": Path(tab.path).name, - "nodes": len(nodes), - "scopes": scopes, - "events": len(nodes) - scopes, - "start_ms": start, - "end_ms": end, - "window_ms": end - start if start is not None and end is not None else None, - } - - -def _tab_metadata(index: int, tab: TraceTab) -> dict: - return { - "id": index, - "hostname": tab.hostname, - "kind": tab.kind, - "tid": tab.tid, - "file": Path(tab.path).name, - "nodes": None, - "scopes": None, - "events": None, - "start_ms": None, - "end_ms": None, - "window_ms": None, - } - - -def _serialize_node(node: TraceNode, node_id: str) -> dict: - return { - "id": node_id, - "invoker": node.invoker, - "args": node.args, - "file": node.short_file, - "line": node.line, - "depth": node.depth, - "is_event": node.is_leaf, - "duration_ms": node.duration_ms, - "timestamp_ms": node.timestamp, - "enter_ms": node.ts_enter, - "exit_ms": node.ts_exit, - "child_count": len(node.children), - "request": _request_identity(node.args), - "linked_requests": _linked_requests(node, node_id), - } - - -def _serialize_level(nodes: list[TraceNode], parent_id: str = "") -> list[dict]: - return [ - _serialize_node(node, f"{parent_id}.{index}" if parent_id else str(index)) - for index, node in enumerate(nodes) - ] - - -def _request_terms(args: str) -> tuple[str, ...]: - values = re.findall(r"=([^,)]+)", args) - terms: list[str] = [] - for value in values: - for term in value.strip().split(): - normalized = term.strip("'\"") - if re.fullmatch(r"\d{4}", normalized) or normalized in {"0", "1"}: - continue - if normalized and not normalized.startswith("0x"): - terms.append(normalized.casefold()) - return tuple(terms) - - -def _request_identity(args: str) -> Optional[str]: - match = re.search(r"(?:^|[\s(])req=([^)]*)", args) - if not match: - return None - return " ".join(match.group(1).strip().casefold().split()) - - -def _request_surface_id(node: TraceNode, node_id: str) -> str: - chain: list[TraceNode] = [] - current: Optional[TraceNode] = node - while current is not None: - chain.append(current) - current = current.parent - parts = node_id.split(".") - for candidate in reversed(chain): - if len(_request_entries(candidate, "", limit=2)) == 1: - if candidate.parent is None and node.depth == 1: - continue - return ".".join(parts[: candidate.depth + 1]) - return node_id - - -def _linked_requests(node: TraceNode, node_id: str) -> list[dict]: - requests = _request_entries(node, node_id, limit=2) - if len(requests) != 1: - return [] - request = requests[0] - current = node - base_depth = len(node_id.split(".")) - for index in request["id"].split(".")[base_depth:]: - current = current.children[int(index)] - return requests if _request_surface_id(current, request["id"]) == node_id else [] - - -def _request_entries( - node: TraceNode, node_id: str, limit: int = 2 -) -> list[dict]: - requests: list[dict] = [] - - identity = _request_identity(node.args) - if identity: - requests.append({"id": node_id, "request": identity}) - - def walk(children: list[TraceNode], parent_id: str) -> None: - for index, child in enumerate(children): - if len(requests) >= limit: - return - child_id = f"{parent_id}.{index}" - child_identity = _request_identity(child.args) - if child_identity: - requests.append({"id": child_id, "request": child_identity}) - walk(child.children, child_id) - - walk(node.children, node_id) - return requests - - -def _is_request_node(node: TraceNode) -> bool: - return ( - "req=" in node.args - or "request" in node.invoker.casefold() - or node.file.replace("\\", "/").endswith("/requests.hpp") - ) - - -def _request_anchor(node: TraceNode) -> TraceNode: - current = node - while current is not None: - if _is_request_node(current): - return current - current = current.parent - return node - - -def _request_score( - terms: tuple[str, ...], candidate: TraceNode, identity: Optional[str] = None -) -> float: - if not terms or not _is_request_node(candidate): - return 0 - candidate_identity = _request_identity(candidate.args) - if identity and candidate_identity: - return 1 if identity == candidate_identity else 0 - source = set(terms) - target = set(_request_terms(candidate.args)) - if not target: - return 0 - score = len(source & target) / len(source | target) - return score if not identity else score * 0.9 - - -def _node_at(roots: list[TraceNode], node_id: str) -> Optional[TraceNode]: - try: - indexes = [int(part) for part in node_id.split(".")] - if not indexes or any(index < 0 for index in indexes): - return None - node = roots[indexes[0]] - for index in indexes[1:]: - node = node.children[index] - return node - except (ValueError, IndexError): - return None - - -def _search_nodes(roots: list[TraceNode], query: str, limit: int = 500) -> list[dict]: - matches: list[dict] = [] - needle = query.casefold() - request_terms = _request_terms(query) if "req=" in needle else () - request_identity = _request_identity(query) - - def walk(nodes: list[TraceNode], parent_id: str = "") -> None: - for index, node in enumerate(nodes): - if len(matches) >= limit: - return - node_id = f"{parent_id}.{index}" if parent_id else str(index) - haystack = f"{node.invoker} {node.args} {node.file}".casefold() - request_match = ( - request_terms - and _request_score(request_terms, node, request_identity) >= 0.6 - ) - if needle in haystack or request_match: - matches.append(_serialize_node(node, node_id)) - walk(node.children, node_id) - - walk(roots) - return matches - - -def _event_nodes(roots: list[TraceNode], query: str = "") -> list[dict]: - events: list[dict] = [] - needle = query.casefold() - - def walk(nodes: list[TraceNode], parent_id: str = "") -> None: - for index, node in enumerate(nodes): - node_id = f"{parent_id}.{index}" if parent_id else str(index) - if node.is_leaf: - haystack = f"{node.invoker} {node.args} {node.file}".casefold() - if not needle or needle in haystack: - event = _serialize_node(node, node_id) - event["context"] = node.parent.invoker if node.parent else "Root" - events.append(event) - walk(node.children, node_id) - - walk(roots) - events.sort( - key=lambda event: ( - event["timestamp_ms"] if event["timestamp_ms"] is not None else -1 - ) - ) - return events - - -def _indexed_nodes( - roots: list[TraceNode], -) -> list[tuple[TraceNode, str, tuple[str, ...]]]: - indexed: list[tuple[TraceNode, str, tuple[str, ...]]] = [] - - def walk( - nodes: list[TraceNode], parent_id: str = "", path: tuple[str, ...] = () - ) -> None: - for index, node in enumerate(nodes): - node_id = f"{parent_id}.{index}" if parent_id else str(index) - node_path = (*path, node.invoker) - indexed.append((node, node_id, node_path)) - walk(node.children, node_id, node_path) - - walk(roots) - return indexed - - -def _percentile(values: list[int], percentile: int) -> Optional[float]: - if not values: - return None - ordered = sorted(values) - position = (len(ordered) - 1) * percentile / 100 - lower = int(position) - upper = min(lower + 1, len(ordered) - 1) - fraction = position - lower - return round(ordered[lower] + (ordered[upper] - ordered[lower]) * fraction, 1) - - -def _trace_analysis(roots: list[TraceNode]) -> dict: - indexed = _indexed_nodes(roots) - scopes = [(node, node_id, path) for node, node_id, path in indexed if not node.is_leaf] - durations = [node.duration_ms for node, _, _ in scopes if node.duration_ms is not None] - slowest = sorted( - ((node, node_id, path) for node, node_id, path in scopes if node.duration_ms is not None), - key=lambda item: item[0].duration_ms or 0, - reverse=True, - )[:10] - - counts = Counter( - (node.invoker, "event" if node.is_leaf else "scope") - for node, _, _ in indexed - ) - frequent = [ - { - "invoker": invoker, - "count": count, - "kind": kind, - } - for (invoker, kind), count in counts.most_common(10) - ] - - paths: dict[str, dict[str, int]] = defaultdict(lambda: {"count": 0, "total_ms": 0}) - for node, _, path in scopes: - if node.duration_ms is None: - continue - label = " > ".join(path) - paths[label]["count"] += 1 - paths[label]["total_ms"] += node.duration_ms - call_paths = [ - {"path": path, **values} - for path, values in sorted( - paths.items(), key=lambda item: item[1]["total_ms"], reverse=True - )[:10] - ] - - return { - "percentiles": { - f"p{percentile}": _percentile(durations, percentile) - for percentile in (50, 90, 95, 99) - }, - "slowest": [ - {**_serialize_node(node, node_id), "path": " > ".join(path)} - for node, node_id, path in slowest - ], - "frequent": frequent, - "call_paths": call_paths, - } - - -def _export_events(tab: TraceTab, summary: dict, query: str, format_name: str) -> tuple[bytes, str, str]: - events = _event_nodes(tab.roots, query) - stem = f"{summary['kind']}-{summary['hostname']}-{summary['tid']}-events" - if format_name == "json": - body = json.dumps({"summary": summary, "query": query, "events": events}, indent=2) - return body.encode("utf-8"), "application/json", f"{stem}.json" - if format_name == "csv": - output = io.StringIO() - writer = csv.writer(output) - writer.writerow(["timestamp_ms", "event", "details", "context", "source", "line"]) - for event in events: - writer.writerow([ - event["timestamp_ms"], event["invoker"], event["args"], - event["context"], event["file"], event["line"], - ]) - return output.getvalue().encode("utf-8"), "text/csv", f"{stem}.csv" - if format_name == "html": - rows = "".join( - "" - f"{html.escape(str(event['timestamp_ms']))}" - f"{html.escape(event['invoker'])}" - f"{html.escape(event['args'])}" - f"{html.escape(event['context'])}" - f"{html.escape(event['file'])}:{event['line']}" - "" - for event in events - ) - title = html.escape(f"{summary['kind']} / {summary['hostname']} / {summary['tid']}") - document = f""" -{title} - -

{title}

{len(events)} events{f' matching {html.escape(query)}' if query else ''}

-{rows}
Time (ms)EventDetailsContextSource
""" - return document.encode("utf-8"), "text/html", f"{stem}.html" - raise ValueError("Unknown export format") - - -def create_web_server( - tabs: list[TraceTab], host: str = "127.0.0.1", port: int = 8765 -) -> ThreadingHTTPServer: - summaries = [_tab_metadata(index, tab) for index, tab in enumerate(tabs)] - - class TraceHandler(BaseHTTPRequestHandler): - def _send_json(self, payload: object, status: int = 200) -> None: - body = json.dumps(payload, separators=(",", ":")).encode("utf-8") - self.send_response(status) - self.send_header("Content-Type", "application/json; charset=utf-8") - self.send_header("Content-Length", str(len(body))) - self.send_header("Cache-Control", "no-store") - self.end_headers() - self.wfile.write(body) - - def _send_asset(self, name: str) -> None: - path = _ASSET_DIR / name - try: - body = path.read_bytes() - except OSError: - self.send_error(404) - return - content_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream" - self.send_response(200) - self.send_header("Content-Type", f"{content_type}; charset=utf-8") - self.send_header("Content-Length", str(len(body))) - self.send_header("Cache-Control", "no-store") - self.end_headers() - self.wfile.write(body) - - def _send_download(self, body: bytes, content_type: str, filename: str) -> None: - self.send_response(200) - self.send_header("Content-Type", f"{content_type}; charset=utf-8") - self.send_header("Content-Disposition", f'attachment; filename="{filename}"') - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.wfile.write(body) - - def do_GET(self) -> None: - parsed = urlparse(self.path) - if parsed.path == "/api/overview": - self._send_json({"tabs": summaries}) - return - if parsed.path == "/api/trace": - try: - index = int(parse_qs(parsed.query).get("id", [""])[0]) - tab = tabs[index] - if index < 0: - raise IndexError - except (ValueError, IndexError): - self._send_json({"error": "Unknown trace"}, status=404) - return - roots = tab.roots - summary = _tab_summary(index, tab) - summaries[index] = summary - self._send_json({ - "summary": summary, - "roots": _serialize_level(roots), - "stats": compute_stats(roots), - "analysis": _trace_analysis(roots), - }) - return - if parsed.path == "/api/children": - query = parse_qs(parsed.query) - try: - index = int(query.get("trace", [""])[0]) - tab = tabs[index] - if index < 0: - raise IndexError - except (ValueError, IndexError): - self._send_json({"error": "Unknown trace"}, status=404) - return - node_id = query.get("node", [""])[0] - node = _node_at(tab.roots, node_id) - if node is None: - self._send_json({"error": "Unknown node"}, status=404) - return - self._send_json({"nodes": _serialize_level(node.children, node_id)}) - return - if parsed.path == "/api/search": - query = parse_qs(parsed.query) - try: - index = int(query.get("trace", [""])[0]) - tab = tabs[index] - if index < 0: - raise IndexError - except (ValueError, IndexError): - self._send_json({"error": "Unknown trace"}, status=404) - return - term = query.get("q", [""])[0].strip() - matches = _search_nodes(tab.roots, term) if term else [] - self._send_json({"matches": matches, "limited": len(matches) >= 500}) - return - if parsed.path == "/api/events": - query = parse_qs(parsed.query) - try: - index = int(query.get("trace", [""])[0]) - offset = max(0, int(query.get("offset", ["0"])[0])) - limit = min(1000, max(1, int(query.get("limit", ["250"])[0]))) - tab = tabs[index] - if index < 0: - raise IndexError - except (ValueError, IndexError): - self._send_json({"error": "Invalid event query"}, status=404) - return - events = _event_nodes(tab.roots, query.get("q", [""])[0].strip()) - self._send_json({ - "events": events[offset:offset + limit], - "total": len(events), - "offset": offset, - }) - return - if parsed.path == "/api/correlate": - query = parse_qs(parsed.query) - try: - source_index = int(query.get("source", [""])[0]) - indexes = [ - int(value) for value in query.get("traces", [""])[0].split(",") - if value - ] - if ( - source_index < 0 - or source_index >= len(tabs) - or not indexes - or any(index < 0 or index >= len(tabs) for index in indexes) - ): - raise ValueError - except ValueError: - self._send_json({"error": "Invalid correlation query"}, status=400) - return - source_node = _node_at( - tabs[source_index].roots, query.get("node", [""])[0] - ) - if source_node is None: - self._send_json({"error": "Unknown source node"}, status=404) - return - source_node = _request_anchor(source_node) - terms = _request_terms(source_node.args) - identity = _request_identity(source_node.args) - request_mode = _is_request_node(source_node) and bool(terms) - - source_equivalents = [ - node - for node, _, _ in _indexed_nodes(tabs[source_index].roots) - if _is_request_node(node) - and ( - _request_identity(node.args) == identity - if identity - else set(_request_terms(node.args)) == set(terms) - ) - ] - source_ordinal = next( - ( - index - for index, node in enumerate(source_equivalents) - if node is source_node - ), - 0, - ) - matches = [] - for index in indexes: - if index == source_index: - continue - indexed = _indexed_nodes(tabs[index].roots) - if request_mode: - scored = [ - (_request_score(terms, node, identity), node, node_id) - for node, node_id, _ in indexed - if _is_request_node(node) - ] - best_score = max((item[0] for item in scored), default=0) - candidates = [ - (node, node_id) - for score, node, node_id in scored - if score == best_score and score >= 0.6 - ] - else: - best_score = 1 - candidates = [ - (node, node_id) - for node, node_id, _ in indexed - if node.invoker == source_node.invoker - and node.timestamp is not None - ] - if not candidates: - continue - if request_mode: - node, node_id = candidates[min(source_ordinal, len(candidates) - 1)] - else: - node, node_id = min( - candidates, - key=lambda item: abs( - (item[0].timestamp or 0) - (source_node.timestamp or 0) - ), - ) - matches.append({ - "trace": index, - "delta_ms": ( - None - if request_mode - else (node.timestamp or 0) - (source_node.timestamp or 0) - ), - "score": round(best_score, 2), - "node": _serialize_node(node, node_id), - }) - self._send_json({ - "mode": "request" if request_mode else "invoker", - "request": identity, - "terms": list(dict.fromkeys(terms)), - "matches": matches, - }) - return - if parsed.path == "/api/export": - query = parse_qs(parsed.query) - try: - index = int(query.get("trace", [""])[0]) - tab = tabs[index] - if index < 0: - raise IndexError - body, content_type, filename = _export_events( - tab, - _tab_summary(index, tab), - query.get("q", [""])[0].strip(), - query.get("format", ["json"])[0], - ) - except (ValueError, IndexError): - self._send_json({"error": "Invalid export query"}, status=400) - return - self._send_download(body, content_type, filename) - return - asset = _ASSETS.get(parsed.path) - if asset is not None: - self._send_asset(asset) - return - self.send_error(404) - - def log_message(self, format: str, *args: object) -> None: - return - - return ThreadingHTTPServer((host, port), TraceHandler) - - -def run_web( - tabs: list[TraceTab], host: str = "127.0.0.1", port: int = 8765 -) -> None: - server = create_web_server(tabs, host, port) - bound_host, bound_port = server.server_address[:2] - browser_host = "127.0.0.1" if bound_host in ("0.0.0.0", "::") else bound_host - print(f"CALF web explorer: http://{browser_host}:{bound_port}") - print("Press Ctrl-C to stop.") - try: - server.serve_forever() - except KeyboardInterrupt: - pass - finally: - server.server_close() diff --git a/profiler/web/app.js b/profiler/web/app.js deleted file mode 100644 index 0028cd3..0000000 --- a/profiler/web/app.js +++ /dev/null @@ -1,648 +0,0 @@ -const workspace = { tabs: [], columns: [], nextColumnId: 1 }; -const $ = (selector, root = document) => root.querySelector(selector); -const columnThemes = [ - ["#315e57", "#e7f0ee"], ["#35679a", "#e8f0f8"], ["#76539a", "#f0eaf6"], - ["#a56324", "#f8eee3"], ["#a64458", "#f8e9ed"], ["#36775a", "#e7f3ec"], -]; -let activeConnection = null; - -function correlationOverlay() { - let overlay = $("#correlation-overlay"); - if (overlay) return overlay; - overlay = document.createElementNS("http://www.w3.org/2000/svg", "svg"); - overlay.id = "correlation-overlay"; - overlay.innerHTML = ''; - const dismiss = event => { - event.preventDefault(); event.stopPropagation(); hideCorrelationArrow(); - }; - $(".correlation-line", overlay).addEventListener("click", dismiss); - $(".correlation-hit", overlay).addEventListener("click", dismiss); - document.body.append(overlay); - return overlay; -} - -function hideCorrelationArrow() { - activeConnection?.observer?.disconnect(); - activeConnection?.hiddenButtons?.forEach(button => button.hidden = false); - document.querySelectorAll(".connection-close").forEach(button => button.remove()); - document.querySelectorAll(".correlation-jump:not(.connection-close)").forEach(button => button.hidden = false); - activeConnection = null; - const overlay = $("#correlation-overlay"); - if (overlay) overlay.remove(); -} - -function drawCorrelationArrow() { - if (!activeConnection) return; - const { sourceRow, targetRow } = activeConnection; - if (!rowIsVisible(sourceRow) || !rowIsVisible(targetRow)) { - hideCorrelationArrow(); return; - } - const source = sourceRow.getBoundingClientRect(); - const target = targetRow.getBoundingClientRect(); - const leftToRight = source.left < target.left; - const x1 = leftToRight ? source.right : source.left; - const x2 = leftToRight ? target.left : target.right; - const y1 = source.top + source.height / 2; - const y2 = target.top + target.height / 2; - const bend = Math.max(50, Math.abs(x2 - x1) * 0.45); - const direction = leftToRight ? 1 : -1; - const path = `M ${x1} ${y1} C ${x1 + bend * direction} ${y1}, ${x2 - bend * direction} ${y2}, ${x2} ${y2}`; - const overlay = correlationOverlay(); - const gradient = $("#correlation-gradient", overlay); - gradient.setAttribute("x1", x1); gradient.setAttribute("y1", y1); - gradient.setAttribute("x2", x2); gradient.setAttribute("y2", y2); - const sourceColor = getComputedStyle(sourceRow.closest(".trace-column")).getPropertyValue("--accent").trim(); - const targetColor = getComputedStyle(targetRow.closest(".trace-column")).getPropertyValue("--accent").trim(); - $("stop:first-child", gradient).setAttribute("stop-color", sourceColor); - $("stop:last-child", gradient).setAttribute("stop-color", targetColor); - $("#correlation-arrowhead path", overlay).setAttribute("fill", targetColor); - $(".correlation-line", overlay).setAttribute("d", path); - $(".correlation-hit", overlay).setAttribute("d", path); - overlay.hidden = false; -} - -function rowIsVisible(row) { - if (!row.isConnected || !row.offsetParent) return false; - const rect = row.getBoundingClientRect(); - if (rect.bottom <= 0 || rect.top >= window.innerHeight || rect.right <= 0 || rect.left >= window.innerWidth) return false; - const scroller = row.closest(".tree, .event-table-wrap"); - if (!scroller) return true; - const clip = scroller.getBoundingClientRect(); - return rect.bottom > clip.top && rect.top < clip.bottom && rect.right > clip.left && rect.left < clip.right; -} - -function addConnectionClose(row) { - const host = row.matches("tr") ? row.lastElementChild : row; - const button = document.createElement("button"); - button.className = "correlation-jump connection-close"; - button.textContent = "Close connection"; - button.addEventListener("click", event => { - event.preventDefault(); event.stopPropagation(); hideCorrelationArrow(); - }); - host.append(button); -} - -function showCorrelationArrow(sourceRow, targetRow, source, target, sourceTrace, targetTrace) { - const observer = new IntersectionObserver(entries => { - if (entries.some(entry => !entry.isIntersecting)) hideCorrelationArrow(); - }, { threshold: 0.01 }); - observer.observe(sourceRow); observer.observe(targetRow); - const hiddenButtons = [ - ...source.root.querySelectorAll(`.correlation-jump[data-trace-id="${targetTrace}"]`), - ...target.root.querySelectorAll(`.correlation-jump[data-trace-id="${sourceTrace}"]`), - ]; - hiddenButtons.forEach(button => button.hidden = true); - activeConnection = { sourceRow, targetRow, source, target, sourceTrace, targetTrace, observer, hiddenButtons }; - addConnectionClose(sourceRow); addConnectionClose(targetRow); - requestAnimationFrame(() => requestAnimationFrame(drawCorrelationArrow)); -} - -function ms(value) { - if (value === null || value === undefined) return "--"; - return value >= 1000 ? `${(value / 1000).toFixed(2)}s` : `${value}ms`; -} - -function durationClass(value) { - if (value === null || value === undefined) return ""; - if (value < 10) return "time-fast"; - if (value < 100) return "time-warm"; - if (value < 1000) return "time-slow"; - return "time-hot"; -} - -function metric(label, value) { - const item = document.createElement("div"); item.className = "metric"; - const small = document.createElement("small"); small.textContent = label; - const strong = document.createElement("strong"); strong.textContent = value; - item.append(small, strong); return item; -} - -function createColumn(kind, initialTraceId = null, pendingNode = null) { - const column = { - id: workspace.nextColumnId++, kind, trace: null, nodes: new Map(), matches: [], - match: -1, searchVersion: 0, loadVersion: 0, view: "tree", eventOffset: 0, - eventTotal: 0, correlationVersion: 0, selectedNode: null, pendingNode, - requestLinks: new Set(), root: document.createElement("section"), - }; - column.root.className = "trace-column"; - const theme = columnThemes[(column.id - 1) % columnThemes.length]; - column.root.style.setProperty("--accent", theme[0]); - column.root.style.setProperty("--accent-soft", theme[1]); - column.root.innerHTML = ` -
- - -
- -
No trace selected

Loading...

-
-
- - -
- -
Export
-
-
-
Call treeevent fast hot
- - -
`; - - const directories = [...new Set(workspace.tabs.map(tab => tab.kind))]; - const select = $(".directory-select", column.root); - directories.forEach(directory => { - const option = document.createElement("option"); option.value = directory; option.textContent = directory; select.append(option); - }); - select.value = kind; - select.addEventListener("change", () => setDirectory(column, select.value)); - $(".remove-column", column.root).addEventListener("click", () => removeColumn(column)); - $(".show-tree", column.root).addEventListener("click", () => setView(column, "tree")); - $(".show-events", column.root).addEventListener("click", () => setView(column, "events")); - $(".load-events", column.root).addEventListener("click", () => loadEvents(column)); - $(".previous-match", column.root).addEventListener("click", () => stepMatch(column, -1)); - $(".next-match", column.root).addEventListener("click", () => stepMatch(column, 1)); - $(".expand-all", column.root).addEventListener("click", () => expandLoadedLevel(column)); - $(".collapse-all", column.root).addEventListener("click", () => column.root.querySelectorAll(".tree-node").forEach(collapseNode)); - $(".show-insights", column.root).addEventListener("click", () => showInsights(column)); - column.root.querySelectorAll(".export-menu [data-format]").forEach(button => button.addEventListener("click", () => exportEvents(column, button.dataset.format))); - $(".copy-link", column.root).addEventListener("click", event => copyLink(column, event.currentTarget)); - const searchInput = $(".search input", column.root); - searchInput.addEventListener("input", () => search(column)); - searchInput.addEventListener("keydown", event => { - if (event.key === "Enter" && column.view === "tree") stepMatch(column, event.shiftKey ? -1 : 1); - }); - - workspace.columns.push(column); - $("#columns").append(column.root); - updateRemoveButtons(); - column.ready = setDirectory(column, kind, initialTraceId); - return column; -} - -function removeColumn(column) { - if (workspace.columns.length === 1) return; - hideCorrelationArrow(); - workspace.columns = workspace.columns.filter(item => item !== column); - column.root.remove(); - updateRemoveButtons(); -} - -function updateRemoveButtons() { - const disabled = workspace.columns.length === 1; - workspace.columns.forEach(column => $(".remove-column", column.root).hidden = disabled); -} - -function setDirectory(column, kind, initialTraceId = null) { - hideCorrelationArrow(); - column.kind = kind; - $(".directory-select", column.root).value = kind; - const tabs = workspace.tabs.filter(tab => tab.kind === kind); - const nav = $(".thread-tabs", column.root); nav.replaceChildren(); - tabs.forEach(tab => { - const button = document.createElement("button"); button.dataset.id = tab.id; - button.textContent = `${tab.hostname} / ${tab.tid}`; - button.title = tab.file; - button.addEventListener("click", () => loadTrace(column, tab.id)); - nav.append(button); - }); - const selected = tabs.find(tab => tab.id === initialTraceId) || tabs[0]; - return selected ? loadTrace(column, selected.id) : Promise.resolve(); -} - -function registerNodes(column, nodes, parent = null) { - for (const node of nodes) { - node.parent = parent; node.children = null; column.nodes.set(node.id, node); - } -} - -async function loadTrace(column, id) { - hideCorrelationArrow(); - const version = ++column.loadVersion; - column.root.querySelectorAll(".thread-tabs button").forEach(item => item.classList.toggle("active", Number(item.dataset.id) === id)); - $(".trace-summary h1", column.root).textContent = "Loading..."; - const response = await fetch(`/api/trace?id=${id}`); - const trace = await response.json(); - if (version !== column.loadVersion) return; - column.trace = trace; column.nodes.clear(); registerNodes(column, trace.roots); - column.requestLinks.clear(); - const summary = trace.summary; - $(".trace-path", column.root).textContent = `${summary.hostname} / ${summary.kind}`; - $(".trace-summary h1", column.root).textContent = `Thread ${summary.tid}`; - $(".metrics", column.root).replaceChildren( - metric("Nodes", summary.nodes.toLocaleString()), metric("Scopes", summary.scopes.toLocaleString()), - metric("Events", summary.events.toLocaleString()), metric("Window", ms(summary.window_ms)), - ); - const input = $(".search input", column.root); input.value = ""; - column.matches = []; column.match = -1; column.eventOffset = 0; column.eventTotal = 0; - $(".match-count", column.root).textContent = ""; - renderTree(column); showEmptyInspector(column); - if (column.view === "events") loadEvents(column, true); - if (column.pendingNode) { - const nodeId = column.pendingNode; column.pendingNode = null; - await revealNodeId(column, nodeId); - } -} - -function createNode(column, node) { - const wrapper = document.createElement("div"); wrapper.className = `tree-node ${node.is_event ? "event" : "scope"}`; wrapper.dataset.nodeId = node.id; - const row = document.createElement("div"); row.className = "node-row"; row.style.paddingLeft = `${8 + node.depth * 16}px`; row.setAttribute("role", "treeitem"); row.tabIndex = -1; - const toggle = document.createElement(node.child_count ? "button" : "span"); toggle.className = node.child_count ? "toggle" : "toggle-spacer"; - if (node.child_count) { toggle.type = "button"; toggle.textContent = "+"; toggle.setAttribute("aria-label", `Expand ${node.invoker}`); toggle.setAttribute("aria-expanded", "false"); } - const name = document.createElement("span"); name.className = "node-name"; name.textContent = node.invoker; - const time = document.createElement("span"); time.className = `node-time ${durationClass(node.duration_ms)}`; time.textContent = node.is_event ? `@${ms(node.timestamp_ms)}` : ms(node.duration_ms); - const args = document.createElement("span"); args.className = "node-args"; args.textContent = node.args ? `| ${node.args}` : ""; - row.dataset.invoker = node.invoker; row.dataset.nodeId = node.id; - row.append(toggle, name, time, args); wrapper.append(row); - row.addEventListener("click", () => selectNode(column, node, row)); - if (node.child_count) toggle.addEventListener("click", event => { event.stopPropagation(); toggleNode(column, wrapper, node); }); - attachRequestJumps(column, node, row); - (node.linked_requests || []).filter(request => request.id !== node.id).forEach(request => addSyncJump(row, column, request)); - return wrapper; -} - -async function expandNode(column, wrapper, node) { - let children = $(":scope > .children", wrapper); - const toggle = $(":scope > .node-row > .toggle", wrapper); - if (node.children === null) { - if (node.loading) return; - node.loading = true; toggle.textContent = "..."; - try { - const traceId = column.trace.summary.id; - const response = await fetch(`/api/children?trace=${traceId}&node=${encodeURIComponent(node.id)}`); - if (!response.ok) throw new Error("Unable to load child traces"); - const payload = await response.json(); - if (!column.trace || column.trace.summary.id !== traceId) return; - node.children = payload.nodes; registerNodes(column, node.children, node); - } catch (error) { toggle.textContent = "+"; console.error(error); return; } - finally { node.loading = false; } - } - if (!children) { - children = document.createElement("div"); children.className = "children"; - node.children.forEach(child => children.append(createNode(column, child))); wrapper.append(children); - } - children.hidden = false; toggle.textContent = "-"; toggle.setAttribute("aria-expanded", "true"); -} - -function collapseNode(wrapper) { - hideCorrelationArrow(); - const children = $(":scope > .children", wrapper); if (!children) return; - children.hidden = true; const toggle = $(":scope > .node-row > .toggle", wrapper); - toggle.textContent = "+"; toggle.setAttribute("aria-expanded", "false"); -} - -async function toggleNode(column, wrapper, node) { - const children = $(":scope > .children", wrapper); - if (!children || children.hidden) await expandNode(column, wrapper, node); else collapseNode(wrapper); -} - -function renderTree(column) { - const tree = $(".tree", column.root); tree.replaceChildren(); - column.trace.roots.forEach(root => tree.append(createNode(column, root))); - $(".tree-empty", column.root).hidden = true; -} - -function selectNode(column, node, row) { - column.root.querySelectorAll(".node-row.selected, .event-table tr.selected").forEach(item => item.classList.remove("selected")); - row.classList.add("selected"); - column.selectedNode = node; - const inspector = $(".inspector", column.root); inspector.replaceChildren(); - const label = document.createElement("div"); label.className = "panel-label"; label.textContent = "Inspector"; - const body = document.createElement("div"); body.className = "inspect-body"; - body.innerHTML = `

`; - $(".inspect-kind", body).textContent = node.is_event ? "Event" : `Scope / depth ${node.depth}`; - $("h2", body).textContent = node.invoker; - const values = [["Source", `${node.file}:${node.line}`], ["Duration", ms(node.duration_ms)], ["Timestamp", ms(node.timestamp_ms)], ["Enter", ms(node.enter_ms)], ["Exit", ms(node.exit_ms)], ["Children", node.child_count]]; - const grid = $(".inspect-grid", body); - values.forEach(([key, value]) => { const cell = document.createElement("div"); const small = document.createElement("small"); small.textContent = key; const span = document.createElement("span"); span.textContent = value; cell.append(small, span); grid.append(cell); }); - $(".inspect-args", body).textContent = node.args || "No arguments"; inspector.append(label, body); - correlateSelection(column, node, inspector); -} - -function showEmptyInspector(column) { - $(".inspector", column.root).innerHTML = '
Inspector
Select a trace row to inspect its timing and source.
'; -} - -function createEventRow(column, event) { - const row = document.createElement("tr"); - row.dataset.invoker = event.invoker; - row.dataset.nodeId = event.id; - [ms(event.timestamp_ms), event.invoker, event.args || "--", event.context, `${event.file}:${event.line}`].forEach((value, index) => { - const cell = document.createElement("td"); cell.textContent = value; - if (index === 0) cell.className = "event-time"; if (index === 1) cell.className = "event-name"; row.append(cell); - }); - row.addEventListener("click", () => selectNode(column, event, row)); - attachRequestJumps(column, event, row); - return row; -} - -async function loadEvents(column, reset = false) { - if (!column.trace) return; - if (reset) { column.eventOffset = 0; $(".event-table tbody", column.root).replaceChildren(); } - const version = ++column.searchVersion; const traceId = column.trace.summary.id; - const query = $(".search input", column.root).value.trim(); - $(".event-count", column.root).textContent = "Loading..."; - const response = await fetch(`/api/events?trace=${traceId}&offset=${column.eventOffset}&limit=250&q=${encodeURIComponent(query)}`); - if (version !== column.searchVersion || !column.trace || column.trace.summary.id !== traceId) return; - const payload = await response.json(); const body = $(".event-table tbody", column.root); - payload.events.forEach(event => body.append(createEventRow(column, event))); - column.eventOffset += payload.events.length; column.eventTotal = payload.total; - $(".event-count", column.root).textContent = `${column.eventOffset.toLocaleString()} of ${column.eventTotal.toLocaleString()}`; - $(".events-empty", column.root).hidden = column.eventTotal > 0; - $(".load-events", column.root).hidden = column.eventOffset >= column.eventTotal; - $(".match-count", column.root).textContent = query ? `${column.eventTotal.toLocaleString()} found` : ""; -} - -function setView(column, view) { - hideCorrelationArrow(); - column.view = view; const events = view === "events"; - $(".tree-panel", column.root).hidden = events; $(".events-panel", column.root).hidden = !events; - $(".tree-actions", column.root).hidden = events; $(".previous-match", column.root).hidden = events; $(".next-match", column.root).hidden = events; - $(".show-tree", column.root).classList.toggle("active", !events); $(".show-tree", column.root).setAttribute("aria-pressed", String(!events)); - $(".show-events", column.root).classList.toggle("active", events); $(".show-events", column.root).setAttribute("aria-pressed", String(events)); - $(".search input", column.root).placeholder = events ? "Filter events, details, or source" : "Invoker, arguments, or source"; - $(".match-count", column.root).textContent = ""; - if (events) loadEvents(column, true); else search(column); -} - -async function revealNodeId(column, nodeId, behavior = "smooth") { - setView(column, "tree"); - const parts = nodeId.split("."); - for (let length = 1; length < parts.length; length += 1) { - const ancestorId = parts.slice(0, length).join("."); - const ancestor = column.nodes.get(ancestorId); - const wrapper = column.root.querySelector(`[data-node-id="${ancestorId}"]`); - if (!ancestor || !wrapper) return null; - await expandNode(column, wrapper, ancestor); - } - const node = column.nodes.get(nodeId); - const wrapper = column.root.querySelector(`[data-node-id="${nodeId}"]`); - if (!node || !wrapper) return null; - const row = $(":scope > .node-row", wrapper); - selectNode(column, node, row); - row.focus({ preventScroll: true }); - const tree = $(".tree", column.root); - const viewport = tree.getBoundingClientRect(); - const bounds = row.getBoundingClientRect(); - const margin = 10; - let delta = 0; - if (bounds.top < viewport.top + margin) delta = bounds.top - viewport.top - margin; - else if (bounds.bottom > viewport.bottom - margin) delta = bounds.bottom - viewport.bottom + margin; - if (delta) tree.scrollTo({ top: tree.scrollTop + delta, behavior }); - return row; -} - -async function revealMatch(column) { - if (!column.matches.length) return; - const match = column.matches[column.match]; const parts = match.id.split("."); - for (let length = 1; length < parts.length; length += 1) { - const ancestorId = parts.slice(0, length).join("."); const ancestor = column.nodes.get(ancestorId); - const wrapper = column.root.querySelector(`[data-node-id="${ancestorId}"]`); - if (!ancestor || !wrapper) return; await expandNode(column, wrapper, ancestor); - } - const node = column.nodes.get(match.id) || match; - const wrapper = column.root.querySelector(`[data-node-id="${node.id}"]`); if (!wrapper) return; - const row = $(":scope > .node-row", wrapper); selectNode(column, node, row); row.scrollIntoView({ block: "center", behavior: "smooth" }); - $(".match-count", column.root).textContent = `${column.match + 1}/${column.matches.length}`; -} - -async function search(column) { - if (!column.trace) return; - if (column.view === "events") { - const version = ++column.searchVersion; await new Promise(resolve => setTimeout(resolve, 180)); - if (version === column.searchVersion) loadEvents(column, true); return; - } - const query = $(".search input", column.root).value.trim(); const version = ++column.searchVersion; - if (!query) { column.matches = []; column.match = -1; $(".match-count", column.root).textContent = ""; $(".tree-empty", column.root).hidden = true; return; } - $(".match-count", column.root).textContent = "..."; await new Promise(resolve => setTimeout(resolve, 180)); - if (version !== column.searchVersion) return; - const traceId = column.trace.summary.id; const response = await fetch(`/api/search?trace=${traceId}&q=${encodeURIComponent(query)}`); - if (version !== column.searchVersion || column.trace.summary.id !== traceId) return; - const payload = await response.json(); column.matches = payload.matches; column.match = column.matches.length ? 0 : -1; - $(".match-count", column.root).textContent = `0/${column.matches.length}${payload.limited ? "+" : ""}`; - $(".tree-empty", column.root).hidden = column.matches.length > 0; - if (column.matches.length) await revealMatch(column); -} - -async function stepMatch(column, direction) { - if (!column.matches.length) return; - column.match = (column.match + direction + column.matches.length) % column.matches.length; await revealMatch(column); -} - -async function expandLoadedLevel(column) { - const currentLevel = [...column.nodes.values()]; - await Promise.all(currentLevel.map(node => { const wrapper = column.root.querySelector(`[data-node-id="${node.id}"]`); return wrapper && node.child_count ? expandNode(column, wrapper, node) : null; })); -} - -function appendTableRow(body, values, onClick = null) { - const row = document.createElement("tr"); - values.forEach(value => { const cell = document.createElement("td"); cell.textContent = value; row.append(cell); }); - if (onClick) { row.classList.add("clickable"); row.addEventListener("click", onClick); } - body.append(row); -} - -function showInsights(column) { - const analysis = column.trace.analysis; - $("#insights-title").textContent = `${column.kind} / Thread ${column.trace.summary.tid}`; - const percentiles = $("#percentiles"); percentiles.replaceChildren(); - Object.entries(analysis.percentiles).forEach(([label, value]) => percentiles.append(metric(label, ms(value)))); - const slowest = $("#slowest-body"); slowest.replaceChildren(); - analysis.slowest.forEach(item => appendTableRow(slowest, [item.invoker, item.path, ms(item.duration_ms)], () => { $("#insights-panel").hidden = true; revealNodeId(column, item.id); })); - const frequent = $("#frequent-body"); frequent.replaceChildren(); - analysis.frequent.forEach(item => appendTableRow(frequent, [item.invoker, item.kind, item.count.toLocaleString()])); - const paths = $("#paths-body"); paths.replaceChildren(); - analysis.call_paths.forEach(item => appendTableRow(paths, [item.path, item.count.toLocaleString(), ms(item.total_ms)])); - const stats = $("#stats-body"); stats.replaceChildren(); - column.trace.stats.forEach(item => appendTableRow(stats, [item.invoker, item.count, ms(item.total_ms), ms(item.mean_ms.toFixed(1)), ms(item.median_ms.toFixed(1)), ms(item.max_ms), ms(item.min_ms), ms(item.std_ms.toFixed(1))])); - $("#insights-panel").hidden = false; -} - -function exportEvents(column, format) { - const query = $(".search input", column.root).value.trim(); - window.location.href = `/api/export?trace=${column.trace.summary.id}&format=${format}&q=${encodeURIComponent(query)}`; - $(".export-menu", column.root).open = false; -} - -async function copyLink(column, button) { - const url = new URL(window.location.href); - url.search = ""; - url.searchParams.set("trace", column.trace.summary.id); - if (column.selectedNode) url.searchParams.set("node", column.selectedNode.id); - try { - await navigator.clipboard.writeText(url.toString()); - const original = button.textContent; button.textContent = "Copied"; - setTimeout(() => { button.textContent = original; }, 1200); - } catch (error) { - window.prompt("Copy trace link", url.toString()); - } -} - -async function openCorrelatedNode(target, traceId, nodeId) { - if (!target.trace || target.trace.summary.id !== traceId) await loadTrace(target, traceId); - return revealNodeId(target, nodeId, "auto"); -} - -function correlationTarget(source, traceId) { - const tab = workspace.tabs.find(item => item.id === traceId); - return workspace.columns.find(item => item !== source && item.kind === tab?.kind) || null; -} - -async function openCorrelationTarget(source, traceId, nodeId) { - const tab = workspace.tabs.find(item => item.id === traceId); - if (!tab) return null; - let target = correlationTarget(source, traceId); - if (!target) { - target = createColumn(tab.kind, traceId); - await target.ready; - } - const row = await openCorrelatedNode(target, traceId, nodeId); - return { target, row }; -} - -async function connectCorrelatedNodes(source, sourceNodeId, traceId, targetNodeId, sourceRow = null) { - hideCorrelationArrow(); - const destinationPromise = openCorrelationTarget(source, traceId, targetNodeId); - const sourcePromise = sourceRow?.isConnected - ? Promise.resolve(sourceRow) - : revealNodeId(source, sourceNodeId, "auto"); - const [destinationResult, sourceResult] = await Promise.allSettled([destinationPromise, sourcePromise]); - if (destinationResult.status === "rejected") throw destinationResult.reason; - const destination = destinationResult.value; - const resolvedSourceRow = sourceResult.status === "fulfilled" ? sourceResult.value : null; - if (resolvedSourceRow && destination?.row) { - showCorrelationArrow(resolvedSourceRow, destination.row, source, destination.target, source.trace.summary.id, traceId); - } -} - -function addCorrelationJump(row, source, sourceNodeId, traceId, node, persistent = false) { - if (!row) return; - const host = row.matches("tr") ? row.lastElementChild : row; - if (host.querySelector(`.correlation-jump[data-trace-id="${traceId}"]`)) return; - const tab = workspace.tabs.find(item => item.id === traceId); - const button = document.createElement("button"); - button.className = "correlation-jump"; - button.classList.toggle("persistent", persistent); - button.dataset.traceId = traceId; - button.textContent = `Open ${tab?.kind || "match"}`; - button.title = `Open the associated request in ${tab?.kind || "another component"} / thread ${tab?.tid || "?"}`; - button.addEventListener("click", async event => { - event.stopPropagation(); - const label = button.textContent; - button.textContent = "Opening..."; button.disabled = true; - try { - await connectCorrelatedNodes(source, sourceNodeId, traceId, node.id, row); - } catch (error) { - console.error(error); - button.textContent = `Retry ${tab?.kind || "match"}`; - button.disabled = false; - return; - } - button.textContent = label; button.disabled = false; - }); - host.append(button); - if (activeConnection && ( - (source === activeConnection.source && traceId === activeConnection.targetTrace) - || (source === activeConnection.target && traceId === activeConnection.sourceTrace) - )) { - button.hidden = true; - activeConnection.hiddenButtons.push(button); - } -} - -function addSyncJump(row, column, request) { - if (row.querySelector(`.sync-jump[data-node-id="${request.id}"]`)) return; - const button = document.createElement("button"); - button.className = "sync-jump"; - button.dataset.nodeId = request.id; - button.textContent = "Go to sync event"; - button.title = `Expand to request ${request.request}`; - button.addEventListener("click", event => { - event.preventDefault(); event.stopPropagation(); - revealNodeId(column, request.id, "auto"); - }); - row.append(button); -} - -async function attachRequestJumps(column, node, row) { - if (!node.request) return; - const key = `${column.trace.summary.id}:${node.id}`; - if (column.requestLinks.has(key)) return; - column.requestLinks.add(key); - const traceIds = workspace.tabs.map(tab => tab.id); - const response = await fetch(`/api/correlate?source=${column.trace.summary.id}&node=${encodeURIComponent(node.id)}&traces=${traceIds.join(",")}`); - const payload = await response.json(); - if (!row.isConnected || !column.trace || column.trace.summary.id !== Number(row.closest(".trace-column")?.querySelector(".thread-tabs .active")?.dataset.id)) return; - payload.matches.forEach(match => addCorrelationJump(row, column, node.id, match.trace, match.node, true)); -} - -async function correlateSelection(column, node, inspector) { - const version = ++column.correlationVersion; - workspace.columns.forEach(item => { - item.root.querySelectorAll(".correlation-jump:not(.persistent)").forEach(button => button.remove()); - item.root.querySelectorAll("[data-invoker]").forEach(row => { - row.classList.toggle("correlated", item !== column && row.dataset.invoker === node.invoker); - }); - }); - const traceIds = workspace.tabs.map(tab => tab.id); - const response = await fetch(`/api/correlate?source=${column.trace.summary.id}&node=${encodeURIComponent(node.id)}&traces=${traceIds.join(",")}`); - if (version !== column.correlationVersion || column.selectedNode !== node) return; - const payload = await response.json(); - const matches = payload.matches; - if (!matches.length) return; - const section = document.createElement("div"); section.className = "correlations"; - const title = document.createElement("small"); - title.textContent = payload.mode === "request" ? `Request interaction: ${payload.request || payload.terms.join(" / ")}` : "Cross-column matches"; - section.append(title); - const sourceRow = [...column.root.querySelectorAll(".node-row, .event-table tr")].find(row => row.dataset.nodeId === node.id); - matches.forEach(match => { - const targetTab = workspace.tabs.find(tab => tab.id === match.trace); - const target = correlationTarget(column, match.trace); - const loadedRow = target?.trace?.summary.id === match.trace - ? [...target.root.querySelectorAll(".node-row, .event-table tr")].find(row => row.dataset.nodeId === match.node.id) - : null; - if (loadedRow) { - loadedRow.classList.add("correlated"); - addCorrelationJump(loadedRow, target, match.node.id, column.trace.summary.id, node, true); - } - addCorrelationJump(sourceRow, column, node.id, match.trace, match.node, true); - const button = document.createElement("button"); - const relation = payload.mode === "request" ? `${Math.round(match.score * 100)}% parameter match` : `${match.delta_ms >= 0 ? "+" : ""}${ms(match.delta_ms)}`; - button.textContent = `${targetTab.kind} / thread ${targetTab.tid} ${relation}`; - button.addEventListener("click", () => connectCorrelatedNodes(column, node.id, match.trace, match.node.id, sourceRow)); section.append(button); - }); - inspector.append(section); -} - -function addColumn() { - const directories = [...new Set(workspace.tabs.map(tab => tab.kind))]; - const used = new Set(workspace.columns.map(column => column.kind)); - createColumn(directories.find(directory => !used.has(directory)) || directories[0]); -} - -$("#add-column").addEventListener("click", addColumn); -$("#close-insights").addEventListener("click", () => $("#insights-panel").hidden = true); -let globalSearchTimer; -$("#global-search").addEventListener("input", event => { - clearTimeout(globalSearchTimer); - globalSearchTimer = setTimeout(() => { - workspace.columns.forEach(column => { $(".search input", column.root).value = event.target.value; search(column); }); - }, 120); -}); -document.addEventListener("keydown", event => { - if (event.key === "/" && !event.target.matches("input, select")) { event.preventDefault(); $("#global-search").focus(); } - if (event.key === "Escape") $("#insights-panel").hidden = true; -}); -window.addEventListener("resize", drawCorrelationArrow); -document.addEventListener("scroll", () => { - if (activeConnection) requestAnimationFrame(drawCorrelationArrow); -}, true); - -fetch("/api/overview").then(response => response.json()).then(data => { - workspace.tabs = data.tabs; $("#columns").replaceChildren(); - const params = new URLSearchParams(window.location.search); - const linkedTrace = Number(params.get("trace")); - const linkedTab = params.has("trace") ? workspace.tabs.find(tab => tab.id === linkedTrace) : null; - if (linkedTab) createColumn(linkedTab.kind, linkedTab.id, params.get("node")); - else if (workspace.tabs.length) createColumn(workspace.tabs[0].kind); - else $("#columns").innerHTML = '
No trace logs found.
'; -}); diff --git a/profiler/web/calf.svg b/profiler/web/calf.svg deleted file mode 100644 index b200c2d..0000000 --- a/profiler/web/calf.svg +++ /dev/null @@ -1,16 +0,0 @@ - - CALF head - - - - - - - - - - - - - - diff --git a/profiler/web/index.html b/profiler/web/index.html deleted file mode 100644 index 52793d8..0000000 --- a/profiler/web/index.html +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - CALF Trace Explorer - - - - -
-
CALF
CALF InspectorTrace explorer
-
-
-
Loading trace directories...
- - - - diff --git a/profiler/web/style.css b/profiler/web/style.css deleted file mode 100644 index 983dc2d..0000000 --- a/profiler/web/style.css +++ /dev/null @@ -1,177 +0,0 @@ -:root { - --ink: #26313d; - --strong: #111820; - --muted: #687583; - --faint: #929ca7; - --canvas: #eef0f1; - --surface: #fff; - --soft: #f7f8f8; - --line: #dde1e3; - --line-strong: #cbd1d4; - --accent: #315e57; - --accent-soft: #e7f0ee; - --green: #27815f; - --yellow: #a66b13; - --orange: #c25a2a; - --red: #bc3f4b; - --cyan: #197980; -} - -* { box-sizing: border-box; } -html, body { height: 100%; } -body { margin: 0; overflow: hidden; color: var(--ink); background: var(--canvas); font: 14px/1.45 Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; -webkit-font-smoothing: antialiased; } -button, input, select { font: inherit; } -button { border: 0; } -[hidden] { display: none !important; } -#correlation-overlay { position: fixed; z-index: 30; inset: 0; width: 100vw; height: 100vh; overflow: visible; pointer-events: none; } -#correlation-overlay .correlation-line { fill: none; stroke: url(#correlation-gradient); stroke-width: 3; marker-end: url(#correlation-arrowhead); filter: drop-shadow(0 1px 1px rgb(255 255 255 / 90%)); pointer-events: stroke; cursor: pointer; } -#correlation-overlay .correlation-hit { fill: none; stroke: rgb(0 0 0 / .001); stroke-width: 18; pointer-events: all; cursor: pointer; } - -.topbar { height: 56px; display: flex; align-items: center; justify-content: space-between; padding: 0 16px; background: var(--surface); border-bottom: 1px solid var(--line); } -.brand { display: flex; align-items: center; gap: 9px; } -.brand-mark { width: 38px; height: 30px; object-fit: contain; } -.brand strong { display: block; color: var(--strong); font-size: 13px; } -.brand small { display: block; color: var(--muted); font-size: 10px; } -.topbar-actions, .health { display: flex; align-items: center; gap: 10px; } -.health { color: var(--muted); font-size: 11px; } -.health span { width: 7px; height: 7px; border-radius: 50%; background: #3c9c74; } -.global-search { width: min(300px, 32vw); display: flex; align-items: center; gap: 7px; height: 32px; padding: 0 9px; border: 1px solid var(--line); border-radius: 5px; } -.global-search:focus-within { border-color: #8fa9a3; box-shadow: 0 0 0 2px var(--accent-soft); } -.global-search span { flex: 0 0 auto; color: var(--faint); font-size: 8px; font-weight: 700; text-transform: uppercase; } -.global-search input { min-width: 0; width: 100%; padding: 0; color: var(--ink); background: transparent; border: 0; outline: 0; font-size: 11px; } -.add-column { min-height: 32px; padding: 0 12px; color: white; background: var(--accent); border-radius: 5px; font-size: 11px; font-weight: 700; cursor: pointer; } -.add-column:hover { background: #274e48; } - -.workspace { height: calc(100vh - 56px); overflow-x: auto; overflow-y: hidden; padding: 10px; } -.columns { min-width: 100%; height: 100%; display: flex; gap: 10px; } -.loading { margin: auto; color: var(--muted); font-size: 12px; } -.trace-column { min-width: 520px; flex: 1 1 0; height: 100%; display: flex; flex-direction: column; overflow: hidden; background: var(--surface); border: 1px solid var(--line-strong); border-radius: 7px; container-type: inline-size; } -.column-head { min-height: 43px; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 6px 10px; background: var(--soft); border-bottom: 1px solid var(--line); } -.column-head label { display: flex; align-items: center; gap: 8px; } -.column-head label span { color: var(--muted); font-size: 9px; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; } -.directory-select { min-width: 135px; height: 29px; padding: 0 28px 0 9px; color: var(--strong); background: var(--surface); border: 1px solid var(--line-strong); border-radius: 4px; font-size: 12px; font-weight: 650; } -.remove-column { padding: 5px 7px; color: var(--faint); background: transparent; font-size: 10px; cursor: pointer; } -.remove-column:hover { color: var(--red); } - -.thread-tabs { min-height: 37px; display: flex; align-items: end; gap: 2px; overflow-x: auto; padding: 5px 7px 0; border-bottom: 1px solid var(--line); scrollbar-width: thin; } -.thread-tabs button { flex: 0 0 auto; height: 31px; padding: 0 9px; color: var(--muted); background: transparent; border-bottom: 2px solid transparent; font-size: 10px; cursor: pointer; } -.thread-tabs button:hover { color: var(--strong); } -.thread-tabs button.active { color: var(--accent); border-bottom-color: var(--accent); font-weight: 700; } - -.trace-summary { min-height: 58px; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 8px 11px; border-bottom: 1px solid var(--line); } -.eyebrow { color: var(--accent); font-size: 8px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; } -.trace-summary h1 { margin: 2px 0 0; color: var(--strong); font-size: 17px; line-height: 1.2; } -.metrics { display: flex; align-items: center; overflow-x: auto; } -.metric { min-width: 72px; padding: 2px 9px; border-left: 1px solid var(--line); } -.metric small { display: block; color: var(--faint); font-size: 8px; font-weight: 700; text-transform: uppercase; } -.metric strong { display: block; margin-top: 1px; color: var(--strong); font-size: 11px; } - -.toolbar { min-height: 43px; display: flex; align-items: center; gap: 4px; padding: 5px 7px; border-bottom: 1px solid var(--line); } -.toolbar button, .load-events, .stats-head button { min-height: 29px; padding: 0 8px; color: #53606c; background: var(--surface); border: 1px solid var(--line); border-radius: 4px; font-size: 10px; font-weight: 650; cursor: pointer; } -.toolbar button:hover, .load-events:hover, .stats-head button:hover { color: var(--accent); border-color: #9eafab; } -.view-switch { flex: 0 0 auto; display: flex; padding: 2px; background: #e8eaeb; border-radius: 5px; } -.view-switch button { min-height: 25px; background: transparent; border: 0; } -.view-switch button.active { color: var(--strong); background: var(--surface); box-shadow: 0 1px 2px rgb(20 30 40 / 10%); } -.search { min-width: 130px; flex: 1; display: flex; align-items: center; gap: 6px; height: 29px; padding: 0 8px; background: var(--surface); border: 1px solid var(--line); border-radius: 4px; } -.search:focus-within { border-color: #8fa9a3; box-shadow: 0 0 0 2px var(--accent-soft); } -.search span { color: var(--faint); font-size: 8px; font-weight: 700; text-transform: uppercase; } -.search input { min-width: 0; width: 100%; padding: 0; color: var(--ink); background: transparent; border: 0; outline: 0; font-size: 11px; } -.search input::placeholder { color: #a2aab2; } -.search kbd { color: var(--muted); white-space: nowrap; font-family: inherit; font-size: 9px; } -.tree-actions { display: flex; gap: 4px; } -.export-menu { position: relative; flex: 0 0 auto; } -.export-menu summary { min-height: 29px; display: grid; place-items: center; padding: 0 8px; color: #53606c; background: var(--surface); border: 1px solid var(--line); border-radius: 4px; font-size: 10px; font-weight: 650; cursor: pointer; list-style: none; } -.export-menu summary::-webkit-details-marker { display: none; } -.export-menu[open] summary { color: var(--accent); border-color: #9eafab; } -.export-menu > div { position: absolute; z-index: 20; top: calc(100% + 4px); right: 0; width: 120px; padding: 4px; background: var(--surface); border: 1px solid var(--line-strong); border-radius: 5px; box-shadow: 0 8px 24px rgb(20 30 35 / 16%); } -.export-menu > div button { width: 100%; display: block; border: 0; text-align: left; } - -.content-grid { min-height: 0; flex: 1; display: grid; grid-template-columns: minmax(0, 1fr) 250px; overflow: hidden; } -.tree-panel, .events-panel { min-width: 0; min-height: 0; display: flex; flex-direction: column; overflow: hidden; border-right: 1px solid var(--line); } -.panel-label { min-height: 34px; display: flex; align-items: center; justify-content: space-between; padding: 0 10px; color: var(--muted); background: var(--soft); border-bottom: 1px solid var(--line); font-size: 9px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; } -.legend { display: flex; align-items: center; gap: 3px; color: var(--faint); font-size: 8px; font-weight: 500; text-transform: lowercase; letter-spacing: 0; } -.legend i { width: 5px; height: 5px; margin-left: 5px; border-radius: 50%; } -.event-dot { background: var(--cyan); } .fast-dot { background: var(--green); } .hot-dot { background: var(--red); } -.tree { min-width: 0; min-height: 0; flex: 1; overflow: auto; padding: 4px 5px 18px; scrollbar-gutter: stable; } -.tree-node { user-select: none; } -.node-row { min-height: 31px; display: flex; align-items: center; gap: 6px; margin: 1px 0; padding-right: 8px; border-radius: 3px; cursor: pointer; } -.node-row:hover { background: #f2f4f4; } -.node-row:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } -.node-row.selected, .event-table tr.selected { background: var(--accent-soft); box-shadow: inset 2px 0 var(--accent); } -.node-row.correlated, .event-table tr.correlated { background: #fff4d8; box-shadow: inset 2px 0 #c18a20; } -.correlation-jump, .sync-jump { flex: 0 0 auto; min-height: 21px; margin-left: auto; padding: 0 6px; color: var(--accent); background: var(--accent-soft); border: 1px solid color-mix(in srgb, var(--accent) 35%, white); border-radius: 3px; white-space: nowrap; font-size: 8px; font-weight: 700; cursor: pointer; } -.correlation-jump:hover, .sync-jump:hover { background: color-mix(in srgb, var(--accent-soft) 70%, var(--accent)); } -.correlation-jump.connection-close { color: #8f2f37; background: #fcedef; border-color: #dda6ac; } -.correlation-jump.connection-close:hover { background: #f8dce0; } -.event-table td .correlation-jump { float: right; margin: -3px 0 -3px 6px; } -.toggle { flex: 0 0 21px; width: 21px; height: 21px; display: grid; place-items: center; padding: 0; color: var(--muted); background: var(--surface); border: 1px solid var(--line-strong); border-radius: 3px; font-size: 14px; line-height: 1; cursor: pointer; } -.toggle:hover { color: var(--accent); border-color: #9aacaa; } -.toggle-spacer { flex: 0 0 21px; width: 21px; } -.node-name { color: #345d75; font: 600 11px ui-monospace, "SFMono-Regular", Consolas, monospace; } -.event .node-name { color: var(--cyan); } -.node-time { flex: 0 0 auto; padding: 2px 4px; color: var(--muted); background: #f0f2f3; border-radius: 3px; font: 9px ui-monospace, monospace; } -.time-fast { color: var(--green); } .time-warm { color: var(--yellow); } .time-slow { color: var(--orange); } .time-hot { color: var(--red); font-weight: 700; } -.node-args { min-width: 0; overflow: hidden; color: var(--muted); text-overflow: ellipsis; white-space: nowrap; font: 9px ui-monospace, monospace; } -.children[hidden] { display: none; } - -.inspector { min-height: 0; overflow: auto; background: var(--soft); } -.inspector-empty { max-width: 230px; padding: 18px 13px; color: var(--muted); font-size: 11px; line-height: 1.55; } -.inspect-body { padding: 14px 12px; } -.inspect-kind { color: var(--accent); font-size: 8px; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; } -.inspect-body h2 { margin: 4px 0 14px; overflow-wrap: anywhere; color: var(--strong); font: 650 13px ui-monospace, monospace; } -.inspect-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 7px; } -.inspect-grid small { display: block; margin-bottom: 2px; color: var(--faint); font-size: 7px; font-weight: 700; text-transform: uppercase; } -.inspect-grid span { color: var(--ink); font: 9px ui-monospace, monospace; } -.inspect-args { margin-top: 14px; padding: 9px; overflow-wrap: anywhere; color: var(--muted); background: var(--surface); border: 1px solid var(--line); border-radius: 4px; font: 9px/1.5 ui-monospace, monospace; } -.correlations { margin-top: 14px; padding-top: 11px; border-top: 1px solid var(--line); } -.correlations small { display: block; margin-bottom: 5px; color: var(--faint); font-size: 7px; font-weight: 700; text-transform: uppercase; } -.correlations button { width: 100%; margin-top: 3px; padding: 6px 7px; overflow: hidden; color: var(--accent); background: var(--accent-soft); border-radius: 3px; text-align: left; text-overflow: ellipsis; white-space: nowrap; font-size: 9px; cursor: pointer; } - -.event-table-wrap { min-height: 0; flex: 1; overflow: auto; } -table { width: 100%; border-collapse: collapse; color: var(--ink); font-size: 10px; } -th { position: sticky; top: 0; z-index: 1; color: var(--muted); background: #f4f6f6; font-size: 8px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; text-align: left; } -th, td { padding: 7px 8px; border-bottom: 1px solid var(--line); } -.event-table { min-width: 650px; table-layout: fixed; } -.event-table th:nth-child(1) { width: 75px; } .event-table th:nth-child(2) { width: 20%; } .event-table th:nth-child(3) { width: 31%; } .event-table th:nth-child(4) { width: 18%; } .event-table th:nth-child(5) { width: 23%; } -.event-table tbody tr { cursor: pointer; } -.event-table tbody tr:hover, .table-wrap tbody tr:hover { background: #f6f8f7; } -.event-table td { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.event-time { color: var(--muted); font-family: ui-monospace, monospace; } -.event-name { color: var(--cyan); font-family: ui-monospace, monospace; font-weight: 650; } -.load-events { align-self: center; margin: 6px; } -.empty { padding: 30px; color: var(--muted); text-align: center; font-size: 10px; } - -.insights-panel { position: fixed; z-index: 40; inset: 5vh 3vw; overflow: hidden; padding: 18px; background: var(--surface); border: 1px solid var(--line); border-radius: 8px; box-shadow: 0 0 0 100vmax rgb(25 31 35 / 35%), 0 20px 60px rgb(25 31 35 / 20%); } -.stats-head { display: flex; align-items: center; justify-content: space-between; } -.stats-head h2 { margin: 3px 0 14px; color: var(--strong); font-size: 19px; } -.table-wrap { max-height: calc(100% - 55px); overflow: auto; border: 1px solid var(--line); border-radius: 5px; } -.table-wrap td:not(:first-child), .table-wrap th:not(:first-child) { text-align: right; } -.table-wrap tr.clickable { cursor: pointer; } -.insights-content { height: calc(100% - 55px); display: grid; grid-template-columns: 1fr 1fr; gap: 16px; overflow: auto; } -.insights-content section { min-width: 0; display: flex; flex-direction: column; } -.insights-content h3 { margin: 0 0 7px; color: var(--strong); font-size: 11px; } -.insights-content .table-wrap { min-height: 160px; max-height: none; flex: 1; } -.insights-content .invoker-stats { grid-column: 1 / -1; min-height: 260px; } -.percentiles { display: grid; grid-template-columns: repeat(4, 1fr); border: 1px solid var(--line); border-radius: 5px; } -.percentiles .metric { padding: 12px; } -.percentiles .metric:first-child { border-left: 0; } - -@container (max-width: 760px) { - .trace-summary { align-items: flex-start; } - .metrics { max-width: 55%; } - .metric { min-width: 60px; padding-inline: 6px; } - .toolbar { flex-wrap: wrap; } - .search { order: -1; min-width: calc(100% - 100px); } - .content-grid { grid-template-columns: 1fr; grid-template-rows: minmax(220px, 1fr) minmax(120px, .38fr); } - .tree-panel, .events-panel { border-right: 0; border-bottom: 1px solid var(--line); } -} - -@media (max-width: 620px) { - .global-search { width: 42vw; } - .global-search span { display: none; } - .workspace { padding: 7px; } - .columns { gap: 7px; } - .trace-column { min-width: calc(100vw - 14px); } - .insights-panel { inset: 3vh 8px; padding: 12px; } - .insights-content { grid-template-columns: 1fr; } -} diff --git a/pyproject.toml b/pyproject.toml index 2876d13..9bdcbe3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,6 @@ [build-system] requires = [ "scikit-build-core[pyproject]==1.0.3", - "dynamic-metadata==0.5.0", "pybind11==3.0.4", "setuptools==80.9.0", "wheel==0.45.1", @@ -10,8 +9,8 @@ build-backend = "scikit_build_core.build" [project] name = "capio-calf" -dynamic = ["version", "dependencies"] -description = "CAPIO Logging Facility and interactive trace inspector" +dynamic = ["version"] +description = "CAPIO Logging Facility" readme = "README.md" license = { file = "LICENSE" } requires-python = ">=3.10" @@ -26,20 +25,11 @@ test = ["pytest>=8.0"] Repository = "https://github.com/High-Performance-IO/CALF" Issues = "https://github.com/High-Performance-IO/CALF/issues" -[project.scripts] -calf = "calf.profiler.__main__:main" - [tool.scikit-build] cmake.version = ">=3.16" -wheel.exclude = [ - "calf/profiler/build/**", - "calf/profiler/*.egg-info/**", - "calf/profiler/__pycache__/**", -] [tool.scikit-build.wheel.packages] "calf" = "bindings/calf" -"calf/profiler" = "profiler" [tool.scikit-build.cmake.define] CALF_BUILD_PYTHON_BINDINGS = true @@ -51,8 +41,3 @@ field = "version" input = "CMakeLists.txt" regex = "(?m)VERSION\\s+(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)" result = "{major}.{minor}.{patch}" - -[[tool.dynamic-metadata]] -provider = "dynamic_metadata.from_file" -field = "dependencies" -path = "requirements.txt" diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 783e8e4..0000000 --- a/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -numpy>=1.24.0 -protobuf>=7.35.1,<8 diff --git a/tests/profiler_loader_tests.py b/tests/profiler_loader_tests.py deleted file mode 100644 index c57eeb8..0000000 --- a/tests/profiler_loader_tests.py +++ /dev/null @@ -1,118 +0,0 @@ -import json -import sys - -from calf.profiler.loader import TraceTab, discover_tabs, load_trace -from calf.protobuf import calf_trace_pb2 - - -def _event(trace, kind, timestamp, name="", args="", track_uuid=1): - packet = trace.packet.add(timestamp=timestamp * 1_000_000) - event = packet.track_event - event.type = kind - event.track_uuid = track_uuid - event.name = name - event.source_location.function_name = name - if args: - annotation = event.debug_annotations.add(name="args") - annotation.string_value = args - - -def test_loads_perfetto_trace_tree_and_truncated_tail(tmp_path): - trace = calf_trace_pb2.Trace() - _event(trace, calf_trace_pb2.TrackEvent.TYPE_SLICE_BEGIN, 10, "outer") - _event(trace, calf_trace_pb2.TrackEvent.TYPE_SLICE_BEGIN, 11, "inner") - _event(trace, calf_trace_pb2.TrackEvent.TYPE_INSTANT, 12, "inner", "event") - _event(trace, calf_trace_pb2.TrackEvent.TYPE_SLICE_END, 13) - _event(trace, calf_trace_pb2.TrackEvent.TYPE_SLICE_END, 14) - - path = tmp_path / "syscall_42.perfetto-trace" - encoded = trace.SerializeToString() - path.write_bytes(encoded[:-1]) - - roots = load_trace(str(path)) - assert roots[0]["invoker"] == "outer" - assert roots[0]["events"][0]["invoker"] == "inner" - assert roots[0]["events"][0]["events"][0]["args"] == "event" - assert roots[0]["events"][0]["ts_exit"] == 13 - assert roots[0]["ts_exit"] is None - - -def test_discovers_json_and_perfetto_traces(tmp_path): - trace_dir = tmp_path / "syscall" / "host" - trace_dir.mkdir(parents=True) - (trace_dir / "syscall_1.log").write_text(json.dumps({"invoker": "json", "ts": 1})) - perfetto_dir = tmp_path / "host" - perfetto_dir.mkdir() - (perfetto_dir / "calf_42.perfetto-trace").write_bytes( - calf_trace_pb2.Trace().SerializeToString() - ) - - tabs = discover_tabs(str(tmp_path)) - assert [(tab.kind, tab.tid) for tab in tabs] == [ - ("syscall", "1"), - ("calf", "process 42 (all threads)"), - ] - - -def test_perfetto_loader_uses_bounded_reads(tmp_path, monkeypatch): - trace = calf_trace_pb2.Trace() - _event(trace, calf_trace_pb2.TrackEvent.TYPE_INSTANT, 10, "event") - path = tmp_path / "trace.perfetto-trace" - path.write_bytes(trace.SerializeToString()) - - real_open = open - - class BoundedReader: - def __init__(self, wrapped): - self.wrapped = wrapped - - def __enter__(self): - return self - - def __exit__(self, *args): - return self.wrapped.__exit__(*args) - - def read(self, size=-1): - assert size >= 0 - return self.wrapped.read(size) - - def bounded_open(*args, **kwargs): - return BoundedReader(real_open(*args, **kwargs)) - - monkeypatch.setattr("builtins.open", bounded_open) - assert load_trace(str(path))[0]["invoker"] == "event" - - -def test_web_server_creation_does_not_load_tabs(tmp_path): - from calf.profiler.web import create_web_server - - path = tmp_path / "trace.perfetto-trace" - path.write_bytes(calf_trace_pb2.Trace().SerializeToString()) - tab = TraceTab(hostname="host", kind="syscall", path=str(path)) - - server = create_web_server([tab], port=0) - try: - assert tab._roots is None - finally: - server.server_close() - - -def test_cli_does_not_load_tabs(tmp_path, monkeypatch): - from calf.profiler import __main__ as profiler_main - - trace_dir = tmp_path / "host" - trace_dir.mkdir(parents=True) - (trace_dir / "calf_42.perfetto-trace").write_bytes( - calf_trace_pb2.Trace().SerializeToString() - ) - captured_tabs = [] - - def fake_run_web(tabs, host, port): - captured_tabs.extend(tabs) - - monkeypatch.setattr("calf.profiler.web.run_web", fake_run_web) - monkeypatch.setattr(sys, "argv", ["calf", str(tmp_path)]) - - profiler_main.main() - assert captured_tabs - assert all(tab._roots is None for tab in captured_tabs) diff --git a/tests/python_bindings_tests.py b/tests/python_bindings_tests.py index 7d1fc89..ab83a7e 100644 --- a/tests/python_bindings_tests.py +++ b/tests/python_bindings_tests.py @@ -2,8 +2,6 @@ import json import os from pathlib import Path -import subprocess -import sys import calf import pytest @@ -76,13 +74,5 @@ def test_color_constants_are_exported(): assert calf.CLI_LEVEL_ERROR == "\x1b[1;31m" -def test_installed_distribution_metadata_and_cli(): +def test_installed_distribution_metadata(): assert calf.__version__ == version("capio-calf") - result = subprocess.run( - [sys.executable, "-m", "calf", "--help"], - check=False, - capture_output=True, - text=True, - ) - assert result.returncode == 0, result.stderr - assert "Web inspector and profiler" in result.stdout