feat: add local analytics sink for self-analysis

- localSink.ts: writes every logEvent to ~/.claude/local_analytics.jsonl
- Modified index.ts: logEvent() now writes locally in parallel with upstream sinks
- analyze_analytics.py: Python analysis script — event stats, tool rankings,
  time distribution, security events, RL preference signals

No upstream data flow is affected. Local writes are non-blocking append.
Testing: one -p 'hello' captured 32 events across 19 event types.
This commit is contained in:
James Feng 2026-06-04 00:33:50 +08:00
parent a30bb60034
commit 64ca325020
3 changed files with 178 additions and 0 deletions

View File

@ -0,0 +1,116 @@
#!/usr/bin/env python3
"""
CC_Pure 本地遥测分析工具
用法:
python3 analyze_analytics.py ~/.claude/local_analytics.jsonl
功能:
- 事件总览按类型统计
- 时间线分析每小时事件分布
- 工具使用排行
- RL 偏好信号分析accept/reject 比例
- 安全事件摘要
"""
import json
import sys
from collections import Counter, defaultdict
from datetime import datetime
def load_events(path: str) -> list[dict]:
events = []
with open(path) as f:
for line in f:
line = line.strip()
if line:
try:
events.append(json.loads(line))
except json.JSONDecodeError:
pass
return events
def analyze(events: list[dict]):
if not events:
print("📭 暂无遥测数据。")
return
sep = "=" * 50
print(f"📊 CC_Pure 使用分析报告")
print(sep)
print(f"总事件数: {len(events)}")
if events:
first = events[0].get("ts", "?")
last = events[-1].get("ts", "?")
print(f"时间范围: {first[:19] if first != '?' else '?'}{last[:19] if last != '?' else '?'}")
# ── 事件类型统计 ──
print(f"\n📋 事件类型统计:")
counter = Counter(e.get("event", "unknown") for e in events)
for name, count in counter.most_common(20):
bar = "" * min(count // max(1, counter.most_common(1)[0][1] // 20), 40)
print(f" {count:>5d} {name:<45s} {bar}")
# ── 工具使用排行 ──
tool_events = defaultdict(int)
for e in events:
name = e.get("event", "")
# Extract tool name from tengu_<tool>_<action> pattern
parts = name.split("_")
if len(parts) >= 3 and parts[0] == "tengu":
tool = parts[1] if parts[1] not in ("tool", "internal", "auto", "read", "git", "exit", "api", "web", "edit", "skill", "quartz", "amber", "plum", "birch", "surreal", "glacier", "kairos", "hive", "unary", "monitor", "config") else "_".join(parts[1:3])
tool_events[tool] += 1
if tool_events:
print(f"\n🔧 工具活动排行:")
for tool, count in sorted(tool_events.items(), key=lambda x: -x[1])[:15]:
print(f" {count:>5d} {tool}")
# ── RL 偏好信号 (accept/reject) ──
accept_count = 0
reject_count = 0
for e in events:
evt = e.get("event", "")
if isinstance(evt, str):
if evt == "accept":
accept_count += 1
elif evt == "reject":
reject_count += 1
if accept_count + reject_count > 0:
total = accept_count + reject_count
print(f"\n✅ 用户偏好信号 (accept/reject):")
print(f" 接受: {accept_count} ({accept_count/total*100:.0f}%)")
print(f" 拒绝: {reject_count} ({reject_count/total*100:.0f}%)")
# ── 时间分布 ──
print(f"\n⏰ 按小时分布:")
hourly = Counter()
for e in events:
ts = e.get("ts", "")
if ts and len(ts) >= 13:
hour = ts[:13]
hourly[hour] += 1
for hour, count in sorted(hourly.items())[-20:]:
bar = "" * (count // max(1, max(hourly.values()) // 30))
print(f" {hour}:00 {count:>5d} {bar}")
# ── 安全事件 ──
security_keywords = ["security", "sandbox", "dangerous", "deny", "malformed"]
security_events = [
e for e in events
if any(kw in str(e.get("event", "")).lower() for kw in security_keywords)
]
if security_events:
print(f"\n🛡️ 安全事件 ({len(security_events)} 条):")
for e in security_events[-10:]:
print(f" [{e.get('ts','?')[:19]}] {e.get('event','?')}")
print("\n" + "=" * 50)
print("💡 提示: 用 `tail -f ~/.claude/local_analytics.jsonl` 实时查看事件流")
if __name__ == "__main__":
path = sys.argv[1] if len(sys.argv) > 1 else f"/home/{__import__('os').environ.get('USER', 'spark')}/.claude/local_analytics.jsonl"
analyze(load_events(path))

View File

@ -136,6 +136,14 @@ export function logEvent(
// to avoid accidentally logging code/filepaths
metadata: LogEventMetadata,
): void {
// Local analytics: write EVERY event to a local JSONL file for self-analysis.
// Runs before the upstream sink so local data is never lost even if the
// upstream sink throws. Import is inline to avoid adding a module-level
// dependency to this zero-dependency entry point.
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { writeLocalEvent } = require('./localSink.js') as typeof import('./localSink.js')
writeLocalEvent(eventName, metadata as Record<string, unknown>)
if (sink === null) {
eventQueue.push({ eventName, metadata, async: false })
return

View File

@ -0,0 +1,54 @@
/**
* Local analytics sink writes events to a JSONL file on disk.
*
* This runs IN PARALLEL with the upstream Datadog/1P sinks (if enabled).
* Events are appended immediately; timestamps are added by the writer.
*
* Output: ~/.claude/local_analytics.jsonl
* Format: one JSON object per line, fields: { ts, event, ...metadata }
*/
import * as fs from 'node:fs'
import * as path from 'node:path'
import * as os from 'node:os'
const LOCAL_ANALYTICS_DIR = path.join(os.homedir(), '.claude')
const LOCAL_ANALYTICS_FILE = path.join(LOCAL_ANALYTICS_DIR, 'local_analytics.jsonl')
// Ensure the directory exists on first write
let dirEnsured = false
function ensureDir(): void {
if (!dirEnsured) {
fs.mkdirSync(LOCAL_ANALYTICS_DIR, { recursive: true })
dirEnsured = true
}
}
/**
* Write a single analytics event to the local JSONL file.
* Non-blocking sync write JSONL appends are fast enough for analytics volume.
*/
export function writeLocalEvent(
eventName: string,
metadata: Record<string, unknown>,
): void {
try {
ensureDir()
const line = JSON.stringify({
ts: new Date().toISOString(),
event: eventName,
...metadata,
}) + '\n'
fs.appendFileSync(LOCAL_ANALYTICS_FILE, line, 'utf-8')
} catch {
// Silently ignore write failures — analytics must never crash the app
}
}
/**
* Return the path to the local analytics file for external analysis tools.
*/
export function getLocalAnalyticsPath(): string {
return LOCAL_ANALYTICS_FILE
}