First-party anonymous metrics: --stats JSONL + report tool

Page views and relay joins/session-lengths appended as JSON lines. Uniques
via daily-salted hash — no IPs, no cookies, no client-side code, no third
parties. tools/stats_report.py prints a per-day table.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-07 15:18:57 +10:00
parent 074126774c
commit 01b9f8097b
2 changed files with 90 additions and 0 deletions

View File

@ -15,6 +15,7 @@ import socketserver
import struct
import sys
import threading
import time
import webbrowser
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
@ -26,6 +27,24 @@ SESSIONS = {} # session id -> set of live WSHandler
LOCK = threading.Lock()
_SERVERS = []
TOKENS = None # None = open relay; a set = hosts must present a valid access code
STATS = None # path to a JSONL file; None = no metrics at all
def anon(ip):
"""Daily-salted hash: counts uniques per day without ever storing an IP."""
day = time.strftime('%Y-%m-%d')
return hashlib.sha1((day + '|' + (ip or '?')).encode()).hexdigest()[:10]
def log_stat(event, **fields):
if not STATS:
return
fields.update(t=int(time.time()), e=event)
try:
with open(STATS, 'a', encoding='utf-8') as f:
f.write(json.dumps(fields) + '\n')
except OSError:
pass
def local_ip():
@ -60,6 +79,15 @@ class WSHandler(socketserver.BaseRequestHandler):
def setup(self):
self.session_id = None
self.send_lock = threading.Lock()
self.hdrs = {}
self.role = None
self.joined_at = None
def _client_ip(self):
xff = self.hdrs.get(b'x-forwarded-for')
if xff:
return xff.decode('utf-8', 'replace').split(',')[0].strip()
return self.client_address[0]
def handle(self):
if not self._handshake():
@ -77,6 +105,9 @@ class WSHandler(socketserver.BaseRequestHandler):
if self.session_id:
with LOCK:
SESSIONS.get(self.session_id, set()).discard(self)
if self.joined_at:
log_stat('session', role=self.role or '?',
secs=int(time.time() - self.joined_at))
# ---- relay protocol (same JSON messages as the old websockets server) ----
@ -101,9 +132,12 @@ class WSHandler(socketserver.BaseRequestHandler):
self._send_json({'type': 'join_denied', 'reason': 'access code required'})
return
self.session_id = sid
self.role = data.get('role') or '?'
self.joined_at = time.time()
with LOCK:
SESSIONS.setdefault(self.session_id, set()).add(self)
self._send_json({'type': 'joined', 'sessionId': self.session_id})
log_stat('join', role=self.role, u=anon(self._client_ip()))
elif t in ('state', 'patientViewport'):
if TOKENS is not None and not self.session_id:
return # hardened relay only forwards for joined connections
@ -142,6 +176,7 @@ class WSHandler(socketserver.BaseRequestHandler):
if b':' in line:
k, v = line.split(b':', 1)
headers[k.strip().lower()] = v.strip()
self.hdrs = headers
key = headers.get(b'sec-websocket-key')
if not key:
return False
@ -229,6 +264,13 @@ class _QuietHTTP(SimpleHTTPRequestHandler):
def log_message(self, *args):
pass
def log_request(self, code='-', size='-'):
path = self.path.split('?')[0].split('#')[0]
if path.endswith('/') or path.endswith('.html'):
ip = (self.headers.get('X-Forwarded-For') or
self.client_address[0]).split(',')[0].strip()
log_stat('page', p=path, u=anon(ip))
def _argval(flag, default):
args = sys.argv[1:]
@ -245,6 +287,10 @@ def main():
with open(tokens_file, encoding='utf-8') as f:
TOKENS = {l.strip() for l in f if l.strip() and not l.startswith('#')}
print('relay hardened: %d access codes loaded' % len(TOKENS))
global STATS
STATS = _argval('--stats', None) # JSONL metrics: pages, joins, session lengths
if STATS:
print('metrics -> %s (anonymous: daily-salted hashes, no IPs stored)' % STATS)
socketserver.ThreadingTCPServer.allow_reuse_address = True
ThreadingHTTPServer.allow_reuse_address = True

44
tools/stats_report.py Normal file
View File

@ -0,0 +1,44 @@
"""Traffic report from server.py --stats JSONL.
python3 tools/stats_report.py /path/to/stats.jsonl [days]
"""
import json
import sys
import time
from collections import defaultdict
path = sys.argv[1] if len(sys.argv) > 1 else 'stats.jsonl'
days_back = int(sys.argv[2]) if len(sys.argv) > 2 else 14
cutoff = time.time() - days_back * 86400
daily = defaultdict(lambda: {'views': 0, 'uniq': set(), 'hosts': 0, 'patients': 0})
sessions = []
for line in open(path, encoding='utf-8'):
try:
d = json.loads(line)
except ValueError:
continue
if d.get('t', 0) < cutoff:
continue
day = time.strftime('%Y-%m-%d', time.localtime(d['t']))
row = daily[day]
if d['e'] == 'page':
row['views'] += 1
row['uniq'].add(d.get('u'))
elif d['e'] == 'join':
row['hosts' if d.get('role') == 'admin' else 'patients'] += 1
elif d['e'] == 'session' and d.get('role') == 'admin':
sessions.append(d.get('secs', 0))
print(f"{'day':12} {'views':>6} {'uniques':>8} {'hosts':>6} {'patients':>9}")
for day in sorted(daily):
r = daily[day]
print(f"{day:12} {r['views']:>6} {len(r['uniq']):>8} {r['hosts']:>6} {r['patients']:>9}")
if sessions:
sessions.sort()
med = sessions[len(sessions) // 2]
print(f"\n{len(sessions)} hosted relay sessions · median {med // 60}m{med % 60:02d}s "
f"· longest {max(sessions) // 60}m")
else:
print('\nno completed relay sessions in range')