"""Metadata-only runtime telemetry for the voice cascade. The tracker is intentionally process-local. It never stores session IDs, transcripts, provider payloads, or audio bytes. A multi-worker deployment must sample every worker separately and aggregate outside the application. """ from __future__ import annotations import os import platform import secrets import threading import time from dataclasses import dataclass from datetime import UTC, datetime from pathlib import Path from pydantic import BaseModel, ConfigDict, Field VOICE_AUDIO_BUFFER_MAX_BYTES = 10 * 1024 * 1024 VOICE_STREAMING_EVENT_QUEUE_MAX_ITEMS = 32 VOICE_UVICORN_WS_MAX_QUEUE = 4 class VoiceRuntimeLimits(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True) max_utterance_audio_bytes: int = Field(ge=1) streaming_event_queue_max_items: int = Field(ge=1) uvicorn_ws_max_queue: int = Field(ge=1) class VoiceRuntimeProcess(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True) worker_instance_id: str = Field(min_length=16, max_length=64) pid: int = Field(ge=1) platform: str started_at_utc: str uptime_seconds: float = Field(ge=0.0) rss_bytes: int = Field(ge=0) peak_rss_bytes: int = Field(ge=0) cpu_user_seconds: float = Field(ge=0.0) cpu_system_seconds: float = Field(ge=0.0) threads: int = Field(ge=1) open_file_descriptors: int | None = Field(default=None, ge=0) class VoiceRuntimeCounters(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True) active_websockets: int = Field(ge=0) websocket_high_water: int = Field(ge=0) websockets_opened_total: int = Field(ge=0) active_streaming_provider_sessions: int = Field(ge=0) streaming_provider_session_high_water: int = Field(ge=0) streaming_provider_sessions_opened_total: int = Field(ge=0) route_audio_buffer_bytes: int = Field(ge=0) route_audio_buffer_high_water_bytes: int = Field(ge=0) audio_bytes_received_total: int = Field(ge=0) audio_overflow_rejections_total: int = Field(ge=0) streaming_event_queue_items: int = Field(ge=0) streaming_event_queue_high_water_items: int = Field(ge=0) streaming_event_queue_saturation_total: int = Field(ge=0) streaming_event_queue_wait_seconds_total: float = Field(ge=0.0) provider_finalize_total: int = Field(ge=0) provider_abort_total: int = Field(ge=0) provider_fallback_total: int = Field(ge=0) websocket_error_total: int = Field(ge=0) class VoiceRuntimeSnapshot(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True) schema_version: str = "vignette.voice-runtime.v1" scope: str = "single_api_worker" privacy_boundary: str = "metadata_only_no_audio_transcript_or_session_ids" reset_supported: bool = False limits: VoiceRuntimeLimits process: VoiceRuntimeProcess counters: VoiceRuntimeCounters @dataclass(slots=True) class _ConnectionState: audio_buffer_bytes: int = 0 streaming_queue_items: int = 0 def _utc_now() -> str: return datetime.now(UTC).isoformat().replace("+00:00", "Z") def _linux_process_memory_bytes() -> tuple[int, int]: status_path = Path("/proc/self/status") if not status_path.is_file(): return 0, 0 values: dict[str, int] = {} try: for line in status_path.read_text(encoding="ascii").splitlines(): if line.startswith(("VmRSS:", "VmHWM:")): key, raw = line.split(":", 1) values[key] = int(raw.strip().split()[0]) * 1024 except (OSError, UnicodeError, ValueError, IndexError): return 0, 0 current = max(0, values.get("VmRSS", 0)) peak = max(current, values.get("VmHWM", 0)) return current, peak def _windows_process_memory_bytes() -> tuple[int, int]: if os.name != "nt": return 0, 0 try: import ctypes from ctypes import wintypes class ProcessMemoryCounters(ctypes.Structure): _fields_ = [ ("cb", wintypes.DWORD), ("PageFaultCount", wintypes.DWORD), ("PeakWorkingSetSize", ctypes.c_size_t), ("WorkingSetSize", ctypes.c_size_t), ("QuotaPeakPagedPoolUsage", ctypes.c_size_t), ("QuotaPagedPoolUsage", ctypes.c_size_t), ("QuotaPeakNonPagedPoolUsage", ctypes.c_size_t), ("QuotaNonPagedPoolUsage", ctypes.c_size_t), ("PagefileUsage", ctypes.c_size_t), ("PeakPagefileUsage", ctypes.c_size_t), ] kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) psapi = ctypes.WinDLL("psapi", use_last_error=True) kernel32.GetCurrentProcess.restype = wintypes.HANDLE psapi.GetProcessMemoryInfo.argtypes = [ wintypes.HANDLE, ctypes.POINTER(ProcessMemoryCounters), wintypes.DWORD, ] psapi.GetProcessMemoryInfo.restype = wintypes.BOOL counters = ProcessMemoryCounters() counters.cb = ctypes.sizeof(counters) handle = kernel32.GetCurrentProcess() ok = psapi.GetProcessMemoryInfo( handle, ctypes.byref(counters), counters.cb, ) if not ok: return 0, 0 current = max(0, int(counters.WorkingSetSize)) peak = max(current, int(counters.PeakWorkingSetSize)) return current, peak except (AttributeError, OSError, TypeError, ValueError): return 0, 0 def process_memory_bytes() -> tuple[int, int]: """Return current and peak RSS when the host exposes process counters.""" current, peak = _linux_process_memory_bytes() if current or peak: return current, peak current, peak = _windows_process_memory_bytes() if current or peak: return current, peak try: import resource usage = resource.getrusage(resource.RUSAGE_SELF) raw_peak = max(0, int(usage.ru_maxrss)) scaled_peak = raw_peak if platform.system() == "Darwin" else raw_peak * 1024 return 0, scaled_peak except (ImportError, OSError, ValueError): return 0, 0 def _open_file_descriptor_count() -> int | None: fd_root = Path("/proc/self/fd") if not fd_root.is_dir(): return None try: return sum(1 for _ in fd_root.iterdir()) except OSError: return None class VoiceRuntimeMetrics: """Thread-safe, metadata-only high-water tracker for one API worker.""" def __init__(self) -> None: self._lock = threading.Lock() self._started_monotonic = time.monotonic() self._started_at_utc = _utc_now() self._worker_instance_id = secrets.token_hex(12) self._next_connection_id = 0 self._connections: dict[int, _ConnectionState] = {} self._websocket_high_water = 0 self._websockets_opened_total = 0 self._active_streaming_provider_sessions = 0 self._streaming_provider_session_high_water = 0 self._streaming_provider_sessions_opened_total = 0 self._route_audio_buffer_bytes = 0 self._route_audio_buffer_high_water_bytes = 0 self._audio_bytes_received_total = 0 self._audio_overflow_rejections_total = 0 self._streaming_event_queue_items = 0 self._streaming_event_queue_high_water_items = 0 self._streaming_event_queue_saturation_total = 0 self._streaming_event_queue_wait_seconds_total = 0.0 self._provider_finalize_total = 0 self._provider_abort_total = 0 self._provider_fallback_total = 0 self._websocket_error_total = 0 def websocket_opened(self) -> int: with self._lock: self._next_connection_id += 1 connection_id = self._next_connection_id self._connections[connection_id] = _ConnectionState() self._websockets_opened_total += 1 self._websocket_high_water = max( self._websocket_high_water, len(self._connections), ) return connection_id def websocket_closed(self, connection_id: int) -> None: with self._lock: connection = self._connections.pop(connection_id, None) if connection is not None: self._route_audio_buffer_bytes = max( 0, self._route_audio_buffer_bytes - connection.audio_buffer_bytes, ) self._streaming_event_queue_items = max( 0, self._streaming_event_queue_items - connection.streaming_queue_items, ) def audio_chunk_received( self, connection_id: int, *, current_buffer_bytes: int, chunk_bytes: int, ) -> None: with self._lock: connection = self._connections.get(connection_id) if connection is None: return next_buffer_bytes = max(0, current_buffer_bytes) self._route_audio_buffer_bytes = max( 0, self._route_audio_buffer_bytes - connection.audio_buffer_bytes + next_buffer_bytes, ) connection.audio_buffer_bytes = next_buffer_bytes self._audio_bytes_received_total += max(0, chunk_bytes) self._route_audio_buffer_high_water_bytes = max( self._route_audio_buffer_high_water_bytes, self._route_audio_buffer_bytes, ) def audio_buffer_cleared(self, connection_id: int) -> None: with self._lock: connection = self._connections.get(connection_id) if connection is not None: self._route_audio_buffer_bytes = max( 0, self._route_audio_buffer_bytes - connection.audio_buffer_bytes, ) connection.audio_buffer_bytes = 0 def audio_overflow_rejected(self, connection_id: int) -> None: with self._lock: self._audio_overflow_rejections_total += 1 connection = self._connections.get(connection_id) if connection is not None: self._route_audio_buffer_bytes = max( 0, self._route_audio_buffer_bytes - connection.audio_buffer_bytes, ) connection.audio_buffer_bytes = 0 def streaming_queue_observed( self, connection_id: int, *, queue_items: int, saturated: bool = False, wait_seconds: float = 0.0, ) -> None: with self._lock: connection = self._connections.get(connection_id) if connection is None: return next_queue_items = max(0, queue_items) self._streaming_event_queue_items = max( 0, self._streaming_event_queue_items - connection.streaming_queue_items + next_queue_items, ) connection.streaming_queue_items = next_queue_items self._streaming_event_queue_high_water_items = max( self._streaming_event_queue_high_water_items, self._streaming_event_queue_items, ) if saturated: self._streaming_event_queue_saturation_total += 1 self._streaming_event_queue_wait_seconds_total += max( 0.0, wait_seconds, ) def streaming_provider_opened(self) -> None: with self._lock: self._active_streaming_provider_sessions += 1 self._streaming_provider_sessions_opened_total += 1 self._streaming_provider_session_high_water = max( self._streaming_provider_session_high_water, self._active_streaming_provider_sessions, ) def streaming_provider_closed(self, *, outcome: str) -> None: with self._lock: self._active_streaming_provider_sessions = max( 0, self._active_streaming_provider_sessions - 1, ) if outcome == "finalized": self._provider_finalize_total += 1 else: self._provider_abort_total += 1 def provider_fallback(self) -> None: with self._lock: self._provider_fallback_total += 1 def websocket_error(self) -> None: with self._lock: self._websocket_error_total += 1 def snapshot(self) -> VoiceRuntimeSnapshot: with self._lock: counters = VoiceRuntimeCounters( active_websockets=len(self._connections), websocket_high_water=self._websocket_high_water, websockets_opened_total=self._websockets_opened_total, active_streaming_provider_sessions=( self._active_streaming_provider_sessions ), streaming_provider_session_high_water=( self._streaming_provider_session_high_water ), streaming_provider_sessions_opened_total=( self._streaming_provider_sessions_opened_total ), route_audio_buffer_bytes=self._route_audio_buffer_bytes, route_audio_buffer_high_water_bytes=( self._route_audio_buffer_high_water_bytes ), audio_bytes_received_total=self._audio_bytes_received_total, audio_overflow_rejections_total=( self._audio_overflow_rejections_total ), streaming_event_queue_items=self._streaming_event_queue_items, streaming_event_queue_high_water_items=( self._streaming_event_queue_high_water_items ), streaming_event_queue_saturation_total=( self._streaming_event_queue_saturation_total ), streaming_event_queue_wait_seconds_total=round( self._streaming_event_queue_wait_seconds_total, 6, ), provider_finalize_total=self._provider_finalize_total, provider_abort_total=self._provider_abort_total, provider_fallback_total=self._provider_fallback_total, websocket_error_total=self._websocket_error_total, ) rss_bytes, peak_rss_bytes = process_memory_bytes() process_times = os.times() return VoiceRuntimeSnapshot( limits=VoiceRuntimeLimits( max_utterance_audio_bytes=VOICE_AUDIO_BUFFER_MAX_BYTES, streaming_event_queue_max_items=( VOICE_STREAMING_EVENT_QUEUE_MAX_ITEMS ), uvicorn_ws_max_queue=VOICE_UVICORN_WS_MAX_QUEUE, ), process=VoiceRuntimeProcess( worker_instance_id=self._worker_instance_id, pid=os.getpid(), platform=platform.system().lower() or "unknown", started_at_utc=self._started_at_utc, uptime_seconds=round( max(0.0, time.monotonic() - self._started_monotonic), 3, ), rss_bytes=rss_bytes, peak_rss_bytes=max(rss_bytes, peak_rss_bytes), cpu_user_seconds=max(0.0, float(process_times.user)), cpu_system_seconds=max(0.0, float(process_times.system)), threads=max(1, threading.active_count()), open_file_descriptors=_open_file_descriptor_count(), ), counters=counters, ) voice_runtime_metrics = VoiceRuntimeMetrics() __all__ = [ "VOICE_AUDIO_BUFFER_MAX_BYTES", "VOICE_STREAMING_EVENT_QUEUE_MAX_ITEMS", "VOICE_UVICORN_WS_MAX_QUEUE", "VoiceRuntimeCounters", "VoiceRuntimeLimits", "VoiceRuntimeMetrics", "VoiceRuntimeProcess", "VoiceRuntimeSnapshot", "process_memory_bytes", "voice_runtime_metrics", ]