logging.go 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165
  1. // Package middleware 提供 MCP 工具调用的横切关注点(日志等)。
  2. package middleware
  3. import (
  4. "context"
  5. "encoding/json"
  6. "fmt"
  7. "runtime/debug"
  8. "strings"
  9. "time"
  10. "github.com/mark3labs/mcp-go/mcp"
  11. "github.com/mark3labs/mcp-go/server"
  12. "go.uber.org/zap"
  13. "fwh-toolbox-server/cmd/mcp/tools/helper"
  14. )
  15. // maxParamsLogBytes 参数写入日志时的最大字节数,超过则截断,避免日志过大。
  16. const maxParamsLogBytes = 2 * 1024
  17. // maxErrLogBytes 错误结果内容写入日志时的最大字节数。
  18. const maxErrLogBytes = 1 * 1024
  19. // requestSeq 为每次调用生成一个进程内自增的请求序号,用于关联同一次调用的多条日志。
  20. var requestSeq int64
  21. // LoggingMiddleware 返回一个 MCP 工具处理中间件,统一记录工具调用日志:
  22. //
  23. // 第 1 类(调用入口/出口):
  24. // - 入口:tool、request_id、脱敏后的 params(截断)
  25. // - 出口:duration_ms、status、result_size / spilled
  26. //
  27. // 第 2 类(错误与异常):
  28. // - handler 返回 Go error -> level=error
  29. // - 业务错误结果(result.IsError)-> level=warn,附错误内容摘要
  30. // - panic -> level=error,附堆栈,并重新抛出(保持 WithRecovery 行为)
  31. func LoggingMiddleware() server.ToolHandlerMiddleware {
  32. return func(next server.ToolHandlerFunc) server.ToolHandlerFunc {
  33. return func(ctx context.Context, req mcp.CallToolRequest) (result *mcp.CallToolResult, err error) {
  34. tool := req.Params.Name
  35. requestID := nextRequestID(tool)
  36. start := time.Now()
  37. // 入口日志
  38. zap.L().Info("[tool] call start",
  39. zap.String("request_id", requestID),
  40. zap.String("tool", tool),
  41. zap.String("params", maskedParams(req)),
  42. )
  43. // panic 兜底:记录后重新抛出,交由上层 WithRecovery 处理
  44. defer func() {
  45. if r := recover(); r != nil {
  46. zap.L().Error("[tool] call panic",
  47. zap.String("request_id", requestID),
  48. zap.String("tool", tool),
  49. zap.Int64("duration_ms", time.Since(start).Milliseconds()),
  50. zap.Any("panic", r),
  51. zap.String("stack", string(debug.Stack())),
  52. )
  53. panic(r)
  54. }
  55. }()
  56. result, err = next(ctx, req)
  57. durationMS := time.Since(start).Milliseconds()
  58. switch {
  59. case err != nil:
  60. // handler 返回 Go error(框架级/传输级错误)
  61. zap.L().Error("[tool] call error",
  62. zap.String("request_id", requestID),
  63. zap.String("tool", tool),
  64. zap.Int64("duration_ms", durationMS),
  65. zap.String("status", "error"),
  66. zap.Error(err),
  67. )
  68. case result != nil && result.IsError:
  69. // 业务错误结果(handler 返回 mcp.NewToolResultError)
  70. zap.L().Warn("[tool] call business_error",
  71. zap.String("request_id", requestID),
  72. zap.String("tool", tool),
  73. zap.Int64("duration_ms", durationMS),
  74. zap.String("status", "business_error"),
  75. zap.String("err", truncate(resultText(result), maxErrLogBytes)),
  76. )
  77. default:
  78. // 成功
  79. size, spilled := resultMeta(result)
  80. zap.L().Info("[tool] call success",
  81. zap.String("request_id", requestID),
  82. zap.String("tool", tool),
  83. zap.Int64("duration_ms", durationMS),
  84. zap.String("status", "success"),
  85. zap.Int("result_size", size),
  86. zap.Bool("spilled", spilled),
  87. )
  88. }
  89. return result, err
  90. }
  91. }
  92. }
  93. // nextRequestID 生成一个进程内唯一的请求 ID,格式:<tool>-<纳秒时间戳>-<序号>。
  94. func nextRequestID(tool string) string {
  95. requestSeq++
  96. return fmt.Sprintf("%s-%d-%d", tool, time.Now().UnixNano(), requestSeq)
  97. }
  98. // maskedParams 将入参序列化并做 PII 脱敏,超长则截断。脱敏失败时不泄露原始值。
  99. func maskedParams(req mcp.CallToolRequest) string {
  100. args := req.GetArguments()
  101. if len(args) == 0 {
  102. return "{}"
  103. }
  104. b, err := json.Marshal(args)
  105. if err != nil {
  106. return "<marshal error>"
  107. }
  108. masked, err := helper.MaskJSONBytes(b)
  109. if err != nil {
  110. // fail-closed:脱敏失败则不输出原始参数
  111. return "<masked: sanitize failed>"
  112. }
  113. return truncate(string(masked), maxParamsLogBytes)
  114. }
  115. // resultText 拼接结果中的文本内容。
  116. func resultText(result *mcp.CallToolResult) string {
  117. if result == nil {
  118. return ""
  119. }
  120. var sb strings.Builder
  121. for _, c := range result.Content {
  122. if tc, ok := c.(mcp.TextContent); ok {
  123. sb.WriteString(tc.Text)
  124. }
  125. }
  126. return sb.String()
  127. }
  128. // resultMeta 返回结果文本字节数,以及是否为落盘结果(spilled)。
  129. func resultMeta(result *mcp.CallToolResult) (size int, spilled bool) {
  130. text := resultText(result)
  131. size = len(text)
  132. // 落盘结果的返回体是包含 "spilled": true 的 JSON 摘要
  133. if strings.Contains(text, `"spilled": true`) || strings.Contains(text, `"spilled":true`) {
  134. spilled = true
  135. }
  136. return size, spilled
  137. }
  138. // truncate 按字节截断日志字段,避免单条日志过大。
  139. func truncate(s string, max int) string {
  140. if len(s) <= max {
  141. return s
  142. }
  143. end := max
  144. for end > 0 && s[end]&0xC0 == 0x80 { // 回退到 UTF-8 rune 边界
  145. end--
  146. }
  147. return s[:end] + "...(truncated)"
  148. }