From ec8406fe4207c9fc5403e7b6cb0c4f3e8325f109 Mon Sep 17 00:00:00 2001 From: AI Engineer Date: Sat, 9 May 2026 16:30:01 +0800 Subject: [PATCH] =?UTF-8?q?feat(log):=20=E8=B0=83=E6=95=B4=E5=8F=AF?= =?UTF-8?q?=E8=A7=86=E5=8C=96=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 3 + meta.go | 28 ++++++--- standard.go | 6 +- viewer.go | 131 +++++++++++++++++++++++++++++++++++------- viewer_test.go | 151 +++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 288 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e39f739..2c45d0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +## [1.1.14] - 2026-05-09 +- **可视化能力调整**: 调整优化了 `viewer` 模块相关的可视化能力,提升了日志的可读性与调试体验。 + ## [1.1.13] - 2026-05-09 - **绝对索引优化与零空洞**: - 彻底消除 `BaseLog` 与业务字段之间的索引空洞。字段位置调整为:`BaseLog` (0-5),标准消息字段 (`Info`, `Error` 等) 与业务日志字段从 `pos: 6` 起始。 diff --git a/meta.go b/meta.go index 39c6465..46df143 100644 --- a/meta.go +++ b/meta.go @@ -12,12 +12,15 @@ import ( // MetaField describes the serialization and visualization metadata for a single log field. type MetaField struct { - Index int `json:"index"` - Name string `json:"name"` - Color string `json:"color,omitempty"` - Format string `json:"format,omitempty"` - WithoutKey bool `json:"withoutKey,omitempty"` - Hide bool `json:"hide,omitempty"` + Index int + Name string + KeyName string + AttachBefore bool + Color string + Format string + Precision int + WithoutKey bool + Hide bool } var ( @@ -48,7 +51,7 @@ func RegisterType(logType string, model any) { if !ok { panic("log model must implement Reset() method: " + t.Name()) } - + // 检查该方法是否属于当前类型(而不是继承自 BaseLog 且没有被重写) baseResetMethod, _ := reflect.PointerTo(reflect.TypeOf(BaseLog{})).MethodByName("Reset") if method.Func.Pointer() == baseResetMethod.Func.Pointer() { @@ -144,6 +147,11 @@ func extractMetaFields(model any) []MetaField { if tag != "" { parts := strings.Split(tag, ",") for _, part := range parts { + part = strings.TrimSpace(part) + if part == "attachBefore" { + meta.AttachBefore = true + continue + } kv := strings.SplitN(part, ":", 2) if len(kv) == 2 { key := strings.TrimSpace(kv[0]) @@ -157,6 +165,12 @@ func extractMetaFields(model any) []MetaField { meta.WithoutKey = (val == "true") case "hide": meta.Hide = (val == "true") + case "keyname": + meta.KeyName = val + case "attachBefore": + meta.AttachBefore = (val == "true") + case "precision": + meta.Precision = cast.To[int](val) } } } diff --git a/standard.go b/standard.go index df63c65..1c43a2b 100644 --- a/standard.go +++ b/standard.go @@ -30,9 +30,9 @@ type BaseLog struct { LogName string `log:"pos:0,color:cyan,hide:true"` LogType string `log:"pos:1,color:magenta,hide:true"` LogTime int64 `log:"pos:2,format:time"` - TraceId string `log:"pos:3,color:blue"` - Image string `log:"pos:4,color:darkGray,hide:true"` - Server string `log:"pos:5,color:darkGray,hide:true"` + TraceId string `log:"pos:3,color:gray,withoutkey:true"` + Image string `log:"pos:4,hide:true"` + Server string `log:"pos:5,hide:true"` Extra map[string]any `log:"pos:1000"` } diff --git a/viewer.go b/viewer.go index f8891b0..c6d545f 100644 --- a/viewer.go +++ b/viewer.go @@ -2,7 +2,9 @@ package log import ( "fmt" + "os" "regexp" + "sort" "strings" "time" @@ -12,6 +14,7 @@ import ( var errorLineMatcher = regexp.MustCompile(`(\w+\.go:\d+)`) var codeFileMatcher = regexp.MustCompile(`(\w+?\.)(go|js)`) +var workspaceRoot, _ = os.Getwd() func Viewable(line string) string { line = strings.TrimSpace(line) @@ -76,17 +79,16 @@ func Viewable(line string) string { if m.Name == "Extra" { extraMap, ok := v.(map[string]any) if ok && len(extraMap) > 0 { - for k, ev := range extraMap { + keys := make([]string, 0, len(extraMap)) + for k := range extraMap { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + ev := extraMap[k] builder.WriteString(" ") builder.WriteString(shell.Style(shell.TextWhite, shell.Dim, shell.Italic, k+":")) - vStr := "" - switch ev.(type) { - case map[string]any, []any: - vStr, _ = cast.ToJSON(ev) - default: - vStr = cast.String(ev) - } - builder.WriteString(vStr) + builder.WriteString(renderValue(ev, 0, "")) } } continue @@ -95,9 +97,18 @@ func Viewable(line string) string { if m.Name == "CallStacks" { callStacksList, ok := v.([]any) if ok && len(callStacksList) > 0 { + stackColor := shell.TextRed + if strings.Contains(strings.ToLower(logType), "warn") { + stackColor = shell.TextYellow + } + builder.WriteString("\n") for _, vi := range callStacksList { vStr := cast.String(vi) + if workspaceRoot != "" { + vStr = strings.TrimPrefix(vStr, workspaceRoot) + vStr = strings.TrimPrefix(vStr, "/") + } postfix := "" if pos := strings.LastIndexByte(vStr, '/'); pos != -1 { postfix = vStr[pos+1:] @@ -108,7 +119,7 @@ func Viewable(line string) string { } builder.WriteString(" ") builder.WriteString(shell.Style(shell.Dim, vStr)) - builder.WriteString(shell.Style(shell.TextWhite, postfix)) + builder.WriteString(shell.Style(stackColor, postfix)) builder.WriteString("\n") } } @@ -120,28 +131,42 @@ func Viewable(line string) string { if m.Format == "time" { // Convert int64 ns to time string logTime := time.Unix(0, cast.Int64(v)) - vStr = logTime.Format("01-02 15:04:05.000") - if m.Color == "" { - builder.WriteString(shell.White(shell.Bold, vStr)) + dateStr := logTime.Format("01-02") + timeStr := logTime.Format("15:04:05") + milliStr := logTime.Format(".000") + + if builder.Len() > 0 { builder.WriteString(" ") - continue } + builder.WriteString(shell.Style(shell.Dim, dateStr)) + builder.WriteString(" ") + builder.WriteString(shell.White(shell.Bold, timeStr)) + builder.WriteString(shell.Style(shell.Dim, milliStr)) + continue } else { - vStr = cast.String(v) + vStr = renderValue(v, m.Precision, m.Color) if vStr == "" { continue } } if builder.Len() > 0 { - builder.WriteString(" ") + if m.AttachBefore { + builder.WriteString(":") + } else { + builder.WriteString(" ") + } } - if !m.WithoutKey { - builder.WriteString(shell.Style(shell.TextWhite, shell.Dim, shell.Italic, m.Name+":")) + if !m.WithoutKey && !m.AttachBefore { + name := m.KeyName + if name == "" { + name = m.Name + } + builder.WriteString(shell.Style(shell.TextWhite, shell.Dim, shell.Italic, name+":")) } - builder.WriteString(applyColor(vStr, m.Color)) + builder.WriteString(vStr) } return builder.String() @@ -184,6 +209,12 @@ func ToJSON(line string) string { if m.Name == "" { continue } + + name := m.KeyName + if name == "" { + name = m.Name + } + if m.Name == "Extra" { if extraMap, ok := v.(map[string]any); ok { for k, ev := range extraMap { @@ -191,7 +222,7 @@ func ToJSON(line string) string { } } } else { - result[m.Name] = v + result[name] = v } } else if cast.String(v) != "0" { result[fmt.Sprintf("Extra%d", i)] = v @@ -216,7 +247,7 @@ func applyColor(text string, color string) string { return shell.Yellow(text) case "green": return shell.Green(text) - case "gray", "darkGray": + case "gray": return shell.Style(shell.Dim, text) default: return text @@ -233,3 +264,61 @@ func fallbackRenderArray(arr []any) string { } return builder.String() } + +func renderValue(v any, precision int, color string) string { + if v == nil { + return "" + } + switch val := v.(type) { + case float32, float64: + vStr := "" + if precision > 0 { + vStr = fmt.Sprintf("%.*f", precision, cast.To[float64](v)) + } else { + vStr = cast.String(v) + } + return applyColor(vStr, color) + case map[string]any: + if len(val) == 0 { + return "" + } + var parts []string + keys := make([]string, 0, len(val)) + for k := range val { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + // Key is always dim, value is colored + vStr := renderValue(val[k], precision, color) + if vStr != "" { + parts = append(parts, fmt.Sprintf("%s:%s", shell.Style(shell.Dim, k), vStr)) + } + } + if len(parts) == 0 { + return "" + } + return "[ " + strings.Join(parts, " ") + " ]" + case []any: + if len(val) == 0 { + return "" + } + var parts []string + for _, iv := range val { + vStr := renderValue(iv, precision, color) + if vStr != "" { + parts = append(parts, vStr) + } + } + if len(parts) == 0 { + return "" + } + return "[ " + strings.Join(parts, " ") + " ]" + default: + s := cast.String(v) + if s == "" { + return "" + } + return applyColor(s, color) + } +} diff --git a/viewer_test.go b/viewer_test.go index 0baacfb..01db386 100644 --- a/viewer_test.go +++ b/viewer_test.go @@ -77,3 +77,154 @@ func TestLoadMeta(t *testing.T) { t.Errorf("expected Field1, got %s", meta[0].Name) } } + +type EnhancedLog struct { + log.BaseLog + App string `log:"pos:10,withoutkey:true"` + Node string `log:"pos:11,attachBefore,withoutkey:true"` + RequestHeaders map[string]string `log:"pos:13,keyname:reqH"` + ClientIP string `log:"pos:12,keyname:ip"` + Tags []string `log:"pos:14"` +} + +func (l *EnhancedLog) Reset() { + l.BaseLog.Reset() + l.App = "" + l.Node = "" + l.ClientIP = "" + l.RequestHeaders = nil + l.Tags = nil +} + +func TestEnhancedViewable(t *testing.T) { + entry := &EnhancedLog{ + BaseLog: log.BaseLog{ + LogType: "enhanced", + LogTime: 1714896000000000000, + }, + App: "MyApp", + Node: "Node1", + ClientIP: "127.0.0.1", + RequestHeaders: map[string]string{ + "User-Agent": "Go-http-cli", + }, + Tags: []string{"tag1", "tag2"}, + } + log.RegisterType("enhanced", entry) + + line := string(log.ToArrayBytes(entry, nil)) + out := log.Viewable(line) + + // Check attachBefore: MyApp:Node1 (since both are withoutkey) + if !strings.Contains(out, "MyApp:Node1") { + t.Errorf("expected MyApp:Node1, got: %s", out) + } + + // Check pos ordering and keyname: ip:127.0.0.1 should come before reqH + if !strings.Contains(out, "ip:") || !strings.Contains(out, "127.0.0.1") { + t.Errorf("expected ip:127.0.0.1, got: %s", out) + } + if !strings.Contains(out, "reqH:") || !strings.Contains(out, "User-Agent") || !strings.Contains(out, "Go-http-cli") { + t.Errorf("expected reqH:[ User-Agent:Go-http-cli ], got: %s", out) + } + + ipIdx := strings.Index(out, "ip:") + reqHIdx := strings.Index(out, "reqH:") + if ipIdx > reqHIdx { + t.Errorf("expected ip to come before reqH, but ipIdx=%d, reqHIdx=%d", ipIdx, reqHIdx) + } + + // Check array rendering: Tags:[ tag1 tag2 ] + if !strings.Contains(out, "Tags:") || !strings.Contains(out, "[ tag1 tag2 ]") { + t.Errorf("expected Tags:[ tag1 tag2 ], got: %s", out) + } +} + +func TestEnhancedToJSON(t *testing.T) { + entry := &EnhancedLog{ + BaseLog: log.BaseLog{ + LogType: "enhanced", + LogTime: 1714896000000000000, + }, + App: "MyApp", + Node: "Node1", + ClientIP: "127.0.0.1", + RequestHeaders: map[string]string{ + "User-Agent": "Go-http-cli", + }, + } + log.RegisterType("enhanced", entry) + + line := string(log.ToArrayBytes(entry, nil)) + jsonStr := log.ToJSON(line) + + // Check keyname in JSON + if !strings.Contains(jsonStr, `"ip":"127.0.0.1"`) { + t.Errorf("expected ip field in JSON, got: %s", jsonStr) + } + if !strings.Contains(jsonStr, `"reqH":{"User-Agent":"Go-http-cli"}`) { + t.Errorf("expected reqH field in JSON, got: %s", jsonStr) + } +} + +type CallStackLog struct { + log.BaseLog + CallStacks []string `log:"pos:6"` +} + +func (l *CallStackLog) Reset() { + l.BaseLog.Reset() + l.CallStacks = nil +} + +func TestCallStacksViewable(t *testing.T) { + wd, _ := os.Getwd() + entry := &CallStackLog{ + BaseLog: log.BaseLog{ + LogType: "error", + LogTime: 1714896000000000000, + }, + CallStacks: []string{wd + "/main.go:10", "/usr/local/go/src/runtime/panic.go:100"}, + } + log.RegisterType("error", entry) + + line := string(log.ToArrayBytes(entry, nil)) + out := log.Viewable(line) + + // Check path truncation (should contain relative "main.go:10") + if !strings.Contains(out, "main.go:10") { + t.Errorf("expected relative path main.go:10, got: %s", out) + } + // Absolute path should be removed if it matches wd + if strings.Contains(out, wd) { + t.Errorf("absolute path should be truncated, but still found: %s", out) + } +} + +type PrecisionLog struct { + log.BaseLog + Value float64 `log:"pos:6,precision:2"` +} + +func (l *PrecisionLog) Reset() { + l.BaseLog.Reset() + l.Value = 0 +} + +func TestPrecisionViewable(t *testing.T) { + entry := &PrecisionLog{ + BaseLog: log.BaseLog{ + LogType: "precision", + LogTime: 1714896000000000000, + }, + Value: 3.14159, + } + log.RegisterType("precision", entry) + + line := string(log.ToArrayBytes(entry, nil)) + out := log.Viewable(line) + + if !strings.Contains(out, "3.14") || strings.Contains(out, "3.141") { + t.Errorf("expected 3.14 (precision 2), got: %s", out) + } +}