feat(log): 调整可视化能力

This commit is contained in:
AI Engineer 2026-05-09 16:30:01 +08:00
parent 03267710dc
commit ec8406fe42
5 changed files with 288 additions and 31 deletions

View File

@ -1,5 +1,8 @@
# Changelog # Changelog
## [1.1.14] - 2026-05-09
- **可视化能力调整**: 调整优化了 `viewer` 模块相关的可视化能力,提升了日志的可读性与调试体验。
## [1.1.13] - 2026-05-09 ## [1.1.13] - 2026-05-09
- **绝对索引优化与零空洞**: - **绝对索引优化与零空洞**:
- 彻底消除 `BaseLog` 与业务字段之间的索引空洞。字段位置调整为:`BaseLog` (0-5),标准消息字段 (`Info`, `Error` 等) 与业务日志字段从 `pos: 6` 起始。 - 彻底消除 `BaseLog` 与业务字段之间的索引空洞。字段位置调整为:`BaseLog` (0-5),标准消息字段 (`Info`, `Error` 等) 与业务日志字段从 `pos: 6` 起始。

28
meta.go
View File

@ -12,12 +12,15 @@ import (
// MetaField describes the serialization and visualization metadata for a single log field. // MetaField describes the serialization and visualization metadata for a single log field.
type MetaField struct { type MetaField struct {
Index int `json:"index"` Index int
Name string `json:"name"` Name string
Color string `json:"color,omitempty"` KeyName string
Format string `json:"format,omitempty"` AttachBefore bool
WithoutKey bool `json:"withoutKey,omitempty"` Color string
Hide bool `json:"hide,omitempty"` Format string
Precision int
WithoutKey bool
Hide bool
} }
var ( var (
@ -48,7 +51,7 @@ func RegisterType(logType string, model any) {
if !ok { if !ok {
panic("log model must implement Reset() method: " + t.Name()) panic("log model must implement Reset() method: " + t.Name())
} }
// 检查该方法是否属于当前类型(而不是继承自 BaseLog 且没有被重写) // 检查该方法是否属于当前类型(而不是继承自 BaseLog 且没有被重写)
baseResetMethod, _ := reflect.PointerTo(reflect.TypeOf(BaseLog{})).MethodByName("Reset") baseResetMethod, _ := reflect.PointerTo(reflect.TypeOf(BaseLog{})).MethodByName("Reset")
if method.Func.Pointer() == baseResetMethod.Func.Pointer() { if method.Func.Pointer() == baseResetMethod.Func.Pointer() {
@ -144,6 +147,11 @@ func extractMetaFields(model any) []MetaField {
if tag != "" { if tag != "" {
parts := strings.Split(tag, ",") parts := strings.Split(tag, ",")
for _, part := range parts { for _, part := range parts {
part = strings.TrimSpace(part)
if part == "attachBefore" {
meta.AttachBefore = true
continue
}
kv := strings.SplitN(part, ":", 2) kv := strings.SplitN(part, ":", 2)
if len(kv) == 2 { if len(kv) == 2 {
key := strings.TrimSpace(kv[0]) key := strings.TrimSpace(kv[0])
@ -157,6 +165,12 @@ func extractMetaFields(model any) []MetaField {
meta.WithoutKey = (val == "true") meta.WithoutKey = (val == "true")
case "hide": case "hide":
meta.Hide = (val == "true") meta.Hide = (val == "true")
case "keyname":
meta.KeyName = val
case "attachBefore":
meta.AttachBefore = (val == "true")
case "precision":
meta.Precision = cast.To[int](val)
} }
} }
} }

View File

@ -30,9 +30,9 @@ type BaseLog struct {
LogName string `log:"pos:0,color:cyan,hide:true"` LogName string `log:"pos:0,color:cyan,hide:true"`
LogType string `log:"pos:1,color:magenta,hide:true"` LogType string `log:"pos:1,color:magenta,hide:true"`
LogTime int64 `log:"pos:2,format:time"` LogTime int64 `log:"pos:2,format:time"`
TraceId string `log:"pos:3,color:blue"` TraceId string `log:"pos:3,color:gray,withoutkey:true"`
Image string `log:"pos:4,color:darkGray,hide:true"` Image string `log:"pos:4,hide:true"`
Server string `log:"pos:5,color:darkGray,hide:true"` Server string `log:"pos:5,hide:true"`
Extra map[string]any `log:"pos:1000"` Extra map[string]any `log:"pos:1000"`
} }

131
viewer.go
View File

@ -2,7 +2,9 @@ package log
import ( import (
"fmt" "fmt"
"os"
"regexp" "regexp"
"sort"
"strings" "strings"
"time" "time"
@ -12,6 +14,7 @@ import (
var errorLineMatcher = regexp.MustCompile(`(\w+\.go:\d+)`) var errorLineMatcher = regexp.MustCompile(`(\w+\.go:\d+)`)
var codeFileMatcher = regexp.MustCompile(`(\w+?\.)(go|js)`) var codeFileMatcher = regexp.MustCompile(`(\w+?\.)(go|js)`)
var workspaceRoot, _ = os.Getwd()
func Viewable(line string) string { func Viewable(line string) string {
line = strings.TrimSpace(line) line = strings.TrimSpace(line)
@ -76,17 +79,16 @@ func Viewable(line string) string {
if m.Name == "Extra" { if m.Name == "Extra" {
extraMap, ok := v.(map[string]any) extraMap, ok := v.(map[string]any)
if ok && len(extraMap) > 0 { 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(" ")
builder.WriteString(shell.Style(shell.TextWhite, shell.Dim, shell.Italic, k+":")) builder.WriteString(shell.Style(shell.TextWhite, shell.Dim, shell.Italic, k+":"))
vStr := "" builder.WriteString(renderValue(ev, 0, ""))
switch ev.(type) {
case map[string]any, []any:
vStr, _ = cast.ToJSON(ev)
default:
vStr = cast.String(ev)
}
builder.WriteString(vStr)
} }
} }
continue continue
@ -95,9 +97,18 @@ func Viewable(line string) string {
if m.Name == "CallStacks" { if m.Name == "CallStacks" {
callStacksList, ok := v.([]any) callStacksList, ok := v.([]any)
if ok && len(callStacksList) > 0 { if ok && len(callStacksList) > 0 {
stackColor := shell.TextRed
if strings.Contains(strings.ToLower(logType), "warn") {
stackColor = shell.TextYellow
}
builder.WriteString("\n") builder.WriteString("\n")
for _, vi := range callStacksList { for _, vi := range callStacksList {
vStr := cast.String(vi) vStr := cast.String(vi)
if workspaceRoot != "" {
vStr = strings.TrimPrefix(vStr, workspaceRoot)
vStr = strings.TrimPrefix(vStr, "/")
}
postfix := "" postfix := ""
if pos := strings.LastIndexByte(vStr, '/'); pos != -1 { if pos := strings.LastIndexByte(vStr, '/'); pos != -1 {
postfix = vStr[pos+1:] postfix = vStr[pos+1:]
@ -108,7 +119,7 @@ func Viewable(line string) string {
} }
builder.WriteString(" ") builder.WriteString(" ")
builder.WriteString(shell.Style(shell.Dim, vStr)) builder.WriteString(shell.Style(shell.Dim, vStr))
builder.WriteString(shell.Style(shell.TextWhite, postfix)) builder.WriteString(shell.Style(stackColor, postfix))
builder.WriteString("\n") builder.WriteString("\n")
} }
} }
@ -120,28 +131,42 @@ func Viewable(line string) string {
if m.Format == "time" { if m.Format == "time" {
// Convert int64 ns to time string // Convert int64 ns to time string
logTime := time.Unix(0, cast.Int64(v)) logTime := time.Unix(0, cast.Int64(v))
vStr = logTime.Format("01-02 15:04:05.000") dateStr := logTime.Format("01-02")
if m.Color == "" { timeStr := logTime.Format("15:04:05")
builder.WriteString(shell.White(shell.Bold, vStr)) milliStr := logTime.Format(".000")
if builder.Len() > 0 {
builder.WriteString(" ") 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 { } else {
vStr = cast.String(v) vStr = renderValue(v, m.Precision, m.Color)
if vStr == "" { if vStr == "" {
continue continue
} }
} }
if builder.Len() > 0 { if builder.Len() > 0 {
builder.WriteString(" ") if m.AttachBefore {
builder.WriteString(":")
} else {
builder.WriteString(" ")
}
} }
if !m.WithoutKey { if !m.WithoutKey && !m.AttachBefore {
builder.WriteString(shell.Style(shell.TextWhite, shell.Dim, shell.Italic, m.Name+":")) 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() return builder.String()
@ -184,6 +209,12 @@ func ToJSON(line string) string {
if m.Name == "" { if m.Name == "" {
continue continue
} }
name := m.KeyName
if name == "" {
name = m.Name
}
if m.Name == "Extra" { if m.Name == "Extra" {
if extraMap, ok := v.(map[string]any); ok { if extraMap, ok := v.(map[string]any); ok {
for k, ev := range extraMap { for k, ev := range extraMap {
@ -191,7 +222,7 @@ func ToJSON(line string) string {
} }
} }
} else { } else {
result[m.Name] = v result[name] = v
} }
} else if cast.String(v) != "0" { } else if cast.String(v) != "0" {
result[fmt.Sprintf("Extra%d", i)] = v result[fmt.Sprintf("Extra%d", i)] = v
@ -216,7 +247,7 @@ func applyColor(text string, color string) string {
return shell.Yellow(text) return shell.Yellow(text)
case "green": case "green":
return shell.Green(text) return shell.Green(text)
case "gray", "darkGray": case "gray":
return shell.Style(shell.Dim, text) return shell.Style(shell.Dim, text)
default: default:
return text return text
@ -233,3 +264,61 @@ func fallbackRenderArray(arr []any) string {
} }
return builder.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)
}
}

View File

@ -77,3 +77,154 @@ func TestLoadMeta(t *testing.T) {
t.Errorf("expected Field1, got %s", meta[0].Name) 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)
}
}