之前所有 tool 调用的 error 和 end 事件只写进 per-tool 文件
(data/logs/tools/<ts>_<tool>_<id>.log), 主日志文件 spark-mcp.log 完全
看不到。tail -f spark-mcp.log 排查问题时形同虚设, 必须去找具体的
per-tool 文件才能看到错误。
修复: toolCallLogger struct 加 base *slog.Logger 字段, StartToolCall
把传入的 base 写进 struct. WithError 在记录错误的同时调 base.Error
("tool.call.error", tool, name, err), End 在写完 per-tool 文件后
调 base.Info("tool.call.end", tool, name, duration_ms, error).
base 走的是 main logger (multiHandler 包装), 自动同时写终端 (遵循
LOG_FORMAT, 默认 text) 和主日志文件 (永远 JSON). LOG_LEVEL 过滤对
这两条事件仍生效, 行为统一.
新增 3 个单元测试 (internal/logging/tool_call_test.go):
- TestStartToolCall_EmitsErrorAndEnd: 验证错误路径两条事件都进
主 logger (用 bytes.Buffer 捕获 slog 输出, 解码 JSON 校验字段)
- TestStartToolCall_NilBaseSafe: 验证 base=nil (测试场景) 不 panic
且 per-tool 文件仍写入
- TestStartToolCall_SuccessfulCall_EmitsEndOnly: 验证成功路径
只发 end 不发 error, duration_ms >= 0
附带: gofmt 重排 internal/logging/tool_call.go import 块 (按字母序)
未提交: spark-mcp-linux-amd64 (本地 build 产物, .gitignore 未拦,
需另决定); .env (gitignored 内容)
Co-Authored-By: Claude <noreply@anthropic.com>
208 lines
4.5 KiB
Go
208 lines
4.5 KiB
Go
package logging
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
const toolCallResultMaxBytes = 10 * 1024
|
|
|
|
var toolNameSanitizer = regexp.MustCompile(`[^a-zA-Z0-9_]+`)
|
|
|
|
func sanitizeToolName(name string) string {
|
|
s := toolNameSanitizer.ReplaceAllString(name, "_")
|
|
if len(s) > 32 {
|
|
s = s[:32]
|
|
}
|
|
s = strings.Trim(s, "_")
|
|
if s == "" {
|
|
s = "tool"
|
|
}
|
|
return s
|
|
}
|
|
|
|
func shortHexID(n int) (string, error) {
|
|
b := make([]byte, n/2+n%2)
|
|
if _, err := rand.Read(b); err != nil {
|
|
return "", err
|
|
}
|
|
return hex.EncodeToString(b)[:n], nil
|
|
}
|
|
|
|
type toolCallLogger struct {
|
|
file *os.File
|
|
base *slog.Logger
|
|
toolName string
|
|
startedAt time.Time
|
|
resultRaw json.RawMessage
|
|
err error
|
|
endOnce sync.Once
|
|
}
|
|
|
|
func (t *toolCallLogger) WithResult(result any) {
|
|
t.resultRaw = marshalResult(result)
|
|
}
|
|
|
|
func (t *toolCallLogger) WithError(err error) {
|
|
t.err = err
|
|
if t.base != nil && err != nil {
|
|
t.base.Error("tool.call.error",
|
|
"tool", t.toolName,
|
|
"err", err.Error(),
|
|
)
|
|
}
|
|
}
|
|
|
|
func (t *toolCallLogger) End() {
|
|
t.endOnce.Do(func() {
|
|
defer func() { _ = t.file.Close() }()
|
|
|
|
durationMs := time.Since(t.startedAt).Milliseconds()
|
|
errStr := ""
|
|
if t.err != nil {
|
|
errStr = t.err.Error()
|
|
}
|
|
|
|
end := map[string]any{
|
|
"event": "end",
|
|
"duration_ms": durationMs,
|
|
"result": nil,
|
|
"error": errStr,
|
|
}
|
|
if t.resultRaw != nil {
|
|
end["result"] = json.RawMessage(t.resultRaw)
|
|
}
|
|
|
|
b, err := json.Marshal(end)
|
|
if err != nil {
|
|
b = []byte(fmt.Sprintf(`{"event":"end","duration_ms":%d,"result":null,"error":%q}`, durationMs, errStr))
|
|
}
|
|
_, _ = t.file.Write(b)
|
|
_, _ = t.file.WriteString("\n")
|
|
|
|
if t.base != nil {
|
|
t.base.Info("tool.call.end",
|
|
"tool", t.toolName,
|
|
"duration_ms", durationMs,
|
|
"error", errStr,
|
|
)
|
|
}
|
|
})
|
|
}
|
|
|
|
// StartToolCall creates a per-tool-call log file and returns a logger that
|
|
// records the call lifecycle.
|
|
func StartToolCall(ctx context.Context, base *slog.Logger, toolName string, params any) (ToolCallLogger, error) {
|
|
logDirMu.RLock()
|
|
dir := logDir
|
|
logDirMu.RUnlock()
|
|
if dir == "" {
|
|
return nil, fmt.Errorf("logging: log directory not initialized; call New first")
|
|
}
|
|
|
|
toolsDir := filepath.Join(dir, "tools")
|
|
if err := os.MkdirAll(toolsDir, 0o700); err != nil {
|
|
return nil, fmt.Errorf("logging: create tools log dir: %w", err)
|
|
}
|
|
|
|
safeName := sanitizeToolName(toolName)
|
|
ts := time.Now().Format("20060102_150405.000")
|
|
id, err := shortHexID(6)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("logging: generate tool call id: %w", err)
|
|
}
|
|
filename := fmt.Sprintf("%s_%s_%s.log", ts, safeName, id)
|
|
path := filepath.Join(toolsDir, filename)
|
|
|
|
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("logging: create tool call log file: %w", err)
|
|
}
|
|
|
|
startedAt := time.Now()
|
|
start := map[string]any{
|
|
"event": "start",
|
|
"tool": toolName,
|
|
"params": marshalValue(params),
|
|
"started_at": startedAt.Format(time.RFC3339Nano),
|
|
}
|
|
if d, ok := ctx.Deadline(); ok {
|
|
start["deadline"] = d.Format(time.RFC3339Nano)
|
|
}
|
|
|
|
b, err := json.Marshal(start)
|
|
if err != nil {
|
|
_ = f.Close()
|
|
return nil, fmt.Errorf("logging: marshal start record: %w", err)
|
|
}
|
|
if _, err := f.Write(b); err != nil {
|
|
_ = f.Close()
|
|
return nil, fmt.Errorf("logging: write start record: %w", err)
|
|
}
|
|
if _, err := f.WriteString("\n"); err != nil {
|
|
_ = f.Close()
|
|
return nil, fmt.Errorf("logging: write start record: %w", err)
|
|
}
|
|
|
|
if base != nil {
|
|
base.Info("tool.call.start", "tool", toolName, "file", filename)
|
|
}
|
|
|
|
return &toolCallLogger{
|
|
file: f,
|
|
base: base,
|
|
toolName: toolName,
|
|
startedAt: startedAt,
|
|
}, nil
|
|
}
|
|
|
|
func marshalValue(v any) json.RawMessage {
|
|
if v == nil {
|
|
return []byte("null")
|
|
}
|
|
b, err := json.MarshalIndent(v, "", " ")
|
|
if err != nil {
|
|
b, err = json.Marshal(v)
|
|
if err != nil {
|
|
return mustMarshalString("<unserializable>")
|
|
}
|
|
}
|
|
return b
|
|
}
|
|
|
|
func marshalResult(v any) json.RawMessage {
|
|
if v == nil {
|
|
return []byte("null")
|
|
}
|
|
b, err := json.MarshalIndent(v, "", " ")
|
|
if err != nil {
|
|
b, err = json.Marshal(v)
|
|
if err != nil {
|
|
return mustMarshalString("<unserializable>")
|
|
}
|
|
}
|
|
if len(b) > toolCallResultMaxBytes {
|
|
trunc := string(b[:toolCallResultMaxBytes]) + "...[truncated]"
|
|
return mustMarshalString(trunc)
|
|
}
|
|
return b
|
|
}
|
|
|
|
func mustMarshalString(s string) json.RawMessage {
|
|
b, err := json.Marshal(s)
|
|
if err != nil {
|
|
return []byte(`"` + strings.ReplaceAll(s, `"`, `\"`) + `"`)
|
|
}
|
|
return b
|
|
}
|