一、方案对比概述
1.1 两种方案的本质差异
在 AI Coding 工具使用数据的采集领域,目前主要有两种技术路线:
| 维度 | 代理网关方案(我们的设计) | 客户端日志采集方案(ai-coding-trace) |
|---|---|---|
| 核心思路 | 在 API 流量路径上插入中间人 | 在客户端读取 AI 工具的本地日志文件 |
| 数据来源 | 拦截 HTTP API 请求/响应 | 解析各工具写入本地的日志、transcript、数据库文件 |
| 部署侧重 | 服务端部署为主 | 客户端部署为主 |
| 类比 | 在高速公路收费站装摄像头(流量必经之路) | 在停车场查看行车记录仪(事后读取记录) |
1.2 架构对比图
【代理网关方案】
AI 工具 ──→ 中心代理网关 ──→ 上游 API
│
拦截并记录流量
│
数据库 + Dashboard
【客户端日志采集方案】
AI 工具 ──→ 上游 API(直连,不经过代理)
│
└─→ 写入本地日志文件
│
采集守护进程读取日志
│
上报中心服务器
│
数据库 + Dashboard
二、ai-coding-trace 方案深度分析
2.1 核心原理
ai-coding-trace 的核心洞察是:几乎所有 AI Coding 工具都会在本地留下操作痕迹。
- Claude Code →
~/.claude/projects/下的 JSONL 会话记录(含完整 API 交互) - Cursor →
~/.cursor/下的 SQLite 数据库和日志文件 - Codex CLI →
~/.codex/下的会话记录 - Qoder → 本地日志和配置目录
- Trae → 应用数据目录中的操作记录
- GitHub Copilot → VS Code 扩展日志
- 通义灵码 → 插件日志和使用记录
这些本地数据蕴含了与 API 流量几乎等价的信息量(Token 用量、模型选择、工具调用、生成代码等),而且无需修改任何网络配置即可获取。
2.2 架构详解
┌─────────────────────────────────────────────────────────────────┐
│ 开发者工作站 │
│ │
│ ┌──────────┐ ┌────────┐ ┌───────┐ ┌───────────┐ │
│ │Claude Code│ │ Cursor │ │ Codex │ │ 通义灵码 │ ... │
│ └────┬─────┘ └───┬────┘ └──┬────┘ └─────┬─────┘ │
│ │ │ │ │ │
│ ▼ ▼ ▼ ▼ │
│ ~/.claude/ ~/.cursor/ ~/.codex/ ~/.tongyi/ │
│ projects/ (SQLite) logs/ logs/ │
│ *.jsonl │
│ │ │ │ │ │
│ └────────────┴──────────┴──────────────┘ │
│ │ │
│ ┌─────────▼──────────┐ │
│ │ 采集守护进程 │ │
│ │ (Daemon Agent) │ │
│ │ │ │
│ │ · 文件系统监控 │ │
│ │ · 多格式解析器 │ │
│ │ · 增量采集 │ │
│ │ · 本地缓存 │ │
│ │ · 定时上报 │ │
│ │ · 本地 Web UI │ │
│ │ :43124 │ │
│ └─────────┬──────────┘ │
│ │ │
└────────────────────────┼──────────────────────────────────────────┘
│ HTTPS 上报
▼
┌─────────────────────┐
│ 中心收集服务器 │
│ │
│ · 数据接收 API │
│ · 数据清洗聚合 │
│ · 持久化存储 │
│ · Dashboard │
└─────────────────────┘
2.3 ai-coding-trace 关键特征
基于钉钉安装文档和相关技术分析,提炼出以下关键特征:
| 特征 | 详情 |
|---|---|
| 安装方式 | npx @ali/ai-coding-trace@latest --workId=工号 一行命令 |
| 运行形态 | 后台守护进程(macOS LaunchAgent / Linux systemd / Windows Service) |
| 本地配置 | http://127.0.0.1:43124/v1/config 提供 Web 配置界面 |
| 日志目录 | ~/.r2c/logs/code-collect/ 本地采集日志 |
| 支持工具 | 20+ 种(Cursor, Claude Code, Codex, Gemini, Qoder, Trae 等) |
| 历史回填 | http://127.0.0.1:43124/v1/config?ui=refresh 可回填历史数据 |
| 数据时效 | T+1(次日可在 Dashboard 查看) |
| macOS 体验 | 菜单栏状态图标 + 本地 Dashboard 小组件 |
| 实时查看 | 提供实时数据查看器 Web 页面 |
2.4 ai-coding-trace 的优势分析
优势一:零网络配置,极低部署摩擦
代理网关方案需要开发者:
- 修改环境变量(
ANTHROPIC_BASE_URL,OPENAI_BASE_URL) - 配置 HTTPS 代理(
HTTPS_PROXY) - 安装并信任 MITM CA 证书
- 确保网关服务高可用(否则所有 AI 工具瘫痪)
而 ai-coding-trace 仅需:
- 运行一行 NPX 命令 → 结束
这是最关键的优势。对于需要快速在 50 人团队中推广的场景,部署摩擦直接决定了方案的可行性。
优势二:无单点故障风险
代理网关是所有 AI 流量的必经之路。一旦网关宕机:
- 所有开发者的 AI 工具无法工作
- 需要紧急切换回直连模式
而日志采集方案中,守护进程仅负责读取和上报,即使守护进程崩溃:
- AI 工具完全不受影响,正常使用
- 仅暂时丢失采集数据,守护进程恢复后可通过历史回填补齐
优势三:天然支持更多工具
代理网关需要为每种 API 协议实现 Parser(Anthropic、OpenAI、Google、私有协议等),且正向代理 MITM 模式存在兼容性风险。
日志采集只需要知道每种工具的日志文件路径和格式,然后实现解析器。即使新工具的 API 协议完全私有,只要它在本地写了日志,就能采集。
优势四:历史数据回填
日志采集可以读取历史日志文件,回填过去的使用数据。代理网关只能从部署时刻开始记录。
优势五:隐私边界更清晰
日志采集可以在客户端本地做数据脱敏和过滤后再上报,开发者对上报内容有更强的可控感。代理网关则是"全流量过网关",虽然技术上也可以脱敏,但心理感受上更具侵入性。
2.5 ai-coding-trace 的劣势
| 劣势 | 说明 |
|---|---|
| 依赖工具日志格式 | 如果 AI 工具更新了日志格式或路径,采集器需要适配更新 |
| 日志可能不完整 | 部分工具的日志不包含完整的 Token usage 信息,需要用 tokenizer 估算 |
| 客户端可绕过 | 开发者可以卸载或禁用守护进程(代理网关也有类似问题) |
| 多平台维护 | 需维护 macOS / Linux / Windows 三个平台的守护进程管理 |
| 数据实时性 | T+1 的时效性不如代理网关的实时性 |
三、方案选择建议
3.1 决策矩阵
| 评估维度 | 权重 | 代理网关方案 | 客户端日志采集方案 |
|---|---|---|---|
| 部署难度 | 25% | ★★☆☆☆ (2分) | ★★★★★ (5分) |
| 数据完整性 | 20% | ★★★★★ (5分) | ★★★★☆ (4分) |
| 系统可靠性 | 20% | ★★★☆☆ (3分) | ★★★★★ (5分) |
| 工具覆盖广度 | 15% | ★★★☆☆ (3分) | ★★★★★ (5分) |
| 数据实时性 | 10% | ★★★★★ (5分) | ★★★☆☆ (3分) |
| 维护成本 | 10% | ★★★☆☆ (3分) | ★★★★☆ (4分) |
| 加权总分 | 3.45 | 4.60 |
3.2 结论
对于 <50 人团队的 AI Coding 工具监控需求,客户端日志采集方案(ai-coding-trace 路线)是更优选择。
核心原因:
- 部署摩擦极低 ------ 一行命令安装,不改变任何开发流程
- 零单点故障 ------ 采集失败不影响开发
- 天然支持 20+ 工具 ------ 无需逐个实现 API Parser
- 历史回填 ------ 可补采安装前的数据
四、复刻实现方案 --- 完整技术设计
4.1 系统架构
┌─────────────────────────────────────────────────────────────────────┐
│ Developer Workstations (×50) │
│ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ ai-code-collector (守护进程) │ │
│ │ │ │
│ │ ┌───────────────────────────────────────────────┐ │ │
│ │ │ Log Watcher (fsnotify) │ │ │
│ │ │ │ │ │
│ │ │ ~/.claude/projects/**/*.jsonl → ClaudeParser │ │ │
│ │ │ ~/.cursor/**/*.sqlite → CursorParser │ │ │
│ │ │ ~/.codex/**/*.jsonl → CodexParser │ │ │
│ │ │ ~/.qoder/logs/ → QoderParser │ │ │
│ │ │ ~/.trae/logs/ → TraeParser │ │ │
│ │ │ ~/.copilot/logs/ → CopilotParser│ │ │
│ │ │ ...更多工具... │ │ │
│ │ └───────────────────┬───────────────────────────┘ │ │
│ │ │ │ │
│ │ ┌───────────────────▼───────────────────────────┐ │ │
│ │ │ Data Extractor │ │ │
│ │ │ │ │ │
│ │ │ · Token usage 提取 │ │ │
│ │ │ · 模型信息提取 │ │ │
│ │ │ · 工具调用统计 │ │ │
│ │ │ · 生成代码提取 │ │ │
│ │ │ · 会话时长计算 │ │ │
│ │ └───────────────────┬───────────────────────────┘ │ │
│ │ │ │ │
│ │ ┌───────────────────▼───────────────────────────┐ │ │
│ │ │ Local Store + Reporter │ │ │
│ │ │ │ │ │
│ │ │ · SQLite 本地缓存 │ │ │
│ │ │ · 批量压缩上报 │ │ │
│ │ │ · 失败重试队列 │ │ │
│ │ │ · 增量位点记录 │ │ │
│ │ └───────────────────┬───────────────────────────┘ │ │
│ │ │ │ │
│ │ ┌───────────────────▼───────────────────────────┐ │ │
│ │ │ Local Web Server (:43124) │ │ │
│ │ │ │ │ │
│ │ │ GET /v1/config → 配置管理界面 │ │ │
│ │ │ GET /v1/config?ui=refresh → 历史回填触发 │ │ │
│ │ │ GET /v1/status → 采集状态查看 │ │ │
│ │ │ GET /v1/stats → 本地统计数据 │ │ │
│ │ └───────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │ │
└─────────────────────────┼────────────────────────────────────────────┘
│ HTTPS (批量上报,每 5 分钟)
▼
┌─────────────────────────────────────────────────────────────────────┐
│ Central Server │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌───────────────────────────┐ │
│ │ Collector │ │ Aggregator │ │ API Server │ │
│ │ API │ │ Worker │ │ │ │
│ │ │ │ │ │ · 开发者数据查询 │ │
│ │ POST /v1/ │ │ · 每日聚合 │ │ · 团队统计 │ │
│ │ report │ │ · AI行匹配 │ │ · 工具对比 │ │
│ │ │ │ · 成本计算 │ │ · 导出报表 │ │
│ └──────┬───────┘ └──────┬───────┘ └──────────┬────────────────┘ │
│ │ │ │ │
│ └─────────────────┴──────────────────────┘ │
│ │ │
│ ┌────────────▼────────────────┐ │
│ │ PostgreSQL + TimescaleDB │ │
│ └────────────────────────────┘ │
│ │ │
│ ┌────────────▼────────────────┐ │
│ │ React Dashboard │ │
│ └─────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
4.2 技术栈选择
| 组件 | 技术 | 理由 |
|---|---|---|
| 采集守护进程 | Go | 编译为单二进制,跨平台分发简单,无运行时依赖 |
| 文件监控 | fsnotify | Go 标准的跨平台文件系统事件库 |
| 本地存储 | SQLite (go-sqlite3) | 零配置嵌入式数据库,存储采集位点和缓存数据 |
| 本地 Web | Go net/http | 内置 HTTP 服务器,提供本地配置和状态查看 |
| 服务端 API | Go + Chi | 轻量路由框架 |
| 服务端数据库 | PostgreSQL + TimescaleDB | 时序数据分区压缩 |
| 消息队列 | 无 | 50 人规模,直接写库即可,无需 MQ |
| 前端 | React + TypeScript + Ant Design + Recharts | 企业级 Dashboard |
| 部署 | Docker Compose (服务端) + 安装脚本 (客户端) | 分离部署 |
| 进程管理 | macOS LaunchAgent / Linux systemd / Windows Service | 系统级守护进程 |
4.3 项目目录结构
ai-code-collector/
├── cmd/
│ ├── collector/ # 客户端守护进程入口
│ │ └── main.go
│ └── server/ # 服务端入口
│ └── main.go
│
├── internal/
│ ├── collector/ # ===== 客户端核心 =====
│ │ ├── daemon.go # 守护进程生命周期管理
│ │ ├── watcher.go # 文件系统监控 (fsnotify)
│ │ ├── checkpoint.go # 增量采集位点管理
│ │ ├── reporter.go # 数据上报(批量、重试、压缩)
│ │ ├── localdb.go # SQLite 本地存储
│ │ ├── webui.go # 本地 Web 配置/状态服务器
│ │ └── backfill.go # 历史数据回填
│ │
│ ├── parser/ # ===== 日志解析器 =====
│ │ ├── registry.go # 解析器注册表
│ │ ├── types.go # 统一数据模型
│ │ ├── claude.go # Claude Code 日志解析
│ │ ├── cursor.go # Cursor 日志解析
│ │ ├── codex.go # Codex CLI 日志解析
│ │ ├── copilot.go # GitHub Copilot 日志解析
│ │ ├── qoder.go # Qoder 日志解析
│ │ ├── trae.go # Trae 日志解析
│ │ ├── gemini.go # Gemini CLI 日志解析
│ │ ├── tongyi.go # 通义灵码日志解析
│ │ ├── kimi.go # Kimi CLI 日志解析
│ │ └── generic.go # 通用 JSONL 解析器
│ │
│ ├── server/ # ===== 服务端核心 =====
│ │ ├── api.go # HTTP API 路由
│ │ ├── ingest.go # 数据接收处理
│ │ ├── aggregator.go # 每日聚合计算
│ │ ├── cost.go # 成本计算(模型价格表)
│ │ ├── query.go # 数据查询服务
│ │ └── export.go # 报表导出
│ │
│ └── model/ # ===== 共享数据模型 =====
│ ├── record.go # 采集记录
│ ├── stats.go # 统计数据
│ └── config.go # 配置定义
│
├── web/ # ===== 前端 Dashboard =====
│ ├── src/
│ │ ├── pages/
│ │ │ ├── Overview.tsx # 总览页
│ │ │ ├── Developer.tsx # 个人详情
│ │ │ ├── ToolComparison.tsx # 工具对比
│ │ │ ├── CostAnalysis.tsx # 成本分析
│ │ │ └── Admin.tsx # 管理设置
│ │ ├── components/
│ │ └── App.tsx
│ └── package.json
│
├── scripts/
│ ├── install.sh # macOS/Linux 一键安装脚本
│ ├── install.ps1 # Windows 安装脚本
│ ├── uninstall.sh # 卸载脚本
│ └── com.ai-code-collector.plist # macOS LaunchAgent 配置
│
├── deploy/
│ ├── docker-compose.yml # 服务端部署
│ ├── Dockerfile.server # 服务端镜像
│ └── nginx.conf # 前端反向代理
│
├── go.mod
├── go.sum
└── README.md
五、客户端守护进程 --- 核心实现
5.1 守护进程生命周期
go
// internal/collector/daemon.go
// Daemon 管理采集器的完整生命周期
type Daemon struct {
config *Config
watcher *Watcher
parsers *parser.Registry
reporter *Reporter
localDB *LocalDB
webServer *WebUIServer
stopCh chan struct{}
}
type Config struct {
DeveloperID string // 开发者工号/ID
ServerURL string // 中心服务器地址
ReportInterval time.Duration // 上报间隔,默认 5 分钟
LocalPort int // 本地 Web 服务端口,默认 43124
DataDir string // 本地数据目录,默认 ~/.ai-code-collector/
LogDir string // 日志目录,默认 ~/.ai-code-collector/logs/
}
func (d *Daemon) Run(ctx context.Context) error {
// 1. 初始化本地 SQLite
// 2. 注册所有解析器
// 3. 启动文件监控
// 4. 启动定时上报
// 5. 启动本地 Web 服务
// 6. 等待停止信号
}
5.2 文件系统监控
go
// internal/collector/watcher.go
// Watcher 监控 AI 工具日志文件的变化
type Watcher struct {
fsWatcher *fsnotify.Watcher
parsers *parser.Registry
checkpoint *CheckpointManager
eventCh chan *parser.CollectedRecord
}
// WatchTargets 定义需要监控的目录
var WatchTargets = []WatchTarget{
{
Tool: "claude-code",
// Claude Code 在 ~/.claude/projects/ 下按项目存储 JSONL 会话记录
Pattern: "~/.claude/projects/**/*.jsonl",
Parser: "claude",
},
{
Tool: "cursor",
// Cursor 在用户数据目录存储 SQLite 数据库
Pattern: "~/Library/Application Support/Cursor/User/globalStorage/*.sqlite",
// Linux: ~/.config/Cursor/User/globalStorage/
Parser: "cursor",
},
{
Tool: "codex",
// Codex CLI 的会话记录
Pattern: "~/.codex/**/*.jsonl",
Parser: "codex",
},
{
Tool: "copilot",
// GitHub Copilot 的 VS Code 扩展日志
Pattern: "~/.vscode/extensions/github.copilot-*/logs/*.log",
Parser: "copilot",
},
// ... 更多工具
}
func (w *Watcher) Start(ctx context.Context) error {
// 1. 展开 ~ 为用户 home 目录
// 2. 对每个 WatchTarget,检测目录是否存在
// 3. 存在的目录添加 fsnotify 监控
// 4. 监听 Create/Write 事件
// 5. 事件触发时,调用对应 Parser 增量解析
// 6. 解析结果发送到 eventCh
}
func (w *Watcher) handleFileEvent(event fsnotify.Event, target WatchTarget) {
// 仅处理 Write 和 Create 事件
if event.Op&(fsnotify.Write|fsnotify.Create) == 0 {
return
}
// 获取上次读取的位点
offset := w.checkpoint.Get(event.Name)
// 从位点处增量读取
p := w.parsers.Get(target.Parser)
records, newOffset, err := p.ParseIncremental(event.Name, offset)
if err != nil {
log.Warn("parse error", "file", event.Name, "err", err)
return
}
// 更新位点
w.checkpoint.Set(event.Name, newOffset)
// 发送采集记录
for _, r := range records {
r.DeveloperID = w.config.DeveloperID
r.Tool = target.Tool
w.eventCh <- r
}
}
5.3 增量位点管理
go
// internal/collector/checkpoint.go
// CheckpointManager 管理每个日志文件的读取位点
// 确保重启后不会重复采集
type CheckpointManager struct {
db *sql.DB // SQLite
}
// 表结构
// CREATE TABLE checkpoints (
// file_path TEXT PRIMARY KEY,
// offset INTEGER NOT NULL DEFAULT 0,
// inode INTEGER, -- 用于检测文件轮转
// updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
// );
func (cm *CheckpointManager) Get(filePath string) int64 {
// 1. 查询文件的 offset
// 2. 检查 inode 是否变化(文件轮转检测)
// 3. 如果 inode 变化,重置 offset 为 0
// 4. 返回 offset
}
func (cm *CheckpointManager) Set(filePath string, offset int64) {
// UPSERT 更新位点
}
5.4 数据上报
go
// internal/collector/reporter.go
// Reporter 负责将采集数据批量上报到中心服务器
type Reporter struct {
serverURL string
httpClient *http.Client
localDB *LocalDB
batchSize int // 默认 100 条
interval time.Duration // 默认 5 分钟
}
// ReportBatch 上报请求体
type ReportBatch struct {
DeveloperID string `json:"developer_id"`
Hostname string `json:"hostname"`
Records []*parser.CollectedRecord `json:"records"`
ReportedAt time.Time `json:"reported_at"`
}
func (r *Reporter) Start(ctx context.Context) {
ticker := time.NewTicker(r.interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
r.flush()
case <-ctx.Done():
r.flush() // 退出前最后一次上报
return
}
}
}
func (r *Reporter) flush() {
// 1. 从本地 SQLite 取出待上报记录
records := r.localDB.GetPendingRecords(r.batchSize)
if len(records) == 0 {
return
}
batch := &ReportBatch{
DeveloperID: r.config.DeveloperID,
Hostname: hostname(),
Records: records,
ReportedAt: time.Now(),
}
// 2. gzip 压缩后 POST 到服务器
data, _ := json.Marshal(batch)
compressed := gzipCompress(data)
resp, err := r.httpClient.Post(
r.serverURL+"/v1/report",
"application/json",
bytes.NewReader(compressed),
)
if err != nil || resp.StatusCode != 200 {
// 3. 上报失败,保留在本地队列,下次重试
log.Warn("report failed, will retry", "err", err)
return
}
// 4. 上报成功,标记已上报
r.localDB.MarkReported(records)
}
5.5 本地 Web 服务
go
// internal/collector/webui.go
// WebUIServer 提供本地 Web 配置和状态界面
type WebUIServer struct {
port int
daemon *Daemon
}
func (s *WebUIServer) Start() error {
mux := http.NewServeMux()
// 配置管理界面
mux.HandleFunc("/v1/config", s.handleConfig)
// 历史回填触发
// GET /v1/config?ui=refresh
mux.HandleFunc("/v1/refresh", s.handleRefresh)
// 采集状态
mux.HandleFunc("/v1/status", s.handleStatus)
// 本地统计(不上报,仅本地查看)
mux.HandleFunc("/v1/stats", s.handleStats)
return http.ListenAndServe(
fmt.Sprintf("127.0.0.1:%d", s.port),
mux,
)
}
func (s *WebUIServer) handleStatus(w http.ResponseWriter, r *http.Request) {
status := map[string]any{
"running": true,
"developer_id": s.daemon.config.DeveloperID,
"watched_tools": s.daemon.watcher.ActiveTools(),
"pending_records": s.daemon.localDB.PendingCount(),
"last_report_at": s.daemon.reporter.LastReportTime(),
"total_collected": s.daemon.localDB.TotalCollected(),
"uptime": time.Since(s.daemon.startTime).String(),
}
json.NewEncoder(w).Encode(status)
}
func (s *WebUIServer) handleRefresh(w http.ResponseWriter, r *http.Request) {
// 触发历史数据回填
// 扫描所有已知日志目录,从头解析未采集的历史数据
go s.daemon.backfill.RunFull()
json.NewEncoder(w).Encode(map[string]string{
"status": "backfill started",
})
}
六、日志解析器 --- 多工具适配
6.1 统一数据模型
go
// internal/parser/types.go
// CollectedRecord 是所有解析器的统一输出格式
type CollectedRecord struct {
ID string `json:"id"`
DeveloperID string `json:"developer_id"`
Tool string `json:"tool"` // claude-code, cursor, codex, ...
Model string `json:"model"` // claude-sonnet-4-20250514, gpt-4o, ...
Provider string `json:"provider"` // anthropic, openai, google, ...
SessionID string `json:"session_id"`
Timestamp time.Time `json:"timestamp"`
// Token 用量
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
CacheReadTokens int `json:"cache_read_tokens"`
CacheWriteTokens int `json:"cache_write_tokens"`
TotalTokens int `json:"total_tokens"`
// 交互信息
ToolCallsCount int `json:"tool_calls_count"`
ToolNames []string `json:"tool_names"`
// 生成代码(用于 AI 行匹配,上报后 7 天清理)
GeneratedCodeLines int `json:"generated_code_lines"`
GeneratedCodeHash string `json:"generated_code_hash"` // SHA256,用于去重
// 会话信息
RequestType string `json:"request_type"` // chat, completion, edit
DurationMs int64 `json:"duration_ms"`
// 原始数据(可选,默认不上报)
RawDataPath string `json:"-"` // 本地原始文件路径
}
6.2 解析器注册表
go
// internal/parser/registry.go
// Parser 接口 ------ 每种 AI 工具实现一个
type Parser interface {
// Name 返回解析器标识
Name() string
// LogPaths 返回该工具的日志文件路径模式列表
// 路径中的 ~ 会被自动展开
LogPaths() []string
// ParseIncremental 从指定位点开始增量解析
// 返回解析出的记录列表和新的位点
ParseIncremental(filePath string, offset int64) ([]*CollectedRecord, int64, error)
// ParseFull 全量解析(用于历史回填)
ParseFull(filePath string) ([]*CollectedRecord, error)
}
// Registry 管理所有已注册的解析器
type Registry struct {
parsers map[string]Parser
}
func NewRegistry() *Registry {
r := &Registry{parsers: make(map[string]Parser)}
// 注册所有内置解析器
r.Register(&ClaudeParser{})
r.Register(&CursorParser{})
r.Register(&CodexParser{})
r.Register(&CopilotParser{})
r.Register(&QoderParser{})
r.Register(&TraeParser{})
r.Register(&GeminiParser{})
r.Register(&TongyiParser{})
r.Register(&KimiParser{})
return r
}
6.3 Claude Code 解析器(详细实现)
Claude Code 的日志是最结构化的,以 JSONL 格式存储完整的 API 交互记录。
go
// internal/parser/claude.go
type ClaudeParser struct{}
func (p *ClaudeParser) Name() string { return "claude" }
func (p *ClaudeParser) LogPaths() []string {
return []string{
"~/.claude/projects/**/*.jsonl",
}
}
func (p *ClaudeParser) ParseIncremental(filePath string, offset int64) ([]*CollectedRecord, int64, error) {
f, err := os.Open(filePath)
if err != nil {
return nil, offset, err
}
defer f.Close()
// 跳到上次读取位置
if offset > 0 {
f.Seek(offset, io.SeekStart)
}
var records []*CollectedRecord
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 1024*1024), 1024*1024) // 1MB buffer
for scanner.Scan() {
line := scanner.Bytes()
record, err := p.parseLine(line)
if err != nil {
continue // 跳过无法解析的行
}
if record != nil {
records = append(records, record)
}
}
// 计算新位点
newOffset, _ := f.Seek(0, io.SeekCurrent)
return records, newOffset, nil
}
func (p *ClaudeParser) parseLine(line []byte) (*CollectedRecord, error) {
// Claude Code JSONL 格式示例:
// {"type":"request","timestamp":"...","message":{"model":"claude-sonnet-4-20250514","messages":[...],"tools":[...]}}
// {"type":"response","timestamp":"...","message":{"usage":{"input_tokens":1234,"output_tokens":567,...},...}}
var entry struct {
Type string `json:"type"`
Timestamp string `json:"timestamp"`
Message json.RawMessage `json:"message"`
}
if err := json.Unmarshal(line, &entry); err != nil {
return nil, err
}
// 我们主要关注 response 类型的条目(包含 usage 信息)
if entry.Type == "response" {
return p.parseResponse(entry.Timestamp, entry.Message)
}
// 也可以解析 request 类型来获取模型、工具调用等信息
if entry.Type == "request" {
return p.parseRequest(entry.Timestamp, entry.Message)
}
return nil, nil
}
func (p *ClaudeParser) parseResponse(ts string, msg json.RawMessage) (*CollectedRecord, error) {
var resp struct {
Model string `json:"model"`
Usage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
CacheReadTokens int `json:"cache_read_input_tokens"`
CacheWriteTokens int `json:"cache_creation_input_tokens"`
} `json:"usage"`
Content []struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
Name string `json:"name,omitempty"`
Input any `json:"input,omitempty"`
} `json:"content"`
}
if err := json.Unmarshal(msg, &resp); err != nil {
return nil, err
}
timestamp, _ := time.Parse(time.RFC3339, ts)
record := &CollectedRecord{
ID: generateID(),
Tool: "claude-code",
Model: resp.Model,
Provider: "anthropic",
Timestamp: timestamp,
InputTokens: resp.Usage.InputTokens,
OutputTokens: resp.Usage.OutputTokens,
CacheReadTokens: resp.Usage.CacheReadTokens,
CacheWriteTokens: resp.Usage.CacheWriteTokens,
TotalTokens: resp.Usage.InputTokens + resp.Usage.OutputTokens,
}
// 提取工具调用信息和生成代码
for _, c := range resp.Content {
if c.Type == "tool_use" {
record.ToolCallsCount++
record.ToolNames = append(record.ToolNames, c.Name)
}
if c.Type == "text" {
// 提取代码块中的代码行数
record.GeneratedCodeLines += countCodeLines(c.Text)
}
}
return record, nil
}
6.4 Cursor 解析器
Cursor 的数据存储在 SQLite 数据库中,需要不同的解析策略。
go
// internal/parser/cursor.go
type CursorParser struct{}
func (p *CursorParser) Name() string { return "cursor" }
func (p *CursorParser) LogPaths() []string {
return []string{
// macOS
"~/Library/Application Support/Cursor/User/globalStorage/cursor.*.sqlite",
"~/Library/Application Support/Cursor/logs/*.log",
// Linux
"~/.config/Cursor/User/globalStorage/cursor.*.sqlite",
"~/.config/Cursor/logs/*.log",
// Windows
// %APPDATA%/Cursor/User/globalStorage/cursor.*.sqlite
}
}
func (p *CursorParser) ParseIncremental(filePath string, offset int64) ([]*CollectedRecord, int64, error) {
if strings.HasSuffix(filePath, ".sqlite") {
return p.parseSQLite(filePath, offset)
}
return p.parseLogFile(filePath, offset)
}
func (p *CursorParser) parseSQLite(filePath string, lastRowID int64) ([]*CollectedRecord, int64, error) {
db, err := sql.Open("sqlite3", filePath+"?mode=ro")
if err != nil {
return nil, lastRowID, err
}
defer db.Close()
// 查询增量数据(rowid > lastRowID)
// 具体表结构需要根据 Cursor 实际的 SQLite schema 适配
rows, err := db.Query(`
SELECT rowid, timestamp, model, prompt_tokens, completion_tokens,
total_tokens, request_type
FROM usage_log
WHERE rowid > ?
ORDER BY rowid ASC
LIMIT 1000
`, lastRowID)
if err != nil {
return nil, lastRowID, err
}
defer rows.Close()
var records []*CollectedRecord
var maxRowID int64 = lastRowID
for rows.Next() {
var rowID int64
var ts string
var model string
var promptTokens, completionTokens, totalTokens int
var reqType string
rows.Scan(&rowID, &ts, &model, &promptTokens,
&completionTokens, &totalTokens, &reqType)
timestamp, _ := time.Parse(time.RFC3339, ts)
records = append(records, &CollectedRecord{
ID: generateID(),
Tool: "cursor",
Model: model,
Provider: inferProvider(model),
Timestamp: timestamp,
InputTokens: promptTokens,
OutputTokens: completionTokens,
TotalTokens: totalTokens,
RequestType: reqType,
})
if rowID > maxRowID {
maxRowID = rowID
}
}
return records, maxRowID, nil
}
6.5 各工具日志路径与格式速查表
| 工具 | 日志路径 (macOS) | 格式 | Token 字段可用性 |
|---|---|---|---|
| Claude Code | ~/.claude/projects/**/*.jsonl |
JSONL(完整 API 交互) | 完整(input/output/cache_read/cache_write) |
| Cursor | ~/Library/Application Support/Cursor/ |
SQLite + Log | 部分(可能需估算) |
| Codex CLI | ~/.codex/**/*.jsonl |
JSONL | 完整(OpenAI 格式) |
| GitHub Copilot | VS Code 扩展目录 | Log 文件 | 有限(需从日志中提取) |
| Gemini CLI | ~/.gemini/logs/ |
JSONL/Log | 完整(Google 格式) |
| Qoder | ~/.qoder/logs/ |
JSONL | 需适配 |
| Trae | ~/.trae/logs/ 或应用数据目录 |
Log/DB | 需适配 |
| 通义灵码 | VS Code 扩展日志 | Log 文件 | 有限 |
| Kimi CLI | ~/.kimi/logs/ |
JSONL | 完整 |
注意:日志路径和格式可能随工具版本更新而变化。采集器需要实现版本检测和多格式兼容逻辑。
七、服务端实现
7.1 数据接收 API
go
// internal/server/ingest.go
func (s *Server) handleReport(w http.ResponseWriter, r *http.Request) {
// 1. 验证请求(开发者 Token 或工号)
devID := r.Header.Get("X-Developer-Id")
if devID == "" {
http.Error(w, "missing developer id", 401)
return
}
// 2. 解压 gzip body
reader, err := gzip.NewReader(r.Body)
if err != nil {
// 如果不是 gzip,直接读原始 body
reader = nil
}
// 3. 解析 ReportBatch
var batch ReportBatch
if reader != nil {
json.NewDecoder(reader).Decode(&batch)
} else {
json.NewDecoder(r.Body).Decode(&batch)
}
// 4. 写入数据库
tx, _ := s.db.Begin()
for _, record := range batch.Records {
tx.Exec(`
INSERT INTO api_requests
(developer_id, tool, model, provider, session_id,
input_tokens, output_tokens, cache_read_tokens,
cache_write_tokens, total_tokens,
tool_calls_count, tool_names,
generated_code_lines, generated_code_hash,
request_type, duration_ms, created_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17)
ON CONFLICT (id) DO NOTHING
`, record.DeveloperID, record.Tool, record.Model, record.Provider,
record.SessionID, record.InputTokens, record.OutputTokens,
record.CacheReadTokens, record.CacheWriteTokens,
record.TotalTokens, record.ToolCallsCount,
pq.Array(record.ToolNames),
record.GeneratedCodeLines, record.GeneratedCodeHash,
record.RequestType, record.DurationMs, record.Timestamp)
}
tx.Commit()
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
7.2 数据库设计 (PostgreSQL + TimescaleDB)
sql
-- 开发者表
CREATE TABLE developers (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
email TEXT,
team TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- API 请求记录(时序表,按天自动分区)
CREATE TABLE api_requests (
id TEXT NOT NULL,
developer_id TEXT NOT NULL REFERENCES developers(id),
tool TEXT NOT NULL, -- claude-code, cursor, codex, ...
model TEXT, -- claude-sonnet-4-20250514, gpt-4o, ...
provider TEXT, -- anthropic, openai, google, ...
session_id TEXT,
input_tokens INTEGER DEFAULT 0,
output_tokens INTEGER DEFAULT 0,
cache_read_tokens INTEGER DEFAULT 0,
cache_write_tokens INTEGER DEFAULT 0,
total_tokens INTEGER DEFAULT 0,
tool_calls_count INTEGER DEFAULT 0,
tool_names TEXT[],
generated_code_lines INTEGER DEFAULT 0,
generated_code_hash TEXT,
request_type TEXT, -- chat, completion, edit
duration_ms BIGINT,
created_at TIMESTAMPTZ NOT NULL
);
-- 转换为 TimescaleDB 时序表
SELECT create_hypertable('api_requests', 'created_at',
chunk_time_interval => INTERVAL '1 day');
-- Git 提交记录
CREATE TABLE git_commits (
id SERIAL PRIMARY KEY,
developer_id TEXT NOT NULL REFERENCES developers(id),
repo TEXT NOT NULL,
commit_hash TEXT NOT NULL UNIQUE,
branch TEXT,
added_lines INTEGER DEFAULT 0,
deleted_lines INTEGER DEFAULT 0,
ai_generated_lines INTEGER DEFAULT 0,
ai_assisted_lines INTEGER DEFAULT 0,
human_lines INTEGER DEFAULT 0,
ai_confidence REAL, -- 0.0 ~ 1.0
committed_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- 每日预聚合统计
CREATE TABLE daily_stats (
developer_id TEXT NOT NULL REFERENCES developers(id),
date DATE NOT NULL,
tool TEXT NOT NULL,
-- Token 统计
total_requests INTEGER DEFAULT 0,
total_input_tokens BIGINT DEFAULT 0,
total_output_tokens BIGINT DEFAULT 0,
total_cache_tokens BIGINT DEFAULT 0,
estimated_cost_usd NUMERIC(10,4) DEFAULT 0,
-- 代码统计
total_code_lines INTEGER DEFAULT 0,
total_tool_calls INTEGER DEFAULT 0,
-- 会话统计
total_sessions INTEGER DEFAULT 0,
avg_session_duration_ms BIGINT DEFAULT 0,
PRIMARY KEY (developer_id, date, tool)
);
-- 模型价格表
CREATE TABLE model_pricing (
model TEXT PRIMARY KEY,
provider TEXT NOT NULL,
input_price_per_1m NUMERIC(10,6), -- 每百万 token 价格 (USD)
output_price_per_1m NUMERIC(10,6),
cache_read_price_per_1m NUMERIC(10,6),
cache_write_price_per_1m NUMERIC(10,6),
effective_from DATE NOT NULL,
effective_to DATE
);
-- 预置主流模型价格
INSERT INTO model_pricing (model, provider, input_price_per_1m, output_price_per_1m, cache_read_price_per_1m, effective_from) VALUES
('claude-opus-4-20250918', 'anthropic', 15.0, 75.0, 1.5, '2025-01-01'),
('claude-sonnet-4-20250514', 'anthropic', 3.0, 15.0, 0.3, '2025-01-01'),
('claude-haiku-4-5-20251001','anthropic', 0.8, 4.0, 0.08, '2025-01-01'),
('gpt-4o', 'openai', 2.5, 10.0, 1.25, '2025-01-01'),
('gpt-4o-mini', 'openai', 0.15, 0.6, 0.075, '2025-01-01'),
('gemini-2.5-pro', 'google', 1.25, 10.0, 0.315, '2025-01-01');
-- 常用查询索引
CREATE INDEX idx_api_requests_developer ON api_requests (developer_id, created_at DESC);
CREATE INDEX idx_api_requests_tool ON api_requests (tool, created_at DESC);
CREATE INDEX idx_git_commits_developer ON git_commits (developer_id, committed_at DESC);
7.3 每日聚合计算
go
// internal/server/aggregator.go
func (a *Aggregator) RunDailyAggregation(date time.Time) error {
dateStr := date.Format("2006-01-02")
_, err := a.db.Exec(`
INSERT INTO daily_stats
(developer_id, date, tool,
total_requests, total_input_tokens, total_output_tokens,
total_cache_tokens, estimated_cost_usd,
total_code_lines, total_tool_calls,
total_sessions, avg_session_duration_ms)
SELECT
r.developer_id,
$1::date,
r.tool,
COUNT(*),
SUM(r.input_tokens),
SUM(r.output_tokens),
SUM(r.cache_read_tokens + r.cache_write_tokens),
SUM(
r.input_tokens * p.input_price_per_1m / 1000000.0
+ r.output_tokens * p.output_price_per_1m / 1000000.0
+ r.cache_read_tokens * COALESCE(p.cache_read_price_per_1m, 0) / 1000000.0
),
SUM(r.generated_code_lines),
SUM(r.tool_calls_count),
COUNT(DISTINCT r.session_id),
AVG(r.duration_ms)
FROM api_requests r
LEFT JOIN model_pricing p ON r.model = p.model
WHERE r.created_at >= $1::date
AND r.created_at < ($1::date + INTERVAL '1 day')
GROUP BY r.developer_id, r.tool
ON CONFLICT (developer_id, date, tool) DO UPDATE SET
total_requests = EXCLUDED.total_requests,
total_input_tokens = EXCLUDED.total_input_tokens,
total_output_tokens = EXCLUDED.total_output_tokens,
total_cache_tokens = EXCLUDED.total_cache_tokens,
estimated_cost_usd = EXCLUDED.estimated_cost_usd,
total_code_lines = EXCLUDED.total_code_lines,
total_tool_calls = EXCLUDED.total_tool_calls,
total_sessions = EXCLUDED.total_sessions,
avg_session_duration_ms = EXCLUDED.avg_session_duration_ms
`, dateStr)
return err
}
7.4 Dashboard API
go
// internal/server/api.go
func (s *Server) setupRoutes() {
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(corsMiddleware)
// 数据接收
r.Post("/v1/report", s.handleReport)
// Dashboard API
r.Route("/api", func(r chi.Router) {
// 总览
r.Get("/overview", s.handleOverview)
// 返回: 总 Token 数, 总成本, 活跃开发者数, 工具分布
// 开发者列表
r.Get("/developers", s.handleDeveloperList)
// 返回: [{id, name, team, total_tokens, total_cost, primary_tool, last_active}]
// 开发者详情
r.Get("/developers/{id}", s.handleDeveloperDetail)
// 返回: 日均 Token, 模型分布, 工具使用趋势, 会话统计
// 工具对比
r.Get("/tools/comparison", s.handleToolComparison)
// 返回: 各工具的 Token 效率, 成本, 活跃用户数
// 成本分析
r.Get("/cost/breakdown", s.handleCostBreakdown)
// 返回: 按日/周/月的成本趋势, 按模型/工具/团队分组
// Token 趋势
r.Get("/tokens/trend", s.handleTokenTrend)
// 参数: granularity=day|week|month, from, to
// Git 统计
r.Get("/git/stats", s.handleGitStats)
// 返回: AI 行占比, 采纳率, 提交行趋势
// 导出
r.Get("/export/csv", s.handleExportCSV)
r.Get("/export/json", s.handleExportJSON)
})
// 静态文件(前端 Dashboard)
r.Handle("/*", http.FileServer(http.Dir("./web/dist")))
s.router = r
}
八、客户端安装脚本
8.1 macOS / Linux 安装脚本
bash
#!/bin/bash
# scripts/install.sh
# AI Code Collector 一键安装脚本
set -e
# ============ 配置 ============
COLLECTOR_VERSION="latest"
SERVER_URL="${AI_COLLECTOR_SERVER:-https://ai-monitor.internal.company.com}"
INSTALL_DIR="$HOME/.ai-code-collector"
BIN_DIR="$INSTALL_DIR/bin"
LOG_DIR="$INSTALL_DIR/logs"
DATA_DIR="$INSTALL_DIR/data"
LOCAL_PORT=43124
# ============ 颜色输出 ============
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m'
info() { echo -e "${GREEN}[INFO]${NC} $1"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; }
# ============ 检测系统 ============
detect_os() {
case "$(uname -s)" in
Darwin*) OS="darwin" ;;
Linux*) OS="linux" ;;
*) error "不支持的操作系统: $(uname -s)" ;;
esac
case "$(uname -m)" in
x86_64|amd64) ARCH="amd64" ;;
arm64|aarch64) ARCH="arm64" ;;
*) error "不支持的架构: $(uname -m)" ;;
esac
info "检测到系统: ${OS}/${ARCH}"
}
# ============ 获取工号 ============
get_developer_id() {
if [ -n "$1" ]; then
DEVELOPER_ID="$1"
else
read -p "请输入你的工号: " DEVELOPER_ID
fi
if [ -z "$DEVELOPER_ID" ]; then
error "工号不能为空"
fi
info "工号: ${DEVELOPER_ID}"
}
# ============ 下载二进制 ============
download_binary() {
info "下载 ai-code-collector..."
mkdir -p "$BIN_DIR" "$LOG_DIR" "$DATA_DIR"
DOWNLOAD_URL="${SERVER_URL}/download/ai-code-collector-${OS}-${ARCH}"
if command -v curl &> /dev/null; then
curl -fsSL "$DOWNLOAD_URL" -o "$BIN_DIR/ai-code-collector"
elif command -v wget &> /dev/null; then
wget -q "$DOWNLOAD_URL" -O "$BIN_DIR/ai-code-collector"
else
error "需要 curl 或 wget"
fi
chmod +x "$BIN_DIR/ai-code-collector"
info "下载完成: $BIN_DIR/ai-code-collector"
}
# ============ 写入配置 ============
write_config() {
cat > "$INSTALL_DIR/config.json" << EOF
{
"developer_id": "${DEVELOPER_ID}",
"server_url": "${SERVER_URL}",
"local_port": ${LOCAL_PORT},
"report_interval_seconds": 300,
"data_dir": "${DATA_DIR}",
"log_dir": "${LOG_DIR}",
"enabled_tools": [
"claude-code", "cursor", "codex", "copilot",
"gemini", "qoder", "trae", "tongyi", "kimi"
]
}
EOF
info "配置已写入: $INSTALL_DIR/config.json"
}
# ============ macOS LaunchAgent ============
install_macos_daemon() {
PLIST_PATH="$HOME/Library/LaunchAgents/com.ai-code-collector.plist"
cat > "$PLIST_PATH" << EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.ai-code-collector</string>
<key>ProgramArguments</key>
<array>
<string>${BIN_DIR}/ai-code-collector</string>
<string>--config</string>
<string>${INSTALL_DIR}/config.json</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>${LOG_DIR}/stdout.log</string>
<key>StandardErrorPath</key>
<string>${LOG_DIR}/stderr.log</string>
<key>WorkingDirectory</key>
<string>${INSTALL_DIR}</string>
</dict>
</plist>
EOF
launchctl load "$PLIST_PATH"
info "macOS LaunchAgent 已安装并启动"
}
# ============ Linux systemd ============
install_linux_daemon() {
SERVICE_PATH="$HOME/.config/systemd/user/ai-code-collector.service"
mkdir -p "$(dirname "$SERVICE_PATH")"
cat > "$SERVICE_PATH" << EOF
[Unit]
Description=AI Code Collector
After=network.target
[Service]
Type=simple
ExecStart=${BIN_DIR}/ai-code-collector --config ${INSTALL_DIR}/config.json
Restart=always
RestartSec=10
StandardOutput=append:${LOG_DIR}/stdout.log
StandardError=append:${LOG_DIR}/stderr.log
WorkingDirectory=${INSTALL_DIR}
[Install]
WantedBy=default.target
EOF
systemctl --user daemon-reload
systemctl --user enable ai-code-collector
systemctl --user start ai-code-collector
info "Linux systemd 用户服务已安装并启动"
}
# ============ 安装 Git Hook(可选)============
install_git_hook() {
HOOK_DIR="$HOME/.config/git"
mkdir -p "$HOOK_DIR/hooks"
cat > "$HOOK_DIR/hooks/post-commit" << 'HOOKEOF'
#!/bin/bash
# AI Code Collector: Git post-commit hook
# 将提交信息上报到本地采集进程
COMMIT_HASH=$(git rev-parse HEAD)
REPO=$(basename "$(git rev-parse --show-toplevel)")
BRANCH=$(git rev-parse --abbrev-ref HEAD)
STATS=$(git diff --numstat HEAD~1 HEAD 2>/dev/null | awk '{a+=$1; d+=$2} END {printf "%d %d", a, d}')
ADDED=$(echo "$STATS" | cut -d' ' -f1)
DELETED=$(echo "$STATS" | cut -d' ' -f2)
curl -s "http://127.0.0.1:43124/v1/git-commit" \
-H "Content-Type: application/json" \
-d "{
\"commit_hash\": \"${COMMIT_HASH}\",
\"repo\": \"${REPO}\",
\"branch\": \"${BRANCH}\",
\"added_lines\": ${ADDED:-0},
\"deleted_lines\": ${DELETED:-0}
}" > /dev/null 2>&1 &
# 不阻塞 git commit
exit 0
HOOKEOF
chmod +x "$HOOK_DIR/hooks/post-commit"
# 配置 git 使用全局 hooks 目录
git config --global core.hooksPath "$HOOK_DIR/hooks"
info "Git post-commit hook 已安装"
}
# ============ 主流程 ============
main() {
echo "=============================="
echo " AI Code Collector 安装程序"
echo "=============================="
echo ""
detect_os
get_developer_id "$1"
download_binary
write_config
# 根据系统安装守护进程
if [ "$OS" = "darwin" ]; then
install_macos_daemon
else
install_linux_daemon
fi
# 可选:安装 Git hook
read -p "是否安装 Git post-commit hook?(y/N) " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
install_git_hook
fi
echo ""
echo "=============================="
info "安装完成!"
echo ""
info "本地配置界面: http://127.0.0.1:${LOCAL_PORT}/v1/config"
info "采集状态查看: http://127.0.0.1:${LOCAL_PORT}/v1/status"
info "历史数据回填: http://127.0.0.1:${LOCAL_PORT}/v1/config?ui=refresh"
info "日志目录: ${LOG_DIR}/"
echo ""
info "提示: 守护进程会在开机时自动启动,无需手动管理"
echo "=============================="
}
main "$@"
8.2 卸载脚本
bash
#!/bin/bash
# scripts/uninstall.sh
set -e
INSTALL_DIR="$HOME/.ai-code-collector"
echo "正在卸载 AI Code Collector..."
# 停止并移除守护进程
case "$(uname -s)" in
Darwin*)
PLIST="$HOME/Library/LaunchAgents/com.ai-code-collector.plist"
if [ -f "$PLIST" ]; then
launchctl unload "$PLIST" 2>/dev/null || true
rm -f "$PLIST"
fi
;;
Linux*)
systemctl --user stop ai-code-collector 2>/dev/null || true
systemctl --user disable ai-code-collector 2>/dev/null || true
rm -f "$HOME/.config/systemd/user/ai-code-collector.service"
systemctl --user daemon-reload
;;
esac
# 移除 Git hook
HOOK="$HOME/.config/git/hooks/post-commit"
if [ -f "$HOOK" ] && grep -q "AI Code Collector" "$HOOK"; then
rm -f "$HOOK"
git config --global --unset core.hooksPath 2>/dev/null || true
fi
# 移除安装目录
rm -rf "$INSTALL_DIR"
echo "卸载完成"
九、服务端部署
9.1 Docker Compose
yaml
# deploy/docker-compose.yml
version: '3.8'
services:
postgres:
image: timescale/timescaledb:latest-pg16
environment:
POSTGRES_DB: ai_code_monitor
POSTGRES_USER: monitor
POSTGRES_PASSWORD: ${DB_PASSWORD:-changeme}
volumes:
- pgdata:/var/lib/postgresql/data
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U monitor"]
interval: 10s
timeout: 5s
retries: 5
server:
build:
context: ..
dockerfile: deploy/Dockerfile.server
environment:
DATABASE_URL: postgres://monitor:${DB_PASSWORD:-changeme}@postgres:5432/ai_code_monitor?sslmode=disable
SERVER_PORT: 8080
AGGREGATION_CRON: "0 2 * * *" # 每天凌晨 2 点聚合
ports:
- "8080:8080"
depends_on:
postgres:
condition: service_healthy
web:
build:
context: ../web
dockerfile: ../deploy/Dockerfile.web
ports:
- "3000:80"
depends_on:
- server
volumes:
pgdata:
9.2 服务端 Dockerfile
dockerfile
# deploy/Dockerfile.server
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /server ./cmd/server/
FROM alpine:3.19
RUN apk add --no-cache ca-certificates tzdata
COPY --from=builder /server /usr/local/bin/server
ENTRYPOINT ["server"]
十、AI 行识别方案
在客户端日志采集架构下,AI 行识别策略需要调整(因为不再有代理网关的实时流量):
10.1 调整后的识别策略
最终置信度 = 0.7×日志代码匹配 + 0.3×Git 启发式
≥ 0.7 → AI 生成行 0.3~0.7 → AI 辅助行 < 0.3 → 人工行
信号 1:日志中的 AI 生成代码与 Git Diff 匹配(权重 70%)
go
// internal/server/matcher.go
// MatchAILines 将 AI 工具生成的代码与 Git diff 新增行进行匹配
func MatchAILines(aiOutputs []AICodeOutput, diffLines []DiffLine) []MatchResult {
var results []MatchResult
for _, diffLine := range diffLines {
bestMatch := 0.0
var matchedOutput *AICodeOutput
for i := range aiOutputs {
// 使用编辑距离计算相似度
similarity := normalizedLevenshtein(
normalize(diffLine.Content),
normalize(aiOutputs[i].Code),
)
if similarity > bestMatch {
bestMatch = similarity
matchedOutput = &aiOutputs[i]
}
}
results = append(results, MatchResult{
Line: diffLine,
Similarity: bestMatch,
MatchedBy: matchedOutput,
IsAI: bestMatch >= 0.85,
})
}
return results
}
func normalize(code string) string {
// 去除缩进、空行、注释等差异
code = strings.TrimSpace(code)
code = strings.ReplaceAll(code, "\t", " ")
return code
}
信号 2:Git 启发式分析(权重 30%)
go
// internal/server/heuristic.go
// HeuristicScore 基于 Git commit 特征判断 AI 生成可能性
func HeuristicScore(commit GitCommit) float64 {
score := 0.0
// 1. 单次提交大量结构化代码(AI 倾向于一次生成大量代码)
if commit.AddedLines > 100 {
score += 0.3
} else if commit.AddedLines > 50 {
score += 0.15
}
// 2. 代码格式高度一致(AI 生成的代码格式通常非常规整)
if commit.FormattingConsistency > 0.95 {
score += 0.2
}
// 3. commit 时间集中在短时间内完成大量代码
if commit.AddedLines > 50 && commit.DurationMinutes < 10 {
score += 0.2
}
// 4. 提交中包含典型 AI 代码模式
if hasAICodePatterns(commit.DiffContent) {
score += 0.15
}
// 5. 开发者在提交前有活跃的 AI 工具会话
if commit.HasRecentAISession {
score += 0.3
}
if score > 1.0 {
score = 1.0
}
return score
}
10.2 匹配流程
┌───────────────────────────────┐
│ Git post-commit hook │
│ 上报 commit 信息到本地进程 │
└──────────────┬────────────────┘
│
┌──────────────▼────────────────┐
│ 本地守护进程接收 commit │
│ 提取 git diff 新增行 │
└──────────────┬────────────────┘
│
┌──────────────▼────────────────┐
│ 查找时间窗口内的 AI 输出 │
│ (commit 前 2 小时内的 │
│ AI 工具生成代码) │
└──────────────┬────────────────┘
│
┌──────────────▼────────────────┐
│ 逐行模糊匹配 │
│ diff 新增行 ↔ AI 输出代码 │
│ 相似度 ≥ 85% → AI 生成行 │
└──────────────┬────────────────┘
│
┌──────────────▼────────────────┐
│ + Git 启发式分析 │
│ 综合计算最终置信度 │
└──────────────┬────────────────┘
│
┌──────────────▼────────────────┐
│ 上报到中心服务器 │
│ 存入 git_commits 表 │
└───────────────────────────────┘
十一、Dashboard 核心页面设计
11.1 总览页 (Overview)
┌─────────────────────────────────────────────────────────────────┐
│ AI Coding Monitor Dashboard [日期范围选择] │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 总 Token │ │ 总成本 │ │ 活跃人数 │ │ AI 行占比 │ │
│ │ 12.5M │ │ $847.32 │ │ 42/50 │ │ 38.5% │ │
│ │ ↑15.2% │ │ ↑8.3% │ │ ↑2 │ │ ↑2.1% │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────┐│
│ │ Token 消耗趋势(折线图 + 柱状图) ││
│ │ x: 日期 y: Token 数量 ││
│ │ 按工具分色:Claude Code(紫) Cursor(蓝) Codex(绿) 其他(灰) ││
│ │ ││
│ │ ──────╱──╲──────╱╲──────╱╲────── ││
│ │ ╱ ╲ ╱ ╲ ╱ ╲ ││
│ │ ────╱──────╲──╱────╲──╱────╲──── ││
│ └─────────────────────────────────────────────────────────────┘│
│ │
│ ┌──────────────────────┐ ┌──────────────────────────────────┐│
│ │ 工具使用分布(饼图) │ │ Top 10 Token 消耗者(柱状图) ││
│ │ │ │ ││
│ │ Claude Code 45% │ │ 张三 ████████████ 320K ││
│ │ Cursor 30% │ │ 李四 █████████ 250K ││
│ │ Codex 15% │ │ 王五 ████████ 210K ││
│ │ 其他 10% │ │ ... ││
│ └──────────────────────┘ └──────────────────────────────────┘│
└─────────────────────────────────────────────────────────────────┘
11.2 开发者详情页
┌─────────────────────────────────────────────────────────────────┐
│ 开发者: 张三 (工号: 123456) [时间范围] │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 本月统计: │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Token 用量│ │ 估算成本 │ │ AI 采纳率 │ │ 提交行数 │ │
│ │ 850K │ │ $52.30 │ │ 72.5% │ │ 3,200 │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────┐│
│ │ 日均使用趋势 ││
│ │ Token (左轴) 提交行 (右轴) AI行占比 (标注) ││
│ └─────────────────────────────────────────────────────────────┘│
│ │
│ ┌──────────────────────┐ ┌──────────────────────────────────┐│
│ │ 模型使用分布 │ │ 工具切换时间线 ││
│ │ │ │ 8:00 Claude Code ││
│ │ claude-sonnet-4 65% │ │ 10:30 Cursor ││
│ │ claude-opus-4 20% │ │ 14:00 Claude Code ││
│ │ gpt-4o 15% │ │ 16:30 Codex ││
│ └──────────────────────┘ └──────────────────────────────────┘│
└─────────────────────────────────────────────────────────────────┘
11.3 成本分析页
┌─────────────────────────────────────────────────────────────────┐
│ 成本分析 [时间范围] │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────┐│
│ │ 月度成本趋势(堆叠面积图,按模型分色) ││
│ └─────────────────────────────────────────────────────────────┘│
│ │
│ ┌──────────────────────────────────────────────────────────────┐│
│ │ 成本明细表 ││
│ │ ││
│ │ 模型 │ 请求数 │ Input Token │ Output Token │ 成本 ││
│ │ ──────────────────┼────────┼─────────────┼──────────────┼────── ││
│ │ claude-sonnet-4 │ 5,230 │ 8.2M │ 1.5M │ $47 ││
│ │ claude-opus-4 │ 320 │ 2.1M │ 0.8M │ $92 ││
│ │ gpt-4o │ 1,850 │ 3.5M │ 0.6M │ $15 ││
│ └──────────────────────────────────────────────────────────────┘│
│ │
│ ┌────────────────────────┐ ┌─────────────────────────────────┐│
│ │ 缓存命中率 │ │ 成本优化建议 ││
│ │ │ │ ││
│ │ Claude: 82% ████████ │ │ · Opus 使用占比高,建议更多 ││
│ │ OpenAI: 45% ████ │ │ 使用 Sonnet 处理日常任务 ││
│ │ 总体: 71% ███████ │ │ · 缓存命中率有提升空间 ││
│ └────────────────────────┘ └─────────────────────────────────┘│
└─────────────────────────────────────────────────────────────────┘
十二、与代理网关方案的完整对比
| 维度 | 代理网关方案 | 客户端日志采集方案 |
|---|---|---|
| 部署步骤 | 修改环境变量 + 安装证书 + 配置代理 | 一行命令安装 |
| 对开发流程的影响 | 需要改变 AI 工具的连接方式 | 完全无感知 |
| 网关宕机影响 | AI 工具全部不可用 | 无影响 |
| 数据实时性 | 实时 | 近实时(5 分钟间隔上报) |
| 数据完整性 | 100%(全部流量经过) | 95%+(取决于日志完整度) |
| 支持新工具 | 需实现 API Parser | 需实现日志 Parser |
| 历史回填 | 不支持 | 支持 |
| Token 准确性 | 从 API 响应直接提取,100% 准确 | 从日志提取,大部分准确,少数需估算 |
| AI 行匹配 | 实时获取 AI 输出,匹配更及时 | 从日志提取 AI 输出,延迟匹配 |
| 隐私感知 | "全流量过网关",心理侵入性较强 | "本地采集 + 脱敏上报",接受度更高 |
| 维护复杂度 | 中心网关需高可用保障 | 客户端更新分发 |
| 扩展到 100+ 人 | 网关需考虑性能瓶颈 | 天然分布式,无瓶颈 |
| 合规审计 | 更容易实现流量审计 | 依赖客户端诚实上报 |
十三、实施路线图
| 阶段 | 时间 | 内容 | 产出 |
|---|---|---|---|
| Phase 1 | 第 1-2 周 | 核心采集框架 | Go 守护进程 + Claude Code Parser + 本地 SQLite + 数据上报 |
| Phase 2 | 第 3-4 周 | 多工具 + 服务端 | Cursor/Codex/Copilot Parser + 服务端 API + PostgreSQL + 基础 Dashboard |
| Phase 3 | 第 5-6 周 | Git 分析 + AI 行 | Git Hook + AI 行匹配 + 采纳率计算 + 成本分析面板 |
| Phase 4 | 第 7-8 周 | 完善 + 推广 | 更多工具 Parser + 安装脚本打磨 + 团队推广 + 告警配置 |
Phase 1 详细任务(第 1-2 周)
第 1 周:
Day 1-2: 项目初始化 + 守护进程框架 (daemon.go, watcher.go)
Day 3: Claude Code JSONL 解析器 (claude.go)
Day 4: 增量位点管理 + SQLite 本地存储 (checkpoint.go, localdb.go)
Day 5: 数据上报模块 (reporter.go)
第 2 周:
Day 1: 本地 Web 服务 + 配置管理 (webui.go)
Day 2: 服务端数据接收 API (ingest.go)
Day 3: PostgreSQL schema + 基础查询
Day 4: 安装脚本 (install.sh) + LaunchAgent/systemd
Day 5: 端到端集成测试
十四、风险与应对
| 风险 | 影响 | 应对措施 |
|---|---|---|
| AI 工具更新日志格式 | 解析器失效,数据丢失 | 版本检测 + 多格式兼容 + 快速更新机制 |
| 部分工具无可用日志 | 该工具数据缺失 | 退化到 Git 启发式分析;联系工具团队 |
| 开发者卸载采集器 | 个人数据缺失 | 管理层推动 + 提供有价值的个人 Dashboard |
| 日志文件过大 | 磁盘/内存压力 | 增量读取 + 只提取元数据 + 不复制原始内容 |
| Token 估算不准确 | 成本数据偏差 | 优先使用 API 响应中的 usage 字段 |
十五、总结
15.1 方案选择
客户端日志采集方案(ai-coding-trace 路线)是 <50 人团队的最佳选择,核心优势:
- 一行命令安装 --- 推广阻力最小
- 零网络配置 --- 不改变任何开发流程
- 无单点故障 --- 采集器崩溃不影响开发
- 天然支持 20+ 工具 --- 新增工具只需写解析器
- 历史数据回填 --- 安装后立即有数据
15.2 与原方案的关系
本方案并非完全取代代理网关方案,而是选择了更适合当前需求的技术路线:
- 代理网关方案的 数据库设计、Dashboard 设计、API 设计 可以复用
- 核心差异在于数据采集层:从"网络流量拦截"改为"本地日志读取"
- AI 行识别的模糊匹配算法和启发式分析完全复用
- 整体架构更轻量、更易维护、更容易推广
15.3 产出清单
本报告覆盖了从需求分析到可落地实施的完整方案:
| 产出 | 说明 |
|---|---|
| 方案对比分析 | 代理网关 vs 日志采集的完整对比 |
| 系统架构设计 | 客户端守护进程 + 中心服务器的完整架构 |
| 项目目录结构 | Go monorepo 完整目录规划 |
| 守护进程实现 | 文件监控、增量采集、位点管理、数据上报 |
| 日志解析器 | Claude Code / Cursor 等多工具解析器接口与实现 |
| 数据库设计 | PostgreSQL + TimescaleDB 完整 DDL |
| 安装脚本 | macOS / Linux 一键安装与卸载 |
| Docker Compose | 服务端一键部署 |
| Dashboard 设计 | 总览、个人、成本分析三大核心页面 |
| AI 行识别 | 日志匹配 + Git 启发式双信号融合方案 |
| 实施路线图 | 4 个阶段、8 周完成 |