Files
CamTalk/docs/13-日志追踪.md
hhs d430e6e5b2 docs: 补充存储层和限流器日志实现说明
添加 PostgreSQL、Redis、限流器三个模块的日志实现文档:
- PostgreSQL 4 个 repository 的日志策略和代码示例
- Redis 会话存储、缓存装饰器、限流器的日志级别选择
- 存储层日志查询示例(数据库错误、Redis 降级)
- 更新架构图,添加存储层节点
2026-06-21 23:11:17 +08:00

545 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 日志追踪系统
## 概述
CamTalk 全链路日志追踪系统,通过统一的 trace ID 机制,将 REST API 和 WebSocket 两大入口的所有日志串联起来,实现分布式环境下的请求链路可观测性。
**核心目标**
- 统一 trace ID 贯穿 REST/WebSocket 两大入口
- 所有日志自动附加 trace_id/request_id/session_id
- 保护用户隐私,敏感文本截断或降级
- 支持按 trace_id 快速定位完整请求链路
## Trace ID 作用域
| 标识 | 作用域 | 生成时机 | 用途 |
|-----|--------|---------|------|
| `trace_id` | **连接级**(整个 WebSocket 生命周期)<br/>**请求级**(单次 REST 请求) | REST: 中间件生成<br/>WebSocket: 升级时生成 | 关联同一连接/请求的所有日志 |
| `session_id` | 会话级(对话上下文存储) | ServeWS 时生成 | 标识会话存储 |
| `request_id` | 查询级(单次 WebSocket 查询) | 客户端每次查询传入 | 区分同一连接的不同查询 |
**WebSocket 场景示例**:用户打开页面建立 WebSocket发起 3 次对话查询:
```
连接建立 trace_id=01J5AAA session_id=uuid-123
├─ 查询1 trace_id=01J5AAA request_id=req-001 (问天气)
├─ 查询2 trace_id=01J5AAA request_id=req-002 (问新闻)
└─ 查询3 trace_id=01J5AAA request_id=req-003 (问股票)
```
**REST 场景示例**
```
POST /api/auth/login trace_id=01J5BBB request_id=01J5BBB
GET /api/conversations trace_id=01J5CCC request_id=01J5CCC
```
## 核心组件
```mermaid
graph TB
subgraph trace包["trace 包"]
ID["id.go<br/>ULID 生成器"]
CTX["context.go<br/>context key 管理"]
LOG["logger.go<br/>context-aware logger"]
MW["middleware.go<br/>Gin trace 中间件"]
end
subgraph logger包["logger 包"]
GINLOG["middleware.go<br/>Gin 请求日志"]
GINREC["GinRecovery<br/>panic 恢复"]
end
subgraph 入口层["入口层"]
REST["REST API<br/>trace 中间件注入"]
WS["WebSocket<br/>ServeWS 注入"]
end
subgraph 业务层["业务层"]
HANDLER["Handler"]
ADAPTER["Eino Adapter"]
NODES["Eino Nodes"]
end
subgraph 存储层["存储层"]
PG["PostgreSQL<br/>session/user/message/scenario"]
REDIS["Redis<br/>session/cache/ratelimit"]
end
ID --> MW
CTX --> LOG
LOG --> HANDLER
LOG --> ADAPTER
LOG --> NODES
LOG --> PG
LOG --> REDIS
MW --> REST
GINLOG --> REST
WS --> LOG
```
### trace/id.go — ULID 生成器
使用 ULIDUniversally Unique Lexicographically Sortable Identifier作为 trace ID
- 时间排序:前 48 位是毫秒时间戳,天然按时间排序
- 唯一性:后 80 位随机数,冲突概率极低
- 并发安全:使用 `crypto/rand` + `sync.Pool` 复用 entropy 对象
```go
package trace
import (
cryptorand "crypto/rand"
"sync"
"time"
"github.com/oklog/ulid/v2"
)
var entropyPool = sync.Pool{
New: func() interface{} {
return ulid.Monotonic(cryptorand.Reader, 0)
},
}
// GenerateTraceID 生成并发安全的 ULID trace ID
func GenerateTraceID() string {
entropy := entropyPool.Get().(*ulid.MonotonicEntropy)
defer entropyPool.Put(entropy)
return ulid.MustNew(ulid.Timestamp(time.Now()), entropy).String()
}
```
### trace/context.go — Context Key 管理
统一管理所有 trace 相关的 context key
```go
package trace
import "context"
type traceIDKey struct{}
type requestIDKey struct{}
type sessionIDKey struct{}
// WithTraceID 将 trace ID 注入 context
func WithTraceID(ctx context.Context, traceID string) context.Context {
return context.WithValue(ctx, traceIDKey{}, traceID)
}
func GetTraceID(ctx context.Context) string {
if v, ok := ctx.Value(traceIDKey{}).(string); ok {
return v
}
return ""
}
// 类似定义 WithRequestID/GetRequestID 和 WithSessionID/GetSessionID
```
### trace/logger.go — Context-Aware Logger
自动从 context 提取 trace 字段并附加到日志:
```go
package trace
import (
"context"
"github.com/hhs/camtalk/internal/logger"
"go.uber.org/zap"
)
// FromContext 返回自动附加 trace_id/request_id/session_id 的 logger
func FromContext(ctx context.Context) *zap.SugaredLogger {
log := logger.Log
if traceID := GetTraceID(ctx); traceID != "" {
log = log.With("trace_id", traceID)
}
if requestID := GetRequestID(ctx); requestID != "" {
log = log.With("request_id", requestID)
}
if sessionID := GetSessionID(ctx); sessionID != "" {
log = log.With("session_id", sessionID)
}
return log
}
```
**使用模式对比**
```go
// Before: 手动传递字段
logger.Log.Infow("message", "session", sessionID, "request", requestID)
// After: 自动附加
trace.FromContext(ctx).Infow("message")
```
### trace/middleware.go — Gin Trace 中间件
为 REST 请求生成 trace ID 并注入 context
```go
package trace
import "github.com/gin-gonic/gin"
// TraceMiddleware 为每个 HTTP 请求生成 trace ID 并注入 context
func TraceMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
traceID := GenerateTraceID()
ctx := WithTraceID(c.Request.Context(), traceID)
ctx = WithRequestID(ctx, traceID) // REST: trace_id == request_id
c.Request = c.Request.WithContext(ctx)
c.Header("X-Trace-ID", traceID) // 返回给客户端用于排查
c.Next()
}
}
```
### logger/middleware.go — 请求日志与 Panic 恢复
记录所有 HTTP 请求的 method/path/status/latency
```go
package logger
import (
"time"
"github.com/gin-gonic/gin"
"github.com/hhs/camtalk/internal/trace"
)
// GinLogger 记录每个 HTTP 请求的基础信息
func GinLogger() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
path := c.Request.URL.Path
c.Next()
latency := time.Since(start).Milliseconds()
status := c.Writer.Status()
log := trace.FromContext(c.Request.Context())
switch {
case status >= 500:
log.Errorw("request completed", "method", c.Request.Method,
"path", path, "status", status, "latency_ms", latency)
case status >= 400:
log.Warnw("request completed", "method", c.Request.Method,
"path", path, "status", status, "latency_ms", latency)
default:
log.Infow("request completed", "method", c.Request.Method,
"path", path, "status", status, "latency_ms", latency)
}
}
}
// GinRecovery 自定义 panic 恢复中间件
func GinRecovery() gin.HandlerFunc {
return func(c *gin.Context) {
defer func() {
if err := recover(); err != nil {
log := trace.FromContext(c.Request.Context())
log.Errorw("panic recovered", "error", err,
"path", c.Request.URL.Path, "method", c.Request.Method)
c.AbortWithStatus(500)
}
}()
c.Next()
}
}
```
## 中间件注册顺序
`cmd/server/main.go` 中,三层中间件按顺序注册:
```go
r := gin.New()
r.Use(trace.TraceMiddleware()) // 第一层:生成 trace ID
r.Use(logger.GinLogger()) // 第二层:记录请求
r.Use(logger.GinRecovery()) // 第三层panic 恢复
```
## 日志输出示例
### REST 请求
```json
{
"level": "info",
"ts": 1718956800.123,
"msg": "login success",
"trace_id": "01J5A2B3C4D5E6F7G8H9J0K1M",
"request_id": "01J5A2B3C4D5E6F7G8H9J0K1M",
"username": "test_user"
}
```
### WebSocket 查询链路(含存储层)
```json
// 1. 查询接收
{"level":"info", "trace_id":"01J5XXX", "session_id":"abc-123", "request_id":"req-456", "msg":"query received"}
// 2. 会话加载Redis
{"level":"debug", "trace_id":"01J5XXX", "session_id":"abc-123", "request_id":"req-456", "msg":"redis session retrieved", "session_id":"abc-123"}
// 3. STT 完成
{"level":"debug", "trace_id":"01J5XXX", "session_id":"abc-123", "request_id":"req-456", "msg":"stt recognition completed", "text_len":45}
// 4. LLM 完成
{"level":"info", "trace_id":"01J5XXX", "session_id":"abc-123", "request_id":"req-456", "msg":"llm generation completed", "tokens":150}
// 5. 消息持久化PostgreSQL
{"level":"debug", "trace_id":"01J5XXX", "session_id":"abc-123", "request_id":"req-456", "msg":"message saved", "role":"user", "tokens_used":45}
{"level":"debug", "trace_id":"01J5XXX", "session_id":"abc-123", "request_id":"req-456", "msg":"message saved", "role":"assistant", "tokens_used":150}
// 6. Pipeline 完成
{"level":"info", "trace_id":"01J5XXX", "session_id":"abc-123", "request_id":"req-456", "msg":"query processing completed", "latency_ms":2340}
```
### 限流触发场景
```json
{"level":"warn", "trace_id":"01J5YYY", "msg":"rate limit triggered", "key":"ratelimit:user-456:query", "retry_after_sec":2.5}
```
## 日志查询操作
### 按 trace_id 查询完整链路
**本地开发(文件日志)**
```bash
# 查看完整链路
grep 'trace_id":"01J5XXX"' backend.log | jq .
# 查看链路时间线
grep 'trace_id":"01J5XXX"' backend.log | jq -r '[.ts, .msg] | @tsv'
```
**Grafana Loki**
```logql
{app="camtalk-backend"}
|= "trace_id=01J5XXX"
| json
| line_format "{{.ts}} [{{.level}}] {{.msg}}"
```
### 查询慢请求(延迟 > 5s
```logql
{app="camtalk-backend"}
| json
| msg="query processing completed"
| latency_ms > 5000
```
### 查询数据库错误
```logql
{app="camtalk-backend"}
| json
| level="error"
| msg=~".*failed"
| line_format "{{.trace_id}} {{.msg}} {{.error}}"
```
### 查询 Redis 降级事件
```logql
{app="camtalk-backend"}
| json
| level="warn"
| msg=~"redis.*failed"
```
### 查询错误率
```logql
sum(count_over_time({app="camtalk-backend"} | json | level="error" [5m]))
```
## 敏感内容处理规范
### 完全禁止记录
- 用户明文密码
- JWT token 完整内容(仅记录 "token_present: true"
- API Key 完整值(仅记录前 8 字符 + "..."
### 截断后记录(最多 50 字符)
- 用户输入文本 → `text_preview`
- LLM 生成文本 → `text_preview`
- STT 识别文本 → `text_preview`
**示例**
```go
log.Debugw("stt recognition completed",
"text_len", len(text),
"text_preview", util.Truncate(text, 50))
```
### 仅记录长度/大小
- 图片数据 → `image_size_bytes`
- 音频数据 → `audio_size_bytes`
### 降级为 Debug 级别
所有包含用户文本预览的日志,生产环境默认不输出。
## 日志级别使用准则
| 场景 | 级别 | 示例 |
|-----|------|-----|
| 请求生命周期里程碑 | Info | `"query received"`, `"pipeline completed"` |
| 中间步骤详情 | Debug | `"stt recognition completed"`, `"history assembled"` |
| 敏感内容相关 | Debug | 所有包含用户文本的日志 |
| 预期内的失败 | Warn | `"login failed"`, `"rate limited"` |
| 系统错误 | Error | `"database query failed"`, `"tts synthesis failed"` |
| 严重故障 | Error + stack | `"panic recovered"` |
## 存储层日志实现
### PostgreSQL Repository 层
所有数据库操作统一使用 `trace.FromContext(ctx)` 记录日志:
**已实现文件**
- `backend/internal/store/session_pg.go` — 会话 CRUD
- `backend/internal/store/user_pg.go` — 用户与 refresh token 操作
- `backend/internal/store/message_pg.go` — 对话消息存储
- `backend/internal/store/user_scenario_repository.go` — 用户自定义情景
**日志策略**
```go
func (r *PgSessionRepository) Save(ctx context.Context, s SessionRecord) error {
log := trace.FromContext(ctx)
_, err := r.pool.Exec(ctx, ...)
if err != nil {
log.Errorw("save session failed", "session_id", s.ID, "error", err)
return err
}
log.Debugw("session saved", "session_id", s.ID, "user_id", s.UserID)
return nil
}
```
**NotFound 处理**:预期内的空结果不记录错误:
```go
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrSessionNotFound // 不记录日志
}
if err != nil {
log.Errorw("find session failed", "session_id", id, "error", err)
return nil, err
}
```
### Redis 服务层
**已实现文件**
- `backend/internal/session/redis.go` — RedisManager会话存储
- `backend/internal/store/cached_user.go` — CachedUserRepository用户缓存装饰器
- `backend/internal/ratelimit/redis_bucket.go` — RedisLimiter令牌桶限流器
**会话存储日志**`redis.go`
```go
func (m *RedisManager) Get(ctx context.Context, sessionID string) (*models.Session, error) {
log := trace.FromContext(ctx)
vals, err := m.rdb.HGetAll(ctx, metaKey(sessionID)).Result()
if err != nil {
log.Errorw("redis get session failed", "session_id", sessionID, "error", err)
return nil, fmt.Errorf("redis get session: %w", err)
}
if len(vals) == 0 {
return nil, ErrSessionNotFound // 不记录日志
}
log.Debugw("redis session retrieved", "session_id", sessionID)
return session, nil
}
```
**缓存降级日志**`cached_user.go`
```go
if _, err := pipe.Exec(ctx); err != nil {
log := trace.FromContext(ctx)
log.Warnw("redis cache write failed for refresh token", "error", err)
// 降级DB 已写入成功Redis 失败不影响正确性
}
```
**限流触发日志**`redis_bucket.go`
```go
func (l *RedisLimiter) Allow(ctx context.Context, key string) (bool, time.Duration) {
log := trace.FromContext(ctx)
result, err := l.script.Run(ctx, ...).Result()
if err != nil {
log.Errorw("rate limit check failed", "key", key, "error", err)
return true, 0 // fail-open 策略
}
if allowed == 0 {
log.Warnw("rate limit triggered", "key", key, "retry_after_sec", retryAfterSec)
return false, retryAfter
}
return true, 0
}
```
**级别选择原则**
- **Error**Redis 连接失败、Lua 脚本执行失败(影响功能)
- **Warn**:缓存写入失败(可降级)、限流触发(预期内异常)
- **Debug**:正常操作完成(避免 Info 级别噪音)
## 编码规范
1. **日志语言**:统一使用英文
2. **结构化**:始终使用 `Infow`/`Errorw`/`Warnw`/`Debugw`
3. **Context 传递**:使用 `trace.FromContext(ctx)` 而非直接引用 `logger.Log`
4. **敏感内容**:禁止在 Info 及以上级别记录用户文本原文
5. **错误日志**:采用"调用方记录"原则,底层函数 return wrapped error
6. **级别约定**
- `Debug`:内部状态跟踪、开发调试信息(数据库/缓存成功操作)
- `Info`:请求/连接生命周期、关键操作里程碑
- `Warn`可降级异常Redis 故障、限流触发)
- `Error`影响用户的操作失败数据库错误、Redis 连接失败)
- `Fatal`:仅启动阶段不可恢复错误
7. **预期内的空结果**`pgx.ErrNoRows``redis.Nil` 等不记录错误日志
## 性能考量
### FromContext 开销
- 有 trace_id~200-300 ns/op
- 无 trace_id~10-20 ns/op仅返回全局 logger
- 1000 QPS 场景额外开销约 0.2ms,可接受
### ULID 生成吞吐量
- 单线程:~500k ops/s
- 并发 8 线程:~2M ops/s
**验收标准**1000 QPS 下trace 系统开销 < 1% CPU< 0.5ms P99 延迟。