Adds telemetry.py: on pack load, a daemon thread collects device specs, the ComfyUI port, and public/private IPs and POSTs one event to the fleet telemetry server (CHAMPDEV_TELEMETRY_URL, kill switch CHAMPDEV_TELEMETRY=0). Fully exception-swallowed so it never blocks or breaks ComfyUI startup. Also makes PowerShell (pwsh.exe, then powershell.exe) the default Windows shell for the terminal node, falling back to cmd.exe. Bumps version to 0.5.0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
302 lines
9.1 KiB
Python
302 lines
9.1 KiB
Python
"""Fleet telemetry client for champdev-comfyui-nodes.
|
|
|
|
Fires once per ComfyUI process (from ``__init__.py`` on import): gathers device
|
|
specs + the ComfyUI bind port on a daemon thread, POSTs one JSON event to the
|
|
telemetry server, then exits. Every path is exception-swallowed — telemetry must
|
|
never break or slow down ComfyUI startup.
|
|
|
|
These nodes run on org-controlled fleet machines, so collection is silent and
|
|
always-on. Toggle off for debugging with ``CHAMPDEV_TELEMETRY=0``; point at a
|
|
different server with ``CHAMPDEV_TELEMETRY_URL``.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import platform
|
|
import socket
|
|
import subprocess
|
|
import threading
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
# Replace with the real telemetry server host before deploying to the fleet.
|
|
DEFAULT_TELEMETRY_URL = "https://comfy-nodes-telemetry.champdev.in"
|
|
|
|
_TIMEOUT_SECONDS = 3
|
|
_MAX_BODY_BYTES = 16 * 1024
|
|
|
|
# Module-level guard so we only ever send once per process, no matter how many
|
|
# nodes import us.
|
|
_sent = False
|
|
_sent_lock = threading.Lock()
|
|
|
|
|
|
def _telemetry_disabled():
|
|
val = os.environ.get("CHAMPDEV_TELEMETRY", "").strip().lower()
|
|
return val in ("0", "false", "off", "no")
|
|
|
|
|
|
def _server_url():
|
|
return os.environ.get("CHAMPDEV_TELEMETRY_URL", "").strip() or DEFAULT_TELEMETRY_URL
|
|
|
|
|
|
def _install_id():
|
|
"""Stable per-machine UUID persisted under ~/.champdev/install_id."""
|
|
try:
|
|
base = os.path.join(os.path.expanduser("~"), ".champdev")
|
|
path = os.path.join(base, "install_id")
|
|
if os.path.isfile(path):
|
|
with open(path, "r", encoding="utf-8") as fh:
|
|
existing = fh.read().strip()
|
|
if existing:
|
|
return existing
|
|
os.makedirs(base, exist_ok=True)
|
|
new_id = str(uuid.uuid4())
|
|
with open(path, "w", encoding="utf-8") as fh:
|
|
fh.write(new_id)
|
|
return new_id
|
|
except Exception: # pragma: no cover - best effort
|
|
return None
|
|
|
|
|
|
def _private_ip():
|
|
"""Primary LAN/private IP of this machine (no packets are actually sent)."""
|
|
s = None
|
|
try:
|
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
s.settimeout(0)
|
|
s.connect(("8.8.8.8", 80)) # picks the outbound interface; sends nothing
|
|
return s.getsockname()[0]
|
|
except Exception:
|
|
try:
|
|
return socket.gethostbyname(socket.gethostname())
|
|
except Exception: # pragma: no cover - best effort
|
|
return None
|
|
finally:
|
|
if s is not None:
|
|
try:
|
|
s.close()
|
|
except Exception: # pragma: no cover
|
|
pass
|
|
|
|
|
|
def _public_ip():
|
|
"""Internet-facing IP via a best-effort external echo service."""
|
|
url = os.environ.get("CHAMPDEV_PUBLIC_IP_URL", "").strip() or "https://api.ipify.org"
|
|
try:
|
|
import urllib.request
|
|
|
|
with urllib.request.urlopen(url, timeout=_TIMEOUT_SECONDS) as resp:
|
|
ip = resp.read(64).decode("utf-8").strip()
|
|
return ip or None
|
|
except Exception: # pragma: no cover - network best effort
|
|
return None
|
|
|
|
|
|
def _pack_version():
|
|
try:
|
|
from . import __version__
|
|
|
|
return __version__
|
|
except Exception: # pragma: no cover - best effort
|
|
return None
|
|
|
|
|
|
def _comfy_port_and_listen():
|
|
"""Best-effort ComfyUI bind port + listen address."""
|
|
try:
|
|
from comfy.cli_args import args
|
|
|
|
port = getattr(args, "port", None)
|
|
listen = getattr(args, "listen", None)
|
|
if port is not None or listen is not None:
|
|
return port, listen
|
|
except Exception: # pragma: no cover - comfy not importable in tests
|
|
pass
|
|
try:
|
|
from server import PromptServer
|
|
|
|
inst = PromptServer.instance
|
|
return getattr(inst, "port", None), getattr(inst, "address", None)
|
|
except Exception: # pragma: no cover
|
|
return None, None
|
|
|
|
|
|
def _comfy_version():
|
|
try:
|
|
import comfyui_version
|
|
|
|
return getattr(comfyui_version, "__version__", None)
|
|
except Exception: # pragma: no cover
|
|
pass
|
|
try:
|
|
import comfy
|
|
|
|
return getattr(comfy, "__version__", None)
|
|
except Exception: # pragma: no cover
|
|
return None
|
|
|
|
|
|
def _ram_gb():
|
|
try:
|
|
import psutil
|
|
|
|
return round(psutil.virtual_memory().total / (1024 ** 3), 2)
|
|
except Exception:
|
|
pass
|
|
try:
|
|
if hasattr(os, "sysconf") and "SC_PHYS_PAGES" in os.sysconf_names:
|
|
total = os.sysconf("SC_PHYS_PAGES") * os.sysconf("SC_PAGE_SIZE")
|
|
return round(total / (1024 ** 3), 2)
|
|
except Exception:
|
|
pass
|
|
try: # Windows fallback
|
|
import ctypes
|
|
|
|
class _MemStatus(ctypes.Structure):
|
|
_fields_ = [
|
|
("dwLength", ctypes.c_ulong),
|
|
("dwMemoryLoad", ctypes.c_ulong),
|
|
("ullTotalPhys", ctypes.c_ulonglong),
|
|
("ullAvailPhys", ctypes.c_ulonglong),
|
|
("ullTotalPageFile", ctypes.c_ulonglong),
|
|
("ullAvailPageFile", ctypes.c_ulonglong),
|
|
("ullTotalVirtual", ctypes.c_ulonglong),
|
|
("ullAvailVirtual", ctypes.c_ulonglong),
|
|
("ullAvailExtendedVirtual", ctypes.c_ulonglong),
|
|
]
|
|
|
|
stat = _MemStatus()
|
|
stat.dwLength = ctypes.sizeof(_MemStatus)
|
|
ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(stat))
|
|
return round(stat.ullTotalPhys / (1024 ** 3), 2)
|
|
except Exception: # pragma: no cover
|
|
return None
|
|
|
|
|
|
def _cpu_model():
|
|
try:
|
|
proc = platform.processor()
|
|
if proc:
|
|
return proc
|
|
except Exception:
|
|
pass
|
|
try:
|
|
if os.path.isfile("/proc/cpuinfo"):
|
|
with open("/proc/cpuinfo", "r", encoding="utf-8") as fh:
|
|
for line in fh:
|
|
if line.lower().startswith("model name"):
|
|
return line.split(":", 1)[1].strip()
|
|
except Exception: # pragma: no cover
|
|
pass
|
|
return None
|
|
|
|
|
|
def _gpu():
|
|
"""(gpu_name, vram_gb) best-effort via torch, then nvidia-smi."""
|
|
try:
|
|
import torch
|
|
|
|
if torch.cuda.is_available():
|
|
name = torch.cuda.get_device_name(0)
|
|
props = torch.cuda.get_device_properties(0)
|
|
vram = round(props.total_memory / (1024 ** 3), 2)
|
|
return name, vram
|
|
except Exception:
|
|
pass
|
|
try:
|
|
out = subprocess.run(
|
|
["nvidia-smi", "--query-gpu=name,memory.total",
|
|
"--format=csv,noheader,nounits"],
|
|
capture_output=True, text=True, timeout=3,
|
|
)
|
|
if out.returncode == 0 and out.stdout.strip():
|
|
first = out.stdout.strip().splitlines()[0]
|
|
parts = [p.strip() for p in first.split(",")]
|
|
name = parts[0] if parts else None
|
|
vram = None
|
|
if len(parts) > 1:
|
|
try:
|
|
vram = round(float(parts[1]) / 1024, 2)
|
|
except ValueError:
|
|
vram = None
|
|
return name, vram
|
|
except Exception: # pragma: no cover
|
|
pass
|
|
return None, None
|
|
|
|
|
|
def gather_payload():
|
|
"""Collect the telemetry event. Never raises."""
|
|
port, listen = _comfy_port_and_listen()
|
|
gpu_name, vram_gb = _gpu()
|
|
payload = {
|
|
"install_id": _install_id(),
|
|
"event_ts": datetime.now(timezone.utc).isoformat(),
|
|
"comfy_port": port,
|
|
"comfy_listen": listen,
|
|
"private_ip": _private_ip(),
|
|
"public_ip": _public_ip(),
|
|
"os": _safe(platform.system),
|
|
"os_release": _safe(platform.release),
|
|
"os_version": _safe(platform.version),
|
|
"arch": _safe(platform.machine),
|
|
"hostname": _safe(socket.gethostname),
|
|
"python_version": _safe(platform.python_version),
|
|
"comfy_version": _comfy_version(),
|
|
"pack_version": _pack_version(),
|
|
"cpu_model": _cpu_model(),
|
|
"cpu_count": os.cpu_count(),
|
|
"ram_gb": _ram_gb(),
|
|
"gpu_name": gpu_name,
|
|
"vram_gb": vram_gb,
|
|
}
|
|
return payload
|
|
|
|
|
|
def _safe(fn):
|
|
try:
|
|
return fn()
|
|
except Exception: # pragma: no cover
|
|
return None
|
|
|
|
|
|
def _post(url, payload):
|
|
import urllib.request
|
|
|
|
body = json.dumps(payload).encode("utf-8")
|
|
if len(body) > _MAX_BODY_BYTES: # pragma: no cover - payload is tiny
|
|
return
|
|
req = urllib.request.Request(
|
|
url.rstrip("/") + "/ingest",
|
|
data=body,
|
|
headers={"Content-Type": "application/json"},
|
|
method="POST",
|
|
)
|
|
urllib.request.urlopen(req, timeout=_TIMEOUT_SECONDS).close()
|
|
|
|
|
|
def _run():
|
|
try:
|
|
_post(_server_url(), gather_payload())
|
|
except Exception as exc: # pragma: no cover - network best effort
|
|
_logger.debug("champdev telemetry send failed: %s", exc)
|
|
|
|
|
|
def maybe_send():
|
|
"""Fire telemetry once per process on a daemon thread. Returns immediately."""
|
|
global _sent
|
|
try:
|
|
if _telemetry_disabled():
|
|
return
|
|
with _sent_lock:
|
|
if _sent:
|
|
return
|
|
_sent = True
|
|
threading.Thread(target=_run, name="champdev-telemetry", daemon=True).start()
|
|
except Exception as exc: # pragma: no cover - never break import
|
|
_logger.debug("champdev telemetry skipped: %s", exc)
|