211 lines
7.4 KiB
Python
211 lines
7.4 KiB
Python
"""
|
|
R14 Service: Log Ring Buffer.
|
|
Bounded ring buffer for capturing recent execution logs.
|
|
|
|
Features:
|
|
- O(1) append with bounded memory
|
|
- Sanitization at retrieval (not at capture)
|
|
- Thread-safe with lock-free deque
|
|
|
|
Usage:
|
|
from services.log_ring_buffer import get_ring_buffer
|
|
|
|
# Add lines (called from logger.py)
|
|
buffer = get_ring_buffer()
|
|
buffer.add_line("Processing node KSampler...")
|
|
|
|
# Retrieve recent lines (called from collect_error_context)
|
|
recent_logs = buffer.get_recent(50)
|
|
"""
|
|
|
|
import logging
|
|
from collections import deque
|
|
from typing import List, Optional, Callable
|
|
from dataclasses import dataclass, field
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# CONFIGURATION
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
@dataclass
|
|
class RingBufferConfig:
|
|
"""Configuration for the log ring buffer."""
|
|
max_lines: int = 1000 # Maximum lines to keep
|
|
sanitize_on_retrieval: bool = True # Apply S6 sanitization
|
|
filter_noise: bool = True # Filter DEBUG/VERBOSE lines
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# LOG RING BUFFER
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
class LogRingBuffer:
|
|
"""
|
|
Bounded ring buffer for recent log lines.
|
|
|
|
Uses deque(maxlen=N) for O(1) append and automatic eviction.
|
|
Thread-safe for single-writer scenarios (GIL protection).
|
|
"""
|
|
|
|
def __init__(self, config: Optional[RingBufferConfig] = None):
|
|
"""
|
|
Initialize ring buffer.
|
|
|
|
Args:
|
|
config: Buffer configuration. Uses defaults if not provided.
|
|
"""
|
|
self.config = config or RingBufferConfig()
|
|
self._buffer: deque = deque(maxlen=self.config.max_lines)
|
|
self._sanitizer: Optional[Callable[[str], str]] = None
|
|
self._header_redactor: Callable[[str], str] | None = None
|
|
|
|
# Try to load sanitizer lazily
|
|
self._load_sanitizer()
|
|
|
|
def _load_sanitizer(self) -> None:
|
|
"""Lazy load S6 sanitizer to avoid circular imports."""
|
|
try:
|
|
# Use the convenience function that returns str, not SanitizationResult
|
|
try:
|
|
from ..sanitizer import (
|
|
redact_sensitive_headers,
|
|
sanitize_for_llm,
|
|
)
|
|
except ImportError as import_error:
|
|
from import_compat import ensure_absolute_import_fallback_allowed
|
|
ensure_absolute_import_fallback_allowed(import_error)
|
|
from sanitizer import (
|
|
redact_sensitive_headers,
|
|
sanitize_for_llm,
|
|
)
|
|
self._sanitizer = sanitize_for_llm
|
|
self._header_redactor = redact_sensitive_headers
|
|
except ImportError:
|
|
# Sanitizer not available, will return raw lines
|
|
pass
|
|
|
|
def add_line(self, line: str) -> None:
|
|
"""
|
|
Add a log line to the buffer.
|
|
|
|
Args:
|
|
line: Raw log line (not sanitized yet)
|
|
"""
|
|
if not line:
|
|
return
|
|
|
|
# CRITICAL: store header-safe lines even when a caller later disables
|
|
# generic retrieval sanitization; raw credentials must never persist.
|
|
if self._header_redactor:
|
|
line = self._header_redactor(line)
|
|
|
|
# Optional noise filtering
|
|
if self.config.filter_noise:
|
|
line_lower = line.lower()
|
|
# Skip very verbose lines
|
|
if any(skip in line_lower for skip in ['debug:', '[trace]', 'verbose:']):
|
|
return
|
|
|
|
self._buffer.append(line)
|
|
|
|
def get_recent(self, n: int = 50, sanitize: Optional[bool] = None) -> List[str]:
|
|
"""
|
|
Get the most recent N log lines.
|
|
|
|
Args:
|
|
n: Number of lines to retrieve
|
|
sanitize: Override config.sanitize_on_retrieval
|
|
|
|
Returns:
|
|
List of log lines (newest last)
|
|
"""
|
|
should_sanitize = sanitize if sanitize is not None else self.config.sanitize_on_retrieval
|
|
|
|
# Get last N items
|
|
recent = list(self._buffer)[-n:]
|
|
|
|
if should_sanitize and self._sanitizer:
|
|
return [self._sanitizer(line) for line in recent]
|
|
|
|
return recent
|
|
|
|
def get_around_error(self, error_pattern: str, window: int = 25) -> List[str]:
|
|
"""
|
|
Get log lines around a specific error pattern.
|
|
|
|
Args:
|
|
error_pattern: Pattern to search for
|
|
window: Number of lines before/after to include
|
|
|
|
Returns:
|
|
List of log lines centered on the pattern
|
|
"""
|
|
all_lines = list(self._buffer)
|
|
|
|
# Find the pattern
|
|
pattern_idx = None
|
|
for i, line in enumerate(all_lines):
|
|
if error_pattern.lower() in line.lower():
|
|
pattern_idx = i
|
|
break
|
|
|
|
if pattern_idx is None:
|
|
return self.get_recent(window * 2)
|
|
|
|
# Get window around pattern
|
|
start = max(0, pattern_idx - window)
|
|
end = min(len(all_lines), pattern_idx + window + 1)
|
|
|
|
result = all_lines[start:end]
|
|
|
|
if self.config.sanitize_on_retrieval and self._sanitizer:
|
|
return [self._sanitizer(line) for line in result]
|
|
|
|
return result
|
|
|
|
def clear(self) -> None:
|
|
"""Clear all buffered lines."""
|
|
self._buffer.clear()
|
|
|
|
def __len__(self) -> int:
|
|
"""Return current buffer size."""
|
|
return len(self._buffer)
|
|
|
|
@property
|
|
def is_empty(self) -> bool:
|
|
"""Check if buffer is empty."""
|
|
return len(self._buffer) == 0
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# GLOBAL INSTANCE
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
# Global ring buffer instance (singleton pattern)
|
|
_ring_buffer: Optional[LogRingBuffer] = None
|
|
|
|
|
|
def get_ring_buffer(config: Optional[RingBufferConfig] = None) -> LogRingBuffer:
|
|
"""
|
|
Get or create the global ring buffer instance.
|
|
|
|
Args:
|
|
config: Configuration for new instance (ignored if already created)
|
|
|
|
Returns:
|
|
The global LogRingBuffer instance
|
|
"""
|
|
global _ring_buffer
|
|
|
|
if _ring_buffer is None:
|
|
_ring_buffer = LogRingBuffer(config)
|
|
|
|
return _ring_buffer
|
|
|
|
|
|
def reset_ring_buffer() -> None:
|
|
"""Reset the global ring buffer (for testing)."""
|
|
global _ring_buffer
|
|
_ring_buffer = None
|