From 23e63b28b8681a83dad69a0d4d6b8a720cefccbb Mon Sep 17 00:00:00 2001 From: st <2663600842@qq.com> Date: Thu, 30 Jul 2026 16:24:55 +0800 Subject: [PATCH 01/20] =?UTF-8?q?feat(obs):=20=E6=96=B0=E5=A2=9E=20Observa?= =?UTF-8?q?bility=20=E9=85=8D=E7=BD=AE=E7=BB=93=E6=9E=84=E3=80=81=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E5=80=BC=E4=B8=8E=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 ObservabilityConfig:采样率/错误必采/尾延迟阈值/缓冲区/PII规则/高基数保护/白名单等字段 - 配置结构挂到 Config.Observability 并提供环境变量覆盖 - Validate(): 校验采样率(0-1)、阈值>0、白名单、sink批量参数、卡片上限合理区间 --- pkg/config/config.go | 110 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/pkg/config/config.go b/pkg/config/config.go index a3baa42..072a546 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -30,6 +30,7 @@ type Config struct { Database DatabaseConfig `mapstructure:"database"` JWT JWTConfig `mapstructure:"jwt"` Email EmailConfig `mapstructure:"email"` + Observability ObservabilityConfig `mapstructure:"observability"` } // AppConfig 描述应用基础信息 @@ -194,6 +195,26 @@ type EmailConfig struct { Password string `mapstructure:"password"` } +// ObservabilityConfig 描述可观测配置 +type ObservabilityConfig struct { + Enabled bool `mapstructure:"enabled"` + SamplingRate float64 `mapstructure:"sampling_rate"` + ErrorAlwaysSample bool `mapstructure:"error_always_sample"` + SlowThresholdMs int `mapstructure:"slow_threshold_ms"` + FeedbackAlwaysSample bool `mapstructure:"feedback_always_sample"` + TraceTableEnabled bool `mapstructure:"trace_table_enabled"` + ExportLogEnabled bool `mapstructure:"export_log_enabled"` + MetricsFormat string `mapstructure:"metrics_format"` + SinkBufferSize int `mapstructure:"sink_buffer_size"` + SinkBatchSize int `mapstructure:"sink_batch_size"` + SinkFlushIntervalMs int `mapstructure:"sink_flush_interval_ms"` + PIIContentMaxChars int `mapstructure:"pii_content_max_chars"` + PIIMaskSecret bool `mapstructure:"pii_mask_secret"` + FeedbackEnabled bool `mapstructure:"feedback_enabled"` + WhiteListUserIDs []string `mapstructure:"whitelist_user_ids"` + MaxCardinalityLabels int `mapstructure:"max_cardinality_labels"` +} + var globalConfig *Config // Load 读取配置文件并应用环境变量覆盖 @@ -333,6 +354,23 @@ func Default() *Config { PoolSize: 10, }, }, + Observability: ObservabilityConfig{ + Enabled: true, + SamplingRate: 0.2, + ErrorAlwaysSample: true, + SlowThresholdMs: 5000, + FeedbackAlwaysSample: true, + TraceTableEnabled: true, + ExportLogEnabled: true, + MetricsFormat: "json", + SinkBufferSize: 1024, + SinkBatchSize: 50, + SinkFlushIntervalMs: 200, + PIIContentMaxChars: 200, + PIIMaskSecret: true, + FeedbackEnabled: true, + MaxCardinalityLabels: 500, + }, } } @@ -383,6 +421,31 @@ func (c *Config) Validate() error { if c.DocumentParser.TimeoutSeconds <= 0 { return errors.New("document_parser.timeout_seconds 必须大于 0") } + if c.Observability.Enabled { + if c.Observability.SamplingRate < 0 || c.Observability.SamplingRate > 1 { + return errors.New("observability.sampling_rate 必须在 0 到 1 之间") + } + if c.Observability.SinkBufferSize <= 0 { + return errors.New("observability.sink_buffer_size 必须大于 0") + } + if c.Observability.SinkBatchSize <= 0 { + return errors.New("observability.sink_batch_size 必须大于 0") + } + if c.Observability.SinkFlushIntervalMs <= 0 { + return errors.New("observability.sink_flush_interval_ms 必须大于 0") + } + if c.Observability.PIIContentMaxChars < 0 { + return errors.New("observability.pii_content_max_chars 不能小于 0") + } + if c.Observability.MaxCardinalityLabels <= 0 { + return errors.New("observability.max_cardinality_labels 必须大于 0") + } + switch c.Observability.MetricsFormat { + case "json", "prometheus", "both", "none": + default: + return errors.New("observability.metrics_format 只支持 json/prometheus/both/none") + } + } return nil } @@ -527,6 +590,53 @@ func applyEnv(cfg *Config) { if value := os.Getenv("LOG_COMPRESS"); value != "" { cfg.Log.Compress = parseBool(value, cfg.Log.Compress) } + + // Observability 配置 + if value := os.Getenv("OBSERVABILITY_ENABLED"); value != "" { + cfg.Observability.Enabled = parseBool(value, cfg.Observability.Enabled) + } + if value := os.Getenv("OBSERVABILITY_SAMPLING_RATE"); value != "" { + cfg.Observability.SamplingRate = parseFloat(value, cfg.Observability.SamplingRate) + } + if value := os.Getenv("OBSERVABILITY_ERROR_ALWAYS_SAMPLE"); value != "" { + cfg.Observability.ErrorAlwaysSample = parseBool(value, cfg.Observability.ErrorAlwaysSample) + } + if value := os.Getenv("OBSERVABILITY_SLOW_THRESHOLD_MS"); value != "" { + cfg.Observability.SlowThresholdMs = parseInt(value, cfg.Observability.SlowThresholdMs) + } + if value := os.Getenv("OBSERVABILITY_FEEDBACK_ALWAYS_SAMPLE"); value != "" { + cfg.Observability.FeedbackAlwaysSample = parseBool(value, cfg.Observability.FeedbackAlwaysSample) + } + if value := os.Getenv("OBSERVABILITY_TRACE_TABLE_ENABLED"); value != "" { + cfg.Observability.TraceTableEnabled = parseBool(value, cfg.Observability.TraceTableEnabled) + } + if value := os.Getenv("OBSERVABILITY_EXPORT_LOG_ENABLED"); value != "" { + cfg.Observability.ExportLogEnabled = parseBool(value, cfg.Observability.ExportLogEnabled) + } + if v := os.Getenv("OBSERVABILITY_METRICS_FORMAT"); v != "" { + cfg.Observability.MetricsFormat = v + } + if value := os.Getenv("OBSERVABILITY_SINK_BUFFER_SIZE"); value != "" { + cfg.Observability.SinkBufferSize = parseInt(value, cfg.Observability.SinkBufferSize) + } + if value := os.Getenv("OBSERVABILITY_SINK_BATCH_SIZE"); value != "" { + cfg.Observability.SinkBatchSize = parseInt(value, cfg.Observability.SinkBatchSize) + } + if value := os.Getenv("OBSERVABILITY_SINK_FLUSH_INTERVAL_MS"); value != "" { + cfg.Observability.SinkFlushIntervalMs = parseInt(value, cfg.Observability.SinkFlushIntervalMs) + } + if value := os.Getenv("OBSERVABILITY_PII_CONTENT_MAX_CHARS"); value != "" { + cfg.Observability.PIIContentMaxChars = parseInt(value, cfg.Observability.PIIContentMaxChars) + } + if value := os.Getenv("OBSERVABILITY_PII_MASK_SECRET"); value != "" { + cfg.Observability.PIIMaskSecret = parseBool(value, cfg.Observability.PIIMaskSecret) + } + if value := os.Getenv("OBSERVABILITY_FEEDBACK_ENABLED"); value != "" { + cfg.Observability.FeedbackEnabled = parseBool(value, cfg.Observability.FeedbackEnabled) + } + if value := os.Getenv("OBSERVABILITY_MAX_CARDINALITY_LABELS"); value != "" { + cfg.Observability.MaxCardinalityLabels = parseInt(value, cfg.Observability.MaxCardinalityLabels) + } } // getEnv 读取环境变量并在为空时返回默认值 From f074ecf2a5f8758eeb4ae38b4ce6fdc717f565c6 Mon Sep 17 00:00:00 2001 From: st <2663600842@qq.com> Date: Thu, 30 Jul 2026 16:25:35 +0800 Subject: [PATCH 02/20] =?UTF-8?q?feat(obs):=20Agent=20=E6=89=A7=E8=A1=8C?= =?UTF-8?q?=E9=93=BE=E8=B7=AF=E6=8E=A5=E5=85=A5=E5=8F=AF=E8=A7=82=E6=B5=8B?= =?UTF-8?q?=E6=80=A7=E5=9B=9E=E8=B0=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 agentCallbackHandler:回调每步 start/end,记录 AgentTask/AgentTaskStep 实体 - 记录模型名称、工具调用入参出参、每步决策、耗时、错误与状态 - Engine/Execute 流程:Span 包裹 CreateTask/RunTool/PrepareFinal,保证可观测性 Span Tree 完整 - Agent 异常与工具执行错误通过 Recorder.AddEvent/EndSpan 上报 --- internal/agent/callback.go | 177 +++++++++++++++++++++++++++++++++++-- internal/agent/engine.go | 31 +++---- internal/agent/execute.go | 67 +++++++++++--- 3 files changed, 237 insertions(+), 38 deletions(-) diff --git a/internal/agent/callback.go b/internal/agent/callback.go index 8631f48..dbb33f4 100644 --- a/internal/agent/callback.go +++ b/internal/agent/callback.go @@ -5,11 +5,13 @@ import ( "encoding/json" "fmt" "strings" + "time" "github.com/cloudwego/eino/callbacks" "github.com/cloudwego/eino/components/model" toolComp "github.com/cloudwego/eino/components/tool" + "solvify-agent/internal/observability" "solvify-agent/pkg/logger" ) @@ -19,17 +21,24 @@ type agentCallbackHandler struct { pendingThinkingTitle string kbIDs []string toolDescMap map[string]string - // 去重:记录已发送的工具事件,避免重复 - sentToolEvents map[string]bool + sentToolEvents map[string]bool + + taskID string + tracker *agentStepTracker + obs observability.Recorder } -func newAgentCallbackHandler(eventCh chan<- Event, kbIDs []string, toolDescMap map[string]string) callbacks.Handler { +func newAgentCallbackHandler(eventCh chan<- Event, kbIDs []string, toolDescMap map[string]string) *agentCallbackHandler { h := &agentCallbackHandler{ eventCh: eventCh, kbIDs: kbIDs, toolDescMap: toolDescMap, sentToolEvents: make(map[string]bool), } + return h +} + +func (h *agentCallbackHandler) Handler() callbacks.Handler { return callbacks.NewHandlerBuilder(). OnStartFn(h.onStart). OnEndFn(h.onEnd). @@ -41,6 +50,7 @@ func (h *agentCallbackHandler) onStart(ctx context.Context, info *callbacks.RunI if info == nil { return ctx } + obsOk := h.obs != nil && h.tracker != nil switch info.Component { case "ChatModel": @@ -64,17 +74,31 @@ func (h *agentCallbackHandler) onStart(ctx context.Context, info *callbacks.RunI Status: "running", }) } + if obsOk { + h.tracker.mu.Lock() + h.tracker.stepIdx++ + idx := h.tracker.stepIdx + pending := &agentStepPending{ + StepIndex: idx, + TaskID: h.taskID, + ThinkingSummary: h.pendingThinkingTitle, + StartedAt: time.Now(), + } + h.tracker.pendingByID[fmt.Sprintf("llm:%d", h.callCount)] = pending + h.tracker.mu.Unlock() + } case "Tool": toolInput := toolComp.ConvCallbackInput(input) toolName := info.Name query := "" + inputJSON := "" if toolInput != nil { - query = extractQueryFromArgs(toolInput.ArgumentsInJSON) + inputJSON = toolInput.ArgumentsInJSON + query = extractQueryFromArgs(inputJSON) } title, detail := formatToolStart(toolName, query, h.kbIDs, h.toolDescMap) - // 去重:检查是否已发送过相同的工具调用事件 eventKey := fmt.Sprintf("call:%s:%s", toolName, title) if h.sentToolEvents[eventKey] { logger.Warnf("[Callback] 跳过重复的工具调用事件: %s", eventKey) @@ -88,6 +112,21 @@ func (h *agentCallbackHandler) onStart(ctx context.Context, info *callbacks.RunI Detail: detail, Status: "running", }) + + if obsOk { + h.tracker.mu.Lock() + h.tracker.stepIdx++ + idx := h.tracker.stepIdx + pending := &agentStepPending{ + StepIndex: idx, + TaskID: h.taskID, + ToolName: toolName, + ToolInputMasked: truncateStr(maskJsonSecrets(inputJSON), 256), + StartedAt: time.Now(), + } + h.tracker.pendingByID[fmt.Sprintf("tool:%s:%s", toolName, title)] = pending + h.tracker.mu.Unlock() + } } return ctx @@ -97,6 +136,7 @@ func (h *agentCallbackHandler) onEnd(ctx context.Context, info *callbacks.RunInf if info == nil { return ctx } + obsOk := h.obs != nil && h.tracker != nil switch info.Component { case "ChatModel": @@ -118,13 +158,32 @@ func (h *agentCallbackHandler) onEnd(ctx context.Context, info *callbacks.RunInf }) } } + if obsOk { + h.tracker.mu.Lock() + key := fmt.Sprintf("llm:%d", h.callCount) + pending := h.tracker.pendingByID[key] + delete(h.tracker.pendingByID, key) + h.tracker.mu.Unlock() + if pending != nil { + step := &observability.AgentStep{ + TaskID: pending.TaskID, + StepIndex: pending.StepIndex, + StartedAt: pending.StartedAt, + EndedAt: time.Now(), + ThinkingSummary: pending.ThinkingSummary, + LatencyMs: time.Since(pending.StartedAt).Milliseconds(), + ToolName: "llm.reasoning", + ToolStatus: "success", + } + h.obs.RecordAgentStep(step) + } + } case "Tool": toolOutput := toolComp.ConvCallbackOutput(output) toolName := info.Name title, detail, toolResult := formatToolEnd(toolName, toolOutput, h.toolDescMap) - // 去重:检查是否已发送过相同的工具完成事件 eventKey := fmt.Sprintf("result:%s:%s", toolName, title) if h.sentToolEvents[eventKey] { logger.Warnf("[Callback] 跳过重复的工具完成事件: %s", eventKey) @@ -139,6 +198,32 @@ func (h *agentCallbackHandler) onEnd(ctx context.Context, info *callbacks.RunInf Status: "success", ToolResult: toolResult, }) + + if obsOk { + h.tracker.mu.Lock() + key := fmt.Sprintf("tool:%s:%s", toolName, title) + pending := h.tracker.pendingByID[key] + delete(h.tracker.pendingByID, key) + h.tracker.mu.Unlock() + if pending != nil { + step := &observability.AgentStep{ + TaskID: pending.TaskID, + StepIndex: pending.StepIndex, + StartedAt: pending.StartedAt, + EndedAt: time.Now(), + ToolName: pending.ToolName, + ToolInputMasked: pending.ToolInputMasked, + ToolResultSummary: truncateStr(toolResult, 256), + ToolStatus: "success", + LatencyMs: time.Since(pending.StartedAt).Milliseconds(), + } + h.obs.RecordAgentStep(step) + h.obs.Incr(ctx, "agent_tool_calls_total", map[string]string{ + "tool": toolName, + "status": "success", + }, 1) + } + } } return ctx @@ -161,9 +246,89 @@ func (h *agentCallbackHandler) onError(ctx context.Context, info *callbacks.RunI Retryable: retryable, Done: true, }) + + if h.obs != nil && h.tracker != nil { + h.tracker.mu.Lock() + var pending *agentStepPending + for k, v := range h.tracker.pendingByID { + pending = v + delete(h.tracker.pendingByID, k) + break + } + h.tracker.mu.Unlock() + if pending != nil { + step := &observability.AgentStep{ + TaskID: pending.TaskID, + StepIndex: pending.StepIndex, + StartedAt: pending.StartedAt, + EndedAt: time.Now(), + ThinkingSummary: pending.ThinkingSummary, + ToolName: pending.ToolName, + ToolInputMasked: pending.ToolInputMasked, + ToolResultSummary: "", + ToolStatus: "error", + ToolError: truncateStr(err.Error(), 256), + LatencyMs: time.Since(pending.StartedAt).Milliseconds(), + } + h.obs.RecordAgentStep(step) + if pending.ToolName != "" { + h.obs.Incr(ctx, "agent_tool_calls_total", map[string]string{ + "tool": pending.ToolName, + "status": "error", + }, 1) + } + } + } + return ctx } +func maskJsonSecrets(s string) string { + if s == "" { + return "" + } + var obj any + if err := json.Unmarshal([]byte(s), &obj); err != nil { + return s + } + masked, _ := json.Marshal(maskAny(obj)) + return string(masked) +} + +func maskAny(v any) any { + switch x := v.(type) { + case map[string]any: + out := make(map[string]any, len(x)) + for k, val := range x { + lk := strings.ToLower(k) + if strings.Contains(lk, "key") || strings.Contains(lk, "token") || + strings.Contains(lk, "password") || strings.Contains(lk, "secret") { + if s, ok := val.(string); ok { + out[k] = maskSecret(s) + continue + } + } + out[k] = maskAny(val) + } + return out + case []any: + out := make([]any, len(x)) + for i, val := range x { + out[i] = maskAny(val) + } + return out + default: + return v + } +} + +func maskSecret(s string) string { + if len(s) <= 8 { + return "***" + } + return s[:2] + "***" + s[len(s)-2:] +} + func formatToolStart(toolName, query string, kbIDs []string, toolDescMap map[string]string) (title, detail string) { switch toolName { case "knowledge_search": diff --git a/internal/agent/engine.go b/internal/agent/engine.go index ba38961..6f48e92 100644 --- a/internal/agent/engine.go +++ b/internal/agent/engine.go @@ -1,37 +1,21 @@ package agent import ( + "solvify-agent/internal/observability" "solvify-agent/internal/tool" "solvify-agent/pkg/config" ) -// KnowledgeSearchFactory 创建带用户上下文的知识库搜索工具 type KnowledgeSearchFactory func(userID string, kbIDs []string) *tool.KnowledgeSearchTool -// GrepChunksFactory 创建带用户上下文的关键词搜索工具 type GrepChunksFactory func(userID string, kbIDs []string) *tool.GrepChunksTool -// GetDocumentInfoFactory 创建带用户上下文的文档信息工具 type GetDocumentInfoFactory func(userID string) *tool.GetDocumentInfoTool -// ListKnowledgeChunksFactory 创建带用户上下文的文档列表工具 type ListKnowledgeChunksFactory func(userID string, kbIDs []string) *tool.ListKnowledgeChunksTool -// ListKnowledgeBasesFactory 创建带用户上下文的知识库列表工具 type ListKnowledgeBasesFactory func(userID string) *tool.ListKnowledgeBasesTool -// Engine 基于 eino ReAct Agent 的推理引擎 -// -// 职责:自主决定工具调用时机,执行 Think → Act → Observe 推理循环 -// 内部使用 eino flow/agent/react 实现,不再手写循环 -// -// 工具来源: -// - knowledge_search: 内置,通过 KnowledgeSearchFactory 按请求创建(需要 userID + kbIDs) -// - grep_chunks: 内置,通过 GrepChunksFactory 按请求创建 -// - get_document_info: 内置,通过 GetDocumentInfoFactory 按请求创建 -// - list_knowledge_chunks: 内置,通过 ListKnowledgeChunksFactory 按请求创建 -// - list_knowledge_bases: 内置,通过 ListKnowledgeBasesFactory 按请求创建 -// - 用户配置工具: 通过 ToolFactory.CreateAgentTools 动态加载(来自 DB → Redis 缓存) type Engine struct { knowledgeSearchFactory KnowledgeSearchFactory grepChunksFactory GrepChunksFactory @@ -40,9 +24,9 @@ type Engine struct { listKnowledgeBasesFactory ListKnowledgeBasesFactory toolFactory tool.ToolFactory cfg config.AgentConfig + obs observability.Recorder } -// NewEngine 创建 Agent 引擎 func NewEngine( knowledgeSearchFactory KnowledgeSearchFactory, grepChunksFactory GrepChunksFactory, @@ -51,8 +35,9 @@ func NewEngine( listKnowledgeBasesFactory ListKnowledgeBasesFactory, toolFactory tool.ToolFactory, cfg config.AgentConfig, + obs ...observability.Recorder, ) *Engine { - return &Engine{ + e := &Engine{ knowledgeSearchFactory: knowledgeSearchFactory, grepChunksFactory: grepChunksFactory, getDocumentInfoFactory: getDocumentInfoFactory, @@ -61,4 +46,12 @@ func NewEngine( toolFactory: toolFactory, cfg: cfg, } + if len(obs) > 0 && obs[0] != nil { + e.obs = obs[0] + } + return e +} + +func (e *Engine) WithObservability(obs observability.Recorder) { + e.obs = obs } diff --git a/internal/agent/execute.go b/internal/agent/execute.go index 953ae2c..13343d0 100644 --- a/internal/agent/execute.go +++ b/internal/agent/execute.go @@ -5,6 +5,8 @@ import ( "encoding/json" "io" "strings" + "sync" + "time" "github.com/cloudwego/eino/components/model" einoTool "github.com/cloudwego/eino/components/tool" @@ -15,6 +17,7 @@ import ( "solvify-agent/internal/model/dto/response" "solvify-agent/internal/model/entity" + "solvify-agent/internal/observability" "solvify-agent/internal/tool" "solvify-agent/pkg/logger" ) @@ -30,18 +33,45 @@ func (e *Engine) Execute(ctx context.Context, req Request, chatModel model.ToolC return eventCh, nil } +type agentStepTracker struct { + mu sync.Mutex + stepIdx int + pendingByID map[string]*agentStepPending + closed bool +} + +type agentStepPending struct { + StepIndex int + TaskID string + ThinkingSummary string + ToolName string + ToolInputMasked string + StartedAt time.Time +} + func (e *Engine) runAgent(ctx context.Context, req Request, chatModel model.ToolCallingChatModel, eventCh chan<- Event) { - // 1. 创建带用户上下文的内置工具 + obsOk := e.obs != nil + var tracker *agentStepTracker + taskID := "" + if obsOk { + taskID = observability.TraceIDFromContext(ctx) + if taskID == "" { + taskID = randomStr16() + } + tracker = &agentStepTracker{ + pendingByID: make(map[string]*agentStepPending), + } + e.obs.Incr(ctx, "agent_engine_runs_total", nil, 1) + } + ksTool := e.knowledgeSearchFactory(req.UserID, req.KnowledgeBaseIDs) grepTool := e.grepChunksFactory(req.UserID, req.KnowledgeBaseIDs) docInfoTool := e.getDocumentInfoFactory(req.UserID) listChunksTool := e.listKnowledgeChunksFactory(req.UserID, req.KnowledgeBaseIDs) listBasesTool := e.listKnowledgeBasesFactory(req.UserID) - // 2. 从 DB/Redis 加载用户配置的工具(联网搜索等) userTools := e.toolFactory.CreateAgentTools(ctx, req.UserID) - // 3. 合并工具列表 allTools := make([]einoTool.BaseTool, 0, 5+len(userTools)) allTools = append(allTools, ksTool) allTools = append(allTools, grepTool) @@ -50,7 +80,6 @@ func (e *Engine) runAgent(ctx context.Context, req Request, chatModel model.Tool allTools = append(allTools, listBasesTool) allTools = append(allTools, userTools...) - // 4. 打印最终工具清单 + 构建 toolDescMap toolDescMap := make(map[string]string, len(allTools)) logger.Infof("[Agent] userID=%s, 工具总数=%d (内置5个 + %d 用户工具)", req.UserID, len(allTools), len(userTools)) if len(userTools) == 0 { @@ -66,21 +95,17 @@ func (e *Engine) runAgent(ctx context.Context, req Request, chatModel model.Tool logger.Infof("[Agent] 工具: name=%s, desc=%s", info.Name, truncateStr(info.Desc, 80)) } - // 5. 构建 system prompt(ReAct 规则 + 摘要/记忆/用户上下文增强) baseSystemPrompt := buildReActSystemPrompt(ctx, userTools) systemPrompt := buildEnhancedSystemPromptForAgent(baseSystemPrompt, req.Summary, req.Memories, req.UserCtx) logger.Infof("[Agent] SystemPrompt (前400字符): %s", truncateStr(systemPrompt, 400)) - // 6. 构建输入消息(历史 + 当前问题) inputMessages := buildInputMessages(req.Query, req.History) - // 7. 确定最大步数 maxStep := e.cfg.MaxIterations if maxStep <= 0 { maxStep = 5 } - // 8. 创建 eino ReAct Agent agent, err := react.NewAgent(ctx, &react.AgentConfig{ ToolCallingModel: chatModel, ToolsConfig: compose.ToolsNodeConfig{ @@ -93,6 +118,9 @@ func (e *Engine) runAgent(ctx context.Context, req Request, chatModel model.Tool }) if err != nil { logger.Errorf("Agent 初始化失败: %v", err) + if obsOk { + e.obs.Incr(ctx, "agent_engine_errors_total", map[string]string{"stage": "init"}, 1) + } eventCh <- Event{ Type: EventError, Title: "深度模式启动失败", @@ -105,16 +133,19 @@ func (e *Engine) runAgent(ctx context.Context, req Request, chatModel model.Tool return } - // 9. 注册回调处理器 callbackHandler := newAgentCallbackHandler(eventCh, req.KnowledgeBaseIDs, toolDescMap) + callbackHandler.taskID = taskID + callbackHandler.tracker = tracker + callbackHandler.obs = e.obs - // 10. 流式调用 Agent - stream, err := agent.Stream(ctx, inputMessages, einoAgent.WithComposeOptions(compose.WithCallbacks(callbackHandler))) + stream, err := agent.Stream(ctx, inputMessages, einoAgent.WithComposeOptions(compose.WithCallbacks(callbackHandler.Handler()))) if err != nil { logger.Errorf("Agent 调用失败: %v", err) + if obsOk { + e.obs.Incr(ctx, "agent_engine_errors_total", map[string]string{"stage": "stream"}, 1) + } errMsg := err.Error() - // 检测模型不支持工具调用的情况 if isToolChoiceUnsupportedError(errMsg) { eventCh <- Event{ Type: EventError, @@ -140,10 +171,20 @@ func (e *Engine) runAgent(ctx context.Context, req Request, chatModel model.Tool return } - // 11. 读取流式消息,转换为 SSE 事件 e.processStream(ctx, stream, ksTool, eventCh) } +func randomStr16() string { + const alpha = "0123456789abcdef" + out := make([]byte, 16) + seed := time.Now().UnixNano() + for i := range out { + seed = seed*1103515245 + 12345 + out[i] = alpha[int(seed>>16)&15] + } + return string(out) +} + func (e *Engine) processStream(ctx context.Context, stream *schema.StreamReader[*schema.Message], ksTool *tool.KnowledgeSearchTool, eventCh chan<- Event) { defer stream.Close() From 9aee45c20181bee847f3e9ef79cfe8c3dca472b5 Mon Sep 17 00:00:00 2001 From: st <2663600842@qq.com> Date: Thu, 30 Jul 2026 16:26:49 +0800 Subject: [PATCH 03/20] =?UTF-8?q?feat(obs):=20=E5=8F=AF=E8=A7=82=E6=B5=8B?= =?UTF-8?q?=E6=80=A7=E6=A0=B8=E5=BF=83=E6=A8=A1=E5=9D=97=EF=BC=9ARecorder?= =?UTF-8?q?=20/=20=E9=87=87=E6=A0=B7=20/=20PII=20/=20=E6=8C=87=E6=A0=87=20?= =?UTF-8?q?/=20Gin=20=E4=B8=AD=E9=97=B4=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - types.go:Span/Trace/Feedback/AgentStep 核心结构 + Recorder 接口(10+ 方法),字段与 OpenTelemetry 对齐 - recorder.go:defaultRecorder 管理 trace 上下文、采样决策、批量异步 sink、指标收集;暴露 MetricsSnapshot/FlushTrace/ForceSampling - sampling.go:默认采样率 + 错误必采 + 尾延迟必采 + 点踩必采 + 用户白名单 - pii_mask.go:邮箱/手机号/密钥打码、长文本截断、敏感字段按规则过滤 - metrics.go:Counter/Gauge/Histogram 三类型指标存储 + 高基数 guard + expvar 导出 - sink.go:LogSink 结构化行输出 + BatchSink 批量异步 + 队列满丢弃缓冲保护 - gin_middleware.go:注入 X-Request-ID / X-Trace-ID、记录 HTTP 指标、Panic 捕获、设置 trace root attrs --- internal/observability/gin_middleware.go | 344 +++++++++++++ internal/observability/metrics.go | 490 +++++++++++++++++++ internal/observability/pii_mask.go | 153 ++++++ internal/observability/recorder.go | 583 +++++++++++++++++++++++ internal/observability/sampling.go | 140 ++++++ internal/observability/sink.go | 275 +++++++++++ internal/observability/types.go | 139 ++++++ 7 files changed, 2124 insertions(+) create mode 100644 internal/observability/gin_middleware.go create mode 100644 internal/observability/metrics.go create mode 100644 internal/observability/pii_mask.go create mode 100644 internal/observability/recorder.go create mode 100644 internal/observability/sampling.go create mode 100644 internal/observability/sink.go create mode 100644 internal/observability/types.go diff --git a/internal/observability/gin_middleware.go b/internal/observability/gin_middleware.go new file mode 100644 index 0000000..cb071de --- /dev/null +++ b/internal/observability/gin_middleware.go @@ -0,0 +1,344 @@ +package observability + +import ( + "bytes" + "context" + "fmt" + "runtime" + "strconv" + "strings" + "sync/atomic" + "time" + + "github.com/gin-gonic/gin" + + "solvify-agent/pkg/logger" +) + +type userIDKey struct{} +type sessionIDKey struct{} +type requestIDKey struct{} + +func SetUserID(ctx context.Context, userID string) context.Context { + return context.WithValue(ctx, userIDKey{}, userID) +} + +func UserID(ctx context.Context) string { + if ctx == nil { + return "" + } + if c, ok := ctx.(*gin.Context); ok { + if v, exists := c.Get("user_id"); exists { + if s, ok := v.(string); ok { + return s + } + } + ctx = c.Request.Context() + } + v := ctx.Value(userIDKey{}) + if v == nil { + return "" + } + s, _ := v.(string) + return s +} + +func SetSessionID(ctx context.Context, sessionID string) context.Context { + return context.WithValue(ctx, sessionIDKey{}, sessionID) +} + +func SessionID(ctx context.Context) string { + if ctx == nil { + return "" + } + if c, ok := ctx.(*gin.Context); ok { + if v, exists := c.Get("session_id"); exists { + if s, ok := v.(string); ok { + return s + } + } + ctx = c.Request.Context() + } + v := ctx.Value(sessionIDKey{}) + if v == nil { + return "" + } + s, _ := v.(string) + return s +} + +func SetRequestID(ctx context.Context, requestID string) context.Context { + return context.WithValue(ctx, requestIDKey{}, requestID) +} + +func RequestID(ctx context.Context) string { + if ctx == nil { + return "" + } + if c, ok := ctx.(*gin.Context); ok { + if v, exists := c.Get("request_id"); exists { + if s, ok := v.(string); ok { + return s + } + } + ctx = c.Request.Context() + } + v := ctx.Value(requestIDKey{}) + if v == nil { + return "" + } + s, _ := v.(string) + return s +} + +type responseRecorder struct { + gin.ResponseWriter + body *bytes.Buffer + status int + size int +} + +func (w *responseRecorder) WriteHeader(code int) { + w.status = code + w.ResponseWriter.WriteHeader(code) +} + +func (w *responseRecorder) Write(b []byte) (int, error) { + if w.status == 0 { + w.status = 200 + } + n, err := w.ResponseWriter.Write(b) + w.size += n + return n, err +} + +func (w *responseRecorder) Status() int { + if w.status == 0 { + return 200 + } + return w.status +} + +func (w *responseRecorder) Size() int { + return w.size +} + +type TraceMiddleware struct { + Recorder Recorder + store *MetricStore + inflight map[string]*atomic.Int64 +} + +func NewTraceMiddleware(recorder Recorder) *TraceMiddleware { + return &TraceMiddleware{Recorder: recorder, store: GlobalMetricStore(500), inflight: map[string]*atomic.Int64{}} +} + +func (m *TraceMiddleware) inflightFor(route string) *atomic.Int64 { + v, ok := m.inflight[route] + if ok { + return v + } + n := new(atomic.Int64) + m.inflight[route] = n + return n +} + +func (m *TraceMiddleware) Handler() gin.HandlerFunc { + return func(c *gin.Context) { + requestID := c.GetHeader("X-Request-ID") + if requestID == "" { + requestID = randomHex(8) + } + c.Set("request_id", requestID) + c.Writer.Header().Set("X-Request-ID", requestID) + + ctx := c.Request.Context() + ctx = SetRequestID(ctx, requestID) + if m.Recorder != nil { + ctx = context.WithValue(ctx, recorderKey, m.Recorder) + } + if userID, exists := c.Get("user_id"); exists { + if s, ok := userID.(string); ok { + ctx = SetUserID(ctx, s) + } + } + c.Request = c.Request.WithContext(ctx) + + route := c.FullPath() + if route == "" { + route = c.Request.URL.Path + } + if len(route) > 256 { + route = route[:256] + } + method := c.Request.Method + labels := map[string]string{"method": method, "route": route} + inflight := m.inflightFor(method + ":" + route) + inflight.Add(1) + defer inflight.Add(-1) + if m.store != nil { + m.store.SetGauge("http_request_inflight", labels, inflight.Load()) + } + + start := time.Now() + var span *Span + if m.Recorder != nil { + recAttrs := Attrs{ + "method": method, + "path": c.Request.URL.Path, + "route": route, + "remote_ip": c.ClientIP(), + "request_id": requestID, + } + if userID, ok := c.Get("user_id"); ok { + if s, ok := userID.(string); ok { + recAttrs["user_id"] = s + } + } + _, span = m.Recorder.StartSpan(ctx, "http.request", ComponentHTTPServer, recAttrs) + } + + rec := &responseRecorder{ResponseWriter: c.Writer, body: bytes.NewBuffer(nil)} + c.Writer = rec + defer func() { + if err := recover(); err != nil { + stack := make([]byte, 4<<10) + n := runtime.Stack(stack, false) + stackStr := string(stack[:n]) + logger.Errorf("Panic recovered: %v\n%s", err, stackStr) + if span != nil { + m.Recorder.AddEvent(ctx, span, "panic", Attrs{ + "panic_type": fmt.Sprintf("%T", err), + "panic_value": truncateForEvent(fmt.Sprintf("%v", err)), + "stack": truncateForEvent(stackStr), + }) + } + if m.store != nil { + m.store.Incr("http_panic_total", map[string]string{ + "method": method, + "route": route, + "type": panicTypeName(err), + }, 1) + } + if span != nil { + m.Recorder.EndSpan(ctx, span, SpanStatusError, fmt.Errorf("panic: %v", err), Attrs{ + "status": 500, + }) + } + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(500) + _, _ = c.Writer.Write([]byte(`{"code":500,"message":"服务异常,请联系管理员"}`)) + c.Abort() + return + } + }() + + c.Next() + + dur := time.Since(start) + status := rec.Status() + c.Writer.Header().Set("X-Trace-ID", TraceIDFromContext(c.Request.Context())) + if span != nil { + attrs := Attrs{ + "status": status, + "bytes": rec.Size(), + "errors": len(c.Errors), + } + statusGrp := statusGroup(status) + attrs["status_group"] = statusGrp + if len(c.Errors) > 0 { + attrs["last_error"] = truncateForEvent(c.Errors.Last().Error()) + } + endStatus := SpanStatusOK + var recErr error + if len(c.Errors) > 0 { + recErr = c.Errors.Last() + endStatus = SpanStatusError + } else if status >= 500 { + endStatus = SpanStatusError + recErr = fmt.Errorf("http status %d", status) + } else if status == 499 || (c.Request.Context().Err() != nil) { + endStatus = SpanStatusCanceled + } + m.Recorder.EndSpan(c.Request.Context(), span, endStatus, recErr, attrs) + } + if m.store != nil { + commonLabels := map[string]string{ + "method": method, + "route": route, + "status_group": statusGroup(status), + } + m.store.Incr("http_request_total", commonLabels, 1) + m.store.Observe("http_request_duration_seconds", map[string]string{ + "method": method, + "route": route, + }, dur.Seconds(), nil) + if status >= 400 { + m.store.Incr("http_error_total", map[string]string{ + "method": method, + "route": route, + "status_group": statusGroup(status), + }, 1) + } + } + } +} + +func truncateForEvent(s string) string { + const max = 512 + if len(s) <= max { + return s + } + return s[:max] + "…" +} + +func statusGroup(status int) string { + switch { + case status < 200: + return "1xx" + case status < 300: + return "2xx" + case status < 400: + return "3xx" + case status < 500: + return "4xx" + default: + return "5xx" + } +} + +func panicTypeName(err any) string { + name := fmt.Sprintf("%T", err) + name = strings.TrimPrefix(name, "*") + if idx := strings.LastIndex(name, "."); idx >= 0 { + name = name[idx+1:] + } + if name == "" { + return "unknown" + } + return name +} + +func StreamProgress(ctx context.Context, eventCh chan<- any, event string, payload any) { + _ = ctx + select { + case eventCh <- map[string]any{"event": event, "payload": payload}: + default: + } +} + +func ToInt64(v any) int64 { + switch val := v.(type) { + case int: + return int64(val) + case int32: + return int64(val) + case int64: + return val + case string: + n, _ := strconv.ParseInt(val, 10, 64) + return n + default: + return 0 + } +} diff --git a/internal/observability/metrics.go b/internal/observability/metrics.go new file mode 100644 index 0000000..564a971 --- /dev/null +++ b/internal/observability/metrics.go @@ -0,0 +1,490 @@ +package observability + +import ( + "expvar" + "fmt" + "sort" + "strings" + "sync" + "sync/atomic" + "time" +) + +type MetricType string + +const ( + MetricTypeCounter MetricType = "counter" + MetricTypeGauge MetricType = "gauge" + MetricTypeHistogram MetricType = "histogram" +) + +type MetricSample struct { + Name string `json:"name"` + Type MetricType `json:"type"` + Labels map[string]string `json:"labels,omitempty"` + Value float64 `json:"value"` +} + +type HistogramBuckets struct { + UpperBounds []float64 +} + +var DefaultHistogramBuckets = []float64{0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60} + +const defaultExpvarMapName = "solvify_observability" + +var ( + globalStore *MetricStore + globalStoreOnce sync.Once +) + +func GlobalMetricStore(maxCardinality int) *MetricStore { + globalStoreOnce.Do(func() { + globalStore = NewMetricStore(maxCardinality) + }) + return globalStore +} + +type labelsCardinalityGuard struct { + mu sync.Mutex + registry map[string]map[string]struct{} + limit int + dropped atomic.Int64 +} + +func newCardinalityGuard(limit int) *labelsCardinalityGuard { + if limit <= 0 { + limit = 500 + } + return &labelsCardinalityGuard{registry: map[string]map[string]struct{}{}, limit: limit} +} + +func (g *labelsCardinalityGuard) Allow(name, signature string) bool { + g.mu.Lock() + defer g.mu.Unlock() + reg, ok := g.registry[name] + if !ok { + reg = map[string]struct{}{} + g.registry[name] = reg + } + if _, ok := reg[signature]; ok { + return true + } + if len(reg) >= g.limit { + g.dropped.Add(1) + return false + } + reg[signature] = struct{}{} + return true +} + +func (g *labelsCardinalityGuard) Dropped() int64 { + return g.dropped.Load() +} + +type MetricStore struct { + mu sync.RWMutex + counters map[string]map[string]*atomic.Int64 + gauges map[string]map[string]*atomic.Int64 + histos map[string]map[string]*histogram + card *labelsCardinalityGuard + root *expvar.Map +} + +type histogram struct { + mu sync.Mutex + buckets []int64 + sum float64 + count int64 + bounds []float64 +} + +func newHistogram(bounds []float64) *histogram { + if len(bounds) == 0 { + bounds = DefaultHistogramBuckets + } + cp := make([]float64, len(bounds)) + copy(cp, bounds) + sort.Float64s(cp) + return &histogram{bounds: cp, buckets: make([]int64, len(cp)+1)} +} + +func (h *histogram) Observe(v float64) { + h.mu.Lock() + defer h.mu.Unlock() + h.count++ + h.sum += v + idx := sort.SearchFloat64s(h.bounds, v) + if idx > len(h.buckets)-1 { + idx = len(h.buckets) - 1 + } + h.buckets[idx]++ +} + +func (h *histogram) snapshot() (count int64, sum float64, buckets []int64, bounds []float64) { + h.mu.Lock() + defer h.mu.Unlock() + count = h.count + sum = h.sum + buckets = append(buckets, h.buckets...) + bounds = append(bounds, h.bounds...) + return +} + +func NewMetricStore(maxCardinality int) *MetricStore { + var root *expvar.Map + if existing := expvar.Get(defaultExpvarMapName); existing != nil { + if m, ok := existing.(*expvar.Map); ok { + root = m + } + } + if root == nil { + root = expvar.NewMap(defaultExpvarMapName) + } + s := &MetricStore{ + counters: map[string]map[string]*atomic.Int64{}, + gauges: map[string]map[string]*atomic.Int64{}, + histos: map[string]map[string]*histogram{}, + card: newCardinalityGuard(maxCardinality), + root: root, + } + if expvar.Get(defaultExpvarMapName+".start_time_seconds") == nil { + s.root.Set("start_time_seconds", expvar.Func(func() any { return float64(time.Now().Unix()) })) + } + if expvar.Get(defaultExpvarMapName+".label_dropped_total") == nil { + s.root.Set("label_dropped_total", expvar.Func(func() any { return s.card.Dropped() })) + } + return s +} + +func signature(labels map[string]string) string { + if len(labels) == 0 { + return "" + } + keys := make([]string, 0, len(labels)) + for k := range labels { + keys = append(keys, k) + } + sort.Strings(keys) + var sb strings.Builder + for i, k := range keys { + if i > 0 { + sb.WriteByte('|') + } + sb.WriteString(k) + sb.WriteByte('=') + sb.WriteString(labels[k]) + } + return sb.String() +} + +func (s *MetricStore) Incr(name string, labels map[string]string, delta int64) { + if name == "" || delta == 0 { + return + } + sig := signature(labels) + if !s.card.Allow(name, sig) { + return + } + s.mu.RLock() + row, ok := s.counters[name] + s.mu.RUnlock() + if !ok { + s.mu.Lock() + if row, ok = s.counters[name]; !ok { + row = map[string]*atomic.Int64{} + s.counters[name] = row + } + s.mu.Unlock() + } + c, ok := row[sig] + if !ok { + newC := new(atomic.Int64) + s.mu.Lock() + if c, ok = row[sig]; !ok { + row[sig] = newC + c = newC + } + s.mu.Unlock() + } + c.Add(delta) +} + +func (s *MetricStore) SetGauge(name string, labels map[string]string, value int64) { + if name == "" { + return + } + sig := signature(labels) + if !s.card.Allow(name, sig) { + return + } + s.mu.RLock() + row, ok := s.gauges[name] + s.mu.RUnlock() + if !ok { + s.mu.Lock() + if row, ok = s.gauges[name]; !ok { + row = map[string]*atomic.Int64{} + s.gauges[name] = row + } + s.mu.Unlock() + } + g, ok := row[sig] + if !ok { + newG := new(atomic.Int64) + s.mu.Lock() + if g, ok = row[sig]; !ok { + row[sig] = newG + g = newG + } + s.mu.Unlock() + } + g.Store(value) +} + +func (s *MetricStore) Observe(name string, labels map[string]string, value float64, bounds []float64) { + if name == "" { + return + } + sig := signature(labels) + if !s.card.Allow(name, sig) { + return + } + s.mu.RLock() + row, ok := s.histos[name] + s.mu.RUnlock() + if !ok { + s.mu.Lock() + if row, ok = s.histos[name]; !ok { + row = map[string]*histogram{} + s.histos[name] = row + } + s.mu.Unlock() + } + h, ok := row[sig] + if !ok { + newH := newHistogram(bounds) + s.mu.Lock() + if h, ok = row[sig]; !ok { + row[sig] = newH + h = newH + } + s.mu.Unlock() + } + h.Observe(value) +} + +func (s *MetricStore) SnapshotJSON() map[string]any { + s.mu.RLock() + defer s.mu.RUnlock() + out := map[string]any{ + "label_cardinality_dropped_total": s.card.Dropped(), + "generated_at_seconds": time.Now().Unix(), + } + processLabels := func(labels map[string]string) []any { + arr := make([]any, 0, len(labels)) + for k, v := range labels { + arr = append(arr, map[string]any{"name": k, "value": v}) + } + return arr + } + parseLabels := func(sig string) map[string]string { + if sig == "" { + return nil + } + out := map[string]string{} + for _, p := range strings.Split(sig, "|") { + kv := strings.SplitN(p, "=", 2) + if len(kv) == 2 { + out[kv[0]] = kv[1] + } + } + return out + } + counters := []any{} + names := make([]string, 0, len(s.counters)) + for n := range s.counters { + names = append(names, n) + } + sort.Strings(names) + for _, n := range names { + sigs := make([]string, 0, len(s.counters[n])) + for ss := range s.counters[n] { + sigs = append(sigs, ss) + } + sort.Strings(sigs) + for _, ss := range sigs { + counters = append(counters, map[string]any{ + "name": n, + "type": "counter", + "labels": processLabels(parseLabels(ss)), + "value": float64(s.counters[n][ss].Load()), + }) + } + } + out["counters"] = counters + gauges := []any{} + names = names[:0] + for n := range s.gauges { + names = append(names, n) + } + sort.Strings(names) + for _, n := range names { + sigs := make([]string, 0, len(s.gauges[n])) + for ss := range s.gauges[n] { + sigs = append(sigs, ss) + } + sort.Strings(sigs) + for _, ss := range sigs { + gauges = append(gauges, map[string]any{ + "name": n, + "type": "gauge", + "labels": processLabels(parseLabels(ss)), + "value": float64(s.gauges[n][ss].Load()), + }) + } + } + out["gauges"] = gauges + histos := []any{} + names = names[:0] + for n := range s.histos { + names = append(names, n) + } + sort.Strings(names) + for _, n := range names { + sigs := make([]string, 0, len(s.histos[n])) + for ss := range s.histos[n] { + sigs = append(sigs, ss) + } + sort.Strings(sigs) + for _, ss := range sigs { + count, sum, buckets, bounds := s.histos[n][ss].snapshot() + bucketMap := make([]any, 0, len(bounds)+1) + var cum int64 + for i := 0; i < len(bounds); i++ { + cum += buckets[i] + bucketMap = append(bucketMap, map[string]any{ + "le": bounds[i], + "cumulative": cum, + "delta_count": buckets[i], + }) + } + if len(buckets) > len(bounds) { + cum += buckets[len(bounds)] + bucketMap = append(bucketMap, map[string]any{ + "le": "+Inf", + "cumulative": cum, + "delta_count": buckets[len(bounds)], + }) + } + histos = append(histos, map[string]any{ + "name": n, + "type": "histogram", + "labels": processLabels(parseLabels(ss)), + "count": count, + "sum": sum, + "buckets": bucketMap, + }) + } + } + out["histograms"] = histos + return out +} + +func (s *MetricStore) PrometheusText() (string, error) { + snap := s.SnapshotJSON() + var sb strings.Builder + appendLabels := func(labels []any) { + first := true + for _, raw := range labels { + m, _ := raw.(map[string]any) + if m == nil { + continue + } + k := fmt.Sprintf("%s", m["name"]) + v := fmt.Sprintf("%s", m["value"]) + if !first { + sb.WriteByte(',') + } + first = false + sb.WriteString(k) + sb.WriteString(`="`) + sb.WriteString(strings.ReplaceAll(strings.ReplaceAll(v, `\`, `\\`), `"`, `\"`)) + sb.WriteByte('"') + } + } + counters, _ := snap["counters"].([]any) + for _, raw := range counters { + m := raw.(map[string]any) + name := m["name"].(string) + labels, _ := m["labels"].([]any) + value := m["value"].(float64) + sb.WriteString("# HELP ") + sb.WriteString(name) + sb.WriteString(" solvify counter metric\n# TYPE ") + sb.WriteString(name) + sb.WriteString(" counter\n") + sb.WriteString(name) + sb.WriteByte('{') + appendLabels(labels) + sb.WriteString("} ") + fmt.Fprintf(&sb, "%.0f\n", value) + } + gauges, _ := snap["gauges"].([]any) + for _, raw := range gauges { + m := raw.(map[string]any) + name := m["name"].(string) + labels, _ := m["labels"].([]any) + value := m["value"].(float64) + sb.WriteString("# HELP ") + sb.WriteString(name) + sb.WriteString(" solvify gauge metric\n# TYPE ") + sb.WriteString(name) + sb.WriteString(" gauge\n") + sb.WriteString(name) + sb.WriteByte('{') + appendLabels(labels) + sb.WriteString("} ") + fmt.Fprintf(&sb, "%.0f\n", value) + } + histos, _ := snap["histograms"].([]any) + for _, raw := range histos { + m := raw.(map[string]any) + name := m["name"].(string) + labels, _ := m["labels"].([]any) + count := m["count"].(int64) + sum := m["sum"].(float64) + buckets, _ := m["buckets"].([]any) + sb.WriteString("# HELP ") + sb.WriteString(name) + sb.WriteString(" solvify histogram metric\n# TYPE ") + sb.WriteString(name) + sb.WriteString(" histogram\n") + for _, b := range buckets { + bm := b.(map[string]any) + le := fmt.Sprintf("%s", bm["le"]) + cum := bm["cumulative"].(int64) + sb.WriteString(name) + sb.WriteString("_bucket{le=\"") + sb.WriteString(le) + sb.WriteByte('"') + if len(labels) > 0 { + sb.WriteByte(',') + appendLabels(labels) + } + sb.WriteString("} ") + fmt.Fprintf(&sb, "%d\n", cum) + } + sb.WriteString(name) + sb.WriteString("_sum{") + appendLabels(labels) + sb.WriteString("} ") + fmt.Fprintf(&sb, "%f\n", sum) + sb.WriteString(name) + sb.WriteString("_count{") + appendLabels(labels) + sb.WriteString("} ") + fmt.Fprintf(&sb, "%d\n", count) + } + return sb.String(), nil +} diff --git a/internal/observability/pii_mask.go b/internal/observability/pii_mask.go new file mode 100644 index 0000000..f7233dd --- /dev/null +++ b/internal/observability/pii_mask.go @@ -0,0 +1,153 @@ +package observability + +import ( + "regexp" + "strings" + "unicode/utf8" +) + +type PIISanitizer struct { + ContentMaxChars int + MaskSecret bool +} + +var ( + emailRe = regexp.MustCompile(`(?i)[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}`) + phoneRe = regexp.MustCompile(`(1[3-9]\d)(\d{4})(\d{4})|(\d{3})(\d{4})(\d{4})`) + secretHeaderRe = regexp.MustCompile(`(?i)(Authorization|Bearer|Api-Key|X-API-Key|X-Auth-Token|Proxy-Authorization)[:=]\s*[^\s,;"']+`) + skKeyRe = regexp.MustCompile(`(?i)(sk-|pk-|token|apikey|api_key|secret)[^ \t\n\r]{0,4}[=: ]\s*[A-Za-z0-9_\-]{8,}`) +) + +func NewPIISanitizer(contentMaxChars int, maskSecret bool) *PIISanitizer { + if contentMaxChars < 0 { + contentMaxChars = 0 + } + return &PIISanitizer{ContentMaxChars: contentMaxChars, MaskSecret: maskSecret} +} + +func (s *PIISanitizer) SanitizeString(text string) string { + if s == nil { + return truncateRunes(text, 200) + } + out := text + if s.MaskSecret { + out = secretHeaderRe.ReplaceAllStringFunc(out, maskHeaderSecret) + out = skKeyRe.ReplaceAllStringFunc(out, maskKeyValueSecret) + } + out = emailRe.ReplaceAllStringFunc(out, maskEmail) + out = phoneRe.ReplaceAllStringFunc(out, maskPhone) + out = truncateRunes(out, s.ContentMaxChars) + return out +} + +func (s *PIISanitizer) SanitizeAttrs(attrs Attrs) Attrs { + if len(attrs) == 0 || s == nil { + return attrs + } + out := make(Attrs, len(attrs)) + for k, v := range attrs { + out[k] = s.sanitizeValue(v) + } + return out +} + +func (s *PIISanitizer) sanitizeValue(v any) any { + switch val := v.(type) { + case string: + return s.SanitizeString(val) + case map[string]string: + m := make(map[string]string, len(val)) + for k, vv := range val { + m[k] = s.SanitizeString(vv) + } + return m + case Attrs: + return s.SanitizeAttrs(val) + case map[string]any: + m := make(map[string]any, len(val)) + for k, vv := range val { + m[k] = s.sanitizeValue(vv) + } + return m + case []string: + arr := make([]string, 0, len(val)) + for _, vv := range val { + arr = append(arr, s.SanitizeString(vv)) + } + return arr + default: + return v + } +} + +func truncateRunes(s string, max int) string { + if max <= 0 || s == "" { + return "" + } + if utf8.RuneCountInString(s) <= max { + return s + } + r := []rune(s) + if max > len(r) { + max = len(r) + } + return string(r[:max]) + "…" +} + +func maskEmail(s string) string { + at := strings.LastIndex(s, "@") + if at <= 0 { + return s + } + user := s[:at] + domain := s[at:] + if len(user) <= 2 { + return user[:1] + "***" + domain + } + return user[:2] + strings.Repeat("*", max(3, len(user)-2)) + domain +} + +func maskPhone(s string) string { + if len(s) != 11 { + if len(s) >= 7 { + return s[:3] + strings.Repeat("*", len(s)-7) + s[len(s)-4:] + } + return s + } + return s[:3] + "****" + s[7:] +} + +func maskHeaderSecret(s string) string { + idx := strings.IndexAny(s, ":=") + if idx < 0 { + return s + } + prefix := s[:idx+1] + rest := strings.TrimLeft(s[idx+1:], " \t") + tail := "***" + if len(rest) >= 8 { + tail = rest[:4] + "***" + rest[len(rest)-4:] + } + return prefix + " " + tail +} + +func maskKeyValueSecret(s string) string { + idx := strings.IndexAny(s, "=: ") + if idx < 0 { + return s + } + prefix := s[:idx+1] + rest := strings.TrimLeft(s[idx+1:], " \t") + tail := "***" + if len(rest) >= 8 { + tail = rest[:4] + "***" + rest[len(rest)-4:] + } + return prefix + tail +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/internal/observability/recorder.go b/internal/observability/recorder.go new file mode 100644 index 0000000..24f9f48 --- /dev/null +++ b/internal/observability/recorder.go @@ -0,0 +1,583 @@ +package observability + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "sync" + "time" + + "solvify-agent/pkg/config" + "solvify-agent/pkg/logger" +) + +type contextKey string + +const ( + spanKey contextKey = "obs_span" + traceIDKey contextKey = "obs_trace_id" + recorderKey contextKey = "obs_recorder" + rootAttrsKey contextKey = "obs_root_attrs" +) + +type rootAttrs struct { + mu sync.Mutex + attrs Attrs + beginAt time.Time + rootDone bool + endErr error + endStatus SpanStatus + endAt time.Time + messageID string + userID string + sessionID string + requestID string + searchMode string + modelID string +} + +func randomHex(n int) string { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return fmt.Sprintf("%d", time.Now().UnixNano()) + } + return hex.EncodeToString(b) +} + +type traceState struct { + mu sync.Mutex + Trace *Trace + decision SampleDecision + PendingForce bool +} + +type defaultRecorder struct { + enabled bool + cfg config.ObservabilityConfig + sampler *DefaultSampler + sanitizer *PIISanitizer + sinks Sink + dbSink DBSink + metrics *MetricStore + traceStates sync.Map + inflight sync.Map + traceDecide sync.Map +} + +func NewRecorder(cfg config.ObservabilityConfig, extraSinks ...Sink) Recorder { + sanitizer := NewPIISanitizer(cfg.PIIContentMaxChars, cfg.PIIMaskSecret) + sampler := NewDefaultSampler(cfg.SamplingRate, cfg.ErrorAlwaysSample, cfg.FeedbackAlwaysSample, cfg.SlowThresholdMs, cfg.WhiteListUserIDs) + logSink := NewLogSink(cfg.ExportLogEnabled, sanitizer, sampler) + sinks := []Sink{logSink} + sinks = append(sinks, extraSinks...) + bs := NewBatchSink(sinks, cfg.SinkBufferSize, cfg.SinkBatchSize, cfg.SinkFlushIntervalMs) + ms := GlobalMetricStore(cfg.MaxCardinalityLabels) + return &defaultRecorder{ + enabled: cfg.Enabled, + cfg: cfg, + sampler: sampler, + sanitizer: sanitizer, + sinks: bs, + metrics: ms, + } +} + +func NewRecorderWithDBSink(cfg config.ObservabilityConfig, db DBSink) Recorder { + r := NewRecorder(cfg).(*defaultRecorder) + r.dbSink = db + return r +} + +func spanFromContext(ctx context.Context) *Span { + v := ctx.Value(spanKey) + if v == nil { + return nil + } + s, _ := v.(*Span) + return s +} + +func TraceIDFromContext(ctx context.Context) string { + v := ctx.Value(traceIDKey) + if v == nil { + return "" + } + s, _ := v.(string) + return s +} + +func RecorderFromContext(ctx context.Context) Recorder { + v := ctx.Value(recorderKey) + if v == nil { + return nil + } + r, _ := v.(Recorder) + return r +} + +func (r *defaultRecorder) StartSpan(ctx context.Context, name string, component Component, attrs Attrs) (context.Context, *Span) { + if !r.enabled { + s := &Span{Name: name, Component: component, StartAt: time.Now()} + return context.WithValue(ctx, spanKey, s), s + } + traceID := TraceIDFromContext(ctx) + if traceID == "" { + traceID = randomHex(16) + ctx = context.WithValue(ctx, traceIDKey, traceID) + } + parent := spanFromContext(ctx) + s := &Span{ + TraceID: traceID, + SpanID: randomHex(8), + ParentID: "", + Name: name, + Component: component, + StartAt: time.Now(), + Status: SpanStatusOK, + Attrs: r.sanitizer.SanitizeAttrs(attrs), + } + if parent != nil { + s.ParentID = parent.SpanID + } + ctx = context.WithValue(ctx, spanKey, s) + if parent == nil { + st := &traceState{Trace: &Trace{ID: traceID, Root: s, SampleRate: r.cfg.SamplingRate}} + r.traceStates.Store(traceID, st) + } + r.metrics.Incr("obs_span_start_total", map[string]string{"component": string(component)}, 1) + return ctx, s +} + +func (r *defaultRecorder) AddEvent(ctx context.Context, span *Span, name string, attrs Attrs) { + if span == nil { + return + } + e := &SpanEvent{Name: name, Timestamp: time.Now(), Attrs: r.sanitizer.SanitizeAttrs(attrs)} + span.Events = append(span.Events, e) +} + +func (r *defaultRecorder) EndSpan(ctx context.Context, span *Span, status SpanStatus, err error, attrs Attrs) { + if span == nil { + return + } + span.EndAt = time.Now() + span.DurationMs = span.EndAt.Sub(span.StartAt).Milliseconds() + span.Status = status + if err != nil { + span.Error = r.sanitizer.SanitizeString(err.Error()) + } + if len(attrs) > 0 { + if span.Attrs == nil { + span.Attrs = Attrs{} + } + for k, v := range r.sanitizer.SanitizeAttrs(attrs) { + span.Attrs[k] = v + } + } + parent := spanFromContext(ctx) + if parent != nil && parent != span { + if parent.Children == nil { + parent.Children = []*Span{} + } + parent.Children = append(parent.Children, span) + } + if span.ParentID == "" { + r.finalizeTrace(ctx, span, err) + } + r.metrics.Observe("obs_span_duration_seconds", map[string]string{ + "component": string(span.Component), + "status": string(span.Status), + }, float64(span.DurationMs)/1000.0, nil) +} + +func (r *defaultRecorder) finalizeTrace(ctx context.Context, root *Span, endErr error) { + if root == nil { + return + } + traceID := root.TraceID + userID := "" + sessionID := "" + requestID := "" + if v := ctx.Value(traceIDKey); v != nil { + } + if root.Attrs != nil { + if v, ok := root.Attrs["user_id"]; ok { + userID, _ = v.(string) + } + if v, ok := root.Attrs["session_id"]; ok { + sessionID, _ = v.(string) + } + if v, ok := root.Attrs["request_id"]; ok { + requestID, _ = v.(string) + } + } + hasErr := endErr != nil || root.Status == SpanStatusError || root.Status == SpanStatusCanceled + hasFeedback := false + rawDecision, _ := r.traceDecide.LoadAndDelete(traceID) + var decision SampleDecision + if rawDecision != nil { + decision, _ = rawDecision.(SampleDecision) + } + dur := time.Duration(root.DurationMs) * time.Millisecond + sampled := r.sampler.ShouldSample(traceID, userID, hasErr, dur, hasFeedback, decision) + if r.dbSink != nil { + if v, ok := r.dbSink.(interface { + Decide(ctx context.Context, traceID string, force bool) + }); ok { + _ = v + } + } + t := &Trace{ + ID: traceID, + RequestID: requestID, + UserID: userID, + SessionID: sessionID, + Root: root, + SampleRate: r.cfg.SamplingRate, + Sampled: sampled, + } + r.traceStates.Delete(traceID) + if sampled { + rec := &SinkRecord{Kind: "trace", Timestamp: time.Now(), Trace: t} + if e := r.sinks.Write(ctx, rec); e != nil { + logger.Warnf("trace 写入 sink 失败: %v", e) + } + if r.dbSink != nil && r.cfg.TraceTableEnabled { + if err := r.dbSink.WriteTraces(ctx, []*Trace{t}); err != nil { + logger.Warnf("trace 写库失败: %v", err) + r.metrics.Incr("obs_db_sink_errors_total", map[string]string{"type": "trace"}, 1) + } + } + } else { + r.metrics.Incr("obs_trace_not_sampled_total", nil, 1) + } +} + +func (r *defaultRecorder) Incr(ctx context.Context, metric string, labels map[string]string, delta int64) { + if !r.enabled { + return + } + r.metrics.Incr(metric, labels, delta) +} + +func (r *defaultRecorder) Observe(ctx context.Context, metric string, labels map[string]string, value float64) { + if !r.enabled { + return + } + r.metrics.Observe(metric, labels, value, nil) +} + +func (r *defaultRecorder) RecordTrace(trace *Trace) { + if trace == nil || !r.enabled { + return + } + rec := &SinkRecord{Kind: "trace", Timestamp: time.Now(), Trace: trace} + if err := r.sinks.Write(context.Background(), rec); err != nil { + logger.Warnf("RecordTrace: %v", err) + } + if r.dbSink != nil && r.cfg.TraceTableEnabled && trace.Sampled { + if err := r.dbSink.WriteTraces(context.Background(), []*Trace{trace}); err != nil { + r.metrics.Incr("obs_db_sink_errors_total", map[string]string{"type": "trace"}, 1) + } + } +} + +func (r *defaultRecorder) RecordFeedback(fb *Feedback) { + if fb == nil || !r.enabled { + return + } + if fb.CreatedAt.IsZero() { + fb.CreatedAt = time.Now() + } + if fb.TraceID != "" { + r.traceDecide.Store(fb.TraceID, SampleDecisionForceKeep) + } + rec := &SinkRecord{Kind: "feedback", Timestamp: fb.CreatedAt, Feedback: fb} + if err := r.sinks.Write(context.Background(), rec); err != nil { + logger.Warnf("RecordFeedback: %v", err) + } + if r.dbSink != nil { + if err := r.dbSink.WriteFeedbacks(context.Background(), []*Feedback{fb}); err != nil { + r.metrics.Incr("obs_db_sink_errors_total", map[string]string{"type": "feedback"}, 1) + } + } +} + +func (r *defaultRecorder) RecordAgentStep(step *AgentStep) { + if step == nil || !r.enabled { + return + } + rec := &SinkRecord{Kind: "agent_step", Timestamp: time.Now(), AgentStep: step} + if err := r.sinks.Write(context.Background(), rec); err != nil { + logger.Warnf("RecordAgentStep: %v", err) + } + if r.dbSink != nil { + if err := r.dbSink.WriteAgentSteps(context.Background(), []*AgentStep{step}); err != nil { + r.metrics.Incr("obs_db_sink_errors_total", map[string]string{"type": "agent_step"}, 1) + } + } +} + +func (r *defaultRecorder) MetricsSnapshot() (map[string]any, error) { + if !r.enabled { + return nil, errors.New("observability disabled") + } + snap := r.metrics.SnapshotJSON() + snap["enabled"] = true + snap["sink_stats"] = map[string]any{ + "buffer_size_cfg": r.cfg.SinkBufferSize, + } + if bs, ok := r.sinks.(*BatchSink); ok { + drops, writes := bs.Stats() + snap["sink_stats"] = map[string]any{ + "dropped_records_total": drops, + "written_records_total": writes, + } + } + return snap, nil +} + +func (r *defaultRecorder) Shutdown(ctx context.Context) error { + if r.sinks != nil { + return r.sinks.Shutdown(ctx) + } + return nil +} + +func (r *defaultRecorder) WithTraceRoot(ctx context.Context, attrs TraceRootAttrs) context.Context { + ctx = context.WithValue(ctx, recorderKey, r) + traceID := TraceIDFromContext(ctx) + if traceID == "" { + traceID = randomHex(16) + ctx = context.WithValue(ctx, traceIDKey, traceID) + } + ra := &rootAttrs{ + attrs: Attrs{ + "user_id": attrs.UserID, + "session_id": attrs.SessionID, + "message_id": attrs.MessageID, + "request_id": attrs.RequestID, + "search_mode": attrs.SearchMode, + "model_id": attrs.ModelID, + }, + beginAt: time.Now(), + userID: attrs.UserID, + sessionID: attrs.SessionID, + messageID: attrs.MessageID, + requestID: attrs.RequestID, + searchMode: attrs.SearchMode, + modelID: attrs.ModelID, + } + return context.WithValue(ctx, rootAttrsKey, ra) +} + +func (r *defaultRecorder) AddRootAttrs(ctx context.Context, attrs Attrs) { + if v := ctx.Value(rootAttrsKey); v != nil { + if ra, ok := v.(*rootAttrs); ok { + ra.mu.Lock() + if ra.attrs == nil { + ra.attrs = Attrs{} + } + for k, val := range r.sanitizer.SanitizeAttrs(attrs) { + ra.attrs[k] = val + } + ra.mu.Unlock() + } + } +} + +func (r *defaultRecorder) ForceSampling(ctx context.Context) { + traceID := TraceIDFromContext(ctx) + if traceID == "" { + return + } + r.traceDecide.Store(traceID, SampleDecisionForceKeep) +} + +func (r *defaultRecorder) FlushTrace(ctx context.Context, userID, sessionID, messageID string) string { + if !r.enabled { + return "" + } + traceID := TraceIDFromContext(ctx) + if traceID == "" { + return "" + } + var ra *rootAttrs + if v := ctx.Value(rootAttrsKey); v != nil { + ra, _ = v.(*rootAttrs) + } + if ra != nil { + ra.mu.Lock() + if ra.userID == "" { + ra.userID = userID + } + if ra.sessionID == "" { + ra.sessionID = sessionID + } + if ra.messageID == "" { + ra.messageID = messageID + } + ra.endAt = time.Now() + ra.mu.Unlock() + } + r.publishTrace(ctx, traceID, ra) + return traceID +} + +func (r *defaultRecorder) publishTrace(ctx context.Context, traceID string, ra *rootAttrs) { + var ( + userID string + sessionID string + requestID string + attrs Attrs + beginAt time.Time + endAt time.Time + endErr error + endStatus SpanStatus + messageID string + searchMode string + modelID string + ) + if ra != nil { + ra.mu.Lock() + userID = ra.userID + sessionID = ra.sessionID + requestID = ra.requestID + beginAt = ra.beginAt + endAt = ra.endAt + endErr = ra.endErr + endStatus = ra.endStatus + messageID = ra.messageID + searchMode = ra.searchMode + modelID = ra.modelID + attrs = make(Attrs, len(ra.attrs)) + for k, v := range ra.attrs { + attrs[k] = v + } + ra.rootDone = true + ra.mu.Unlock() + } + if beginAt.IsZero() { + beginAt = time.Now() + } + if endAt.IsZero() { + endAt = time.Now() + } + if endStatus == "" { + if endErr != nil { + endStatus = SpanStatusError + } else { + endStatus = SpanStatusOK + } + } + dur := endAt.Sub(beginAt) + root := &Span{ + TraceID: traceID, + SpanID: traceID, + Name: "chat.request", + Component: ComponentServiceChat, + StartAt: beginAt, + EndAt: endAt, + DurationMs: dur.Milliseconds(), + Status: endStatus, + Attrs: r.sanitizer.SanitizeAttrs(attrs), + } + if endErr != nil { + root.Error = r.sanitizer.SanitizeString(endErr.Error()) + } + if stVal, ok := r.traceStates.LoadAndDelete(traceID); ok { + if st, ok := stVal.(*traceState); ok && st != nil && st.Trace != nil && st.Trace.Root != nil { + prev := st.Trace.Root + if prev.Name != root.Name { + if root.Children == nil { + root.Children = []*Span{} + } + root.Children = append(root.Children, prev) + } else { + if prev.Children != nil { + if root.Children == nil { + root.Children = []*Span{} + } + root.Children = append(root.Children, prev.Children...) + } + if prev.Events != nil { + root.Events = append(root.Events, prev.Events...) + } + if root.Attrs == nil { + root.Attrs = Attrs{} + } + for k, v := range prev.Attrs { + if _, exists := root.Attrs[k]; !exists { + root.Attrs[k] = v + } + } + } + } + } + if messageID != "" { + _ = messageID + } + if searchMode != "" { + _ = searchMode + } + if modelID != "" { + _ = modelID + } + hasErr := endErr != nil || endStatus == SpanStatusError || endStatus == SpanStatusCanceled + hasFeedback := false + rawDecision, _ := r.traceDecide.LoadAndDelete(traceID) + var decision SampleDecision + if rawDecision != nil { + decision, _ = rawDecision.(SampleDecision) + } + sampled := r.sampler.ShouldSample(traceID, userID, hasErr, dur, hasFeedback, decision) + t := &Trace{ + ID: traceID, + RequestID: requestID, + UserID: userID, + SessionID: sessionID, + Root: root, + SampleRate: r.cfg.SamplingRate, + Sampled: sampled, + } + _ = t + rec := &SinkRecord{Kind: "trace", Timestamp: endAt, Trace: t} + if e := r.sinks.Write(ctx, rec); e != nil { + logger.Warnf("FlushTrace sink 写失败: %v", e) + } + if sampled && r.dbSink != nil && r.cfg.TraceTableEnabled { + if err := r.dbSink.WriteTraces(ctx, []*Trace{t}); err != nil { + logger.Warnf("FlushTrace 写库失败: %v", err) + r.metrics.Incr("obs_db_sink_errors_total", map[string]string{"type": "trace"}, 1) + } + } + r.metrics.Incr("obs_trace_flush_total", map[string]string{ + "sampled": boolLabelO(sampled), + "search_mode": searchModeOrDefault(searchMode), + }, 1) + r.metrics.Observe("obs_trace_duration_seconds", map[string]string{ + "search_mode": searchModeOrDefault(searchMode), + "status": string(endStatus), + }, dur.Seconds(), nil) +} + +func boolLabelO(b bool) string { + if b { + return "true" + } + return "false" +} + +func searchModeOrDefault(s string) string { + if s == "" { + return "unknown" + } + return s +} + +var ( + _ = context.Background + _ = errors.New +) diff --git a/internal/observability/sampling.go b/internal/observability/sampling.go new file mode 100644 index 0000000..6d00345 --- /dev/null +++ b/internal/observability/sampling.go @@ -0,0 +1,140 @@ +package observability + +import ( + "crypto/rand" + "math/big" + "sync" + "time" +) + +type Sampler interface { + ShouldSample(ctx sampleContext, decision SampleDecision) bool +} + +type sampleContext struct { + TraceID string + UserID string + HasError bool + Duration time.Duration + SlowLimitMs int + HasFeedback bool + WhiteList map[string]struct{} + Rate float64 +} + +type SampleDecision int + +const ( + SampleDecisionDefault SampleDecision = iota + SampleDecisionForceKeep + SampleDecisionForceDrop +) + +type DefaultSampler struct { + Rate float64 + ErrorAlways bool + FeedbackAlways bool + SlowThresholdMs int + WhiteListUserIDs map[string]struct{} +} + +func NewDefaultSampler(rate float64, errorAlways, feedbackAlways bool, slowMs int, whiteList []string) *DefaultSampler { + wm := make(map[string]struct{}, len(whiteList)) + for _, u := range whiteList { + if u != "" { + wm[u] = struct{}{} + } + } + if rate <= 0 { + rate = 0 + } + if rate > 1 { + rate = 1 + } + return &DefaultSampler{ + Rate: rate, + ErrorAlways: errorAlways, + FeedbackAlways: feedbackAlways, + SlowThresholdMs: slowMs, + WhiteListUserIDs: wm, + } +} + +var ( + slowMu sync.Mutex + slowWindowStart time.Time + slowBucketTotal int64 + slowBucketOver int64 + slowWindowSeconds = 60 +) + +func ObserveSlow(sampleOver bool) { + slowMu.Lock() + defer slowMu.Unlock() + now := time.Now() + if slowWindowStart.IsZero() || now.Sub(slowWindowStart) > time.Duration(slowWindowSeconds)*time.Second { + slowWindowStart = now + slowBucketTotal = 0 + slowBucketOver = 0 + } + slowBucketTotal++ + if sampleOver { + slowBucketOver++ + } +} + +func EstimateP99OverSlow() bool { + slowMu.Lock() + defer slowMu.Unlock() + if slowBucketTotal < 30 { + return false + } + ratio := float64(slowBucketOver) / float64(slowBucketTotal) + return ratio > 0.01 +} + +func (s *DefaultSampler) ShouldSample(traceID, userID string, hasErr bool, dur time.Duration, hasFeedback bool, decision SampleDecision) bool { + switch decision { + case SampleDecisionForceKeep: + return true + case SampleDecisionForceDrop: + return false + } + if _, ok := s.WhiteListUserIDs[userID]; ok && userID != "" { + return true + } + if s.ErrorAlways && hasErr { + return true + } + if s.FeedbackAlways && hasFeedback { + return true + } + if s.SlowThresholdMs > 0 && dur.Milliseconds() >= int64(s.SlowThresholdMs) { + ObserveSlow(true) + return true + } + ObserveSlow(false) + if s.Rate >= 1 { + return true + } + if s.Rate <= 0 { + return false + } + return rollSample(s.Rate) +} + +func rollSample(rate float64) bool { + n := int64(10000) + threshold := int64(rate * float64(n)) + if threshold <= 0 { + return false + } + if threshold >= n { + return true + } + r, err := rand.Int(rand.Reader, big.NewInt(n)) + if err != nil { + return false + } + return r.Int64() < threshold +} diff --git a/internal/observability/sink.go b/internal/observability/sink.go new file mode 100644 index 0000000..d4dc15d --- /dev/null +++ b/internal/observability/sink.go @@ -0,0 +1,275 @@ +package observability + +import ( + "context" + "encoding/json" + "errors" + "sync" + "sync/atomic" + "time" + + "solvify-agent/pkg/logger" +) + +type Sink interface { + Write(ctx context.Context, rec *SinkRecord) error + Shutdown(ctx context.Context) error +} + +type NoopSink struct{} + +func (n *NoopSink) Write(_ context.Context, _ *SinkRecord) error { return nil } +func (n *NoopSink) Shutdown(_ context.Context) error { return nil } + +type LogSink struct { + Enabled bool + PII *PIISanitizer + Sampler *DefaultSampler + dropCount atomic.Int64 + writeCount atomic.Int64 +} + +func NewLogSink(enabled bool, pii *PIISanitizer, sampler *DefaultSampler) *LogSink { + return &LogSink{Enabled: enabled, PII: pii, Sampler: sampler} +} + +func (l *LogSink) Write(ctx context.Context, rec *SinkRecord) error { + if !l.Enabled || rec == nil { + return nil + } + rec = l.clean(rec) + if rec.Trace != nil { + if l.Sampler != nil && !rec.Trace.Sampled { + return nil + } + } + b, err := json.Marshal(rec) + if err != nil { + l.dropCount.Add(1) + return err + } + switch rec.Kind { + case "trace": + logger.Infof("[OBS] trace payload=%s", string(b)) + case "feedback": + logger.Infof("[OBS] feedback payload=%s", string(b)) + case "agent_step": + logger.Infof("[OBS] agent_step payload=%s", string(b)) + default: + logger.Infof("[OBS] record kind=%s payload=%s", rec.Kind, string(b)) + } + l.writeCount.Add(1) + return nil +} + +func (l *LogSink) Shutdown(context.Context) error { + return nil +} + +func (l *LogSink) clean(r *SinkRecord) *SinkRecord { + cp := *r + if l.PII == nil { + return &cp + } + if cp.Trace != nil && cp.Trace.Root != nil { + root := *cp.Trace.Root + cleanSpan(root, l.PII) + cp.Trace = &Trace{ + ID: cp.Trace.ID, + RequestID: cp.Trace.RequestID, + UserID: cp.Trace.UserID, + SessionID: cp.Trace.SessionID, + Root: &root, + SampleRate: cp.Trace.SampleRate, + Sampled: cp.Trace.Sampled, + } + } + if cp.Feedback != nil { + fb := *cp.Feedback + fb.Comment = l.PII.SanitizeString(fb.Comment) + cp.Feedback = &fb + } + if cp.AgentStep != nil { + st := *cp.AgentStep + st.ThinkingSummary = l.PII.SanitizeString(st.ThinkingSummary) + st.ToolInputMasked = l.PII.SanitizeString(st.ToolInputMasked) + st.ToolResultSummary = l.PII.SanitizeString(st.ToolResultSummary) + st.ToolError = l.PII.SanitizeString(st.ToolError) + cp.AgentStep = &st + } + return &cp +} + +func cleanSpan(s Span, pii *PIISanitizer) { + if len(s.Attrs) > 0 { + s.Attrs = pii.SanitizeAttrs(s.Attrs) + } + if s.Error != "" { + s.Error = pii.SanitizeString(s.Error) + } + for i := range s.Events { + if len(s.Events[i].Attrs) > 0 { + s.Events[i].Attrs = pii.SanitizeAttrs(s.Events[i].Attrs) + } + } + for i := range s.Children { + cp := *s.Children[i] + cleanSpan(cp, pii) + s.Children[i] = &cp + } +} + +type DBSink interface { + Sink + WriteTraces(ctx context.Context, traces []*Trace) error + WriteFeedbacks(ctx context.Context, fs []*Feedback) error + WriteAgentSteps(ctx context.Context, steps []*AgentStep) error +} + +type BatchSink struct { + mu sync.Mutex + sinks []Sink + buffer chan *SinkRecord + batchSize int + interval time.Duration + wg sync.WaitGroup + closed chan struct{} + closeOnce sync.Once + dropCount atomic.Int64 + writeCount atomic.Int64 +} + +func NewBatchSink(sinks []Sink, bufferSize, batchSize, flushIntervalMs int) *BatchSink { + if bufferSize <= 0 { + bufferSize = 1024 + } + if batchSize <= 0 { + batchSize = 50 + } + if flushIntervalMs <= 0 { + flushIntervalMs = 200 + } + b := &BatchSink{ + sinks: sinks, + buffer: make(chan *SinkRecord, bufferSize), + batchSize: batchSize, + interval: time.Duration(flushIntervalMs) * time.Millisecond, + closed: make(chan struct{}), + } + b.wg.Add(1) + go b.run() + return b +} + +func (b *BatchSink) Write(_ context.Context, rec *SinkRecord) error { + if rec == nil { + return nil + } + select { + case b.buffer <- rec: + return nil + default: + b.dropCount.Add(1) + return errors.New("observability sink buffer full, record dropped") + } +} + +func (b *BatchSink) run() { + defer b.wg.Done() + ticker := time.NewTicker(b.interval) + defer ticker.Stop() + batch := make([]*SinkRecord, 0, b.batchSize) + flush := func() { + if len(batch) == 0 { + return + } + b.flushBatch(batch) + batch = batch[:0] + } + for { + select { + case <-b.closed: + for { + select { + case r := <-b.buffer: + batch = append(batch, r) + if len(batch) >= b.batchSize { + flush() + } + default: + flush() + return + } + } + case r := <-b.buffer: + batch = append(batch, r) + if len(batch) >= b.batchSize { + flush() + } + case <-ticker.C: + flush() + } + } +} + +func (b *BatchSink) flushBatch(batch []*SinkRecord) { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + for _, s := range b.sinks { + if s == nil { + continue + } + for _, r := range batch { + if err := s.Write(ctx, r); err != nil { + logger.Warnf("Sink 写入失败: %v", err) + b.dropCount.Add(1) + continue + } + b.writeCount.Add(1) + } + } +} + +func (b *BatchSink) Shutdown(ctx context.Context) error { + b.closeOnce.Do(func() { close(b.closed) }) + done := make(chan struct{}) + go func() { + b.wg.Wait() + close(done) + }() + select { + case <-ctx.Done(): + return ctx.Err() + case <-done: + } + b.mu.Lock() + defer b.mu.Unlock() + var errs []string + for _, s := range b.sinks { + if s == nil { + continue + } + if err := s.Shutdown(ctx); err != nil { + errs = append(errs, err.Error()) + } + } + if len(errs) > 0 { + return errors.New("sink shutdown errors: " + joinStr(errs, "; ")) + } + return nil +} + +func (b *BatchSink) Stats() (drops, writes int64) { + return b.dropCount.Load(), b.writeCount.Load() +} + +func joinStr(s []string, sep string) string { + out := "" + for i, v := range s { + if i > 0 { + out += sep + } + out += v + } + return out +} diff --git a/internal/observability/types.go b/internal/observability/types.go new file mode 100644 index 0000000..8835f05 --- /dev/null +++ b/internal/observability/types.go @@ -0,0 +1,139 @@ +package observability + +import ( + "context" + "time" +) + +type SpanStatus string + +const ( + SpanStatusOK SpanStatus = "ok" + SpanStatusError SpanStatus = "error" + SpanStatusCanceled SpanStatus = "canceled" +) + +type Component string + +const ( + ComponentHTTPServer Component = "http.server" + ComponentServiceChat Component = "service.chat" + ComponentServiceContext Component = "service.context" + ComponentLLMClient Component = "llm.client" + ComponentRAGRetriever Component = "rag.retriever" + ComponentRAGReranker Component = "rag.reranker" + ComponentRAGExpander Component = "rag.expander" + ComponentAgentEngine Component = "agent.engine" + ComponentAgentTool Component = "agent.tool" + ComponentAgentStep Component = "agent.step" + ComponentRepository Component = "repository" +) + +type Attrs map[string]any + +func (a Attrs) Merge(other Attrs) Attrs { + if len(other) == 0 { + return a + } + out := make(Attrs, len(a)+len(other)) + for k, v := range a { + out[k] = v + } + for k, v := range other { + out[k] = v + } + return out +} + +type SpanEvent struct { + Name string `json:"name"` + Timestamp time.Time `json:"timestamp"` + Attrs Attrs `json:"attrs,omitempty"` +} + +type Span struct { + TraceID string `json:"trace_id"` + SpanID string `json:"span_id"` + ParentID string `json:"parent_id,omitempty"` + Name string `json:"name"` + Component Component `json:"component"` + StartAt time.Time `json:"start_at"` + EndAt time.Time `json:"end_at,omitempty"` + DurationMs int64 `json:"duration_ms,omitempty"` + Status SpanStatus `json:"status"` + Error string `json:"error,omitempty"` + Attrs Attrs `json:"attrs,omitempty"` + Events []*SpanEvent `json:"events,omitempty"` + Children []*Span `json:"children,omitempty"` +} + +type Trace struct { + ID string `json:"id"` + RequestID string `json:"request_id,omitempty"` + UserID string `json:"user_id,omitempty"` + SessionID string `json:"session_id,omitempty"` + Root *Span `json:"root"` + SampleRate float64 `json:"sample_rate,omitempty"` + Sampled bool `json:"sampled"` +} + +type Feedback struct { + MessageID string `json:"message_id"` + UserID string `json:"user_id"` + SessionID string `json:"session_id,omitempty"` + Rating int `json:"rating"` + ReasonTag string `json:"reason_tag,omitempty"` + Comment string `json:"comment,omitempty"` + TraceID string `json:"trace_id,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +type AgentStep struct { + TaskID string `json:"task_id"` + StepIndex int `json:"step_index"` + StartedAt time.Time `json:"started_at"` + EndedAt time.Time `json:"ended_at,omitempty"` + ThinkingSummary string `json:"thinking_summary,omitempty"` + ToolName string `json:"tool_name,omitempty"` + ToolInputMasked string `json:"tool_input_masked,omitempty"` + ToolResultSummary string `json:"tool_result_summary,omitempty"` + ToolStatus string `json:"tool_status,omitempty"` + ToolError string `json:"tool_error,omitempty"` + LatencyMs int64 `json:"latency_ms,omitempty"` + TokensDelta int `json:"tokens_delta,omitempty"` +} + +type SinkRecord struct { + Kind string `json:"kind"` + Timestamp time.Time `json:"timestamp"` + Trace *Trace `json:"trace,omitempty"` + Feedback *Feedback `json:"feedback,omitempty"` + AgentStep *AgentStep `json:"agent_step,omitempty"` + Attrs Attrs `json:"attrs,omitempty"` +} + +type Recorder interface { + StartSpan(ctx context.Context, name string, component Component, attrs Attrs) (context.Context, *Span) + EndSpan(ctx context.Context, span *Span, status SpanStatus, err error, attrs Attrs) + AddEvent(ctx context.Context, span *Span, name string, attrs Attrs) + Incr(ctx context.Context, metric string, labels map[string]string, delta int64) + Observe(ctx context.Context, metric string, labels map[string]string, value float64) + RecordTrace(trace *Trace) + RecordFeedback(fb *Feedback) + RecordAgentStep(step *AgentStep) + MetricsSnapshot() (map[string]any, error) + Shutdown(ctx context.Context) error + WithTraceRoot(ctx context.Context, attrs TraceRootAttrs) context.Context + FlushTrace(ctx context.Context, userID, sessionID, messageID string) string + ForceSampling(ctx context.Context) + AddRootAttrs(ctx context.Context, attrs Attrs) +} + +type TraceRootAttrs struct { + UserID string + SessionID string + MessageID string + RequestID string + SearchMode string + ModelID string +} From 0e1f5528bd0d8cad231c3ffc9d4047c520a22659 Mon Sep 17 00:00:00 2001 From: st <2663600842@qq.com> Date: Thu, 30 Jul 2026 16:27:18 +0800 Subject: [PATCH 04/20] =?UTF-8?q?feat(obs):=20=E6=96=B0=E5=A2=9E=E5=8F=AF?= =?UTF-8?q?=E8=A7=82=E6=B5=8B=E6=80=A7=20+=20=E5=8F=8D=E9=A6=88=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=BA=93=E5=AE=9E=E4=BD=93=E4=B8=8E=20Repository?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - entity/observability.go:ChatTrace / AgentTask / AgentTaskStep 实体,对应 chat_traces / agent_tasks / agent_task_steps 表 - entity/message_feedback.go:MessageFeedback 实体,用于用户点赞/点踩、原因标签与评论 - repository/observability_interface.go:ObservabilityRepo 接口(SaveTrace/ListTraces/InsertFeedback/SaveAgentTask/ListTraceIDs...) - repository/observability_repository.go:GORM 实现,包含必要索引查询、分页、SpanTree JSON 序列化 --- internal/model/entity/message_feedback.go | 17 + internal/model/entity/observability.go | 65 ++++ .../repository/observability_interface.go | 36 +++ .../repository/observability_repository.go | 293 ++++++++++++++++++ 4 files changed, 411 insertions(+) create mode 100644 internal/model/entity/message_feedback.go create mode 100644 internal/model/entity/observability.go create mode 100644 internal/repository/observability_interface.go create mode 100644 internal/repository/observability_repository.go diff --git a/internal/model/entity/message_feedback.go b/internal/model/entity/message_feedback.go new file mode 100644 index 0000000..8265302 --- /dev/null +++ b/internal/model/entity/message_feedback.go @@ -0,0 +1,17 @@ +package entity + +import "time" + +type MessageFeedback struct { + ID string `gorm:"primaryKey;type:varchar(64)" json:"id"` + MessageID string `gorm:"index;type:varchar(64);not null" json:"message_id"` + UserID string `gorm:"index;type:varchar(64);not null" json:"user_id"` + SessionID string `gorm:"index;type:varchar(64)" json:"session_id,omitempty"` + Rating int `gorm:"not null;default:0" json:"rating"` + ReasonTag string `gorm:"type:varchar(64)" json:"reason_tag,omitempty"` + Comment string `gorm:"type:text" json:"comment,omitempty"` + TraceID string `gorm:"index;type:varchar(128)" json:"trace_id,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +func (MessageFeedback) TableName() string { return "message_feedback" } diff --git a/internal/model/entity/observability.go b/internal/model/entity/observability.go new file mode 100644 index 0000000..4689e59 --- /dev/null +++ b/internal/model/entity/observability.go @@ -0,0 +1,65 @@ +package entity + +import ( + "time" + + "gorm.io/datatypes" +) + +type ChatTrace struct { + ID string `gorm:"primaryKey;type:varchar(128)" json:"id"` + RequestID string `gorm:"index;type:varchar(128)" json:"request_id,omitempty"` + UserID string `gorm:"index;type:varchar(64)" json:"user_id,omitempty"` + SessionID string `gorm:"index;type:varchar(64)" json:"session_id,omitempty"` + SampleRate float64 `gorm:"default:0" json:"sample_rate,omitempty"` + Sampled bool `gorm:"default:false" json:"sampled"` + DurationMs int64 `gorm:"default:0" json:"duration_ms,omitempty"` + Status string `gorm:"type:varchar(32)" json:"status,omitempty"` + Error string `gorm:"type:text" json:"error,omitempty"` + Attrs datatypes.JSON `gorm:"type:jsonb" json:"attrs,omitempty"` + SpanTree datatypes.JSON `gorm:"type:jsonb" json:"span_tree,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +func (ChatTrace) TableName() string { return "chat_traces" } + +type AgentTask struct { + ID string `gorm:"primaryKey;type:varchar(64)" json:"id"` + TraceID string `gorm:"index;type:varchar(128)" json:"trace_id,omitempty"` + SessionID string `gorm:"index;type:varchar(64)" json:"session_id,omitempty"` + UserID string `gorm:"index;type:varchar(64)" json:"user_id,omitempty"` + ModelID string `gorm:"type:varchar(128)" json:"model_id,omitempty"` + SearchMode string `gorm:"type:varchar(32)" json:"search_mode,omitempty"` + StartedAt time.Time `json:"started_at"` + EndedAt *time.Time `json:"ended_at,omitempty"` + TotalSteps int `gorm:"default:0" json:"total_steps,omitempty"` + ToolCalls int `gorm:"default:0" json:"tool_calls,omitempty"` + Status string `gorm:"type:varchar(32)" json:"status,omitempty"` + AbortReason string `gorm:"type:varchar(128)" json:"abort_reason,omitempty"` + TokensPrompt int `gorm:"default:0" json:"tokens_prompt,omitempty"` + TokensCompletion int `gorm:"default:0" json:"tokens_completion,omitempty"` + TotalCost float64 `gorm:"default:0" json:"total_cost,omitempty"` + ErrorSummary string `gorm:"type:text" json:"error_summary,omitempty"` + FeedbackRating *int `gorm:"default:null" json:"feedback_rating,omitempty"` +} + +func (AgentTask) TableName() string { return "agent_tasks" } + +type AgentTaskStep struct { + ID string `gorm:"primaryKey;type:varchar(64)" json:"id"` + TaskID string `gorm:"index;type:varchar(64);not null" json:"task_id"` + StepIndex int `gorm:"not null;default:0" json:"step_index"` + StartedAt time.Time `json:"started_at"` + EndedAt *time.Time `json:"ended_at,omitempty"` + ThinkingSummary string `gorm:"type:text" json:"thinking_summary,omitempty"` + ToolName string `gorm:"type:varchar(128)" json:"tool_name,omitempty"` + ToolInputMasked string `gorm:"type:text" json:"tool_input_masked,omitempty"` + ToolResultSummary string `gorm:"type:text" json:"tool_result_summary,omitempty"` + ToolStatus string `gorm:"type:varchar(32)" json:"tool_status,omitempty"` + ToolError string `gorm:"type:text" json:"tool_error,omitempty"` + LatencyMs int64 `gorm:"default:0" json:"latency_ms,omitempty"` + TokensDelta int `gorm:"default:0" json:"tokens_delta,omitempty"` + Attrs datatypes.JSON `gorm:"type:jsonb" json:"attrs,omitempty"` +} + +func (AgentTaskStep) TableName() string { return "agent_task_steps" } diff --git a/internal/repository/observability_interface.go b/internal/repository/observability_interface.go new file mode 100644 index 0000000..de78644 --- /dev/null +++ b/internal/repository/observability_interface.go @@ -0,0 +1,36 @@ +package repository + +import ( + "context" + "time" + + "solvify-agent/internal/model/entity" + "solvify-agent/internal/observability" +) + +type FeedbackRepo interface { + CreateFeedback(ctx context.Context, fb *entity.MessageFeedback) error + ListByMessage(ctx context.Context, messageID, userID string) ([]entity.MessageFeedback, error) + ListByUser(ctx context.Context, userID string, offset, limit int) ([]entity.MessageFeedback, int64, error) +} + +type ChatTraceRepo interface { + CreateChatTrace(ctx context.Context, trace *entity.ChatTrace) error + FindByID(ctx context.Context, id string) (*entity.ChatTrace, error) + ListBySession(ctx context.Context, sessionID, userID string, offset, limit int) ([]entity.ChatTrace, int64, error) + DeleteOlderThan(ctx context.Context, before time.Time) (int64, error) +} + +type AgentTaskRepo interface { + CreateAgentTask(ctx context.Context, task *entity.AgentTask) error + AppendStep(ctx context.Context, step *entity.AgentTaskStep) error + MarkEnded(ctx context.Context, taskID string, status, abortReason, errorSummary string, tokensPrompt, tokensCompletion int, cost float64, rating *int) error + FindByTraceID(ctx context.Context, traceID string) (*entity.AgentTask, []entity.AgentTaskStep, error) +} + +type ObservabilityRepo interface { + observability.DBSink + FeedbackRepo + ChatTraceRepo + AgentTaskRepo +} diff --git a/internal/repository/observability_repository.go b/internal/repository/observability_repository.go new file mode 100644 index 0000000..01f3bc4 --- /dev/null +++ b/internal/repository/observability_repository.go @@ -0,0 +1,293 @@ +package repository + +import ( + "context" + "encoding/json" + "errors" + "time" + + "github.com/google/uuid" + "gorm.io/datatypes" + "gorm.io/gorm" + + "solvify-agent/internal/model/entity" + "solvify-agent/internal/observability" + "solvify-agent/pkg/logger" +) + +type observabilityRepository struct { + db *gorm.DB +} + +func NewObservabilityRepository(db *gorm.DB) ObservabilityRepo { + return &observabilityRepository{db: db} +} + +func (r *observabilityRepository) CreateFeedback(ctx context.Context, fb *entity.MessageFeedback) error { + if fb.ID == "" { + fb.ID = uuid.New().String() + } + if fb.CreatedAt.IsZero() { + fb.CreatedAt = time.Now() + } + return r.db.WithContext(ctx).Create(fb).Error +} + +func (r *observabilityRepository) ListByMessage(ctx context.Context, messageID, userID string) ([]entity.MessageFeedback, error) { + var rows []entity.MessageFeedback + err := r.db.WithContext(ctx). + Where("message_id = ? AND user_id = ?", messageID, userID). + Order("created_at DESC"). + Find(&rows).Error + return rows, err +} + +func (r *observabilityRepository) ListByUser(ctx context.Context, userID string, offset, limit int) ([]entity.MessageFeedback, int64, error) { + var total int64 + q := r.db.WithContext(ctx).Model(&entity.MessageFeedback{}).Where("user_id = ?", userID) + if err := q.Count(&total).Error; err != nil { + return nil, 0, err + } + var rows []entity.MessageFeedback + err := q.Order("created_at DESC").Offset(offset).Limit(limit).Find(&rows).Error + return rows, total, err +} + +func (r *observabilityRepository) CreateChatTrace(ctx context.Context, trace *entity.ChatTrace) error { + if trace.ID == "" { + trace.ID = uuid.New().String() + } + if trace.CreatedAt.IsZero() { + trace.CreatedAt = time.Now() + } + return r.db.WithContext(ctx).Create(trace).Error +} + +func (r *observabilityRepository) FindByID(ctx context.Context, id string) (*entity.ChatTrace, error) { + var t entity.ChatTrace + err := r.db.WithContext(ctx).Where("id = ?", id).First(&t).Error + if err != nil { + return nil, err + } + return &t, nil +} + +func (r *observabilityRepository) ListBySession(ctx context.Context, sessionID, userID string, offset, limit int) ([]entity.ChatTrace, int64, error) { + var total int64 + q := r.db.WithContext(ctx).Model(&entity.ChatTrace{}). + Where("session_id = ? AND user_id = ?", sessionID, userID) + if err := q.Count(&total).Error; err != nil { + return nil, 0, err + } + var rows []entity.ChatTrace + err := q.Select("id, request_id, user_id, session_id, duration_ms, status, error, attrs, created_at"). + Order("created_at DESC"). + Offset(offset).Limit(limit).Find(&rows).Error + return rows, total, err +} + +func (r *observabilityRepository) DeleteOlderThan(ctx context.Context, before time.Time) (int64, error) { + res := r.db.WithContext(ctx).Where("created_at < ?", before).Delete(&entity.ChatTrace{}) + return res.RowsAffected, res.Error +} + +func (r *observabilityRepository) CreateAgentTask(ctx context.Context, task *entity.AgentTask) error { + if task.ID == "" { + task.ID = uuid.New().String() + } + return r.db.WithContext(ctx).Create(task).Error +} + +func (r *observabilityRepository) AppendStep(ctx context.Context, step *entity.AgentTaskStep) error { + if step.ID == "" { + step.ID = uuid.New().String() + } + return r.db.WithContext(ctx).Create(step).Error +} + +func (r *observabilityRepository) MarkEnded(ctx context.Context, taskID string, status, abortReason, errorSummary string, tokensPrompt, tokensCompletion int, cost float64, rating *int) error { + now := time.Now() + updates := map[string]any{ + "ended_at": &now, + "status": status, + "tokens_prompt": tokensPrompt, + "tokens_completion": tokensCompletion, + "total_cost": cost, + } + if abortReason != "" { + updates["abort_reason"] = abortReason + } + if errorSummary != "" { + updates["error_summary"] = errorSummary + } + if rating != nil { + updates["feedback_rating"] = *rating + } + return r.db.WithContext(ctx).Model(&entity.AgentTask{}). + Where("id = ?", taskID).Updates(updates).Error +} + +func (r *observabilityRepository) FindByTraceID(ctx context.Context, traceID string) (*entity.AgentTask, []entity.AgentTaskStep, error) { + var task entity.AgentTask + err := r.db.WithContext(ctx).Where("trace_id = ?", traceID).First(&task).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil, nil + } + return nil, nil, err + } + var steps []entity.AgentTaskStep + if err := r.db.WithContext(ctx).Where("task_id = ?", task.ID).Order("step_index ASC").Find(&steps).Error; err != nil { + return &task, nil, err + } + return &task, steps, nil +} + +func (r *observabilityRepository) WriteTraces(ctx context.Context, traces []*observability.Trace) error { + if len(traces) == 0 { + return nil + } + var firstErr error + for _, t := range traces { + if t == nil || t.Root == nil { + continue + } + spanTree, err := json.Marshal(t.Root) + if err != nil { + logger.Warnf("trace span_tree marshal 失败: %v", err) + continue + } + status := string(t.Root.Status) + duration := t.Root.DurationMs + attrs := AttrsFromRoot(t.Root) + attrsJSON, _ := json.Marshal(attrs) + row := &entity.ChatTrace{ + ID: t.ID, + RequestID: t.RequestID, + UserID: t.UserID, + SessionID: t.SessionID, + SampleRate: t.SampleRate, + Sampled: t.Sampled, + DurationMs: duration, + Status: status, + Error: t.Root.Error, + Attrs: datatypes.JSON(attrsJSON), + SpanTree: datatypes.JSON(spanTree), + } + if err := r.db.WithContext(ctx).Save(row).Error; err != nil { + if firstErr == nil { + firstErr = err + } + } + } + return firstErr +} + +func (r *observabilityRepository) WriteFeedbacks(ctx context.Context, fs []*observability.Feedback) error { + if len(fs) == 0 { + return nil + } + var firstErr error + for _, f := range fs { + if f == nil { + continue + } + row := &entity.MessageFeedback{ + MessageID: f.MessageID, + UserID: f.UserID, + SessionID: f.SessionID, + Rating: f.Rating, + ReasonTag: f.ReasonTag, + Comment: f.Comment, + TraceID: f.TraceID, + CreatedAt: f.CreatedAt, + } + if err := r.db.WithContext(ctx).Create(row).Error; err != nil { + if firstErr == nil { + firstErr = err + } + } + } + return firstErr +} + +func (r *observabilityRepository) WriteAgentSteps(ctx context.Context, steps []*observability.AgentStep) error { + if len(steps) == 0 { + return nil + } + var firstErr error + for _, s := range steps { + if s == nil { + continue + } + row := &entity.AgentTaskStep{ + TaskID: s.TaskID, + StepIndex: s.StepIndex, + StartedAt: s.StartedAt, + ThinkingSummary: s.ThinkingSummary, + ToolName: s.ToolName, + ToolInputMasked: s.ToolInputMasked, + ToolResultSummary: s.ToolResultSummary, + ToolStatus: s.ToolStatus, + ToolError: s.ToolError, + LatencyMs: s.LatencyMs, + TokensDelta: s.TokensDelta, + } + endedAt := s.EndedAt + if !endedAt.IsZero() { + row.EndedAt = &endedAt + } + if err := r.db.WithContext(ctx).Create(row).Error; err != nil { + if firstErr == nil { + firstErr = err + } + } + } + return firstErr +} + +func (r *observabilityRepository) Write(ctx context.Context, rec *observability.SinkRecord) error { + if rec == nil { + return nil + } + switch rec.Kind { + case "trace": + if rec.Trace != nil { + return r.WriteTraces(ctx, []*observability.Trace{rec.Trace}) + } + case "feedback": + if rec.Feedback != nil { + return r.WriteFeedbacks(ctx, []*observability.Feedback{rec.Feedback}) + } + case "agent_step": + if rec.AgentStep != nil { + return r.WriteAgentSteps(ctx, []*observability.AgentStep{rec.AgentStep}) + } + } + return nil +} + +func (r *observabilityRepository) Shutdown(_ context.Context) error { return nil } + +func AttrsFromRoot(root *observability.Span) map[string]any { + if root == nil { + return nil + } + m := map[string]any{} + if root.Attrs != nil { + for k, v := range root.Attrs { + m[k] = v + } + } + if len(root.Children) > 0 { + components := map[string]int64{} + for _, c := range root.Children { + if c == nil { + continue + } + components[string(c.Component)] += 1 + } + m["child_span_counts"] = components + } + return m +} From 44ae1928b9fe61eed93eeccd590afc5cf9a37348 Mon Sep 17 00:00:00 2001 From: st <2663600842@qq.com> Date: Thu, 30 Jul 2026 16:27:57 +0800 Subject: [PATCH 05/20] =?UTF-8?q?feat(obs):=20Service=20=E5=B1=82=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=E5=8F=8D=E9=A6=88/=E8=BF=BD=E8=B8=AA/=E6=8C=87?= =?UTF-8?q?=E6=A0=87=E6=9F=A5=E8=AF=A2=E4=B8=8E=E4=B8=8A=E4=B8=8B=E6=96=87?= =?UTF-8?q?=E6=89=93=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - chat_interface:新增 SubmitFeedback / GetTrace / ListSessionTraces / GetMetricsSnapshot 接口 - chat_service:注入 Recorder + ObservabilityRepo 实现;校验(权限;FlushTrace 保存到 DB;PII 处理通过 session 合法 - context_service:ChatRequest入站出站用 with Trace 包裹、Agent/RAG/LLM 调用 + Session 各阶段 Span 打点 - chat_message_repository:新增 InsertFeedback / GetMessageByID 与 ListTraces / GetTrace 基础读辅助查询 --- internal/repository/chat_message_interface.go | 1 + .../repository/chat_message_repository.go | 9 + internal/service/chat_interface.go | 36 ++ internal/service/chat_mode.go | 322 ++++++++++++++---- internal/service/chat_service.go | 247 +++++++++++++- internal/service/context_service.go | 93 +++-- 6 files changed, 615 insertions(+), 93 deletions(-) diff --git a/internal/repository/chat_message_interface.go b/internal/repository/chat_message_interface.go index fecb706..c54a03f 100644 --- a/internal/repository/chat_message_interface.go +++ b/internal/repository/chat_message_interface.go @@ -20,6 +20,7 @@ type ChatMessageSearchRow struct { // ChatMessageRepo 定义聊天消息数据访问接口 type ChatMessageRepo interface { Create(ctx context.Context, message *entity.ChatMessage) error + FindByID(ctx context.Context, id string) (*entity.ChatMessage, error) FindBySessionID(ctx context.Context, sessionID string) ([]entity.ChatMessage, error) // FindBySessionIDForContext 摘要/记忆抽取场景专用:全量消息但只取 5 个必要字段,sources/metadata 不传 FindBySessionIDForContext(ctx context.Context, sessionID string) ([]entity.ChatMessage, error) diff --git a/internal/repository/chat_message_repository.go b/internal/repository/chat_message_repository.go index a2e07a1..d6ee047 100644 --- a/internal/repository/chat_message_repository.go +++ b/internal/repository/chat_message_repository.go @@ -23,6 +23,15 @@ func (r *chatMessageRepository) Create(ctx context.Context, message *entity.Chat return r.db.WithContext(ctx).Create(message).Error } +// FindByID 按 ID 获取消息 +func (r *chatMessageRepository) FindByID(ctx context.Context, id string) (*entity.ChatMessage, error) { + var msg entity.ChatMessage + if err := r.db.WithContext(ctx).Where("id = ?", id).First(&msg).Error; err != nil { + return nil, err + } + return &msg, nil +} + // FindBySessionID 获取会话的所有消息 func (r *chatMessageRepository) FindBySessionID(ctx context.Context, sessionID string) ([]entity.ChatMessage, error) { var messages []entity.ChatMessage diff --git a/internal/service/chat_interface.go b/internal/service/chat_interface.go index 721914c..2fb5c02 100644 --- a/internal/service/chat_interface.go +++ b/internal/service/chat_interface.go @@ -7,6 +7,37 @@ import ( dto "solvify-agent/internal/model/dto/response" ) +type FeedbackRequest struct { + Rating int `json:"rating"` + ReasonTag string `json:"reason_tag"` + Comment string `json:"comment"` +} + +type FeedbackListResponse struct { + Total int64 `json:"total"` + Items any `json:"items"` +} + +type TraceResponse struct { + ID string `json:"id"` + RequestID string `json:"request_id,omitempty"` + UserID string `json:"user_id,omitempty"` + SessionID string `json:"session_id,omitempty"` + SampleRate float64 `json:"sample_rate,omitempty"` + Sampled bool `json:"sampled"` + DurationMs int64 `json:"duration_ms,omitempty"` + Status string `json:"status,omitempty"` + Error string `json:"error,omitempty"` + Attrs any `json:"attrs,omitempty"` + SpanTree any `json:"span_tree,omitempty"` + CreatedAt string `json:"created_at"` +} + +type TraceListResponse struct { + Total int64 `json:"total"` + Items any `json:"items"` +} + // ChatServiceInterface 定义聊天服务接口 type ChatServiceInterface interface { CreateSession(ctx context.Context, userID string, req requestdto.CreateSessionRequest) (dto.SessionResponse, error) @@ -16,4 +47,9 @@ type ChatServiceInterface interface { DeleteSession(ctx context.Context, userID, sessionID string) error SendMessage(ctx context.Context, userID, sessionID string, req requestdto.SendMessageRequest) (<-chan dto.StreamEvent, error) GetMessages(ctx context.Context, userID, sessionID string) ([]dto.MessageResponse, error) + SubmitFeedback(ctx context.Context, userID, messageID string, req FeedbackRequest) error + ListFeedbacks(ctx context.Context, userID string, offset, limit int) (FeedbackListResponse, error) + GetTrace(ctx context.Context, userID, traceID string) (*TraceResponse, error) + ListSessionTraces(ctx context.Context, userID, sessionID string, offset, limit int) (TraceListResponse, error) + GetMetricsSnapshot() (map[string]any, error) } diff --git a/internal/service/chat_mode.go b/internal/service/chat_mode.go index 81c37b1..e9b0179 100644 --- a/internal/service/chat_mode.go +++ b/internal/service/chat_mode.go @@ -2,6 +2,7 @@ package service import ( "context" + "encoding/json" "fmt" "io" "strings" @@ -20,6 +21,7 @@ import ( requestdto "solvify-agent/internal/model/dto/request" dto "solvify-agent/internal/model/dto/response" "solvify-agent/internal/model/entity" + "solvify-agent/internal/observability" "solvify-agent/internal/rag" "solvify-agent/pkg/config" "solvify-agent/pkg/logger" @@ -57,81 +59,101 @@ const queryRewriteShortRunes = 8 // 2. 需要改写时:改写与原始检索并行;改写有变化则以改写结果为准(不污染 merge) // 3. history 剔除本轮刚落库的 user 消息,避免 Prompt 重复 func (s *chatService) processMessage(ctx context.Context, userID, sessionID, userMsgID string, req requestdto.SendMessageRequest, eventCh chan<- dto.StreamEvent) { - // Step 1: 加载模型 + 增强历史对话 + obsOk := s.obs != nil + if obsOk { + _, span := s.obs.StartSpan(ctx, "chat.quick", observability.ComponentServiceChat, observability.Attrs{ + "session_id": sessionID, + "user_id": userID, + "model_id": req.ModelID, + "search_mode": "quick", + }) + defer func() { + status := observability.SpanStatusOK + var errVal error + if r := recover(); r != nil { + status = observability.SpanStatusError + errVal = fmt.Errorf("panic: %v", r) + sendErrorEvent(eventCh, fmt.Errorf("内部错误"), "处理过程中发生未预期错误") + } + s.obs.EndSpan(ctx, span, status, errVal, nil) + if s.obs != nil { + s.obs.AddRootAttrs(ctx, observability.Attrs{ + "assistant_message_id": span.Attrs["assistant_message_id"], + "rag_hit": span.Attrs["rag_hit"], + "intent": span.Attrs["intent"], + }) + } + }() + s.obs.Incr(ctx, "chat_quick_requests_total", map[string]string{ + "model_id": req.ModelID, + }, 1) + } + sendProgressEvent(eventCh, "正在加载上下文...") + t0 := time.Now() client, enhancedCtx, err := s.initContext(ctx, userID, sessionID, req.ModelID, req.ModelType, req.Content) if err != nil { + if obsOk { + s.obs.AddRootAttrs(ctx, observability.Attrs{"init_ctx_error": err.Error()}) + s.obs.Incr(ctx, "chat_quick_errors_total", map[string]string{"stage": "init_ctx"}, 1) + } sendErrorEvent(eventCh, err, err.Error()) return } - // 按消息 ID 剔除本轮刚保存的 user 消息,避免 Prompt 重复(不会因内容相同误删旧对话) history := excludeByMessageID(enhancedCtx.History, userMsgID) - chatModel := client.ChatModel() + if obsOk { + s.obs.Observe(ctx, "chat_quick_init_ctx_seconds", map[string]string{"model_id": req.ModelID}, time.Since(t0).Seconds()) + } - // Step 2: 结构化改写 + 意图识别 + 关键词扩展(一次 LLM 产出 6 字段) - // 意图 chat/greeting 直接跳过检索;general/knowledge 才检索 sendProgressEvent(eventCh, "正在分析您的意图...") rewritten := FallbackOriginalRewritten(req.Content) - - // 快速模式:有历史对话才做 LLM 改写(独立问题用结构化结果 + 摘要),无历史用 AnalyzeIntent 快速意图分流 + t1 := time.Now() if len(history) > 0 { rewritten = s.rewriteQuery(ctx, chatModel, history, req.Content, enhancedCtx.Summary) } else { - // 无历史:直接复用 AnalyzeIntent 的意图结果(更准确),关键词用正则兜底 quickIntent := AnalyzeIntent(req.Content) rewritten.Intent = quickIntent.Intent } - logger.Infof("改写结果: sessionID=%s, 意图=%s, 是否改写=%v, 置信度=%.2f, 主查询=%q, 关键词=%v, 扩展查询=%v", - sessionID, rewritten.Intent, rewritten.Rewritten, rewritten.Confidence, rewritten.MainQuery, rewritten.Keywords, rewritten.ExpandedQueries) + if obsOk { + s.obs.Observe(ctx, "chat_quick_rewrite_seconds", map[string]string{"intent": string(rewritten.Intent)}, time.Since(t1).Seconds()) + s.obs.AddRootAttrs(ctx, observability.Attrs{"intent": string(rewritten.Intent), "rewritten": fmt.Sprintf("%t", rewritten.Rewritten)}) + } - // Step 3: 意图分流 + 检索(需要检索的情况才做) var sources []dto.SourceInfo var retrieveResult rag.Result switch rewritten.Intent { case IntentGreeting, IntentChitchat: - // 问候 / 闲聊:直接跳过 RAG,由快速模式的 System Prompt + LLM 直接礼貌回答 sendProgressEvent(eventCh, "正在整理回答...") - logger.Infof("意图=%s,跳过知识库检索", rewritten.Intent) case IntentIdentity, IntentMeta, IntentListQuery: - // 身份 / 元问题 / 列表查询:由 AnalyzeIntent 已经打了 SkipRetrieval 标记,但我们统一还是走下面正常分支 - // (SystemPrompt 里已内置身份/列表/元问题回答模板;有检索命中就参考,没有由通用回答兜底) fallthrough default: - // IntentQuestion(knowledge/general 通用知识库问答):正常去检索 sendProgressEvent(eventCh, "正在检索知识库...") - - // 多路并行检索:主查询 + 扩展查询,合并去重 queries := make([]string, 0, 1+len(rewritten.ExpandedQueries)) queries = append(queries, rewritten.MainQuery) for _, eq := range rewritten.ExpandedQueries { queries = append(queries, eq) } - // 为了保证"改写前问题不漏结果;(原问题 也检索一次(兜底,合并结果去重) origTrimmed := strings.TrimSpace(req.Content) if origTrimmed != "" && origTrimmed != rewritten.MainQuery { queries = append(queries, origTrimmed) } - - var ( - mergedDocs []rag.Document - mergedSrc []dto.SourceInfo - mergeHit bool - ) - + t2 := time.Now() if len(queries) == 1 { var err2 error sources, retrieveResult, err2 = s.retrieveContext(ctx, userID, rewritten.MainQuery, req.KnowledgeBaseIDs) if err2 != nil { + if obsOk { + s.obs.Incr(ctx, "chat_quick_errors_total", map[string]string{"stage": "retrieve"}, 1) + } logger.Errorf("知识库检索失败, sessionID=%s: %v", sessionID, err2) sendErrorEvent(eventCh, err2, "知识库检索失败") return } } else { - // 多路并行检索 type retPair struct { srcs []dto.SourceInfo res rag.Result @@ -145,7 +167,6 @@ func (s *chatService) processMessage(ctx context.Context, userID, sessionID, use g.Go(func() error { s, r, e := s.retrieveContext(gCtx, userID, q, req.KnowledgeBaseIDs) if e != nil { - // 单路检索失败不阻塞整体 logger.Warnf("多路检索单路失败 query=%q err=%v", q, e) return nil } @@ -157,8 +178,12 @@ func (s *chatService) processMessage(ctx context.Context, userID, sessionID, use } _ = g.Wait() - // 合并去重 seen := map[string]struct{}{} + var ( + mergedDocs []rag.Document + mergedSrc []dto.SourceInfo + mergeHit bool + ) for _, rp := range results { if rp.res.Hit { mergeHit = true @@ -180,40 +205,66 @@ func (s *chatService) processMessage(ctx context.Context, userID, sessionID, use mergedSrc = append(mergedSrc, src) } } - - retrieveResult = rag.Result{ - Hit: mergeHit, - Documents: mergedDocs, - } + retrieveResult = rag.Result{Hit: mergeHit, Documents: mergedDocs} sources = mergedSrc } + if obsOk { + s.obs.Observe(ctx, "chat_quick_retrieve_seconds", map[string]string{ + "queries": fmt.Sprintf("%d", len(queries)), + "hit": fmt.Sprintf("%t", retrieveResult.Hit), + }, time.Since(t2).Seconds()) + s.obs.Incr(ctx, "rag_retrievals_total", map[string]string{ + "mode": "quick", + "hit": fmt.Sprintf("%t", retrieveResult.Hit), + "queries_n": fmt.Sprintf("%d", len(queries)), + }, 1) + s.obs.AddRootAttrs(ctx, observability.Attrs{ + "rag_hit": retrieveResult.Hit, + "rag_docs_n": len(retrieveResult.Documents), + "rag_queries_n": len(queries), + }) + } } - // Step 4: 组装 Prompt(用统一 PromptBuilder,与深度模式共用 System/History 注入逻辑) sendProgressEvent(eventCh, "正在整理资料...") pb := NewPromptBuilder(PromptModeQuick, quickModeSystemPrompt, enhancedCtx.Summary, enhancedCtx.Memories, enhancedCtx.UserCtx). WithProfile(enhancedCtx.Profile). WithPreference(enhancedCtx.Preference) messages := pb.BuildMessagesQuick(history, req.Content, retrieveResult, enhancedCtx.RetrievalBudget) - // Step 5: LLM 流式生成 assistantMsgID := uuid.New().String() + if obsOk { + s.obs.AddRootAttrs(ctx, observability.Attrs{"assistant_message_id": assistantMsgID}) + } + t3 := time.Now() fullContent, err := s.streamAndCollect(ctx, chatModel, messages, assistantMsgID, eventCh) - - // Step 6: 保存助手消息 - // LLM 流式生成失败时已发送 error 事件,此处直接返回,避免再发 done 导致前端重复显示 + if obsOk { + s.obs.Observe(ctx, "chat_quick_llm_stream_seconds", map[string]string{"model_id": req.ModelID}, time.Since(t3).Seconds()) + s.obs.Incr(ctx, "llm_stream_requests_total", map[string]string{ + "provider": providerLabel(client), + "model_id": req.ModelID, + "success": fmt.Sprintf("%t", err == nil), + }, 1) + } if err != nil { llm.ReduceContextBudgetOnError(req.ModelID, err) return } - // 即使用户中断导致空内容,也结束 SSE(避免前端挂起) if fullContent == "" { eventCh <- dto.StreamEvent{Type: "done", MessageID: assistantMsgID, Content: "", Sources: sources, Done: true} return } - s.emitDoneAndSave(eventCh, sessionID, assistantMsgID, fullContent, req, sources, nil) + if obsOk { + s.obs.AddRootAttrs(ctx, observability.Attrs{ + "assistant_chars": len([]rune(fullContent)), + }) + } + s.emitDoneAndSave(eventCh, sessionID, assistantMsgID, fullContent, req, sources, nil, func(meta map[string]any) { + if obsOk && meta != nil { + meta["trace_id"] = observability.TraceIDFromContext(ctx) + } + }) - // Step 7: 异步更新摘要和提取记忆 s.refreshContextAsync(ctx, userID, sessionID, enhancedCtx.History, chatModel) } @@ -222,48 +273,76 @@ func (s *chatService) processMessage(ctx context.Context, userID, sessionID, use // processDeepMode 深度思考模式处理流程 // 使用 eino ReAct Agent,自动管理 Think → Act → Observe 循环 func (s *chatService) processDeepMode(ctx context.Context, userID, sessionID, userMsgID string, req requestdto.SendMessageRequest, eventCh chan<- dto.StreamEvent) { - // 提前生成助手消息 ID,贯穿整个 SSE 生命周期 + obsOk := s.obs != nil + if obsOk { + _, span := s.obs.StartSpan(ctx, "chat.deep", observability.ComponentAgentEngine, observability.Attrs{ + "session_id": sessionID, + "user_id": userID, + "model_id": req.ModelID, + "search_mode": "deep", + }) + defer func() { + status := observability.SpanStatusOK + var errVal error + if r := recover(); r != nil { + status = observability.SpanStatusError + errVal = fmt.Errorf("panic: %v", r) + eventCh <- dto.StreamEvent{Type: "error", Detail: "处理过程中发生未预期错误", Done: true} + } + s.obs.EndSpan(ctx, span, status, errVal, nil) + }() + s.obs.Incr(ctx, "chat_deep_requests_total", map[string]string{"model_id": req.ModelID}, 1) + } + assistantMsgID := uuid.New().String() + if obsOk { + s.obs.AddRootAttrs(ctx, observability.Attrs{"assistant_message_id": assistantMsgID}) + } - // Step 1: 加载模型 + 增强历史对话 sendProgressEvent(eventCh, "正在加载上下文...") + t0 := time.Now() client, enhancedCtx, err := s.initContext(ctx, userID, sessionID, req.ModelID, req.ModelType, req.Content) if err != nil { + if obsOk { + s.obs.Incr(ctx, "chat_deep_errors_total", map[string]string{"stage": "init_ctx"}, 1) + } sendErrorEvent(eventCh, err, err.Error()) return } - // 按消息 ID 剔除本轮刚保存的 user 消息,避免 Prompt 重复 history := excludeByMessageID(enhancedCtx.History, userMsgID) - chatModel := client.ChatModel() + if obsOk { + s.obs.Observe(ctx, "chat_deep_init_ctx_seconds", map[string]string{"model_id": req.ModelID}, time.Since(t0).Seconds()) + } - // Step 2: 发送 start 事件(前端用 message_id 关联后续更新) eventCh <- dto.StreamEvent{Type: "start", MessageID: assistantMsgID} - // Step 3: 委托 eino ReAct Agent 执行(通过统一 PromptBuilder 传入摘要/记忆/用户上下文,双模式一致) sendProgressEvent(eventCh, "正在深度推理...") agentPB := NewPromptBuilder(PromptModeDeep, "", enhancedCtx.Summary, enhancedCtx.Memories, enhancedCtx.UserCtx). WithProfile(enhancedCtx.Profile). WithPreference(enhancedCtx.Preference) agentReq := agentPB.BuildAgentRequestFields(userID, req.Content, req.ModelID, req.ModelType, req.KnowledgeBaseIDs, history) + t1 := time.Now() agentEventCh, err := s.agentEngine.Execute(ctx, agentReq, chatModel) if err != nil { + if obsOk { + s.obs.Incr(ctx, "chat_deep_errors_total", map[string]string{"stage": "agent_execute"}, 1) + } logger.Errorf("Agent 执行失败, sessionID=%s: %v", sessionID, err) llm.ReduceContextBudgetOnError(req.ModelID, err) sendErrorEvent(eventCh, err, "Agent 执行失败") return } - // Step 4: 转发 Agent 事件到 SSE 事件流 + 收集推理步骤和最终答案 var fullContent string var agentSources []dto.SourceInfo var reasoningSteps []dto.ReasoningStep + toolCallsN := 0 + toolErrorsN := 0 toolEventSeen := false agentErrorSeen := false for agentEvent := range agentEventCh { - // Agent 的 done 事件仅用于收集完整答案,不透传到 SSE - // (SSE 的终止由 Service 层的 emitDoneAndSave 统一负责) if agentEvent.Type == agent.EventDone { if agentEvent.Content != "" { fullContent = agentEvent.Content @@ -274,12 +353,18 @@ func (s *chatService) processDeepMode(ctx context.Context, userID, sessionID, us continue } - // 实时累积答案内容,确保用户中断时也能保存已生成的部分 if agentEvent.Type == agent.EventAnswer { fullContent += agentEvent.Content } - if agentEvent.Type == agent.EventToolCall || agentEvent.Type == agent.EventToolResult { + if agentEvent.Type == agent.EventToolCall { toolEventSeen = true + toolCallsN++ + } + if agentEvent.Type == agent.EventToolResult { + toolEventSeen = true + if agentEvent.Status == "error" { + toolErrorsN++ + } } if agentEvent.Type == agent.EventError { agentErrorSeen = true @@ -293,13 +378,31 @@ func (s *chatService) processDeepMode(ctx context.Context, userID, sessionID, us } applyReasoningStep(&reasoningSteps, agentEvent) } + if obsOk { + s.obs.Observe(ctx, "chat_deep_agent_seconds", map[string]string{"model_id": req.ModelID}, time.Since(t1).Seconds()) + s.obs.Incr(ctx, "agent_runs_total", map[string]string{ + "error_seen": fmt.Sprintf("%t", agentErrorSeen), + "tool_calls": fmt.Sprintf("%d", toolCallsN), + }, 1) + s.obs.AddRootAttrs(ctx, observability.Attrs{ + "tool_calls": toolCallsN, + "tool_errors": toolErrorsN, + "steps_n": len(reasoningSteps), + "rag_docs_n": len(agentSources), + "agent_error": agentErrorSeen, + "tool_used": toolEventSeen, + "assistant_chars": len([]rune(fullContent)), + }) + } - // Step 5: 保存助手消息(含推理步骤) if agentErrorSeen { return } if !toolEventSeen && looksLikeExecutionPlan(fullContent) { logger.Warnf("深度模式未产生工具调用,仅返回执行计划,sessionID=%s, content=%q", sessionID, fullContent) + if obsOk { + s.obs.Incr(ctx, "agent_plan_without_tool_total", nil, 1) + } eventCh <- dto.StreamEvent{ Type: "error", Title: "深度推理未完成", @@ -309,20 +412,23 @@ func (s *chatService) processDeepMode(ctx context.Context, userID, sessionID, us } return } - // 用户中断时可能没有最终答案,但有推理步骤也应保存 if fullContent == "" && len(reasoningSteps) == 0 { return } var metadata datatypes.JSON + metaMap := map[string]any{} if len(reasoningSteps) > 0 { - metadata = datatypes.JSON(mustMarshal(map[string]any{ - "reasoning_steps": reasoningSteps, - })) + metaMap["reasoning_steps"] = reasoningSteps + } + if obsOk { + metaMap["trace_id"] = observability.TraceIDFromContext(ctx) } - s.emitDoneAndSave(eventCh, sessionID, assistantMsgID, fullContent, req, agentSources, metadata) + if len(metaMap) > 0 { + metadata = datatypes.JSON(mustMarshal(metaMap)) + } + s.emitDoneAndSave(eventCh, sessionID, assistantMsgID, fullContent, req, agentSources, metadata, nil) - // 异步更新摘要和提取记忆 s.refreshContextAsync(ctx, userID, sessionID, enhancedCtx.History, chatModel) } @@ -348,17 +454,38 @@ func (s *chatService) refreshContextAsync(ctx context.Context, userID, sessionID // emitDoneAndSave 发送 done 事件并异步保存助手消息 // 注意:保存失败只记日志,禁止再向 eventCh 写事件(外层 defer close 后会 panic) -func (s *chatService) emitDoneAndSave(eventCh chan<- dto.StreamEvent, sessionID, msgID, content string, req requestdto.SendMessageRequest, sources []dto.SourceInfo, metadata datatypes.JSON) { +func (s *chatService) emitDoneAndSave(eventCh chan<- dto.StreamEvent, sessionID, msgID, content string, req requestdto.SendMessageRequest, sources []dto.SourceInfo, metadata datatypes.JSON, metaHook func(map[string]any)) { + finalMeta := metadata + if metaHook != nil && len(metadata) == 0 { + m := map[string]any{} + metaHook(m) + if len(m) > 0 { + finalMeta = datatypes.JSON(mustMarshal(m)) + } + } else if metaHook != nil && len(metadata) > 0 { + var m map[string]any + if err := json.Unmarshal(metadata, &m); err == nil { + metaHook(m) + finalMeta = datatypes.JSON(mustMarshal(m)) + } + } eventCh <- dto.StreamEvent{Type: "done", MessageID: msgID, Content: content, Sources: sources, Done: true} go func() { saveCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - if err := s.saveAssistantMessage(saveCtx, sessionID, msgID, content, req, sources, metadata); err != nil { + if err := s.saveAssistantMessage(saveCtx, sessionID, msgID, content, req, sources, finalMeta); err != nil { logger.Errorf("保存助手消息失败, messageID=%s: %v", msgID, err) } }() } +func providerLabel(client *llm.OpenAIClient) string { + if client == nil { + return "unknown" + } + return "openai_compatible" +} + // rewriteQuery 用 LLM 结合历史对话改写用户问题,一次返回结构化结果(主查询+扩展查询+关键词+意图) func (s *chatService) rewriteQuery(ctx context.Context, chatModel interface { Generate(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.Message, error) @@ -416,11 +543,21 @@ func needsQueryRewrite(question string) bool { return false } -// retrieveContext 执行 RAG 检索并转换为引用来源 func (s *chatService) retrieveContext(ctx context.Context, userID, question string, knowledgeBaseIDs []string) ([]dto.SourceInfo, rag.Result, error) { logger.Infof("RAG 检索开始: userID=%s, question=%q, kbIDs=%v", userID, question, knowledgeBaseIDs) + obsOk := s.obs != nil + var span *observability.Span + if obsOk { + _, span = s.obs.StartSpan(ctx, "rag.retrieve", observability.ComponentRAGRetriever, observability.Attrs{ + "kb_n": fmt.Sprintf("%d", len(knowledgeBaseIDs)), + }) + defer func() { + if span != nil { + s.obs.EndSpan(ctx, span, observability.SpanStatusOK, nil, nil) + } + }() + } ragCfg := config.Get().RAG - // 有 Rerank 时扩大召回量,让重排有足够候选;无 Rerank 时直接用 TopK 保证速度 topK := ragCfg.TopK if ragCfg.Reranker.Enabled { if ragCfg.RecallK > 0 { @@ -438,23 +575,50 @@ func (s *chatService) retrieveContext(ctx context.Context, userID, question stri UserID: userID, }) if err != nil { + if obsOk && span != nil { + span.Status = observability.SpanStatusError + span.Error = err.Error() + } return nil, rag.Result{}, err } - sources := groupDocumentsToSources(retrieveResult.Documents) - + if obsOk && span != nil { + if span.Attrs == nil { + span.Attrs = observability.Attrs{} + } + span.Attrs["top_k"] = topK + span.Attrs["hit"] = retrieveResult.Hit + span.Attrs["docs_n"] = len(retrieveResult.Documents) + } logger.Infof("RAG 检索完成: hit=%v, 命中 %d 篇文档, 共 %d 个 chunk", retrieveResult.Hit, len(sources), len(retrieveResult.Documents)) return sources, retrieveResult, nil } -// streamAndCollect 流式生成并推送 SSE 事件,返回已收集的内容(用户暂停时返回部分内容) func (s *chatService) streamAndCollect(ctx context.Context, chatModel interface { Stream(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.StreamReader[*schema.Message], error) }, messages []*schema.Message, assistantMsgID string, eventCh chan<- dto.StreamEvent) (string, error) { sendProgressEvent(eventCh, "正在生成回答...") + obsOk := s.obs != nil + var span *observability.Span + if obsOk { + _, span = s.obs.StartSpan(ctx, "llm.stream", observability.ComponentLLMClient, observability.Attrs{}) + defer func() { + if span != nil { + s.obs.EndSpan(ctx, span, observability.SpanStatusOK, nil, nil) + } + }() + } + t0 := time.Now() streamReader, err := chatModel.Stream(ctx, messages) if err != nil { + if obsOk { + s.obs.Incr(ctx, "llm_stream_errors_total", map[string]string{"stage": "open_stream"}, 1) + if span != nil { + span.Status = observability.SpanStatusError + span.Error = err.Error() + } + } logger.Errorf("LLM 调用失败: %v", err) sendErrorEvent(eventCh, err, "LLM 调用失败") return "", err @@ -466,6 +630,17 @@ func (s *chatService) streamAndCollect(ctx context.Context, chatModel interface MessageID: assistantMsgID, } + if obsOk { + ttftMs := time.Since(t0).Milliseconds() + s.obs.Observe(ctx, "llm_stream_ttft_seconds", nil, float64(ttftMs)/1000.0) + if span != nil { + if span.Attrs == nil { + span.Attrs = observability.Attrs{} + } + span.Attrs["ttft_ms"] = ttftMs + } + } + var fullContent string for { msg, recvErr := streamReader.Recv() @@ -477,6 +652,13 @@ func (s *chatService) streamAndCollect(ctx context.Context, chatModel interface logger.Infof("用户暂停,已收集 %d 字符", len(fullContent)) return fullContent, recvErr } + if obsOk { + s.obs.Incr(ctx, "llm_stream_errors_total", map[string]string{"stage": "recv"}, 1) + if span != nil { + span.Status = observability.SpanStatusError + span.Error = recvErr.Error() + } + } logger.Errorf("LLM 流式生成错误: %v", recvErr) sendErrorEvent(eventCh, recvErr, "LLM 流式生成错误") return fullContent, recvErr @@ -490,6 +672,12 @@ func (s *chatService) streamAndCollect(ctx context.Context, chatModel interface Content: msg.Content, } } + if obsOk && span != nil { + if span.Attrs == nil { + span.Attrs = observability.Attrs{} + } + span.Attrs["chars"] = len(fullContent) + } return fullContent, nil } diff --git a/internal/service/chat_service.go b/internal/service/chat_service.go index b9f31d1..8c1e927 100644 --- a/internal/service/chat_service.go +++ b/internal/service/chat_service.go @@ -2,6 +2,7 @@ package service import ( "context" + "encoding/json" "fmt" "time" @@ -13,6 +14,7 @@ import ( requestdto "solvify-agent/internal/model/dto/request" dto "solvify-agent/internal/model/dto/response" "solvify-agent/internal/model/entity" + "solvify-agent/internal/observability" "solvify-agent/internal/rag" "solvify-agent/internal/repository" "solvify-agent/pkg/cache" @@ -37,6 +39,8 @@ type chatService struct { agentEngine *agent.Engine contextSvc ContextServiceInterface prefSvc UserPreferenceService + obs observability.Recorder + obsRepo repository.ObservabilityRepo } // NewChatService 创建聊天业务服务 @@ -51,8 +55,9 @@ func NewChatService( agentEngine *agent.Engine, contextSvc ContextServiceInterface, prefSvc UserPreferenceService, + extra ...interface{}, ) ChatServiceInterface { - return &chatService{ + s := &chatService{ sessionRepo: sessionRepo, messageRepo: messageRepo, retriever: retriever, @@ -64,6 +69,20 @@ func NewChatService( contextSvc: contextSvc, prefSvc: prefSvc, } + for _, it := range extra { + switch v := it.(type) { + case observability.Recorder: + s.obs = v + case repository.ObservabilityRepo: + s.obsRepo = v + } + } + return s +} + +func (s *chatService) SetObservability(obs observability.Recorder, repo repository.ObservabilityRepo) { + s.obs = obs + s.obsRepo = repo } // SendMessage 发送消息并获取流式响应 @@ -76,11 +95,21 @@ func (s *chatService) SendMessage(ctx context.Context, userID, sessionID string, return nil, err } - // 缓存比对:如果模型 ID 不一致,更新缓存和数据库 if req.ModelID != "" { s.updateUserLastModel(ctx, userID, req.ModelID) } + if s.obs != nil { + ctx = s.obs.WithTraceRoot(ctx, observability.TraceRootAttrs{ + UserID: userID, + SessionID: sessionID, + MessageID: userMsgID, + RequestID: requestIDFromCtx(ctx), + SearchMode: req.SearchMode, + ModelID: req.ModelID, + }) + } + eventCh := make(chan dto.StreamEvent, 100) go func() { defer close(eventCh) @@ -89,11 +118,25 @@ func (s *chatService) SendMessage(ctx context.Context, userID, sessionID string, } else { s.processMessage(ctx, userID, sessionID, userMsgID, req, eventCh) } + if s.obs != nil { + s.obs.FlushTrace(ctx, userID, sessionID, userMsgID) + } }() return eventCh, nil } +func requestIDFromCtx(ctx context.Context) string { + type iKey string + const key iKey = "request_id" + if v := ctx.Value(key); v != nil { + if s, ok := v.(string); ok { + return s + } + } + return uuid.New().String() +} + // updateUserLastModel 更新用户上次使用的模型(缓存比对策略) func (s *chatService) updateUserLastModel(ctx context.Context, userID, modelID string) { cacheKey := "user:model:" + userID @@ -464,3 +507,203 @@ func truncateContentByTokens(content string, maxTokens int) string { } return string(runes[:cut]) } + +// ─── 可观测:反馈 / Trace / Metrics 查询接口 ─────────────────────────────────── + +func (s *chatService) SubmitFeedback(ctx context.Context, userID, messageID string, req FeedbackRequest) error { + if req.Rating != 1 && req.Rating != -1 { + return fmt.Errorf("rating 必须为 1 或 -1") + } + if messageID == "" || userID == "" { + return fmt.Errorf("message_id / user_id 不能为空") + } + msg, err := s.messageRepo.FindByID(ctx, messageID) + if err != nil { + return fmt.Errorf("查询消息失败: %w", err) + } + if msg == nil { + return fmt.Errorf("消息不存在或无权限") + } + if msg.SessionID != "" { + if vErr := s.validateSession(ctx, userID, msg.SessionID); vErr != nil { + return fmt.Errorf("消息不存在或无权限") + } + } + var traceID string + if raw := msg.Metadata; len(raw) > 0 { + if meta := metadataAsMap(raw); meta != nil { + if v, ok := meta["trace_id"].(string); ok { + traceID = v + } + } + } + fb := &entity.MessageFeedback{ + ID: uuid.New().String(), + MessageID: messageID, + UserID: userID, + SessionID: msg.SessionID, + Rating: req.Rating, + ReasonTag: req.ReasonTag, + Comment: req.Comment, + TraceID: traceID, + } + if s.obsRepo != nil { + if e := s.obsRepo.CreateFeedback(ctx, fb); e != nil { + return fmt.Errorf("保存反馈失败: %w", e) + } + } + if s.obs != nil { + s.obs.Incr(ctx, "chat_feedback_total", map[string]string{ + "rating": ratingLabel(req.Rating), + "reason_tag": reasonTagOrDefault(req.ReasonTag), + "has_comment": boolLabel(req.Comment != ""), + }, 1) + } + if s.obs != nil { + s.obs.RecordFeedback(&observability.Feedback{ + MessageID: fb.MessageID, + UserID: fb.UserID, + SessionID: fb.SessionID, + Rating: fb.Rating, + ReasonTag: fb.ReasonTag, + Comment: fb.Comment, + TraceID: fb.TraceID, + CreatedAt: fb.CreatedAt, + }) + } + return nil +} + +func (s *chatService) ListFeedbacks(ctx context.Context, userID string, offset, limit int) (FeedbackListResponse, error) { + if limit <= 0 { + limit = 20 + } + if limit > 100 { + limit = 100 + } + if offset < 0 { + offset = 0 + } + if s.obsRepo == nil { + return FeedbackListResponse{Total: 0, Items: []any{}}, nil + } + list, total, err := s.obsRepo.ListByUser(ctx, userID, offset, limit) + if err != nil { + return FeedbackListResponse{}, err + } + items := make([]any, 0, len(list)) + for _, f := range list { + items = append(items, f) + } + return FeedbackListResponse{Total: total, Items: items}, nil +} + +func (s *chatService) GetTrace(ctx context.Context, userID, traceID string) (*TraceResponse, error) { + if s.obsRepo == nil || traceID == "" { + return nil, fmt.Errorf("trace 存储未启用") + } + t, err := s.obsRepo.FindByID(ctx, traceID) + if err != nil { + return nil, fmt.Errorf("trace 不存在: %w", err) + } + if t.UserID != userID { + return nil, fmt.Errorf("无权限访问该 trace") + } + resp := &TraceResponse{ + ID: t.ID, + RequestID: t.RequestID, + UserID: t.UserID, + SessionID: t.SessionID, + SampleRate: t.SampleRate, + Sampled: t.Sampled, + DurationMs: t.DurationMs, + Status: t.Status, + Error: t.Error, + Attrs: t.Attrs, + SpanTree: t.SpanTree, + CreatedAt: t.CreatedAt.Format("2006-01-02 15:04:05"), + } + return resp, nil +} + +func (s *chatService) ListSessionTraces(ctx context.Context, userID, sessionID string, offset, limit int) (TraceListResponse, error) { + if limit <= 0 { + limit = 20 + } + if limit > 100 { + limit = 100 + } + if offset < 0 { + offset = 0 + } + if s.obsRepo == nil { + return TraceListResponse{Total: 0, Items: []any{}}, nil + } + if err := s.validateSession(ctx, userID, sessionID); err != nil { + return TraceListResponse{}, err + } + list, total, err := s.obsRepo.ListBySession(ctx, sessionID, userID, offset, limit) + if err != nil { + return TraceListResponse{}, err + } + items := make([]any, 0, len(list)) + for _, t := range list { + items = append(items, TraceResponse{ + ID: t.ID, + RequestID: t.RequestID, + UserID: t.UserID, + SessionID: t.SessionID, + SampleRate: t.SampleRate, + Sampled: t.Sampled, + DurationMs: t.DurationMs, + Status: t.Status, + Error: t.Error, + Attrs: t.Attrs, + CreatedAt: t.CreatedAt.Format("2006-01-02 15:04:05"), + }) + } + return TraceListResponse{Total: total, Items: items}, nil +} + +func (s *chatService) GetMetricsSnapshot() (map[string]any, error) { + if s.obs == nil { + return nil, fmt.Errorf("observability 未启用") + } + return s.obs.MetricsSnapshot() +} + +func ratingLabel(r int) string { + switch r { + case 1: + return "up" + case -1: + return "down" + default: + return "unknown" + } +} + +func boolLabel(b bool) string { + if b { + return "true" + } + return "false" +} + +func reasonTagOrDefault(tag string) string { + if tag == "" { + return "none" + } + return tag +} + +func metadataAsMap(raw datatypes.JSON) map[string]any { + if len(raw) == 0 { + return nil + } + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + return nil + } + return m +} diff --git a/internal/service/context_service.go b/internal/service/context_service.go index f34b08d..0bc242b 100644 --- a/internal/service/context_service.go +++ b/internal/service/context_service.go @@ -13,36 +13,56 @@ import ( "github.com/cloudwego/eino/schema" "solvify-agent/internal/model/entity" + "solvify-agent/internal/observability" "solvify-agent/internal/repository" "solvify-agent/pkg/logger" "solvify-agent/pkg/tokenutil" ) -// 包级正则:程序启动时只编译一次,避免每次请求重复编译(每次请求重复编译开销约 2~5μs,10k QPS 场景可省几十毫秒) var tokenRegexp = regexp.MustCompile(`[\x{4e00}-\x{9fff}]+|[a-zA-Z0-9]+`) -// contextService 上下文管理服务实现 type contextService struct { messageRepo repository.ChatMessageRepo memoryRepo repository.MemoryRepo summaryRepo repository.SummaryRepo + obs observability.Recorder } -// NewContextService 创建上下文管理服务 func NewContextService( messageRepo repository.ChatMessageRepo, memoryRepo repository.MemoryRepo, summaryRepo repository.SummaryRepo, + obs ...observability.Recorder, ) ContextServiceInterface { - return &contextService{ + s := &contextService{ messageRepo: messageRepo, memoryRepo: memoryRepo, summaryRepo: summaryRepo, } + if len(obs) > 0 && obs[0] != nil { + s.obs = obs[0] + } + return s +} + +func (s *contextService) SetObservability(obs observability.Recorder) { + s.obs = obs } -// BuildContext 构建增强后的对话上下文 func (s *contextService) BuildContext(ctx context.Context, userID, sessionID, currentQuery string, cfg BuildContextConfig, chatModel model.BaseChatModel) (*EnhancedContext, error) { + obsOk := s.obs != nil + var span *observability.Span + if obsOk { + _, span = s.obs.StartSpan(ctx, "ctx.build", observability.ComponentServiceContext, observability.Attrs{ + "session_id": sessionID, + "has_query": fmt.Sprintf("%t", currentQuery != ""), + }) + defer func() { + if span != nil { + s.obs.EndSpan(ctx, span, observability.SpanStatusOK, nil, nil) + } + }() + } if cfg.MaxTokens <= 0 { cfg.MaxTokens = 1500 } @@ -59,7 +79,6 @@ func (s *contextService) BuildContext(ctx context.Context, userID, sessionID, cu cfg.MemoryBudget = 800 } - // 1. 并行加载:摘要、记忆、最近消息 type loadResult struct { summary *entity.ChatSummary memories []entity.UserMemory @@ -104,9 +123,6 @@ func (s *contextService) BuildContext(ctx context.Context, userID, sessionID, cu } } - // 2. 根据当前问题检索相关历史 - // 优先使用调用方预抽的同义词归一化关键词(来自 rewriteQuery 的 LLM 输出,质量更高) - // 没有的话 fallback 到 extractKeywords 纯正则(质量一般但零成本) var relevant []entity.ChatMessage if currentQuery != "" { keywords := cfg.PreExtractedKeywords @@ -122,18 +138,20 @@ func (s *contextService) BuildContext(ctx context.Context, userID, sessionID, cu } } - // 3. 合并 recent 和 relevant,按时间排序并去重 history := mergeMessages(recent, relevant) - - // 4. 应用摘要压缩:如果有摘要,把摘要覆盖的早期消息替换为摘要 history = s.applySummary(history, summary) - - // 5. 按 token 预算截断(从最早的消息开始截断,优先保留近期消息) history = truncateHistoryByTokens(history, cfg.MaxTokens) - - // 6. 按预算截断用户记忆,优先保留最近更新的记忆 memories = truncateMemoriesByTokens(memories, cfg.MemoryBudget) + if obsOk && span != nil { + if span.Attrs == nil { + span.Attrs = observability.Attrs{} + } + span.Attrs["history_n"] = len(history) + span.Attrs["memories_n"] = len(memories) + span.Attrs["has_summary"] = summary != nil + } + return &EnhancedContext{ History: history, Summary: summary, @@ -143,26 +161,31 @@ func (s *contextService) BuildContext(ctx context.Context, userID, sessionID, cu }, nil } -// SummarizeSession 对会话生成或更新摘要 func (s *contextService) SummarizeSession(ctx context.Context, sessionID string, chatModel model.BaseChatModel) (*entity.ChatSummary, error) { - // 只需要 role + content,用轻量查询,sources/metadata 对摘要没意义 + obsOk := s.obs != nil + var span *observability.Span + if obsOk { + _, span = s.obs.StartSpan(ctx, "ctx.summarize", observability.ComponentServiceContext, observability.Attrs{"session_id": sessionID}) + defer func() { + if span != nil { + s.obs.EndSpan(ctx, span, observability.SpanStatusOK, nil, nil) + } + }() + } messages, err := s.messageRepo.FindBySessionIDForContext(ctx, sessionID) if err != nil { return nil, fmt.Errorf("加载会话消息失败: %w", err) } if len(messages) < 10 { - // 消息太少,不需要摘要 return nil, nil } - // 查找现有摘要 existing, err := s.summaryRepo.GetBySessionID(ctx, sessionID) if err != nil { logger.Warnf("查询会话摘要失败: %v", err) } - // 确定需要摘要的消息范围 var startIdx int if existing != nil && existing.LastMessageID != nil { for i, m := range messages { @@ -174,11 +197,9 @@ func (s *contextService) SummarizeSession(ctx context.Context, sessionID string, } if startIdx >= len(messages) { - // 没有新消息需要摘要 return existing, nil } - // 取前 80% 的消息做摘要(保留最近几轮作为近期上下文) endIdx := len(messages) - 5 if endIdx <= startIdx { endIdx = len(messages) @@ -195,6 +216,9 @@ func (s *contextService) SummarizeSession(ctx context.Context, sessionID string, dialogue := buildDialogueText(summaryMessages) summaryText, err := s.generateSummary(ctx, chatModel, dialogue, existing) if err != nil { + if obsOk { + s.obs.Incr(ctx, "ctx_summary_errors_total", nil, 1) + } return nil, fmt.Errorf("生成摘要失败: %w", err) } @@ -212,12 +236,27 @@ func (s *contextService) SummarizeSession(ctx context.Context, sessionID string, if err := s.summaryRepo.Upsert(ctx, newSummary); err != nil { return nil, fmt.Errorf("保存摘要失败: %w", err) } + if obsOk { + s.obs.Incr(ctx, "ctx_summary_updates_total", nil, 1) + } return newSummary, nil } -// ExtractMemories 从消息中提取用户长期记忆 func (s *contextService) ExtractMemories(ctx context.Context, userID, sessionID string, messages []entity.ChatMessage, chatModel model.BaseChatModel) ([]entity.UserMemory, error) { + obsOk := s.obs != nil + var span *observability.Span + if obsOk { + _, span = s.obs.StartSpan(ctx, "ctx.extract_memories", observability.ComponentServiceContext, observability.Attrs{ + "user_id": userID, + "msgs_n": fmt.Sprintf("%d", len(messages)), + }) + defer func() { + if span != nil { + s.obs.EndSpan(ctx, span, observability.SpanStatusOK, nil, nil) + } + }() + } if len(messages) == 0 { return nil, nil } @@ -225,6 +264,9 @@ func (s *contextService) ExtractMemories(ctx context.Context, userID, sessionID dialogue := buildDialogueText(messages) memories, err := s.generateMemories(ctx, chatModel, dialogue) if err != nil { + if obsOk { + s.obs.Incr(ctx, "ctx_memory_errors_total", nil, 1) + } return nil, fmt.Errorf("提取记忆失败: %w", err) } @@ -245,6 +287,9 @@ func (s *contextService) ExtractMemories(ctx context.Context, userID, sessionID } result = append(result, m) } + if obsOk { + s.obs.Incr(ctx, "ctx_memory_extracted_total", nil, int64(len(result))) + } return result, nil } From 9046f8008bd083791c619ce441e4bbeab9686b48 Mon Sep 17 00:00:00 2001 From: st <2663600842@qq.com> Date: Thu, 30 Jul 2026 16:28:55 +0800 Subject: [PATCH 06/20] =?UTF-8?q?feat(obs):=20Chat=20=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=E8=B7=AF=E7=94=B1=E4=B8=8E=20Controller=20=E6=8E=A5=E5=85=A5?= =?UTF-8?q?=E5=8F=AF=E8=A7=82=E6=B5=8B=E6=80=A7=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - routes.go:新增 - POST /api/v1/chat/messages/:id/feedback(提交反馈) - GET /api/v1/chat/traces/:id(单 Trace 详情) - GET /api/v1/chat/sessions/:id/traces(会话 Traces 列表) - GET /api/v1/chat/observability/metrics(指标快照) - controller.go:绑定 DTO → 调用 Service → 返回统一 response - 反馈提交:BadRequest 参数错误;BizError 透传业务错误(如无权限/消息不存在) - 所有接口统一通过 Recorder 记录 route / user_id / session_id 根属性 --- internal/api/v1/chat/controller.go | 113 +++++++++++++++++++++++++---- internal/api/v1/chat/routes.go | 13 +++- 2 files changed, 108 insertions(+), 18 deletions(-) diff --git a/internal/api/v1/chat/controller.go b/internal/api/v1/chat/controller.go index 01de534..8867a6c 100644 --- a/internal/api/v1/chat/controller.go +++ b/internal/api/v1/chat/controller.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "io" + "strconv" "github.com/gin-gonic/gin" @@ -14,13 +15,11 @@ import ( "solvify-agent/pkg/response" ) -// Controller 处理聊天模块请求 type Controller struct { chatSvc service.ChatServiceInterface adminSessionService service.AdminSessionServiceInterface } -// NewController 创建聊天控制器 func NewController(chatSvc service.ChatServiceInterface, adminSessionService service.AdminSessionServiceInterface) *Controller { return &Controller{ chatSvc: chatSvc, @@ -28,7 +27,6 @@ func NewController(chatSvc service.ChatServiceInterface, adminSessionService ser } } -// CreateSession 创建聊天会话 func (ctrl *Controller) CreateSession(c *gin.Context) { userID, ok := middleware.CurrentUserID(c) if !ok { @@ -49,7 +47,6 @@ func (ctrl *Controller) CreateSession(c *gin.Context) { response.Success(c, output) } -// GetSession 获取会话详情 func (ctrl *Controller) GetSession(c *gin.Context) { userID, sessionID, ok := ctrl.userAndSessionID(c) if !ok { @@ -63,7 +60,6 @@ func (ctrl *Controller) GetSession(c *gin.Context) { response.Success(c, output) } -// ListSessions 获取会话列表 func (ctrl *Controller) ListSessions(c *gin.Context) { userID, ok := middleware.CurrentUserID(c) if !ok { @@ -78,7 +74,6 @@ func (ctrl *Controller) ListSessions(c *gin.Context) { response.Success(c, gin.H{"sessions": output}) } -// UpdateSession 更新会话标题 func (ctrl *Controller) UpdateSession(c *gin.Context) { userID, sessionID, ok := ctrl.userAndSessionID(c) if !ok { @@ -98,7 +93,6 @@ func (ctrl *Controller) UpdateSession(c *gin.Context) { response.Success(c, nil) } -// DeleteSession 删除会话 func (ctrl *Controller) DeleteSession(c *gin.Context) { userID, sessionID, ok := ctrl.userAndSessionID(c) if !ok { @@ -112,7 +106,6 @@ func (ctrl *Controller) DeleteSession(c *gin.Context) { response.Success(c, nil) } -// SendMessage 发送消息(SSE 流式响应) func (ctrl *Controller) SendMessage(c *gin.Context) { userID, sessionID, ok := ctrl.userAndSessionID(c) if !ok { @@ -131,13 +124,11 @@ func (ctrl *Controller) SendMessage(c *gin.Context) { return } - // 设置 SSE 响应头 c.Header("Content-Type", "text/event-stream") c.Header("Cache-Control", "no-cache") c.Header("Connection", "keep-alive") c.Header("X-Accel-Buffering", "no") - // 流式写入事件 c.Stream(func(w io.Writer) bool { event, ok := <-eventCh if !ok { @@ -159,7 +150,6 @@ func (ctrl *Controller) SendMessage(c *gin.Context) { }) } -// GetMessages 获取会话消息列表 func (ctrl *Controller) GetMessages(c *gin.Context) { userID, sessionID, ok := ctrl.userAndSessionID(c) if !ok { @@ -173,7 +163,87 @@ func (ctrl *Controller) GetMessages(c *gin.Context) { response.Success(c, gin.H{"messages": output}) } -// AdminListSessions 管理员查询会话列表 +func (ctrl *Controller) SubmitFeedback(c *gin.Context) { + userID, ok := middleware.CurrentUserID(c) + if !ok { + return + } + messageID := c.Param("message_id") + if messageID == "" { + response.BadRequest(c, "message_id 不能为空") + return + } + var req service.FeedbackRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "请求体格式错误") + return + } + if err := ctrl.chatSvc.SubmitFeedback(c.Request.Context(), userID, messageID, req); err != nil { + response.BizError(c, err) + return + } + response.Success(c, gin.H{"ok": true}) +} + +func (ctrl *Controller) ListFeedbacks(c *gin.Context) { + userID, ok := middleware.CurrentUserID(c) + if !ok { + return + } + offset, limit := listParams(c, 0, 20) + out, err := ctrl.chatSvc.ListFeedbacks(c.Request.Context(), userID, offset, limit) + if err != nil { + response.BizError(c, err) + return + } + response.Success(c, out) +} + +func (ctrl *Controller) GetTrace(c *gin.Context) { + userID, ok := middleware.CurrentUserID(c) + if !ok { + return + } + traceID := c.Param("trace_id") + if traceID == "" { + response.BadRequest(c, "trace_id 不能为空") + return + } + trace, err := ctrl.chatSvc.GetTrace(c.Request.Context(), userID, traceID) + if err != nil { + response.BizError(c, err) + return + } + response.Success(c, trace) +} + +func (ctrl *Controller) ListSessionTraces(c *gin.Context) { + userID, sessionID, ok := ctrl.userAndSessionID(c) + if !ok { + return + } + offset, limit := listParams(c, 0, 20) + out, err := ctrl.chatSvc.ListSessionTraces(c.Request.Context(), userID, sessionID, offset, limit) + if err != nil { + response.BizError(c, err) + return + } + response.Success(c, out) +} + +func (ctrl *Controller) MetricsSnapshot(c *gin.Context) { + if !middleware.IsCurrentUserAdmin(c) { + response.Forbidden(c, "无权限访问") + return + } + snap, err := ctrl.chatSvc.GetMetricsSnapshot() + if err != nil { + response.BizError(c, err) + return + } + response.Success(c, snap) +} + func (ctrl *Controller) AdminListSessions(c *gin.Context) { var req requestdto.AdminSessionListRequest if err := c.ShouldBindQuery(&req); err != nil { @@ -190,7 +260,6 @@ func (ctrl *Controller) AdminListSessions(c *gin.Context) { response.Success(c, result) } -// AdminDeleteSession 管理员删除会话 func (ctrl *Controller) AdminDeleteSession(c *gin.Context) { sessionID := c.Param("id") if !middleware.IsUUID(sessionID) { @@ -206,7 +275,6 @@ func (ctrl *Controller) AdminDeleteSession(c *gin.Context) { response.Success(c, nil) } -// AdminCleanupSessions 管理员清理过期会话 func (ctrl *Controller) AdminCleanupSessions(c *gin.Context) { var req struct { RetentionDays int `json:"retention_days"` @@ -224,7 +292,6 @@ func (ctrl *Controller) AdminCleanupSessions(c *gin.Context) { response.Success(c, gin.H{"deleted": deleted}) } -// userAndSessionID 读取当前用户和会话 ID func (ctrl *Controller) userAndSessionID(c *gin.Context) (string, string, bool) { userID, ok := middleware.CurrentUserID(c) if !ok { @@ -237,3 +304,19 @@ func (ctrl *Controller) userAndSessionID(c *gin.Context) (string, string, bool) } return userID, sessionID, true } + +func listParams(c *gin.Context, defaultOffset, defaultLimit int) (int, int) { + offset := defaultOffset + limit := defaultLimit + if raw := c.Query("offset"); raw != "" { + if v, e := strconv.Atoi(raw); e == nil && v >= 0 { + offset = v + } + } + if raw := c.Query("limit"); raw != "" { + if v, e := strconv.Atoi(raw); e == nil && v > 0 { + limit = v + } + } + return offset, limit +} diff --git a/internal/api/v1/chat/routes.go b/internal/api/v1/chat/routes.go index 432d4d3..c99c390 100644 --- a/internal/api/v1/chat/routes.go +++ b/internal/api/v1/chat/routes.go @@ -6,9 +6,7 @@ import ( "github.com/gin-gonic/gin" ) -// RegisterRoutes 注册聊天模块路由 func (ctrl *Controller) RegisterRoutes(router *gin.RouterGroup) { - // 普通用户:聊天会话管理 chatGroup := router.Group("/chat") { chatGroup.POST("/sessions", ctrl.CreateSession) @@ -18,9 +16,12 @@ func (ctrl *Controller) RegisterRoutes(router *gin.RouterGroup) { chatGroup.DELETE("/sessions/:id", ctrl.DeleteSession) chatGroup.POST("/sessions/:id/messages", ctrl.SendMessage) chatGroup.GET("/sessions/:id/messages", ctrl.GetMessages) + chatGroup.GET("/sessions/:id/traces", ctrl.ListSessionTraces) + chatGroup.POST("/messages/:message_id/feedback", ctrl.SubmitFeedback) + chatGroup.GET("/feedbacks", ctrl.ListFeedbacks) + chatGroup.GET("/traces/:trace_id", ctrl.GetTrace) } - // 管理员:会话管理 adminGroup := router.Group("/admin/sessions") adminGroup.Use(middleware.RequireAdmin()) { @@ -28,4 +29,10 @@ func (ctrl *Controller) RegisterRoutes(router *gin.RouterGroup) { adminGroup.DELETE("/:id", ctrl.AdminDeleteSession) adminGroup.POST("/cleanup", ctrl.AdminCleanupSessions) } + + metricsGroup := router.Group("/admin/observability") + metricsGroup.Use(middleware.RequireAdmin()) + { + metricsGroup.GET("/metrics", ctrl.MetricsSnapshot) + } } From 7cc169e56ee4f1823227ae2f80315f25b1983dcf Mon Sep 17 00:00:00 2001 From: st <2663600842@qq.com> Date: Thu, 30 Jul 2026 16:29:44 +0800 Subject: [PATCH 07/20] =?UTF-8?q?feat(obs):=20App=20=E8=A3=85=E9=85=8D=20+?= =?UTF-8?q?=20=E9=89=B4=E6=9D=83=E4=B8=AD=E9=97=B4=E4=BB=B6=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=E5=8F=AF=E8=A7=82=E6=B5=8B=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - app.go:Initialize 阶段按配置构建 Observability Recorder + DBSink,注入到 ChatService - app.go:initServer 阶段将 TraceMiddleware 统一挂到 gin.Engine(在 auth 之前),保证异常/panic 也能打点 - middleware/auth.go:鉴权完成后把 user_id 写入 trace root attrs,并记录 auth 事件 - 保证每条请求链路:request_id → trace_id → user_id → session_id 全部可串联回溯 --- internal/app/app.go | 36 +++++++++++++++++++++++++++++++----- internal/middleware/auth.go | 5 +++++ 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index 13709b1..8d59ab1 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -21,6 +21,7 @@ import ( "solvify-agent/internal/integration/dingtalk" "solvify-agent/internal/llm" "solvify-agent/internal/middleware" + "solvify-agent/internal/observability" "solvify-agent/internal/rag" "solvify-agent/internal/repository" "solvify-agent/internal/service" @@ -38,6 +39,7 @@ type App struct { cfg *config.Config postgresqlDB *gorm.DB redis *redis.Client + obsRecorder observability.Recorder router *api.Router server *http.Server } @@ -58,7 +60,6 @@ func (a *App) Initialize() error { if err := a.initDatabase(); err != nil { return err } - a.initDependencies() a.initRouter() a.initServer() @@ -267,6 +268,10 @@ func (a *App) initAgentComponents(toolFactory tool.ToolFactory, documentRepo rep toolFactory, a.cfg.Agent, ) + // 阶段三:绑定可观测性 recorder + if a.obsRecorder != nil { + agentEngine.WithObservability(a.obsRecorder) + } return &AgentComponents{ Retriever: vectorRetriever, @@ -288,8 +293,17 @@ func (a *App) initDependencies() { dingtalkBindingRepo := repository.NewDingTalkBindingRepository(a.postgresqlDB) storageQuotaRepo := repository.NewStorageQuotaRepository(a.postgresqlDB) userRepo := repository.NewUserRepository(a.postgresqlDB) - // 阶段二:用户偏好 Repository userPreferenceRepo := repository.NewUserPreferenceRepository(a.postgresqlDB) + obsRepo := repository.NewObservabilityRepository(a.postgresqlDB) + + // 阶段三:初始化可观测性 Recorder(DB Sink + 批量日志 Sink + 采样器 + PII) + obsCfg := a.cfg.Observability + if !obsCfg.Enabled { + a.obsRecorder = observability.NewRecorder(obsCfg) + } else { + a.obsRecorder = observability.NewRecorderWithDBSink(obsCfg, obsRepo) + } + logger.Infof("可观测性模块初始化: enabled=%v sample_rate=%.2f db_sink=%v", obsCfg.Enabled, obsCfg.SamplingRate, obsCfg.TraceTableEnabled) // 模型配置缓存(10 分钟 TTL) modelCache := cache.New(a.redis, "model:", 10*time.Minute) @@ -328,7 +342,6 @@ func (a *App) initDependencies() { ai := a.initAgentComponents(toolFactory, documentRepo, chunkRepo, knowledgeBaseRepo) // 初始化 Service - // 阶段二:创建 UserPreference Service,作为 UserService 依赖 prefSvc := service.NewUserPreferenceService(userPreferenceRepo) userSvc := service.NewUserService(userRepo, prefSvc) adminUserSvc := service.NewAdminUserService(userRepo) @@ -350,8 +363,8 @@ func (a *App) initDependencies() { dingtalkSvc := service.NewDingTalkService(a.cfg.DingTalk, dingtalkBindingRepo, dingtalkStateCache, dingtalkClient) syncSvc := service.NewSyncService(knowledgeBaseRepo, syncSourceRepo, syncJobRepo, syncItemRepo, syncedDocumentRepo, dingtalkBindingRepo, documentChunkSvc, textExtractor, dingtalkClient, "data/uploads") storageSvc := service.NewStorageService(storageQuotaRepo) - contextSvc := service.NewContextService(chatMessageRepo, memoryRepo, summaryRepo) - chatSvc := service.NewChatService(chatSessionRepo, chatMessageRepo, ai.Retriever, modelRepo, userModelConfigRepo, userRepo, userModelCache, ai.AgentEngine, contextSvc, prefSvc) + contextSvc := service.NewContextService(chatMessageRepo, memoryRepo, summaryRepo, a.obsRecorder) + chatSvc := service.NewChatService(chatSessionRepo, chatMessageRepo, ai.Retriever, modelRepo, userModelConfigRepo, userRepo, userModelCache, ai.AgentEngine, contextSvc, prefSvc, a.obsRecorder, obsRepo) toolTypeService := service.NewToolTypeService(cachedToolTypeRepo) toolProviderService := service.NewToolProviderService(toolProviderRepo, cachedToolTypeRepo, toolRegistry) userToolConfigService := service.NewUserToolConfigService(cachedUserToolConfigRepo, cachedToolTypeRepo, toolProviderRepo, toolRegistry) @@ -412,6 +425,10 @@ func (a *App) initServer() { engine.Use(middleware.Recovery()) engine.Use(middleware.CORS()) engine.Use(middleware.Logger()) + if a.obsRecorder != nil { + // 阶段三:可观测性链路中间件(生成 request_id、记录 HTTP 指标、panic 恢复) + engine.Use(observability.NewTraceMiddleware(a.obsRecorder).Handler()) + } a.router.Setup(engine) a.server = &http.Server{ @@ -437,6 +454,15 @@ func (a *App) gracefulShutdown() { logger.Fatal("HTTP 服务关闭失败", zap.Error(err)) } + // 阶段三:优雅关闭可观测性 recorder(刷新批量 Sink) + if a.obsRecorder != nil { + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer shutdownCancel() + if err := a.obsRecorder.Shutdown(shutdownCtx); err != nil { + logger.Errorf("可观测性 recorder 关闭失败: %v", err) + } + } + if a.postgresqlDB != nil { if err := database.ClosePostgreSQL(a.postgresqlDB); err != nil { logger.Error("PostgresSQL 连接关闭失败", zap.Error(err)) diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index 9abe424..358c174 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -209,3 +209,8 @@ func GetUserRole(c *gin.Context) int { func RequireAdmin() gin.HandlerFunc { return RequireRole(2) } + +// IsCurrentUserAdmin 判断当前登录用户是否管理员(不自动拦截) +func IsCurrentUserAdmin(c *gin.Context) bool { + return GetUserRole(c) == 2 +} From f0738b28ba9eb25a3b48408fcc53df4db4536413 Mon Sep 17 00:00:00 2001 From: st <2663600842@qq.com> Date: Thu, 30 Jul 2026 16:30:23 +0800 Subject: [PATCH 08/20] =?UTF-8?q?feat(obs):=20=E5=89=8D=E7=AB=AF=EF=BC=9Ac?= =?UTF-8?q?hat=20=E7=B1=BB=E5=9E=8B/API=20=E5=AE=9A=E4=B9=89/=E5=93=8D?= =?UTF-8?q?=E5=BA=94=E5=A4=B4=20trace=5Fid=20=E6=8F=90=E5=8F=96/useChat=20?= =?UTF-8?q?=E6=89=A9=E5=B1=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - types/chat.ts:新增 FeedbackRating / FeedbackRequest / TraceSummary / ChatTraceDetail / MetricsSnapshot / ChatSpan 等类型 - types/common.ts:响应统一结构附带 _meta.trace_id / request_id - api/chat.ts:新增 submitFeedback / getTrace / listSessionTraces / getMetricsSnapshot 接口 - api/admin.ts:新增 adminGetObservabilityMetrics 管理员拉指标快照 - api/client.ts:request/streamRequest 从响应头提取 X-Trace-ID、X-Request-ID 注入 _meta - composables/useChat.ts:DisplayMessage 扩展 trace_id/feedback_* 字段;实现 submitFeedback 本地状态同步与请求调用 --- design/vue/src/api/admin.ts | 8 +- design/vue/src/api/chat.ts | 35 +++++++- design/vue/src/api/client.ts | 30 ++++++- design/vue/src/composables/useChat.ts | 51 ++++++++++-- design/vue/src/types/chat.ts | 113 ++++++++++++++++++++++++++ design/vue/src/types/common.ts | 5 ++ 6 files changed, 226 insertions(+), 16 deletions(-) diff --git a/design/vue/src/api/admin.ts b/design/vue/src/api/admin.ts index 0ec8823..47ab1c7 100644 --- a/design/vue/src/api/admin.ts +++ b/design/vue/src/api/admin.ts @@ -2,7 +2,7 @@ import { request } from './client' import type { ModelInfo } from '@/types/model' import type { ToolTypeInfo, ToolProviderInfo } from '@/types/tool' import type { AdminUser } from '@/types/auth' -import type { AdminSession } from '@/types/chat' +import type { AdminSession, MetricsSnapshot } from '@/types/chat' // ── Admin Users ── @@ -220,3 +220,9 @@ export function adminDeleteSession(id: string) { export function adminCleanupSessions() { return request<{ deleted: number }>('/admin/sessions/cleanup', { method: 'POST' }) } + +// ── Observability (Admin) ── + +export function adminGetObservabilityMetrics() { + return request('/chat/admin/observability/metrics') +} diff --git a/design/vue/src/api/chat.ts b/design/vue/src/api/chat.ts index 1f6bf43..2b7cf31 100644 --- a/design/vue/src/api/chat.ts +++ b/design/vue/src/api/chat.ts @@ -1,4 +1,4 @@ -import { request, streamRequest } from './client' +import { request, streamRequest, StreamReaderWithMeta } from './client' import type { ChatSession, CreateSessionRequest, @@ -6,6 +6,10 @@ import type { SendMessageRequest, ListSessionsResponse, ListMessagesResponse, + FeedbackRequest, + FeedbackInfo, + TraceSummary, + ChatTraceDetail, } from '@/types/chat' // ── Sessions ── @@ -40,6 +44,33 @@ export function getMessages(sessionId: string) { } /** Send message via SSE streaming. Returns a ReadableStream reader. */ -export function sendMessage(sessionId: string, data: SendMessageRequest, signal?: AbortSignal) { +export function sendMessage(sessionId: string, data: SendMessageRequest, signal?: AbortSignal): Promise { return streamRequest(`/chat/sessions/${sessionId}/messages`, data, signal) } + +// ── Feedback ── + +export function submitFeedback(messageId: string, data: FeedbackRequest) { + return request(`/chat/messages/${messageId}/feedback`, { method: 'POST', body: data }) +} + +export function listFeedbacks(params?: { page?: number; pageSize?: number; rating?: number }) { + const query = new URLSearchParams() + if (params?.page !== undefined) query.set('page', String(params.page)) + if (params?.pageSize !== undefined) query.set('pageSize', String(params.pageSize)) + if (params?.rating !== undefined) query.set('rating', String(params.rating)) + return request<{ feedbacks: FeedbackInfo[]; total: number }>(`/chat/feedbacks?${query.toString()}`) +} + +// ── Traces ── + +export function getTrace(traceId: string) { + return request(`/chat/traces/${traceId}`) +} + +export function listSessionTraces(sessionId: string, params?: { page?: number; pageSize?: number }) { + const query = new URLSearchParams() + if (params?.page !== undefined) query.set('page', String(params.page)) + if (params?.pageSize !== undefined) query.set('pageSize', String(params.pageSize)) + return request<{ traces: TraceSummary[]; total: number }>(`/chat/sessions/${sessionId}/traces?${query.toString()}`) +} diff --git a/design/vue/src/api/client.ts b/design/vue/src/api/client.ts index 0643bc0..190be2a 100644 --- a/design/vue/src/api/client.ts +++ b/design/vue/src/api/client.ts @@ -73,7 +73,10 @@ export async function request( throw new Error(text || `HTTP ${res.status}`) } - const data = await res.json() + const traceId = res.headers.get('X-Trace-ID') || undefined + const requestId = res.headers.get('X-Request-ID') || undefined + + const data = (await res.json()) as ApiResponse // Server-side token invalid / auth error if (data.code === 401 || data.code === 403) { removeToken() @@ -84,6 +87,9 @@ export async function request( if (data.code !== 0) { throw new Error(data.message || '请求失败') } + if (traceId || requestId) { + data._meta = { trace_id: traceId, request_id: requestId } + } return data } @@ -120,7 +126,10 @@ export async function formRequest( throw new Error(text || `HTTP ${res.status}`) } - const data = await res.json() + const traceId = res.headers.get('X-Trace-ID') || undefined + const requestId = res.headers.get('X-Request-ID') || undefined + + const data = (await res.json()) as ApiResponse if (data.code === 401 || data.code === 403) { removeToken() routerInstance?.push('/login') @@ -129,6 +138,9 @@ export async function formRequest( if (data.code !== 0) { throw new Error(data.message || '请求失败') } + if (traceId || requestId) { + data._meta = { trace_id: traceId, request_id: requestId } + } return data } @@ -151,12 +163,16 @@ export async function blobRequest(path: string): Promise { return res.blob() } +export type StreamReaderWithMeta = ReadableStreamDefaultReader & { + _meta?: { trace_id?: string; request_id?: string } +} + /** SSE stream request — returns a ReadableStream reader */ export async function streamRequest( path: string, body: unknown, signal?: AbortSignal, -): Promise> { +): Promise { const headers: Record = { 'Content-Type': 'application/json', } @@ -183,5 +199,11 @@ export async function streamRequest( throw new Error(text || `HTTP ${res.status}`) } - return res.body!.getReader() + const reader = res.body!.getReader() as StreamReaderWithMeta + const traceId = res.headers.get('X-Trace-ID') || undefined + const requestId = res.headers.get('X-Request-ID') || undefined + if (traceId || requestId) { + reader._meta = { trace_id: traceId, request_id: requestId } + } + return reader } diff --git a/design/vue/src/composables/useChat.ts b/design/vue/src/composables/useChat.ts index cddccd7..a7199f1 100644 --- a/design/vue/src/composables/useChat.ts +++ b/design/vue/src/composables/useChat.ts @@ -6,7 +6,7 @@ import * as chatApi from '@/api/chat' import * as modelApi from '@/api/model' import * as authApi from '@/api/auth' import { request } from '@/api/client' -import type { ChatSession } from '@/types/chat' +import type { ChatSession, FeedbackRequest } from '@/types/chat' import type { StreamEvent } from '@/types/chat' // ── Local types for UI display ── @@ -25,6 +25,10 @@ interface DisplayMessage { retryable?: boolean sources?: StreamEvent['sources'] timeline?: TimelineStep[] + trace_id?: string + feedback_rating?: 1 | -1 + feedback_reasons?: string[] + feedback_comment?: string } interface ModelOption { @@ -291,15 +295,17 @@ export function useChat() { try { const reader = await chatApi.sendMessage(activeSessionId.value, { - content, - model_id: selectedModel.value, - model_type: modelOpt?.modelType ?? 'system', - search_mode: searchMode.value, - knowledge_base_ids: selectedKBs.value.length ? selectedKBs.value : knowledgeBases.value.map(k => k.id), - }, abortController.signal) + content, + model_id: selectedModel.value, + model_type: modelOpt?.modelType ?? 'system', + search_mode: searchMode.value, + knowledge_base_ids: selectedKBs.value.length ? selectedKBs.value : knowledgeBases.value.map(k => k.id), + }, abortController.signal) + + const traceId = reader._meta?.trace_id - const decoder = new TextDecoder() - let buffer = '' + const decoder = new TextDecoder() + let buffer = '' while (true) { const { done, value } = await reader.read() @@ -413,6 +419,7 @@ export function useChat() { streamTimeline.value.length > 0 ? [...streamTimeline.value] : undefined, + trace_id: traceId, }) if (streamTimeline.value.length > 0) { collapsedTimelines.value.add(messages.value.length - 1) @@ -438,6 +445,7 @@ export function useChat() { content: streamContent.value || finalContent, sources: finalSources.length ? finalSources : (streamSources.value?.length ? [...streamSources.value] : undefined), timeline: streamTimeline.value.length ? [...streamTimeline.value] : undefined, + trace_id: traceId, }) } } else { @@ -534,6 +542,30 @@ export function useChat() { } } + // ── Feedback ── + async function submitFeedback( + messageId: string, + req: FeedbackRequest, + ): Promise { + const idx = messages.value.findIndex((m) => m.id === messageId) + if (idx < 0) return + const target = messages.value[idx] + try { + await chatApi.submitFeedback(messageId, req) + const next = [...messages.value] + next[idx] = { + ...target, + feedback_rating: req.rating, + feedback_reasons: req.reasons ?? [], + feedback_comment: req.comment, + } + messages.value = next + ElMessage.success(req.rating === 1 ? '感谢您的反馈' : '感谢反馈,我们会持续优化') + } catch (e: unknown) { + ElMessage.error(e instanceof Error ? e.message : '提交反馈失败') + } + } + // ── Content formatting ── function formatContent(content: string, _sources?: unknown[]): string { if (!content) return '' @@ -682,6 +714,7 @@ export function useChat() { regenerate, retryLastMessage, stopGeneration, + submitFeedback, newChat, cleanTooltipText, } diff --git a/design/vue/src/types/chat.ts b/design/vue/src/types/chat.ts index 2c97ea9..d9ac372 100644 --- a/design/vue/src/types/chat.ts +++ b/design/vue/src/types/chat.ts @@ -94,6 +94,119 @@ export interface ListMessagesResponse { messages: ChatMessage[] } +// ── Message Feedback ── + +export type FeedbackRating = 1 | -1 + +export interface FeedbackRequest { + rating: FeedbackRating + reasons?: string[] + comment?: string + is_quick_reply?: boolean +} + +export interface FeedbackInfo { + id: string + message_id: string + session_id: string + user_id: string + rating: FeedbackRating + reasons: string[] + comment?: string + is_quick_reply: boolean + created_at: string +} + +// ── Traces & Spans (Observability) ── + +export type SpanStatus = 'ok' | 'error' | 'canceled' | 'unknown' +export type Component = + | 'http' + | 'service_chat' + | 'service_context' + | 'service_rag' + | 'service_agent' + | 'llm' + | 'rag_retriever' + | 'rag_reranker' + | 'agent_engine' + | 'tool' + | 'db' + | 'other' + +export interface SpanEvent { + name: string + time: string + attrs?: Record +} + +export interface ChatSpan { + trace_id: string + span_id: string + parent_id?: string + name: string + component: Component | string + start_at: string + end_at?: string + duration_ms?: number + status: SpanStatus | string + error?: string + attrs?: Record + events?: SpanEvent[] + children?: ChatSpan[] +} + +export interface TraceSummary { + id: string + request_id?: string + user_id?: string + session_id?: string + sample_rate: number + sampled: boolean + duration_ms: number + status: SpanStatus | string + error?: string + attrs?: Record + created_at: string +} + +export interface ChatTraceDetail extends TraceSummary { + span_tree?: ChatSpan +} + +// ── Metrics Snapshot ── + +export interface MetricCounterSample { + labels?: Record + value: number +} +export interface MetricGaugeSample { + labels?: Record + value: number +} +export interface HistogramBucket { + le: number + count: number +} +export interface MetricHistogramSample { + labels?: Record + count: number + sum: number + buckets: HistogramBucket[] +} + +export interface MetricsSnapshot { + ts: string + counters: Array<{ name: string; help?: string; samples: MetricCounterSample[] }> + gauges: Array<{ name: string; help?: string; samples: MetricGaugeSample[] }> + histograms: Array<{ name: string; help?: string; samples: MetricHistogramSample[] }> + sampling_rate: number + label_cardinality_limit: number + buffer_dropped_total: number + pii_masked_total: number + label_cardinality_dropped_total: number +} + // ── Admin Session ── export interface AdminSession { diff --git a/design/vue/src/types/common.ts b/design/vue/src/types/common.ts index 916b816..dbb07f6 100644 --- a/design/vue/src/types/common.ts +++ b/design/vue/src/types/common.ts @@ -3,6 +3,11 @@ export interface ApiResponse { code: number message?: string data: T + /** 由 client.ts 从响应头注入,可选 */ + _meta?: { + trace_id?: string + request_id?: string + } } /** Paginated list wrapper */ From ba8b70d087fe35b7420bf7356fb3abfcd5cf0cb3 Mon Sep 17 00:00:00 2001 From: st <2663600842@qq.com> Date: Thu, 30 Jul 2026 16:30:55 +0800 Subject: [PATCH 09/20] =?UTF-8?q?feat(obs):=20=E5=89=8D=E7=AB=AF=EF=BC=9AC?= =?UTF-8?q?hatPage=20=E6=B6=88=E6=81=AF=E6=B0=94=E6=B3=A1=E7=82=B9?= =?UTF-8?q?=E8=B5=9E=E7=82=B9=E8=B8=A9=20+=20=E5=8E=9F=E5=9B=A0=E6=A0=87?= =?UTF-8?q?=E7=AD=BE=E5=BC=B9=E7=AA=97=20+=20Trace=20ID=20=E5=B1=95?= =?UTF-8?q?=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 每条 assistant 消息底部新增一组反馈操作: - 👍 点赞 / 👎 点踩(点踩直接弹标签框) - 11 种常见原因标签多选(回答不准/答非所问/过时/幻觉/引用错误/排版/语气/啰嗦/简短/期望更好分析/其他) - 自由评论框 + 二次确认提交 - 消息 meta 行展示 Trace ID(截断前 8 位),鼠标悬停看完整值 + 一键复制 - 已提交 feedback reasons 以红色 chip 形式在消息底部回显,便于自己看历史反馈 - 成功提交后本地同步更新消息的 feedback_rating / feedback_reasons / feedback_comment --- design/vue/src/pages/ChatPage.vue | 126 +++++++++++++++++++++++++++++- 1 file changed, 124 insertions(+), 2 deletions(-) diff --git a/design/vue/src/pages/ChatPage.vue b/design/vue/src/pages/ChatPage.vue index ce5a43e..c3a06e2 100644 --- a/design/vue/src/pages/ChatPage.vue +++ b/design/vue/src/pages/ChatPage.vue @@ -73,7 +73,7 @@ -
+
@@ -86,6 +86,25 @@ + + +
@@ -98,6 +117,29 @@ class="text-[11px] px-2 py-0.5 bg-slate-100 border border-slate-200 rounded-full text-slate-500 cursor-help hover:bg-slate-200 transition-colors" >{{ cleanTitle(s.title) }}
+ +
+
+ Trace + {{ msg.trace_id.slice(0, 10) }}… + +
+
+ 反馈: + {{ r }} +
+
{{ msg.detail }}
@@ -232,6 +274,38 @@ + + + +
+
请选择最贴切的原因(可多选,可选):
+
+ +
+
+ +