package loggerx import ( "bytes" "context" "encoding/json" "fmt" "path/filepath" "runtime" "sort" "strconv" "strings" "time" ) // resolveBasePath 计算进程工作目录(用于把绝对路径裁成相对路径) // 只在 NewLogger 里算一次:filepath.Abs 要走系统调用,而每条日志都会用到 func resolveBasePath() string { p, err := filepath.Abs("./") if err != nil { return "" } return strings.ReplaceAll(p, "\\", "/") } // basePath 进程工作目录 // 值放在共享指针里,Channel()/WriteAsync() 拷贝出的实例才能看到同一个结果 func (l *Logger) basePath() string { if l.basePathVal == nil { return "" } return *l.basePathVal } // levelOf 事件名 -> 级别 func levelOf(event string) Level { switch event { case "debug": return LevelDebug case "warn": return LevelWarn case "error": return LevelError default: return LevelInfo } } func (l *Logger) logger(ctx context.Context, event string, v ...any) { // 级别过滤:低于设定级别的日志直接丢弃,连 JSON 都不序列化 if levelOf(event) < l.option.minLevel { return } // 调用方可能是 log 包(Logger.Write -> logger),所以这里取第 2 层 pc, file, line, ok := runtime.Caller(2) var funcName string if ok && pc != 0 { if fn := runtime.FuncForPC(pc); fn != nil { funcName = strings.TrimPrefix(filepath.Ext(fn.Name()), ".") } file = strings.TrimPrefix(strings.ReplaceAll(file, "\\", "/"), l.basePath()) } nowTime := time.Now().In(l.option.timeZone).Format("2006-01-02 15:04:05.000000") // ctx 允许为 nil,不能直接调 Value var traceId string if ctx != nil { traceId, _ = ctx.Value(l.option.traceField).(string) } // error 转成带堆栈的字符串(%+v 会带上 pkg/errors 的调用栈) // 注意:这里会写进调用方传入的切片,因此先复制一份,不改调用方的内存 args := v for idx, val := range args { if _, isErr := val.(error); isErr { if &args[0] == &v[0] { args = append([]any(nil), v...) } args[idx] = fmt.Sprintf("%+v", val) } } var gid string if l.option.isGid { gid = getGID() } fd := FormatData{ Level: event, Time: nowTime, File: file + ":" + strconv.Itoa(line), Func: funcName, Gid: gid, Content: args, TraceId: traceId, Expand: l.option.expandData, } line1 := l.marshalLine(fd, nowTime, file, line, gid, traceId) // 一条日志 = 一行完整合法的 JSON(+ 换行), // 这样 Fluent Bit / Vector / Loki / jq 等采集端可以直接解析。 // 旧实现写成 "\n[info]{...}":既不是 JSON 行,也没有行尾换行 if _, err := l.write(event, line1); err != nil { l.reportError(fmt.Errorf("loggerx: 写入日志失败: %w", err)) } if l.option.errorToInfo && event == "error" { if _, err := l.write("info", line1); err != nil { l.reportError(fmt.Errorf("loggerx: 写入 info 日志失败: %w", err)) } } } // marshalLine 按当前配置(格式 + 前缀)渲染一条日志,返回以换行结尾的一行 // // 前缀/换行都 append 进目标切片,不额外分配一个容器再拷贝: // 这条路径每条日志都走,多一次分配就会体现在吞吐上 func (l *Logger) marshalLine(fd FormatData, nowTime, file string, line int, gid, traceId string) []byte { prefix := l.option.prefix var body []byte if l.option.format == FormatText { body = marshalText(fd) } else { body = marshalLog(fd, nowTime, file, line, l.option.escapeHTML, gid, traceId) body = bytes.TrimRight(body, "\n") } out := make([]byte, 0, len(prefix)+len(body)+1) out = append(out, prefix...) out = append(out, body...) return append(out, '\n') } // marshalText 把一条日志渲染成单行紧凑文本 // 形如:level=info time=2026-01-02 15:04:05.000000 file=/a.go:12 func=main gid=7 content=hi // 值里含空格/引号/换行时用 %q 包起来,保证「一行一条」且不歧义 // // 用 append 到 []byte 而不是 strings.Builder + Sprintf: // 文本格式的意义就是便宜,走反射/格式化会把省下的 JSON 成本又花回去 func marshalText(fd FormatData) []byte { var buf []byte buf = append(buf, "level="...) buf = append(buf, fd.Level...) buf = appendTextField(buf, "time", fd.Time) buf = appendTextField(buf, "file", fd.File) if fd.Func != "" { buf = appendTextField(buf, "func", fd.Func) } if fd.Gid != "" { buf = appendTextField(buf, "gid", fd.Gid) } if fd.TraceId != "" { buf = appendTextField(buf, "traceId", fd.TraceId) } buf = append(buf, " content="...) buf = appendContent(buf, fd.Content) for _, k := range sortedKeys(fd.Expand) { buf = appendTextField(buf, k, fd.Expand[k]) } return buf } // appendTextField 追加一个 key=value 字段,必要时加引号 func appendTextField(buf []byte, key, val string) []byte { buf = append(buf, ' ') buf = append(buf, key...) buf = append(buf, '=') if val == "" { return append(buf, `""`...) } if strings.ContainsAny(val, " \t\"'=\n\r") { return strconv.AppendQuote(buf, val) } return append(buf, val...) } // appendContent 渲染 content(多个参数用空格分隔) // 单个值走快速路径,避免 fmt 的反射开销 func appendContent(buf []byte, v any) []byte { switch val := v.(type) { case nil: return append(buf, `""`...) case []any: for i, item := range val { if i > 0 { buf = append(buf, ' ') } buf = appendValue(buf, item) } if len(val) == 0 { buf = append(buf, `""`...) } return buf case []string: if len(val) == 0 { return append(buf, `""`...) } return append(buf, strings.Join(val, " ")...) case string: return appendValue(buf, val) default: return appendValue(buf, val) } } // appendValue 渲染单个值,需要时加引号 func appendValue(buf []byte, v any) []byte { var s string switch val := v.(type) { case string: s = val case error: s = val.Error() case fmt.Stringer: s = val.String() case int: return strconv.AppendInt(buf, int64(val), 10) case int64: return strconv.AppendInt(buf, val, 10) case bool: return strconv.AppendBool(buf, val) default: s = fmt.Sprintf("%v", v) } if s == "" { return append(buf, `""`...) } if strings.ContainsAny(s, " \t\"'=\n\r") { return strconv.AppendQuote(buf, s) } return append(buf, s...) } // sortedKeys 让 map 输出顺序稳定,便于 diff 与测试 func sortedKeys(m map[string]string) []string { if len(m) == 0 { return nil } keys := make([]string, 0, len(m)) for k := range m { keys = append(keys, k) } sort.Strings(keys) return keys } // marshalLog 序列化一条日志为 JSON(返回以换行结尾) // // json 序列化失败时(chan/func 等不可序列化类型,或循环引用)不能静默丢弃, // 否则会留下一行没有内容的坏记录,需要降级成可读文本 func marshalLog(fd FormatData, nowTime, file string, line int, escapeHTML bool, gid, traceId string) []byte { var ( b []byte err error ) if escapeHTML { b, err = json.Marshal(fd) } else { var buf bytes.Buffer encoder := json.NewEncoder(&buf) encoder.SetEscapeHTML(false) err = encoder.Encode(fd) b = bytes.TrimRight(buf.Bytes(), "\n") } if err == nil { return append(b, '\n') } // 降级:逐字段尝试,失败的内容用 %+v 兜底 fallback := FormatData{ Level: fd.Level, Time: nowTime, File: file + ":" + strconv.Itoa(line), Func: fd.Func, Gid: gid, TraceId: traceId, Content: stringifyValues(fd.Content), Expand: fd.Expand, } if fb, ferr := json.Marshal(fallback); ferr == nil { return append(fb, '\n') } return []byte(fmt.Sprintf("{\"level\":%q,\"time\":%q,\"content\":%q,\"marshal_error\":%q}\n", fd.Level, nowTime, fmt.Sprintf("%+v", fd.Content), err.Error())) } // stringifyValues 把不可序列化的值转成字符串 func stringifyValues(v any) []string { switch val := v.(type) { case nil: return nil case []any: out := make([]string, 0, len(val)) for _, item := range val { out = append(out, fmt.Sprintf("%+v", item)) } return out case []string: return val default: return []string{fmt.Sprintf("%+v", val)} } } // getGID 获取当前 goroutine 的 ID func getGID() string { b := make([]byte, 64) b = b[:runtime.Stack(b, false)] b = bytes.TrimPrefix(b, []byte("goroutine ")) idx := bytes.IndexByte(b, ' ') if idx < 0 { // 解析失败时不要 panic return "0" } return string(b[:idx]) } // FormatData 一条日志的落盘结构 type FormatData struct { Level string `json:"level,omitempty"` Time string `json:"time,omitempty"` File string `json:"file,omitempty"` Func string `json:"func,omitempty"` Gid string `json:"gid,omitempty"` Content interface{} `json:"content,omitempty"` TraceId string `json:"traceId,omitempty"` Expand map[string]string `json:"expand,omitempty"` }