Purpose: Distributed telemetry aggregation system for multi-machine lens validation monitoring
Status: Phase 3 Complete ✅ (HMAC auth, SQLite WAL, WebSocket reconnect, Bun hub, Vue dashboard, Claude Code hook)
GraphLens Observability Hub collects telemetry from multiple Soulfield OS instances and provides real-time monitoring through WebSocket-powered dashboards. The system uses HMAC-SHA256 authentication to prevent telemetry spoofing and stores 30 days of history in a SQLite database with WAL mode for concurrent writes.
Architecture:
┌─────────────────┐
│ Machine 1 │──┐
│ Claude Code │ │ HMAC-signed
│ Hook │ │ POST /telemetry
└─────────────────┘ │
│
┌─────────────────┐ │ ┌──────────────────┐
│ Machine 2 │──┼──▶│ Observability │
│ Claude Code │ │ │ Hub (Bun) │
│ Hook │ │ │ :3000 │
└─────────────────┘ │ └──────────────────┘
│ │
┌─────────────────┐ │ │ WebSocket
│ Machine 3 │──┘ │ broadcast
│ Claude Code │ │
│ Hook │ ▼
└─────────────────┘ ┌─────────────────┐
│ Vue Dashboard │
│ (Live metrics) │
└─────────────────┘
Key Features:
- HMAC Authentication: Prevents telemetry spoofing with 5-minute replay window
- SQLite WAL Mode: Concurrent writes from multiple sources
- WebSocket Broadcasting: Real-time updates to connected dashboards
- Auto-Reconnect: Exponential backoff with event buffering
- Rate Limiting: 100 requests/minute per IP
- 30-Day Retention: Automatic cleanup via maintenance script
- Cross-Platform: Works with Bun (native WebSocket) and Node.js (ws package)
# Required: Node.js ≥20 or Bun ≥1.0
node --version # v20.0.0 or higher
# OR
bun --version # 1.0.0 or higher
# Required: better-sqlite3 (for Node.js)
npm install better-sqlite3
# Required: ws package (for Node.js, not needed for Bun)
npm install ws
# Optional: Bun (for native WebSocket support)
curl -fsSL https://bun.sh/install | bash# 1. Navigate to observability hub directory
cd tools/observability-hub
# 2. Generate authentication secret (64 hex chars)
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
# Example output: 42e17aed4830bcb165d7ad5dcfe7fd3c18924ee4463455e83ed74b170bc96c86
# 3. Add to environment (.env or export)
export GRAPHLENS_AUTH_SECRET="42e17aed4830bcb165d7ad5dcfe7fd3c18924ee4463455e83ed74b170bc96c86"
# 4. Start hub (Bun for best performance)
bun run index.js
# OR with Node.js
node index.js
# 5. Verify health
curl http://localhost:3000/health# 1. Enable GraphLens distributed mode
export GRAPHLENS_DISTRIBUTED=1
export GRAPHLENS_HUB_URL="http://your-hub-server:3000"
export GRAPHLENS_AUTH_SECRET="<same-secret-as-hub>"
# 2. Add to .env file
cat >> .env << 'EOF'
# GraphLens Distributed Observability
GRAPHLENS_DISTRIBUTED=1
GRAPHLENS_HUB_URL=http://your-hub-server:3000
GRAPHLENS_AUTH_SECRET=42e17aed4830bcb165d7ad5dcfe7fd3c18924ee4463455e83ed74b170bc96c86
EOF
# 3. Verify hook sends telemetry
curl -X POST http://localhost:8790/chat \
-H 'Content-Type: application/json' \
-d '{"text":"@marketing test"}'
# 4. Check hub received event
curl http://your-hub-server:3000/telemetry/query?hours=1Hub Server:
| Variable | Required | Default | Description |
|---|---|---|---|
GRAPHLENS_AUTH_SECRET |
✅ Yes | None | Shared secret for HMAC auth (min 32 chars) |
GRAPHLENS_HUB_PORT |
No | 3000 |
HTTP/WebSocket server port |
Client Machines (Hooks):
| Variable | Required | Default | Description |
|---|---|---|---|
GRAPHLENS_DISTRIBUTED |
✅ Yes | 0 |
Enable distributed mode (1=on, 0=off) |
GRAPHLENS_HUB_URL |
✅ Yes | None | Hub server URL (http://host:port) |
GRAPHLENS_AUTH_SECRET |
✅ Yes | None | Shared secret matching hub |
ENABLE_GRAPHLENS_CONTENT |
No | false |
Include full sentence content in telemetry |
ENABLE_GRAPHLENS_ALERTS |
No | false |
Enable alert generation |
Legacy (Phase 1/2 flags):
| Variable | Default | Description |
|---|---|---|
GRAPHLENS_TELEMETRY_INTERVAL |
15 |
Sampling interval (seconds) |
GRAPHLENS_ALERT_P95_THRESHOLD |
80 |
P95 latency alert threshold (ms) |
GRAPHLENS_ALERT_PASS_RATE_THRESHOLD |
10 |
Pass rate alert threshold (%) |
# Generate 32-byte (64 hex chars) secret
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
# Using backend utility
node -e "console.log(require('./backend/services/hmac-auth.cjs').generateSecret())"Security Notes:
- Minimum 32 characters required
- Use different secrets for dev/staging/prod
- Rotate secrets quarterly
- Never commit secrets to git (add to .gitignore)
With Bun (Recommended):
cd tools/observability-hub
export GRAPHLENS_AUTH_SECRET="your-secret-here"
bun run index.jsWith Node.js:
cd tools/observability-hub
export GRAPHLENS_AUTH_SECRET="your-secret-here"
node index.jsExpected Output:
[Hub] Using Bun native WebSocket
[Hub] Database initialized
🚀 GraphLens Telemetry Hub running on port 3000
POST /telemetry - Submit telemetry (requires HMAC signature)
GET /telemetry/query?hours=24 - Query historical data
GET /telemetry/stats?hours=24 - Get statistics
GET /health - Health check
WS /ws - WebSocket for live updates
Database: /home/user/soulfield/tools/observability-hub/telemetry.db
Auth: HMAC-SHA256 (5-minute replay window)
Health check with database and telemetry stats (no authentication required).
Example:
curl http://localhost:3000/healthResponse:
{
"status": "ok",
"database": {
"size_mb": "2.45",
"row_count": 1523,
"journal_mode": "wal"
},
"telemetry": {
"total_count": 1523,
"pass_rate": "82.5",
"p95_execution_ms": 23
},
"websocket": {
"connected_clients": 2
},
"uptime": 86400
}Submit signed telemetry event (requires HMAC signature).
Example:
# Generate signature
PAYLOAD='{"timestamp":"2025-10-31T12:00:00Z","agent_id":"marketing","quality_score":85,"overall_pass":true}'
SECRET="your-secret-here"
# Sign with Node.js
SIGNATURE=$(node -e "
const crypto = require('crypto');
const payload = $PAYLOAD;
const canonical = JSON.stringify(payload, Object.keys(payload).sort());
const hmac = crypto.createHmac('sha256', '$SECRET');
hmac.update(canonical);
console.log(hmac.digest('hex'));
")
# Submit telemetry
curl -X POST http://localhost:3000/telemetry \
-H "Content-Type: application/json" \
-H "X-GraphLens-Signature: $SIGNATURE" \
-d "$PAYLOAD"Response:
{"success":true,"id":1524}Query historical telemetry data.
Query Parameters:
hours- Time range in hours (default: 24)limit- Max rows to return (default: 1000)
Example:
# Last 3 hours, max 100 rows
curl 'http://localhost:3000/telemetry/query?hours=3&limit=100'Response:
[
{
"id": 1524,
"timestamp": "2025-10-31T12:00:00Z",
"machine_id": "dev-laptop",
"agent_id": "marketing",
"quality_score": 85,
"gap_count": 2,
"community_count": 5,
"execution_ms": 23,
"overall_pass": 1,
"bottleneck_type": null,
"raw_json": "{...}",
"created_at": 1730376000
}
]Get aggregated statistics and breakdowns.
Query Parameters:
hours- Time range in hours (default: 24)groupBy- Group by field:agent_idormachine_id(default:agent_id)
Example:
curl 'http://localhost:3000/telemetry/stats?hours=24&groupBy=agent_id'Response:
{
"stats": {
"total_count": 1523,
"pass_count": 1256,
"avg_execution_ms": 18.5,
"max_execution_ms": 67,
"machine_count": 3,
"agent_count": 5,
"pass_rate": "82.5",
"p95_execution_ms": 23
},
"breakdown": [
{
"group_key": "marketing",
"count": 523,
"pass_count": 450,
"avg_execution_ms": 19.2
},
{
"group_key": "finance",
"count": 412,
"pass_count": 380,
"avg_execution_ms": 17.8
}
]
}Connect to live telemetry stream:
const ws = new WebSocket('ws://localhost:3000/ws');
ws.onopen = () => {
console.log('Connected to GraphLens Hub');
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'telemetry') {
console.log('New telemetry event:', data.data);
}
};
// Server sends 'ping' every 30s, respond with 'pong'
ws.on('message', (data) => {
if (data === 'ping') {
ws.send('pong');
}
});Using Auto-Reconnect Client:
const { ReconnectingWebSocket } = require('./client/reconnect.js');
const ws = new ReconnectingWebSocket('ws://localhost:3000/ws', {
maxRetries: Infinity,
retryDelays: [1000, 2000, 4000, 8000, 16000, 30000],
heartbeatInterval: 30000,
heartbeatTimeout: 90000
});
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log('Telemetry:', data);
};
ws.onstatechange = (newState, oldState) => {
console.log(`WebSocket state: ${oldState} → ${newState}`);
};Run manual cleanup:
cd tools/observability-hub
node maintenance.jsSchedule via cron (runs daily at 3 AM):
crontab -e
# Add this line:
0 3 * * * cd /path/to/soulfield/tools/observability-hub && node maintenance.js >> logs/cron.log 2>&1Maintenance tasks:
- Database integrity check (PRAGMA integrity_check)
- Delete telemetry older than 30 days
- Incremental vacuum (reclaim space)
- Log database size and statistics
Create service file:
sudo nano /etc/systemd/system/graphlens-hub.serviceService configuration:
[Unit]
Description=GraphLens Telemetry Hub
After=network.target
[Service]
Type=simple
User=soulfield
WorkingDirectory=/home/soulfield/soulfield/tools/observability-hub
Environment="GRAPHLENS_AUTH_SECRET=your-secret-here"
Environment="GRAPHLENS_HUB_PORT=3000"
Environment="NODE_ENV=production"
ExecStart=/usr/local/bin/bun run index.js
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal
SyslogIdentifier=graphlens-hub
[Install]
WantedBy=multi-user.targetEnable and start service:
# Reload systemd
sudo systemctl daemon-reload
# Enable auto-start on boot
sudo systemctl enable graphlens-hub
# Start service
sudo systemctl start graphlens-hub
# Check status
sudo systemctl status graphlens-hub
# View logs
sudo journalctl -u graphlens-hub -fHTTPS with automatic SSL:
# /etc/caddy/Caddyfile
graphlens.yourdomain.com {
reverse_proxy localhost:3000
# WebSocket support
@websockets {
header Connection *Upgrade*
header Upgrade websocket
}
reverse_proxy @websockets localhost:3000
# Security headers
header {
Strict-Transport-Security "max-age=31536000;"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "no-referrer-when-downgrade"
}
# Rate limiting (optional)
rate_limit {
zone graphlens {
key {remote_host}
events 100
window 1m
}
}
# Access log
log {
output file /var/log/caddy/graphlens-access.log
}
}Reload Caddy:
sudo systemctl reload caddy# /etc/nginx/sites-available/graphlens
upstream graphlens_backend {
server 127.0.0.1:3000;
}
server {
listen 80;
server_name graphlens.yourdomain.com;
# Redirect to HTTPS
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name graphlens.yourdomain.com;
# SSL certificates (use certbot)
ssl_certificate /etc/letsencrypt/live/graphlens.yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/graphlens.yourdomain.com/privkey.pem;
# WebSocket support
location /ws {
proxy_pass http://graphlens_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 86400;
}
# HTTP endpoints
location / {
proxy_pass http://graphlens_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Security headers
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
# Rate limiting
limit_req_zone $binary_remote_addr zone=graphlens:10m rate=100r/m;
limit_req zone=graphlens burst=20 nodelay;
}Enable and reload:
sudo ln -s /etc/nginx/sites-available/graphlens /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx# Allow hub port (if not using reverse proxy)
sudo ufw allow 3000/tcp
# Or allow only from specific machines
sudo ufw allow from 192.168.1.0/24 to any port 3000
# Allow HTTPS (if using reverse proxy)
sudo ufw allow 443/tcp
# Reload firewall
sudo ufw reloadSymptom: 401 MISSING_SIGNATURE or SIGNATURE_MISMATCH
Cause: HMAC secret mismatch between client and hub
Fix:
# 1. Check hub secret
sudo journalctl -u graphlens-hub -n 100 | grep AUTH
# 2. Check client .env file
cat .env | grep GRAPHLENS_AUTH_SECRET
# 3. Ensure secrets match exactly
# Hub: GRAPHLENS_AUTH_SECRET=abc123...
# Client: GRAPHLENS_AUTH_SECRET=abc123... (same)
# 4. Regenerate and update both sides
NEW_SECRET=$(node -e "console.log(require('crypto').randomBytes(32).toString('hex'))")
echo "GRAPHLENS_AUTH_SECRET=$NEW_SECRET"
# Update .env files and restart servicesSymptom: ECONNREFUSED when posting telemetry
Cause: Hub not running or firewall blocking
Fix:
# 1. Check hub is running
sudo systemctl status graphlens-hub
# OR
curl http://localhost:3000/health
# 2. Check firewall rules
sudo ufw status | grep 3000
# 3. Check port binding
sudo netstat -tlnp | grep 3000
# 4. Restart hub if needed
sudo systemctl restart graphlens-hubSymptom: Error: database is locked
Cause: WAL mode not enabled or too many concurrent writes
Fix:
# 1. Check WAL mode
sqlite3 tools/observability-hub/telemetry.db "PRAGMA journal_mode;"
# Should output: wal
# 2. Enable WAL mode if disabled
sqlite3 tools/observability-hub/telemetry.db "PRAGMA journal_mode=WAL;"
# 3. Check for stale WAL files
ls -la tools/observability-hub/*.db*
# If -wal or -shm files are huge, run maintenance:
node tools/observability-hub/maintenance.js
# 4. Increase SQLite timeout (in db.js)
# this.db.pragma('busy_timeout = 5000'); // 5 secondsSymptom: Dashboard shows "disconnected" or frequent reconnects
Cause: Firewall blocking WebSocket or heartbeat timeout
Fix:
# 1. Check WebSocket handshake
wscat -c ws://localhost:3000/ws
# Should connect and show "ping" every 30s
# 2. Check reverse proxy WebSocket config (if using Caddy/Nginx)
# Ensure Upgrade header is proxied
# 3. Adjust heartbeat timeouts (client/reconnect.js)
# heartbeatInterval: 30000, // Send ping every 30s
# heartbeatTimeout: 90000, // Close after 90s no pong
# 4. Check for NAT/firewall timeout
# Some routers close idle connections after 60s
# Reduce heartbeatInterval to 20000 if neededSymptom: Hub using >500MB RAM
Cause: Large database or too many WebSocket clients
Fix:
# 1. Check database size
du -h tools/observability-hub/telemetry.db
# Should be <100MB for 30 days
# 2. Run maintenance cleanup
node tools/observability-hub/maintenance.js
# 3. Reduce retention period (in maintenance.js)
# db.cleanup(7); // Keep only 7 days instead of 30
# 4. Limit WebSocket clients
# Add connection limit in index.js:
# if (wsClients.size >= 10) {
# ws.close();
# return;
# }Symptom: 429 RATE_LIMIT_EXCEEDED responses
Cause: Client sending >100 requests/minute
Fix:
# 1. Check request rate from client
grep "POST /telemetry" /var/log/caddy/graphlens-access.log | \
awk '{print $1}' | sort | uniq -c | sort -rn
# 2. Adjust rate limit in index.js
# const RATE_LIMIT_MAX = 200; // Increase from 100
# 3. Or batch telemetry events client-side
# Send every 60s instead of real-time
# 4. Whitelist specific IPs (add to index.js)
# const WHITELIST = ['192.168.1.100'];
# if (WHITELIST.includes(clientIp)) {
# // Skip rate limit
# }# Start hub in test mode
cd tools/observability-hub
GRAPHLENS_AUTH_SECRET="test-secret-32-chars-minimum123" node index.js &
# Run integration tests
./test-hub.sh
# Stop hub
pkill -f "node index.js"# Generate valid signature
SECRET="test-secret-32-chars-minimum123"
PAYLOAD='{"timestamp":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","agent_id":"test","overall_pass":true}'
SIGNATURE=$(node -e "
const crypto = require('crypto');
const payload = $PAYLOAD;
const canonical = JSON.stringify(payload, Object.keys(payload).sort());
const hmac = crypto.createHmac('sha256', '$SECRET');
hmac.update(canonical);
console.log(hmac.digest('hex'));
")
# Submit telemetry
curl -X POST http://localhost:3000/telemetry \
-H "Content-Type: application/json" \
-H "X-GraphLens-Signature: $SIGNATURE" \
-d "$PAYLOAD"# Open database shell
sqlite3 tools/observability-hub/telemetry.db
# View schema
.schema
# Count rows
SELECT COUNT(*) FROM telemetry;
# Recent events
SELECT * FROM telemetry ORDER BY timestamp DESC LIMIT 10;
# Stats by agent
SELECT agent_id, COUNT(*), AVG(execution_ms)
FROM telemetry
GROUP BY agent_id;
# Exit
.quitClient Hub
│ │
├─── Generate payload ─────────┤
│ {timestamp, agent_id, ...}│
│ │
├─── Sign with HMAC ───────────┤
│ HMAC-SHA256(payload, secret)
│ │
├─── POST /telemetry ──────────▶
│ X-GraphLens-Signature: abc123...
│ │
│ Validate ────┐
│ signature │
│ timestamp │
│ (5 min TTL) │
│ ◀───────┘
│ │
│◀─── 200 OK {id: 1524} ───────┤
│ │
│ Store in DB
│ Broadcast WS
Write-Ahead Logging (WAL):
- Writes go to separate -wal file
- Readers access main database concurrently
- Periodic checkpoints merge -wal → main DB
- ~10x faster than DELETE mode for concurrent writes
Files:
telemetry.db- Main databasetelemetry.db-wal- Write-ahead log (pending commits)telemetry.db-shm- Shared memory index
Checkpoint Strategy:
- Auto-checkpoint when -wal reaches 1000 pages (~4MB)
- Manual checkpoint:
PRAGMA wal_checkpoint(TRUNCATE); - Runs during maintenance.js cleanup
Purpose: Detect stale connections (NAT timeout, firewall, crash)
Flow:
Server Client
│ │
├─── ping ─────────────▶│
│ │
│◀──── pong ────────────┤
│ │
(wait 30s) │
│ │
├─── ping ─────────────▶│
│ │
│ (no response) │
│ │
(90s timeout) │
│ │
└─── close ───────────▶ │
│
reconnect
Tuning:
heartbeatInterval: 30000- Send ping every 30sheartbeatTimeout: 90000- Close after 90s no pong- Reduce interval for unstable networks (20s)
- Increase timeout for high-latency connections (120s)
- DEPLOYMENT.md - Multi-machine deployment guide
- GRAPHLENS-IMPLEMENTATION-START-HERE-2025-10-31.md - Phase 1/2 implementation
- backend/services/hmac-auth.cjs - HMAC authentication implementation
- CHANGELOG.md - Version history
Last Updated: 2025-10-31 Phase: 3 (Production-ready distributed observability) Maintainer: Sentinel (via Claude Code CLI)