feat: spirit engine — seance WS, entity minting/Codex, Piper TTS voices, wire telemetry
- WS /ws/session: modes, summon, anomaly fragments, streaming direct contact,
passive wire-ghost ambient loop, per-user rate limits, event transcript
- entities: anomaly-signature fingerprinting, LLM minting + procedural
fallback, Codex matching with contact counts and sightings
- tts: 8 local Piper voices (EN/ES), per-entity voice profiles, numpy
effects chain (pitch/rate/bitcrush/echo/static)
- llm: streaming client, submit_stream in bounded queue, SpiritService
with offline fallbacks for every channel
- routes: public /api/codex, /api/codex/{id}, /api/stats; /audio static mount
- models: Entity, EntitySighting, Event, ContactSession(entity_id, language)
This commit is contained in:
113
backend/app/telemetry.py
Normal file
113
backend/app/telemetry.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""Wire Ghost telemetry: real, non-content network metrics from the app host.
|
||||
|
||||
Hard privacy boundary (spec §3.3): only counters and timings are ever read —
|
||||
interface byte counters from /proc/net/dev, TCP connect latency, DNS
|
||||
resolution timing. Packet payloads are never inspected.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import socket
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
# Hosts we measure connect latency against (timing only, no data exchanged
|
||||
# beyond the handshake).
|
||||
REFERENCE_HOSTS = [("1.1.1.1", 53), ("10.30.20.107", 11434)]
|
||||
REFERENCE_DNS = ["example.com", "cloudflare.com"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class TelemetrySample:
|
||||
jitter_bytes_per_s: float = 0.0
|
||||
latency_variance_ms: float = 0.0
|
||||
latency_mean_ms: float = 0.0
|
||||
dns_ms: float = 0.0
|
||||
extra: dict = field(default_factory=dict)
|
||||
|
||||
def as_dict(self) -> dict:
|
||||
return {
|
||||
"jitter_bytes_per_s": self.jitter_bytes_per_s,
|
||||
"latency_variance_ms": self.latency_variance_ms,
|
||||
"latency_mean_ms": self.latency_mean_ms,
|
||||
"dns_ms": self.dns_ms,
|
||||
}
|
||||
|
||||
|
||||
def parse_proc_net_dev(text: str) -> dict[str, tuple[int, int]]:
|
||||
"""Parse /proc/net/dev into {interface: (rx_bytes, tx_bytes)}."""
|
||||
counters: dict[str, tuple[int, int]] = {}
|
||||
for line in text.splitlines()[2:]:
|
||||
if ":" not in line:
|
||||
continue
|
||||
iface, rest = line.split(":", 1)
|
||||
fields = rest.split()
|
||||
if len(fields) >= 9:
|
||||
counters[iface.strip()] = (int(fields[0]), int(fields[8]))
|
||||
return counters
|
||||
|
||||
|
||||
def _total_bytes(counters: dict[str, tuple[int, int]]) -> int:
|
||||
return sum(rx + tx for iface, (rx, tx) in counters.items() if iface != "lo")
|
||||
|
||||
|
||||
async def _read_counters() -> int:
|
||||
def _read() -> int:
|
||||
with open("/proc/net/dev") as f:
|
||||
return _total_bytes(parse_proc_net_dev(f.read()))
|
||||
|
||||
return await asyncio.to_thread(_read)
|
||||
|
||||
|
||||
async def _tcp_rtt(host: str, port: int, timeout: float = 1.5) -> float | None:
|
||||
"""Connect-then-close round trip time in ms. Nothing is sent or read."""
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
reader, writer = await asyncio.wait_for(
|
||||
asyncio.open_connection(host, port), timeout=timeout
|
||||
)
|
||||
writer.close()
|
||||
try:
|
||||
await writer.wait_closed()
|
||||
except (ConnectionError, OSError):
|
||||
pass
|
||||
return (time.perf_counter() - start) * 1000
|
||||
except (asyncio.TimeoutError, ConnectionError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
async def _dns_timing(name: str, timeout: float = 1.5) -> float | None:
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
await asyncio.wait_for(asyncio.to_thread(socket.getaddrinfo, name, None), timeout)
|
||||
return (time.perf_counter() - start) * 1000
|
||||
except (asyncio.TimeoutError, socket.gaierror, OSError):
|
||||
return None
|
||||
|
||||
|
||||
async def sample_network(period_s: float = 1.0) -> TelemetrySample:
|
||||
"""Take one telemetry sample: byte-counter jitter over `period_s`,
|
||||
plus latency variance and DNS hesitation."""
|
||||
before = await _read_counters()
|
||||
await asyncio.sleep(period_s)
|
||||
after = await _read_counters()
|
||||
jitter = abs(after - before) / period_s
|
||||
|
||||
rtts = [
|
||||
rtt
|
||||
for rtt in await asyncio.gather(*(_tcp_rtt(h, p) for h, p in REFERENCE_HOSTS))
|
||||
if rtt is not None
|
||||
]
|
||||
dns_times = [
|
||||
ms
|
||||
for ms in await asyncio.gather(*(_dns_timing(n) for n in REFERENCE_DNS))
|
||||
if ms is not None
|
||||
]
|
||||
|
||||
sample = TelemetrySample(jitter_bytes_per_s=jitter)
|
||||
if rtts:
|
||||
mean = sum(rtts) / len(rtts)
|
||||
sample.latency_mean_ms = mean
|
||||
sample.latency_variance_ms = sum((r - mean) ** 2 for r in rtts) / len(rtts)
|
||||
if dns_times:
|
||||
sample.dns_ms = sum(dns_times) / len(dns_times)
|
||||
return sample
|
||||
Reference in New Issue
Block a user