da9c8334d8
- SSE Keepalive Ping (15s心跳防止代理断连) - Timing HTTP 头 (X-Timing-Queue/Inference/Total-Ms) - Adapter Request-ID 传播到后端 - Session 清理日志回调 - Server 安全加固 (ReadHeaderTimeout/MaxHeaderBytes 防 slowloris) - Usage Tracker 数据保留清理 (retentionDays + 定期清理) - Config Reload 后 Adapter Registry 更新 (RegisterIfAbsent + RWMutex) - Rate Limiter 空闲 Bucket 清理 (30分钟过期) - Shutdown Drain 超时可配置 (ShutdownDrainSeconds) - Config 模型字段校验增强 (provider/endpoint/actual_model) - Auth 过期 Key 自动清理 (5分钟扫描) - Admin API Rate Limiting - Adapter Health Check 独立超时 (每个 adapter 3s) - TCP 连接阶段超时 (DialContext 5s + KeepAlive 30s) - 幂等键缓存、审计日志、Gzip 中间件、CORS Expose Headers - Backpressure 响应头、熔断器 Prometheus 指标 - 连接池优化、Trace-ID 全链路传播
294 lines
7.4 KiB
Go
294 lines
7.4 KiB
Go
package usage
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"sync"
|
|
"time"
|
|
|
|
_ "github.com/mattn/go-sqlite3"
|
|
)
|
|
|
|
// AppUsage 记录单个应用的用量统计。
|
|
type AppUsage struct {
|
|
AppID string `json:"app_id"`
|
|
RequestCount int64 `json:"request_count"`
|
|
InputTokens int64 `json:"input_tokens"`
|
|
OutputTokens int64 `json:"output_tokens"`
|
|
TotalTokens int64 `json:"total_tokens"`
|
|
ErrorCount int64 `json:"error_count"`
|
|
WindowStart string `json:"window_start"`
|
|
WindowEnd string `json:"window_end"`
|
|
}
|
|
|
|
// Tracker 管理 per-app 用量统计(内存滑动窗口 + SQLite 持久化)。
|
|
type Tracker struct {
|
|
mu sync.RWMutex
|
|
apps map[string]*appCounters
|
|
window time.Duration
|
|
db *sql.DB
|
|
logCh chan usageLogEntry // 异步写入 channel
|
|
stopCh chan struct{}
|
|
retentionDays int // 历史数据保留天数
|
|
}
|
|
|
|
type appCounters struct {
|
|
requestCount int64
|
|
inputTokens int64
|
|
outputTokens int64
|
|
errorCount int64
|
|
windowStart time.Time
|
|
}
|
|
|
|
type usageLogEntry struct {
|
|
appID string
|
|
inputTokens int
|
|
outputTokens int
|
|
isError bool
|
|
recordedAt time.Time
|
|
}
|
|
|
|
// NewTracker 创建一个用量统计器。
|
|
// window 为统计窗口时长(如 1 小时),窗口到期后自动重置。
|
|
// dbPath 为 SQLite 持久化路径,空字符串则仅用内存。
|
|
// retentionDays 为历史数据保留天数,超过此天数的数据自动清理(0 表示不清理)。
|
|
func NewTracker(window time.Duration, dbPath string, retentionDays int) (*Tracker, error) {
|
|
t := &Tracker{
|
|
apps: make(map[string]*appCounters),
|
|
window: window,
|
|
logCh: make(chan usageLogEntry, 1024),
|
|
stopCh: make(chan struct{}),
|
|
retentionDays: retentionDays,
|
|
}
|
|
|
|
if dbPath != "" {
|
|
db, err := sql.Open("sqlite3", dbPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open usage db: %w", err)
|
|
}
|
|
if err := initUsageDB(db); err != nil {
|
|
return nil, fmt.Errorf("init usage db: %w", err)
|
|
}
|
|
t.db = db
|
|
go t.logWriter() // 启动异步写入 goroutine
|
|
}
|
|
|
|
return t, nil
|
|
}
|
|
|
|
func initUsageDB(db *sql.DB) error {
|
|
schema := `
|
|
CREATE TABLE IF NOT EXISTS usage_logs (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
app_id TEXT NOT NULL,
|
|
input_tokens INTEGER NOT NULL DEFAULT 0,
|
|
output_tokens INTEGER NOT NULL DEFAULT 0,
|
|
is_error INTEGER NOT NULL DEFAULT 0,
|
|
recorded_at TEXT NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_usage_app ON usage_logs(app_id);
|
|
CREATE INDEX IF NOT EXISTS idx_usage_time ON usage_logs(recorded_at);`
|
|
_, err := db.Exec(schema)
|
|
return err
|
|
}
|
|
|
|
// Record 记录一次请求的用量。
|
|
func (t *Tracker) Record(appID string, inputTokens, outputTokens int, isError bool) {
|
|
now := time.Now()
|
|
|
|
// 更新内存计数器
|
|
t.mu.Lock()
|
|
c, ok := t.apps[appID]
|
|
if !ok {
|
|
c = &appCounters{windowStart: now}
|
|
t.apps[appID] = c
|
|
}
|
|
|
|
// 窗口过期则重置
|
|
if now.Sub(c.windowStart) > t.window {
|
|
c.requestCount = 0
|
|
c.inputTokens = 0
|
|
c.outputTokens = 0
|
|
c.errorCount = 0
|
|
c.windowStart = now
|
|
}
|
|
|
|
c.requestCount++
|
|
c.inputTokens += int64(inputTokens)
|
|
c.outputTokens += int64(outputTokens)
|
|
if isError {
|
|
c.errorCount++
|
|
}
|
|
t.mu.Unlock()
|
|
|
|
// 异步写入 SQLite(非阻塞,channel 满则丢弃)
|
|
if t.db != nil {
|
|
select {
|
|
case t.logCh <- usageLogEntry{appID, inputTokens, outputTokens, isError, now}:
|
|
default: // channel 满则丢弃,避免阻塞主流程
|
|
}
|
|
}
|
|
}
|
|
|
|
// logWriter 后台 goroutine,从 channel 读取用量日志并批量写入 SQLite。
|
|
func (t *Tracker) logWriter() {
|
|
batch := make([]usageLogEntry, 0, 64)
|
|
ticker := time.NewTicker(5 * time.Second)
|
|
defer ticker.Stop()
|
|
|
|
flush := func() {
|
|
if len(batch) == 0 {
|
|
return
|
|
}
|
|
tx, err := t.db.Begin()
|
|
if err != nil {
|
|
batch = batch[:0]
|
|
return
|
|
}
|
|
stmt, _ := tx.Prepare(`INSERT INTO usage_logs (app_id, input_tokens, output_tokens, is_error, recorded_at) VALUES (?, ?, ?, ?, ?)`)
|
|
if stmt != nil {
|
|
for _, e := range batch {
|
|
errInt := 0
|
|
if e.isError {
|
|
errInt = 1
|
|
}
|
|
stmt.Exec(e.appID, e.inputTokens, e.outputTokens, errInt, e.recordedAt.Format(time.RFC3339))
|
|
}
|
|
stmt.Close()
|
|
}
|
|
tx.Commit()
|
|
batch = batch[:0]
|
|
}
|
|
|
|
for {
|
|
select {
|
|
case e := <-t.logCh:
|
|
batch = append(batch, e)
|
|
if len(batch) >= 64 {
|
|
flush()
|
|
}
|
|
case <-ticker.C:
|
|
flush()
|
|
// 定期清理过期数据
|
|
if t.retentionDays > 0 {
|
|
t.cleanupOldLogs()
|
|
}
|
|
case <-t.stopCh:
|
|
// 排空 channel 后 flush
|
|
for len(t.logCh) > 0 {
|
|
batch = append(batch, <-t.logCh)
|
|
}
|
|
flush()
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// Get 返回指定应用的当前用量快照。
|
|
func (t *Tracker) Get(appID string) AppUsage {
|
|
t.mu.RLock()
|
|
defer t.mu.RUnlock()
|
|
|
|
c, ok := t.apps[appID]
|
|
if !ok {
|
|
return AppUsage{AppID: appID}
|
|
}
|
|
return AppUsage{
|
|
AppID: appID,
|
|
RequestCount: c.requestCount,
|
|
InputTokens: c.inputTokens,
|
|
OutputTokens: c.outputTokens,
|
|
TotalTokens: c.inputTokens + c.outputTokens,
|
|
ErrorCount: c.errorCount,
|
|
WindowStart: c.windowStart.Format(time.RFC3339),
|
|
WindowEnd: c.windowStart.Add(t.window).Format(time.RFC3339),
|
|
}
|
|
}
|
|
|
|
// GetAll 返回所有应用的用量快照。
|
|
func (t *Tracker) GetAll() []AppUsage {
|
|
t.mu.RLock()
|
|
defer t.mu.RUnlock()
|
|
|
|
result := make([]AppUsage, 0, len(t.apps))
|
|
for appID, c := range t.apps {
|
|
result = append(result, AppUsage{
|
|
AppID: appID,
|
|
RequestCount: c.requestCount,
|
|
InputTokens: c.inputTokens,
|
|
OutputTokens: c.outputTokens,
|
|
TotalTokens: c.inputTokens + c.outputTokens,
|
|
ErrorCount: c.errorCount,
|
|
WindowStart: c.windowStart.Format(time.RFC3339),
|
|
WindowEnd: c.windowStart.Add(t.window).Format(time.RFC3339),
|
|
})
|
|
}
|
|
return result
|
|
}
|
|
|
|
// GetHistory 返回指定应用的历史用量(从 SQLite 查询,按小时聚合)。
|
|
// hours 为查询的时间范围(最近 N 小时)。
|
|
func (t *Tracker) GetHistory(appID string, hours int) ([]map[string]any, error) {
|
|
if t.db == nil {
|
|
return nil, fmt.Errorf("persistence not enabled")
|
|
}
|
|
if hours <= 0 {
|
|
hours = 24
|
|
}
|
|
|
|
since := time.Now().Add(-time.Duration(hours) * time.Hour).Format(time.RFC3339)
|
|
rows, err := t.db.Query(
|
|
`SELECT
|
|
substr(recorded_at, 1, 13) as hour_bucket,
|
|
COUNT(*) as request_count,
|
|
SUM(input_tokens) as input_tokens,
|
|
SUM(output_tokens) as output_tokens,
|
|
SUM(is_error) as error_count
|
|
FROM usage_logs
|
|
WHERE app_id = ? AND recorded_at >= ?
|
|
GROUP BY hour_bucket
|
|
ORDER BY hour_bucket DESC`,
|
|
appID, since,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("query usage history: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var result []map[string]any
|
|
for rows.Next() {
|
|
var hourBucket string
|
|
var reqCount, inputTokens, outputTokens, errCount int64
|
|
if err := rows.Scan(&hourBucket, &reqCount, &inputTokens, &outputTokens, &errCount); err != nil {
|
|
return nil, err
|
|
}
|
|
result = append(result, map[string]any{
|
|
"hour": hourBucket,
|
|
"request_count": reqCount,
|
|
"input_tokens": inputTokens,
|
|
"output_tokens": outputTokens,
|
|
"total_tokens": inputTokens + outputTokens,
|
|
"error_count": errCount,
|
|
})
|
|
}
|
|
return result, rows.Err()
|
|
}
|
|
|
|
// Close 关闭数据库连接。
|
|
func (t *Tracker) Close() error {
|
|
close(t.stopCh)
|
|
if t.db != nil {
|
|
return t.db.Close()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// cleanupOldLogs 清理超过保留期限的用量日志。
|
|
func (t *Tracker) cleanupOldLogs() {
|
|
cutoff := time.Now().Add(-time.Duration(t.retentionDays) * 24 * time.Hour).Format(time.RFC3339)
|
|
if _, err := t.db.Exec(`DELETE FROM usage_logs WHERE recorded_at < ?`, cutoff); err != nil {
|
|
// 清理失败不影响主流程,下次再试
|
|
return
|
|
}
|
|
}
|