blob: 8e555e600a3e844844d6220e1263b57c9844724d [file]
#!/usr/bin/env fuchsia-vendored-python
# Copyright 2026 The Fuchsia Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Linux-specific system-wide and process-tree resource profiler.
Outputs a consolidated JSON trace file compatible with chrome://tracing.
"""
import argparse
import bisect
import datetime
import json
import os
import signal
import time
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, Iterator, Optional, Sequence
# Type alias representing a Chrome trace event dictionary
TraceEvent = dict[str, Any]
# --- Immutable Dataclass Data Structures representing raw parsed states ---
@dataclass(frozen=True, slots=True)
class ProcStatData:
cpu_ticks: list[int]
procs_running: Optional[int]
procs_blocked: Optional[int]
intr: Optional[int]
ctxt: Optional[int]
@dataclass(frozen=True, slots=True)
class MemInfoData:
free: int
buffers: int
cached: int
swap_used: int
dirty: int
writeback: int
@dataclass(frozen=True, slots=True)
class VmStatData:
pgpgin: int
pgpgout: int
pswpin: int
pswpout: int
@dataclass(frozen=True, slots=True)
class FileNrData:
allocated: int
free_allocated: int
@dataclass(frozen=True, slots=True)
class LoadAvgData:
load1: float
load5: float
load15: float
@dataclass(frozen=True, slots=True)
class PressureLine:
avg10: float
avg60: float
avg300: float
@dataclass(frozen=True, slots=True)
class PressureData:
some: Optional[PressureLine]
full: Optional[PressureLine]
@dataclass(frozen=True, slots=True)
class InterfaceNetworkData:
ifname: str
rx_bytes: int
rx_packets: int
tx_bytes: int
tx_packets: int
# --- Pure, Module-Scope Utility Functions ---
def make_counter_event(
name: str, category: str, ts_us: int, unit: str, value: Any
) -> TraceEvent:
"""Helper to construct a standardized Chrome trace counter event dictionary.
Args:
name: The metric name (e.g., 'cpu.user').
category: The category string (e.g., 'system', 'network').
ts_us: Timestamp in microseconds since the start of profiling.
unit: The unit name of the value (e.g., 'percent', 'bytes', 'count').
value: The numerical metric value.
Returns:
A dictionary matching the Chrome Trace Counter Event schema.
"""
return {
"name": name,
"cat": category,
"ph": "C",
"pid": 1,
"tid": 1,
"ts": ts_us,
"args": {unit: value},
}
# --- Pure, Module-Scope Parsing Functions (Decoupled from I/O and self-state) ---
def parse_proc_stat(lines: list[str]) -> ProcStatData:
"""Parses /proc/stat lines into ProcStatData.
Args:
lines: Raw list of lines read from /proc/stat.
Returns:
A ProcStatData instance containing parsed CPU ticks, runnable/blocked
process counts, context switches, and interrupt counts.
"""
cpu_ticks: list[int] = []
procs_running: Optional[int] = None
procs_blocked: Optional[int] = None
intr: Optional[int] = None
ctxt: Optional[int] = None
for line in lines:
if line.startswith("cpu "):
cpu_ticks = [int(x) for x in line.split()[1:]]
elif line.startswith("procs_running "):
procs_running = int(line.split()[1])
elif line.startswith("procs_blocked "):
procs_blocked = int(line.split()[1])
elif line.startswith("intr "):
intr = int(line.split()[1])
elif line.startswith("ctxt "):
ctxt = int(line.split()[1])
return ProcStatData(
cpu_ticks=cpu_ticks,
procs_running=procs_running,
procs_blocked=procs_blocked,
intr=intr,
ctxt=ctxt,
)
def parse_proc_meminfo(lines: list[str]) -> MemInfoData:
"""Parses /proc/meminfo lines into MemInfoData.
Args:
lines: Raw list of lines read from /proc/meminfo.
Returns:
A MemInfoData instance with calculated values in bytes representing free,
buffered, cached, swap used, dirty, and writeback memory.
"""
mem_dict = {}
for line in lines:
parts = line.split()
if len(parts) >= 2:
mem_dict[parts[0].strip(":")] = int(parts[1]) * 1024
free = mem_dict.get("MemFree", 0)
buffers = mem_dict.get("Buffers", 0)
cached = (
mem_dict.get("Cached", 0)
+ mem_dict.get("SReclaimable", 0)
- mem_dict.get("Shmem", 0)
)
swap_used = mem_dict.get("SwapTotal", 0) - mem_dict.get("SwapFree", 0)
dirty = mem_dict.get("Dirty", 0)
writeback = mem_dict.get("Writeback", 0)
return MemInfoData(
free=free,
buffers=buffers,
cached=cached,
swap_used=swap_used,
dirty=dirty,
writeback=writeback,
)
def parse_proc_vmstat(lines: list[str]) -> VmStatData:
"""Parses /proc/vmstat lines into VmStatData.
Args:
lines: Raw list of lines read from /proc/vmstat.
Returns:
A VmStatData instance containing page in/out and swap in/out counts in bytes.
"""
vm_dict = {}
for line in lines:
parts = line.split()
if len(parts) == 2:
vm_dict[parts[0]] = int(parts[1])
# pgpgin/pgpgout are in KiB, convert to Bytes
pg_in = vm_dict.get("pgpgin", 0) * 1024
pg_out = vm_dict.get("pgpgout", 0) * 1024
# pswpin/pswpout are in pages (typically 4KiB), convert to Bytes
pswp_in = vm_dict.get("pswpin", 0) * 4096
pswp_out = vm_dict.get("pswpout", 0) * 4096
return VmStatData(
pgpgin=pg_in,
pgpgout=pg_out,
pswpin=pswp_in,
pswpout=pswp_out,
)
def parse_file_nr(content: str) -> FileNrData:
"""Parses /proc/sys/fs/file-nr content into FileNrData.
Args:
content: The text content of /proc/sys/fs/file-nr.
Returns:
A FileNrData instance containing system-wide allocated and free allocated
file descriptors.
"""
parts = content.split()
allocated = int(parts[0]) if len(parts) > 0 else 0
free_allocated = int(parts[1]) if len(parts) > 1 else 0
return FileNrData(allocated=allocated, free_allocated=free_allocated)
def parse_loadavg(content: str) -> Optional[LoadAvgData]:
"""Parses /proc/loadavg content into LoadAvgData.
Args:
content: The text content of /proc/loadavg.
Returns:
A LoadAvgData instance containing 1m, 5m, and 15m system load averages,
or None if parsing fails.
"""
parts = content.split()
if len(parts) >= 3:
try:
return LoadAvgData(
load1=float(parts[0]),
load5=float(parts[1]),
load15=float(parts[2]),
)
except ValueError:
pass
return None
def parse_pressure_file(lines: list[str]) -> PressureData:
"""Parses a pressure file (e.g. /proc/pressure/cpu) into PressureData.
Args:
lines: Raw list of lines read from a pressure stall information file.
Returns:
A PressureData instance containing 10s, 60s, 300s avg stalls for 'some'
and 'full' categories, where applicable.
"""
some: Optional[PressureLine] = None
full: Optional[PressureLine] = None
for line in lines:
parts = line.split()
if not parts:
continue
prefix = parts[0]
metrics = {}
for item in parts[1:]:
if "=" in item:
k, v = item.split("=", 1)
metrics[k] = float(v)
if "avg10" in metrics and "avg60" in metrics and "avg300" in metrics:
line_data = PressureLine(
avg10=metrics["avg10"],
avg60=metrics["avg60"],
avg300=metrics["avg300"],
)
if prefix == "some":
some = line_data
elif prefix == "full":
full = line_data
return PressureData(some=some, full=full)
def parse_net_dev(lines: list[str]) -> list[InterfaceNetworkData]:
"""Parses /proc/net/dev lines into a list of InterfaceNetworkData.
Filters out loopback, virtual, and completely inactive interfaces.
Args:
lines: Raw list of lines read from /proc/net/dev.
Returns:
A list of InterfaceNetworkData instances, one per active physical interface.
"""
results: list[InterfaceNetworkData] = []
# Skip headers (first two lines)
for line in lines[2:]:
parts = line.split()
if len(parts) < 17:
continue
ifname = parts[0].strip(":")
# Filter out loopback and virtual/inactive interfaces
if ifname == "lo" or ifname.startswith(
("veth", "docker", "br-", "virbr", "dummy", "tun", "tap")
):
continue
try:
rx_bytes, rx_packets = int(parts[1]), int(parts[2])
tx_bytes, tx_packets = int(parts[9]), int(parts[10])
except ValueError:
continue
# Skip interfaces with absolutely no traffic
if rx_bytes == 0 and tx_bytes == 0:
continue
results.append(
InterfaceNetworkData(
ifname=ifname,
rx_bytes=rx_bytes,
rx_packets=rx_packets,
tx_bytes=tx_bytes,
tx_packets=tx_packets,
)
)
return results
def get_process_tree_pids(proc_dir: Path, parent_pid: int) -> set[int]:
"""Constructs a set of all active descendant PIDs of a parent PID.
Optimized using sorting and binary search (bisect_left) to run in
O(N log N) time, avoiding slow O(N^2) linear map scans.
Args:
proc_dir: Path to the proc directory filesystem (e.g. Path("/proc")).
parent_pid: The root process PID to traverse.
Returns:
A set of PIDs including the parent_pid and all its child and descendant
PIDs found active in proc_dir.
"""
pids = {parent_pid}
try:
# Build an array of (ppid, child_pid) pairs
parent_map: list[tuple[int, int]] = []
for entry in os.scandir(proc_dir):
if entry.is_dir() and entry.name.isdigit():
try:
with open(os.path.join(entry.path, "stat"), "r") as f:
stat_line = f.read()
rpar_idx = stat_line.rfind(")")
fields = stat_line[rpar_idx + 2 :].split()
ppid = int(fields[1])
parent_map.append((ppid, int(entry.name)))
except (IOError, ValueError, IndexError):
continue
# Sort to support O(log N) binary search
parent_map.sort()
# Traverse the tree from parent_pid
queue = [parent_pid]
while queue:
curr = queue.pop(-1)
# Find the left-most index matching (curr, 0)
pos = bisect.bisect_left(parent_map, (curr, 0))
while pos < len(parent_map):
ppid, child = parent_map[pos]
pos += 1
if ppid != curr:
break
if child not in pids:
pids.add(child)
queue.append(child)
except Exception:
pass
return pids
def count_process_tree_fds(proc_dir: Path, pids: set[int]) -> int:
"""Counts the total number of open file descriptors in the process tree.
Args:
proc_dir: Path to the proc directory filesystem (e.g. Path("/proc")).
pids: The set of PIDs to count file descriptors for.
Returns:
An integer representing the sum of open file descriptors across all pids.
"""
total_fds = 0
for pid in pids:
try:
total_fds += len(os.listdir(proc_dir / str(pid) / "fd"))
except (IOError, FileNotFoundError):
continue
return total_fds
# --- Pluggable Metric Sampler Base Interface ---
class MetricSampler:
"""Interface for isolated, state-encapsulated metric pollers."""
def sample(
self, ts_us: int, elapsed: Optional[float]
) -> Iterator[TraceEvent]:
"""Polls raw performance stats and yields Chrome Trace counter events.
Args:
ts_us: Microseconds since start of session.
elapsed: Seconds elapsed since previous polling loop.
"""
raise NotImplementedError("Subclasses must implement sample")
# --- Concrete, Pluggable Sampler Implementations ---
class CpuAndSystemSampler(MetricSampler):
"""Sampler for CPU utilization, context switches, interrupts, and task counts.
Polls '/proc/stat' and tracks internal state to compute relative CPU state
percentages, system-wide context switches per second, and interrupts per second.
Metrics yielded:
- cpu.user, cpu.system, cpu.idle, cpu.wait_io, cpu.stolen, cpu.kvm_guest (percent)
- processes.running, processes.blocked (count)
- system.interrupts (count_per_second)
- system.context_switches (count_per_second)
"""
def __init__(self, proc_dir: Path):
self._proc_dir = proc_dir
self._prev_cpu_ticks: Optional[list[int]] = None
self._prev_intr: Optional[int] = None
self._prev_ctxt: Optional[int] = None
def sample(
self, ts_us: int, elapsed: Optional[float]
) -> Iterator[TraceEvent]:
try:
with open(self._proc_dir / "stat", "r") as f:
lines = f.readlines()
except IOError:
return
data = parse_proc_stat(lines)
# 1. CPU utilisation tick percentages
if data.cpu_ticks:
ticks = data.cpu_ticks
if elapsed and self._prev_cpu_ticks:
deltas = [t - p for t, p in zip(ticks, self._prev_cpu_ticks)]
total_delta = sum(deltas)
if total_delta > 0:
yield make_counter_event(
"cpu.user",
"system",
ts_us,
"percent",
(deltas[0] + deltas[1]) / total_delta * 100,
)
yield make_counter_event(
"cpu.system",
"system",
ts_us,
"percent",
deltas[2] / total_delta * 100,
)
yield make_counter_event(
"cpu.idle",
"system",
ts_us,
"percent",
deltas[3] / total_delta * 100,
)
yield make_counter_event(
"cpu.wait_io",
"system",
ts_us,
"percent",
deltas[4] / total_delta * 100,
)
if len(deltas) > 7:
yield make_counter_event(
"cpu.stolen",
"system",
ts_us,
"percent",
deltas[7] / total_delta * 100,
)
if len(deltas) > 8:
yield make_counter_event(
"cpu.kvm_guest",
"system",
ts_us,
"percent",
deltas[8] / total_delta * 100,
)
self._prev_cpu_ticks = ticks
# 2. Running/Blocked processes
if data.procs_running is not None:
yield make_counter_event(
"processes.running",
"system",
ts_us,
"count",
data.procs_running,
)
if data.procs_blocked is not None:
yield make_counter_event(
"processes.blocked",
"system",
ts_us,
"count",
data.procs_blocked,
)
# 3. System interrupts
if data.intr is not None:
total_intr = data.intr
if elapsed and self._prev_intr is not None:
yield make_counter_event(
"system.interrupts",
"system",
ts_us,
"count_per_second",
(total_intr - self._prev_intr) / elapsed,
)
self._prev_intr = total_intr
# 4. Context switches
if data.ctxt is not None:
total_ctxt = data.ctxt
if elapsed and self._prev_ctxt is not None:
yield make_counter_event(
"system.context_switches",
"system",
ts_us,
"count_per_second",
(total_ctxt - self._prev_ctxt) / elapsed,
)
self._prev_ctxt = total_ctxt
class MemorySampler(MetricSampler):
"""Sampler for system-wide memory allocation states from /proc/meminfo.
Polls '/proc/meminfo' on each tick and parses various memory usage categories
in bytes. This class is stateless since it only reports absolute, current quantities.
Metrics yielded:
- memory.free: Available physical RAM bytes.
- memory.buffers: Disk block buffer cache bytes.
- memory.cache: Page cache RAM bytes (incorporating SReclaimable, subtracting Shmem).
- memory.swap_used: Swapped memory bytes.
- memory.dirty: RAM bytes awaiting writeback to disk.
- memory.writeback: RAM bytes actively being written back to disk.
"""
def __init__(self, proc_dir: Path):
self._proc_dir = proc_dir
def sample(
self, ts_us: int, elapsed: Optional[float]
) -> Iterator[TraceEvent]:
try:
with open(self._proc_dir / "meminfo", "r") as f:
lines = f.readlines()
except IOError:
return
data = parse_proc_meminfo(lines)
yield make_counter_event(
"memory.free", "system", ts_us, "bytes", data.free
)
yield make_counter_event(
"memory.buffers", "system", ts_us, "bytes", data.buffers
)
yield make_counter_event(
"memory.cache", "system", ts_us, "bytes", data.cached
)
yield make_counter_event(
"memory.swap_used", "system", ts_us, "bytes", data.swap_used
)
yield make_counter_event(
"memory.dirty", "system", ts_us, "bytes", data.dirty
)
yield make_counter_event(
"memory.writeback", "system", ts_us, "bytes", data.writeback
)
class DiskIoAndPagingSampler(MetricSampler):
"""Sampler for system-wide virtual memory paging and swap rates from /proc/vmstat.
Polls '/proc/vmstat' on each tick and computes average disk paging and swap I/O
bandwidth in bytes per second by tracking historical counters across polls.
Metrics yielded:
- block.in: Disk blocks read into memory (bytes_per_second).
- block.out: Disk blocks written out of memory (bytes_per_second).
- swap.in: Swapped-in pages from swap space (bytes_per_second).
- swap.out: Swapped-out pages to swap space (bytes_per_second).
"""
def __init__(self, proc_dir: Path):
self._proc_dir = proc_dir
self._prev_pgpg: Optional[tuple[int, int]] = None
self._prev_pswp: Optional[tuple[int, int]] = None
def sample(
self, ts_us: int, elapsed: Optional[float]
) -> Iterator[TraceEvent]:
try:
with open(self._proc_dir / "vmstat", "r") as f:
lines = f.readlines()
except IOError:
return
data = parse_proc_vmstat(lines)
if elapsed and self._prev_pgpg:
yield make_counter_event(
"block.in",
"system",
ts_us,
"bytes_per_second",
(data.pgpgin - self._prev_pgpg[0]) / elapsed,
)
yield make_counter_event(
"block.out",
"system",
ts_us,
"bytes_per_second",
(data.pgpgout - self._prev_pgpg[1]) / elapsed,
)
if elapsed and self._prev_pswp:
yield make_counter_event(
"swap.in",
"system",
ts_us,
"bytes_per_second",
(data.pswpin - self._prev_pswp[0]) / elapsed,
)
yield make_counter_event(
"swap.out",
"system",
ts_us,
"bytes_per_second",
(data.pswpout - self._prev_pswp[1]) / elapsed,
)
self._prev_pgpg = (data.pgpgin, data.pgpgout)
self._prev_pswp = (data.pswpin, data.pswpout)
class FileDescriptorSampler(MetricSampler):
"""Sampler for system-wide and process-tree open file descriptor counts.
Polls '/proc/sys/fs/file-nr' to track system-wide allocated file descriptors,
and optionally walks the descendant process tree of a given target PID to
sum all open files under '/proc/[PID]/fd/'.
Metrics yielded:
- fds.system: Total allocated file descriptors system-wide (count).
- fds.process: Sum of open file descriptors across the target PID's tree (count).
"""
def __init__(self, proc_dir: Path, target_pid: Optional[int] = None):
self._proc_dir = proc_dir
self._target_pid = target_pid
def sample(
self, ts_us: int, elapsed: Optional[float]
) -> Iterator[TraceEvent]:
try:
with open(self._proc_dir / "sys/fs/file-nr", "r") as f:
content = f.read()
data = parse_file_nr(content)
yield make_counter_event(
"fds.system",
"system",
ts_us,
"count",
data.allocated - data.free_allocated,
)
except IOError:
pass
if self._target_pid:
pids = get_process_tree_pids(self._proc_dir, self._target_pid)
if pids:
fds_count = count_process_tree_fds(self._proc_dir, pids)
yield make_counter_event(
"fds.process", "process", ts_us, "count", fds_count
)
class LoadAverageSampler(MetricSampler):
"""Sampler for 1-minute, 5-minute, and 15-minute system load averages from /proc/loadavg.
Polls '/proc/loadavg' on each tick to parse CPU and task load averages. This
class is stateless since it only reports absolute averages computed by the kernel.
Metrics yielded:
- loadavg.1min: 1-minute load average (load).
- loadavg.5min: 5-minute load average (load).
- loadavg.15min: 15-minute load average (load).
"""
def __init__(self, proc_dir: Path):
self._proc_dir = proc_dir
def sample(
self, ts_us: int, elapsed: Optional[float]
) -> Iterator[TraceEvent]:
try:
with open(self._proc_dir / "loadavg", "r") as f:
content = f.read()
data = parse_loadavg(content)
if data:
yield make_counter_event(
"loadavg.1min", "system", ts_us, "load", data.load1
)
yield make_counter_event(
"loadavg.5min", "system", ts_us, "load", data.load5
)
yield make_counter_event(
"loadavg.15min", "system", ts_us, "load", data.load15
)
except IOError:
pass
class PressureSampler(MetricSampler):
"""Sampler for Pressure Stall Information (PSI) averages for CPU, memory, and IO resources.
Polls files under '/proc/pressure/' (cpu, memory, io) to capture average resource
stalls. This provides high-fidelity signals when system execution is resource-constrained.
Metrics yielded:
- pressure.[resource].some.avg10: Percentage of time some tasks were stalled (percent).
- pressure.[resource].full.avg10: Percentage of time all tasks were stalled (percent).
"""
def __init__(self, proc_dir: Path):
self._proc_dir = proc_dir
def sample(
self, ts_us: int, elapsed: Optional[float]
) -> Iterator[TraceEvent]:
for resource in ("cpu", "memory", "io"):
try:
path = self._proc_dir / "pressure" / resource
if not path.exists():
continue
with open(path, "r") as f:
lines = f.readlines()
data = parse_pressure_file(lines)
if data.some:
yield make_counter_event(
f"pressure.{resource}.some.avg10",
"pressure",
ts_us,
"percent",
data.some.avg10,
)
if data.full:
yield make_counter_event(
f"pressure.{resource}.full.avg10",
"pressure",
ts_us,
"percent",
data.full.avg10,
)
except IOError:
pass
class NetworkSampler(MetricSampler):
"""Sampler for upload/download bytes and packet bandwidth from /proc/net/dev.
Polls '/proc/net/dev' on each tick, filters virtual/loopback interfaces, and
calculates upload/download bandwidth and packet rates by comparing absolute
byte/packet counters between consecutive polls.
Metrics yielded:
- network.[interface].rx.bytes / network.[interface].tx.bytes (bytes_per_second)
- network.[interface].rx.packets / network.[interface].tx.packets (count_per_second)
- network.rx.bytes / network.tx.bytes: Summed system-wide download/upload bandwidth (bytes_per_second)
- network.rx.packets / network.tx.packets: Summed system-wide download/upload packet rate (count_per_second)
"""
def __init__(self, proc_dir: Path):
self._proc_dir = proc_dir
self._prev_net: dict[str, tuple[int, int, int, int]] = {}
def sample(
self, ts_us: int, elapsed: Optional[float]
) -> Iterator[TraceEvent]:
try:
with open(self._proc_dir / "net/dev", "r") as f:
lines = f.readlines()
except IOError:
return
parsed_interfaces = parse_net_dev(lines)
total_rx_bytes, total_rx_pkts = 0.0, 0.0
total_tx_bytes, total_tx_pkts = 0.0, 0.0
for item in parsed_interfaces:
ifname = item.ifname
rx_bytes, rx_pkts = item.rx_bytes, item.rx_packets
tx_bytes, tx_pkts = item.tx_bytes, item.tx_packets
if elapsed and ifname in self._prev_net:
prev_rx_b, prev_rx_p, prev_tx_b, prev_tx_p = self._prev_net[
ifname
]
rx_bytes_sec = (rx_bytes - prev_rx_b) / elapsed
rx_pkts_sec = (rx_pkts - prev_rx_p) / elapsed
tx_bytes_sec = (tx_bytes - prev_tx_b) / elapsed
tx_pkts_sec = (tx_pkts - prev_tx_p) / elapsed
# Per-interface details
yield make_counter_event(
f"network.{ifname}.rx.bytes",
"network",
ts_us,
"bytes_per_second",
rx_bytes_sec,
)
yield make_counter_event(
f"network.{ifname}.rx.packets",
"network",
ts_us,
"count_per_second",
rx_pkts_sec,
)
yield make_counter_event(
f"network.{ifname}.tx.bytes",
"network",
ts_us,
"bytes_per_second",
tx_bytes_sec,
)
yield make_counter_event(
f"network.{ifname}.tx.packets",
"network",
ts_us,
"count_per_second",
tx_pkts_sec,
)
# Add to aggregate summary
total_rx_bytes += rx_bytes_sec
total_rx_pkts += rx_pkts_sec
total_tx_bytes += tx_bytes_sec
total_tx_pkts += tx_pkts_sec
self._prev_net[ifname] = (rx_bytes, rx_pkts, tx_bytes, tx_pkts)
if elapsed and (total_rx_bytes > 0 or total_tx_bytes > 0):
# Consolidated System-Wide Ingress (download) / Egress (upload) Summary
yield make_counter_event(
"network.rx.bytes",
"network",
ts_us,
"bytes_per_second",
total_rx_bytes,
)
yield make_counter_event(
"network.rx.packets",
"network",
ts_us,
"count_per_second",
total_rx_pkts,
)
yield make_counter_event(
"network.tx.bytes",
"network",
ts_us,
"bytes_per_second",
total_tx_bytes,
)
yield make_counter_event(
"network.tx.packets",
"network",
ts_us,
"count_per_second",
total_tx_pkts,
)
# --- Pluggable Coordinator Profiler Engine ---
class SystemProfiler:
"""Stateful sampler engine coordinating pluggable list of sub-samplers.
This class is completely decoupled from I/O writing, making it extremely
easy to instantiate and unit-test in memory.
Example:
sampler = SystemProfiler(
samplers=[CpuAndSystemSampler(Path("/proc"))],
metadata={"BUILD_UUID": "abc"},
time_fn=time.time
)
with streaming_trace_writer(Path("system_profile.json")) as write_event:
# Poll/sample loop
for event in sampler.sample(ts_us=100000):
write_event(event)
"""
def __init__(
self,
samplers: Sequence[MetricSampler],
metadata: Optional[dict[str, str]],
time_fn: Callable[[], float],
):
"""Initializes the coordinator SystemProfiler.
Args:
samplers: A list of concrete MetricSamplers to coordinate.
metadata: Optional dictionary of metadata to append to the trace file.
time_fn: Required function to retrieve current epoch timestamp.
"""
self._samplers = samplers
self._metadata = metadata or {}
self._time_fn = time_fn
self._start_time = datetime.datetime.now(datetime.timezone.utc)
self._is_first_sample = True
# State to compute rates
self._prev_time: Optional[float] = None
def sample(self, ts_us: int) -> Iterator[TraceEvent]:
"""Executes a single sampling loop yielding trace events across all sub-samplers.
Calculates the time elapsed since the previous call to `sample()`
automatically, encapsulation-safe.
Args:
ts_us: Timestamp in microseconds since profiling started.
"""
now = self._time_fn()
# Yield metadata block as the very first trace event
if self._is_first_sample:
self._is_first_sample = False
elapsed = None
self._prev_time = now
# Derive start_time cleanly from our single time source
start_time = datetime.datetime.fromtimestamp(
now, datetime.timezone.utc
)
yield {
"name": "system_profiler_metadata",
"cat": "metadata",
"ph": "M",
"args": {
"version": 1,
**self._metadata,
"start_time": start_time.isoformat(),
},
}
else:
elapsed = (now - self._prev_time) if self._prev_time else None
self._prev_time = now
# Drive and yield events from all pluggable samplers
for sampler in self._samplers:
yield from sampler.sample(ts_us, elapsed)
# --- Stateful JSON File Streaming Trace Writer Context Manager ---
# Chrome Trace Format (JSON Array Schema) Documentation:
# --------------------------------------------------------
# The profiler outputs a flat JSON array containing TraceEvent dictionaries.
# This format is fully compatible with chrome://tracing and ui.perfetto.dev.
#
# File structure:
# [
# <initial metadata block (ph: "M")>,
# <telemetry counter event 1 (ph: "C")>,
# <telemetry counter event 2 (ph: "C")>,
# ...
# ]
#
# 1. Telemetry Counter Event (ph: "C") Schema:
# Represented as a JSON object indicating a resource sample at a specific tick:
# {
# "name": "cpu.user", # Metric name (e.g., 'cpu.user', 'memory.free', etc.)
# "cat": "system", # Metric category (e.g., 'system', 'network', 'pressure')
# "ph": "C", # Event phase: 'C' stands for Counter Event
# "pid": 1, # Hardcoded process ID context (arbitrary for system-wide stats)
# "tid": 1, # Hardcoded thread ID context
# "ts": 123450, # Elapsed microseconds since profiling start (int)
# "args": { "percent": 15.5 } # Key-value arguments representing counter name and sampled value
# }
#
# 2. Metadata Event (ph: "M") Schema:
# The very first element in the array containing session properties and schema versioning:
# {
# "name": "system_profiler_metadata",
# "cat": "metadata",
# "ph": "M", # Event phase: 'M' stands for Metadata Event
# "args": {
# "version": 1, # Schema version (integer)
# "start_time": "2026-...", # ISO-8601 utc session start string
# "FX_BUILD_UUID": "..." # Optional build run UUID tag
# }
# }
@contextmanager
def streaming_trace_writer(
output_path: Path,
) -> Iterator[Callable[[TraceEvent], None]]:
"""A completely generic streaming JSON array writer context manager.
Creates and opens the destination file, writes the array header,
yields a simple callback function to append events, and cleanly
appends the closing bracket on exiting the context block.
Args:
output_path: Destination path for the output JSON trace file.
Yields:
A callable function that accepts a TraceEvent and appends it to the file.
"""
output_path.parent.mkdir(parents=True, exist_ok=True)
with open(output_path, "w", buffering=1, encoding="utf-8") as f:
f.write("[\n")
is_first_event = True
def write_event(event: TraceEvent) -> None:
nonlocal is_first_event
if is_first_event:
f.write(" " + json.dumps(event))
is_first_event = False
else:
f.write(",\n " + json.dumps(event))
f.flush()
try:
yield write_event
finally:
f.write("\n]\n")
# --- Execution Driver ---
def run_profiler(
interval_sec: float,
output_path: Path,
target_pid: Optional[int],
metadata: dict[str, str],
proc_dir: str,
time_fn: Callable[[], float],
) -> None:
"""Coordinates signal handling, loop timing, and drives sampling data into the streamer.
Args:
interval_sec: Sampling interval in seconds.
output_path: Path to the output JSON trace file.
target_pid: Optional process PID to profile descendant FDs.
metadata: Meta fields to include in the trace.
proc_dir: Path to the proc filesystem root.
time_fn: Required function to retrieve current epoch timestamp.
"""
proc_path = Path(proc_dir)
samplers: list[MetricSampler] = [
CpuAndSystemSampler(proc_path),
MemorySampler(proc_path),
DiskIoAndPagingSampler(proc_path),
FileDescriptorSampler(proc_path, target_pid),
LoadAverageSampler(proc_path),
PressureSampler(proc_path),
NetworkSampler(proc_path),
]
sampler = SystemProfiler(
samplers=samplers,
metadata=metadata,
time_fn=time_fn,
)
running = True
def handler(signum: int, frame: Any) -> None:
nonlocal running
running = False
signal.signal(signal.SIGINT, handler)
signal.signal(signal.SIGTERM, handler)
signal.signal(signal.SIGHUP, handler)
start_time_epoch = time_fn()
with streaming_trace_writer(output_path) as write_event:
while running:
start_loop = time_fn()
now = time_fn()
ts_us = int((now - start_time_epoch) * 1_000_000)
for event in sampler.sample(ts_us):
write_event(event)
elapsed_loop = time_fn() - start_loop
sleep_time = max(0.01, interval_sec - elapsed_loop)
time.sleep(sleep_time)
def main() -> None:
"""Core command-line execution entry point for the utility."""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--interval",
type=float,
default=1.0,
help="Sampling interval in seconds",
)
parser.add_argument(
"--output",
type=Path,
required=True,
help="Path to write Chrome trace JSON file",
)
parser.add_argument(
"--pid",
type=int,
help="Target root PID to profile (creates descendant FD counts)",
)
parser.add_argument(
"--metadata",
type=str,
help="Comma-separated key:value pairs for trace metadata",
)
parser.add_argument(
"--proc-dir",
default="/proc",
help="Custom path for proc filesystem (for testing)",
)
args = parser.parse_args()
metadata_dict = {}
if args.metadata:
for item in args.metadata.split(","):
if ":" in item:
k, v = item.split(":", 1)
metadata_dict[k] = v
run_profiler(
interval_sec=args.interval,
output_path=args.output,
target_pid=args.pid,
metadata=metadata_dict,
proc_dir=args.proc_dir,
time_fn=time.time,
)
if __name__ == "__main__":
main()