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:
parent
074126774c
commit
01b9f8097b
46
server.py
46
server.py
@ -15,6 +15,7 @@ import socketserver
|
|||||||
import struct
|
import struct
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
|
import time
|
||||||
import webbrowser
|
import webbrowser
|
||||||
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
@ -26,6 +27,24 @@ SESSIONS = {} # session id -> set of live WSHandler
|
|||||||
LOCK = threading.Lock()
|
LOCK = threading.Lock()
|
||||||
_SERVERS = []
|
_SERVERS = []
|
||||||
TOKENS = None # None = open relay; a set = hosts must present a valid access code
|
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():
|
def local_ip():
|
||||||
@ -60,6 +79,15 @@ class WSHandler(socketserver.BaseRequestHandler):
|
|||||||
def setup(self):
|
def setup(self):
|
||||||
self.session_id = None
|
self.session_id = None
|
||||||
self.send_lock = threading.Lock()
|
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):
|
def handle(self):
|
||||||
if not self._handshake():
|
if not self._handshake():
|
||||||
@ -77,6 +105,9 @@ class WSHandler(socketserver.BaseRequestHandler):
|
|||||||
if self.session_id:
|
if self.session_id:
|
||||||
with LOCK:
|
with LOCK:
|
||||||
SESSIONS.get(self.session_id, set()).discard(self)
|
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) ----
|
# ---- 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'})
|
self._send_json({'type': 'join_denied', 'reason': 'access code required'})
|
||||||
return
|
return
|
||||||
self.session_id = sid
|
self.session_id = sid
|
||||||
|
self.role = data.get('role') or '?'
|
||||||
|
self.joined_at = time.time()
|
||||||
with LOCK:
|
with LOCK:
|
||||||
SESSIONS.setdefault(self.session_id, set()).add(self)
|
SESSIONS.setdefault(self.session_id, set()).add(self)
|
||||||
self._send_json({'type': 'joined', 'sessionId': self.session_id})
|
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'):
|
elif t in ('state', 'patientViewport'):
|
||||||
if TOKENS is not None and not self.session_id:
|
if TOKENS is not None and not self.session_id:
|
||||||
return # hardened relay only forwards for joined connections
|
return # hardened relay only forwards for joined connections
|
||||||
@ -142,6 +176,7 @@ class WSHandler(socketserver.BaseRequestHandler):
|
|||||||
if b':' in line:
|
if b':' in line:
|
||||||
k, v = line.split(b':', 1)
|
k, v = line.split(b':', 1)
|
||||||
headers[k.strip().lower()] = v.strip()
|
headers[k.strip().lower()] = v.strip()
|
||||||
|
self.hdrs = headers
|
||||||
key = headers.get(b'sec-websocket-key')
|
key = headers.get(b'sec-websocket-key')
|
||||||
if not key:
|
if not key:
|
||||||
return False
|
return False
|
||||||
@ -229,6 +264,13 @@ class _QuietHTTP(SimpleHTTPRequestHandler):
|
|||||||
def log_message(self, *args):
|
def log_message(self, *args):
|
||||||
pass
|
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):
|
def _argval(flag, default):
|
||||||
args = sys.argv[1:]
|
args = sys.argv[1:]
|
||||||
@ -245,6 +287,10 @@ def main():
|
|||||||
with open(tokens_file, encoding='utf-8') as f:
|
with open(tokens_file, encoding='utf-8') as f:
|
||||||
TOKENS = {l.strip() for l in f if l.strip() and not l.startswith('#')}
|
TOKENS = {l.strip() for l in f if l.strip() and not l.startswith('#')}
|
||||||
print('relay hardened: %d access codes loaded' % len(TOKENS))
|
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
|
socketserver.ThreadingTCPServer.allow_reuse_address = True
|
||||||
ThreadingHTTPServer.allow_reuse_address = True
|
ThreadingHTTPServer.allow_reuse_address = True
|
||||||
|
|||||||
44
tools/stats_report.py
Normal file
44
tools/stats_report.py
Normal 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')
|
||||||
Loading…
Reference in New Issue
Block a user