Files
rays eb86f889b3 fix(logging): unify Doctor startup output
Standardize backend log identities and severity highlighting while keeping persisted logs ANSI-free. Restore the encoding-aware startup banner and emit its art as one tightly packed block.
2026-08-09 05:38:01 +08:00

551 lines
20 KiB
Python

"""
Telemetry module for ComfyUI-Doctor.
Provides opt-in anonymous usage data collection with local-only storage (Phase 1-3).
All data is stored locally; no network upload in Phase 1-3.
Features:
- TelemetryStore: Thread-safe event buffer with atomic file writes
- RateLimiter: Token bucket algorithm (60 events/minute)
- Event validation: Allowlist-based category/action/label validation
- PII detection: Blacklist scan for sensitive patterns
"""
import json
import os
import re
import threading
import time
import tempfile
import uuid
from dataclasses import dataclass, field, asdict
from datetime import timedelta
from typing import Optional, List, Dict, Set, Tuple, Any
try:
try:
from .services.doctor_paths import get_doctor_data_dir as _get_canonical_doctor_data_dir
except ImportError as import_error:
from import_compat import ensure_absolute_import_fallback_allowed
ensure_absolute_import_fallback_allowed(import_error)
from services.doctor_paths import get_doctor_data_dir as _get_canonical_doctor_data_dir
except Exception:
_get_canonical_doctor_data_dir = None
try:
from .services.time_utils import parse_utc_timestamp, utc_filename_timestamp, utc_isoformat, utc_now
except ImportError as import_error:
from import_compat import ensure_absolute_import_fallback_allowed
ensure_absolute_import_fallback_allowed(import_error)
from services.time_utils import parse_utc_timestamp, utc_filename_timestamp, utc_isoformat, utc_now
try:
from .terminal_output import emit_doctor_log
except ImportError as import_error:
from import_compat import ensure_absolute_import_fallback_allowed
ensure_absolute_import_fallback_allowed(import_error)
from terminal_output import emit_doctor_log
# ═══════════════════════════════════════════════════════════════════════════
# CONSTANTS
# ═══════════════════════════════════════════════════════════════════════════
SCHEMA_VERSION = "1.0"
MAX_EVENTS = 500
MAX_LABEL_LENGTH = 64
MAX_VALUE = 10000
EVENT_TTL_DAYS = 7
# Allowlist for category → actions
ALLOWED_EVENTS: Dict[str, List[str]] = {
"feature": ["tab_switch"],
"analysis": ["pattern_matched", "llm_called"],
"resolution": ["marked"],
"session": ["start", "end"],
}
# Allowlist for labels per category/action
ALLOWED_LABELS: Dict[str, Dict[str, List[str]]] = {
"feature": {
"tab_switch": ["chat", "stats", "settings"],
},
"analysis": {
"pattern_matched": [], # Dynamic: loaded from PatternLoader
"llm_called": [
"openai", "deepseek", "anthropic", "ollama",
"lmstudio", "gemini", "groq", "openrouter", "xai", "custom"
],
},
"resolution": {
"marked": ["resolved", "unresolved", "ignored"],
},
"session": {
"start": [],
"end": [],
},
}
# PII detection patterns (blacklist)
PII_PATTERNS = [
re.compile(r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}'), # Email
re.compile(r'[A-Za-z]:\\Users\\[^\\]+'), # Windows user path
re.compile(r'/home/[^/]+'), # Linux home
re.compile(r'/Users/[^/]+'), # macOS home
re.compile(r'sk-[a-zA-Z0-9]{20,}'), # API key pattern
]
# ═══════════════════════════════════════════════════════════════════════════
# DATA CLASSES
# ═══════════════════════════════════════════════════════════════════════════
@dataclass
class TelemetryEvent:
"""Represents a single telemetry event."""
schema_version: str
event_id: str
timestamp: str
category: str
action: str
label: Optional[str] = None
value: Optional[int] = None
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for JSON serialization."""
result = {
"schema_version": self.schema_version,
"event_id": self.event_id,
"timestamp": self.timestamp,
"category": self.category,
"action": self.action,
}
if self.label is not None:
result["label"] = self.label
if self.value is not None:
result["value"] = self.value
return result
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "TelemetryEvent":
"""Create from dictionary."""
return cls(
schema_version=data.get("schema_version", SCHEMA_VERSION),
event_id=data.get("event_id", ""),
timestamp=data.get("timestamp", ""),
category=data.get("category", ""),
action=data.get("action", ""),
label=data.get("label"),
value=data.get("value"),
)
# ═══════════════════════════════════════════════════════════════════════════
# RATE LIMITER
# ═══════════════════════════════════════════════════════════════════════════
# R7: Import shared RateLimiter from rate_limiter.py (avoid code duplication)
try:
from .rate_limiter import RateLimiter
except ImportError as import_error:
from import_compat import ensure_absolute_import_fallback_allowed
ensure_absolute_import_fallback_allowed(import_error)
from rate_limiter import RateLimiter
# ═══════════════════════════════════════════════════════════════════════════
# PATTERN ALLOWLIST
# ═══════════════════════════════════════════════════════════════════════════
_pattern_cache: Optional[Set[str]] = None
_pattern_cache_time: float = 0
PATTERN_CACHE_TTL = 300 # 5 minutes
def get_pattern_allowlist() -> Set[str]:
"""Get the current set of valid pattern IDs from PatternLoader."""
global _pattern_cache, _pattern_cache_time
now = time.time()
if _pattern_cache is not None and (now - _pattern_cache_time) < PATTERN_CACHE_TTL:
return _pattern_cache
try:
try:
from .pattern_loader import PatternLoader
except ImportError as import_error:
from import_compat import ensure_absolute_import_fallback_allowed
ensure_absolute_import_fallback_allowed(import_error)
from pattern_loader import PatternLoader
loader = PatternLoader()
patterns = loader.get_all_patterns()
_pattern_cache = {p.get("id", "") for p in patterns if p.get("id")}
_pattern_cache_time = now
return _pattern_cache
except Exception:
# If PatternLoader fails, return empty set (all patterns → __unknown__)
return set()
def validate_pattern_label(label: str) -> str:
"""Validate pattern ID, return __unknown__ if not in allowlist."""
if not label:
return "__unknown__"
allowlist = get_pattern_allowlist()
if label in allowlist:
return label
return "__unknown__"
# ═══════════════════════════════════════════════════════════════════════════
# VALIDATION
# ═══════════════════════════════════════════════════════════════════════════
def contains_pii(text: str) -> bool:
"""Check if text contains PII patterns."""
if not text:
return False
for pattern in PII_PATTERNS:
if pattern.search(text):
return True
return False
def validate_event(data: Dict[str, Any]) -> Tuple[bool, str, Optional[TelemetryEvent]]:
"""
Validate event data against schema and allowlists.
Returns:
(is_valid, error_message, event_or_none)
"""
# 1. Required fields
category = data.get("category")
action = data.get("action")
if not category or not action:
return False, "Missing required field: category or action", None
# 2. Category allowlist
if category not in ALLOWED_EVENTS:
return False, f"Invalid category: {category}", None
# 3. Action allowlist
if action not in ALLOWED_EVENTS[category]:
return False, f"Invalid action: {action} for category {category}", None
# 4. Label validation
label = data.get("label")
if label is not None:
# Check length
if len(label) > MAX_LABEL_LENGTH:
return False, f"Label too long: {len(label)} > {MAX_LABEL_LENGTH}", None
# Check PII
if contains_pii(label):
return False, "Label contains PII pattern", None
# Check allowlist
allowed_labels = ALLOWED_LABELS.get(category, {}).get(action, [])
if allowed_labels: # Static allowlist exists
if label not in allowed_labels:
return False, f"Invalid label: {label}", None
elif category == "analysis" and action == "pattern_matched":
# Dynamic pattern validation
label = validate_pattern_label(label)
# 5. Value validation
value = data.get("value")
if value is not None:
if not isinstance(value, int):
return False, "Value must be integer", None
if value < 0 or value > MAX_VALUE:
return False, f"Value out of range: {value}", None
# Create event
event = TelemetryEvent(
schema_version=SCHEMA_VERSION,
event_id=str(uuid.uuid4()),
timestamp=utc_isoformat(),
category=category,
action=action,
label=label,
value=value,
)
return True, "", event
# ═══════════════════════════════════════════════════════════════════════════
# TELEMETRY STORE
# ═══════════════════════════════════════════════════════════════════════════
def get_doctor_data_dir() -> str:
"""
Get the Doctor data directory (cross-platform).
R18: Prefer the shared canonical resolver (Desktop/portable safe).
Fallback: OS temp dir (never write under Desktop resources).
"""
if _get_canonical_doctor_data_dir:
try:
return _get_canonical_doctor_data_dir()
except Exception:
pass
data_dir = os.path.join(tempfile.gettempdir(), "ComfyUI-Doctor")
try:
os.makedirs(data_dir, exist_ok=True)
except Exception:
pass
return data_dir
def _legacy_telemetry_path() -> str:
extension_root = os.path.dirname(os.path.abspath(__file__))
return os.path.join(extension_root, "data", "telemetry.json")
def _migrate_legacy_telemetry_file(target_path: str) -> None:
"""
One-time best-effort migration from legacy `<extension_root>/data/telemetry.json`
to the canonical data dir path.
"""
legacy_path = _legacy_telemetry_path()
if legacy_path == target_path:
return
if not os.path.exists(legacy_path) or os.path.exists(target_path):
return
try:
os.makedirs(os.path.dirname(target_path), exist_ok=True)
with open(legacy_path, "rb") as src:
payload = src.read()
with open(target_path, "wb") as dst:
dst.write(payload)
ts = utc_filename_timestamp()
os.rename(legacy_path, f"{legacy_path}.migrated-{ts}")
except Exception:
# Migration failures must not break startup or telemetry.
pass
def get_telemetry_path() -> str:
"""Get telemetry file path."""
target_path = os.path.join(get_doctor_data_dir(), "telemetry.json")
_migrate_legacy_telemetry_file(target_path)
return target_path
class TelemetryStore:
"""
Thread-safe telemetry event storage with atomic file writes.
Features:
- Max 500 events buffer
- 7-day TTL auto-purge
- Atomic write via temp file + rename
- Single-writer lock
"""
def __init__(self, filepath: Optional[str] = None, enabled: bool = False):
"""
Initialize telemetry store.
Args:
filepath: Path to telemetry JSON file (default: auto-detect)
enabled: Whether telemetry is enabled
"""
self._filepath = filepath or get_telemetry_path()
self._enabled = enabled
self._lock = threading.Lock()
self._buffer: List[TelemetryEvent] = []
self._loaded = False
self._rate_limiter = RateLimiter(max_per_minute=60)
@property
def enabled(self) -> bool:
"""Check if telemetry is enabled."""
return self._enabled
@enabled.setter
def enabled(self, value: bool) -> None:
"""Set telemetry enabled state."""
self._enabled = value
@property
def filepath(self) -> str:
"""Get the telemetry file path."""
return self._filepath
def _load(self) -> None:
"""Load events from file (internal, called with lock held)."""
if self._loaded:
return
try:
if os.path.exists(self._filepath):
with open(self._filepath, "r", encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, list):
self._buffer = [
TelemetryEvent.from_dict(e)
for e in data
if isinstance(e, dict)
]
except (json.JSONDecodeError, OSError) as e:
emit_doctor_log(f"Telemetry load warning: {e}", "WARNING")
self._buffer = []
self._loaded = True
def _save_atomic(self) -> None:
"""Save events atomically via temp file + rename."""
try:
# Ensure directory exists
dir_path = os.path.dirname(self._filepath)
if dir_path and not os.path.exists(dir_path):
os.makedirs(dir_path, exist_ok=True)
# Write to temp file
temp_path = self._filepath + ".tmp"
with open(temp_path, "w", encoding="utf-8") as f:
json.dump(
[e.to_dict() for e in self._buffer],
f,
ensure_ascii=False,
indent=2,
)
# Atomic rename
os.replace(temp_path, self._filepath)
except OSError as e:
emit_doctor_log(f"Telemetry save warning: {e}", "WARNING")
def _purge_old_events(self) -> None:
"""Remove events older than TTL."""
cutoff = utc_now() - timedelta(days=EVENT_TTL_DAYS)
self._buffer = [
event
for event in self._buffer
if (parse_utc_timestamp(event.timestamp) or cutoff) >= cutoff
]
def _enforce_limit(self) -> None:
"""Enforce max events limit (remove oldest)."""
if len(self._buffer) > MAX_EVENTS:
self._buffer = self._buffer[-MAX_EVENTS:]
def track(self, data: Dict[str, Any]) -> Tuple[bool, str]:
"""
Record a telemetry event.
Args:
data: Event data (category, action, label?, value?)
Returns:
(success, message)
"""
# Check if enabled
if not self._enabled:
return False, "Telemetry disabled"
# Rate limit
if not self._rate_limiter.allow():
return False, "Rate limited"
# Validate
is_valid, error_msg, event = validate_event(data)
if not is_valid or event is None:
return False, error_msg
# Store
with self._lock:
self._load()
self._buffer.append(event)
self._purge_old_events()
self._enforce_limit()
self._save_atomic()
return True, "Event recorded"
def get_buffer(self) -> List[Dict[str, Any]]:
"""Get all buffered events as dictionaries."""
with self._lock:
self._load()
return [e.to_dict() for e in self._buffer]
def get_stats(self) -> Dict[str, Any]:
"""Get buffer statistics."""
with self._lock:
self._load()
if not self._buffer:
return {
"count": 0,
"oldest": None,
"newest": None,
}
return {
"count": len(self._buffer),
"oldest": self._buffer[0].timestamp if self._buffer else None,
"newest": self._buffer[-1].timestamp if self._buffer else None,
}
def clear(self) -> None:
"""Clear all buffered events."""
with self._lock:
self._buffer = []
self._save_atomic()
def export_json(self) -> str:
"""Export buffer as JSON string."""
with self._lock:
self._load()
return json.dumps(
[e.to_dict() for e in self._buffer],
ensure_ascii=False,
indent=2,
)
def __len__(self) -> int:
"""Return buffer size."""
with self._lock:
self._load()
return len(self._buffer)
# ═══════════════════════════════════════════════════════════════════════════
# SINGLETON INSTANCE
# ═══════════════════════════════════════════════════════════════════════════
# Global telemetry store instance (disabled by default)
_telemetry_store: Optional[TelemetryStore] = None
def get_telemetry_store() -> TelemetryStore:
"""Get the global telemetry store instance."""
global _telemetry_store
if _telemetry_store is None:
_telemetry_store = TelemetryStore(enabled=False)
return _telemetry_store
def track_event(category: str, action: str, label: Optional[str] = None, value: Optional[int] = None) -> bool:
"""
Convenience function to track a telemetry event.
Args:
category: Event category (feature, analysis, resolution, session)
action: Event action
label: Optional label
value: Optional numeric value
Returns:
True if event was recorded, False otherwise
"""
store = get_telemetry_store()
success, _ = store.track({
"category": category,
"action": action,
"label": label,
"value": value,
})
return success