在Golang项目里,错误日志如果缺乏统一规范,不同包可能用不同的时间格式、不同的字段名,甚至不记录调用栈,这会大幅增加故障排查成本。实现统一错误日志格式,本质是把错误产生、包装、输出三个环节标准化,让所有日志拥有相同结构与足够上下文。

为什么需要统一错误日志格式
Go标准库中的log包默认只输出时间和消息,没有层级概念,也没有结构化字段。当系统变复杂,多个 goroutine 同时写日志时,很难区分哪条错误属于哪个请求。很多团队初期随意使用fmt.Println或者log.Print记录错误,后期接入日志收集系统时才发现格式混乱,无法按字段检索。
统一格式通常包含几个核心字段:时间戳、日志级别、错误描述、调用文件与行号、请求标识,以及被包装的原始错误。这样在 ELK 或 Loki 中可以直接用级别过滤、用 trace_id 串联链路。从可维护性看,规范日志也是代码评审的一部分,能强迫开发者在返回错误时补充上下文。
使用 errors 包装保留错误链
Go 1.13 之后,errors 包提供了 Wrap 相关语义,通过 fmt.Errorf 配合 %w 可以构建错误链,同时使用 errors.Is 和 errors.As 做判定。但默认的 error 没有堆栈,我们需要自定义类型来记录产生点。
下面代码展示一个带堆栈的自定义错误,以及在业务函数中包装底层错误的方式。这样日志组件就能把底层错误和当前上下文一起输出。
package errutil
import (
"fmt"
"runtime"
)
type StackError struct {
msg string
stack string
}
func (e *StackError) Error() string {
return e.msg
}
func NewStackError(format string, args ...interface{}) *StackError {
// 获取调用栈,跳过两层
_, file, line, _ := runtime.Caller(1)
stack := fmt.Sprintf("%s:%d", file, line)
return &StackError{
msg: fmt.Sprintf(format, args...),
stack: stack,
}
}
// Wrap 把底层错误包装成带上下文的 StackError
func Wrap(err error, ctx string) *StackError {
if err == nil {
return nil
}
_, file, line, _ := runtime.Caller(1)
return &StackError{
msg: fmt.Sprintf("%s: %v", ctx, err),
stack: fmt.Sprintf("%s:%d", file, line),
}
}
// 业务代码中使用
func queryUser(id int) error {
err := dbPing()
if err != nil {
return Wrap(err, "queryUser failed")
}
return nil
}
func dbPing() error {
return NewStackError("db connection refused")
}
基于 slog 定制结构化日志处理器
log/slog 是 Go 1.21 引入的结构化日志包,支持 Handler 自定义格式。我们可以写一个 Handler,在每条记录里注入公共字段,并把错误对象的堆栈提取出来。
下面示例通过 slog.Handler 包装,在输出时统一加上服务名与版本,同时将 error 类型转换为包含 stack 的字段。这样无论业务代码调用 slog.Error 还是直接写,格式都一致。
package logutil
import (
"context"
"log/slog"
)
type uniformHandler struct {
next slog.Handler
}
func (h *uniformHandler) Enabled(ctx context.Context, level slog.Level) bool {
return h.next.Enabled(ctx, level)
}
func (h *uniformHandler) Handle(ctx context.Context, r slog.Record) error {
// 注入统一字段
r.AddAttrs(slog.String("service", "user-api"))
r.AddAttrs(slog.String("version", "v1.0"))
// 如果包含 error 属性,补充堆栈信息
r.Attrs(func(a slog.Attr) bool {
if a.Key == "err" {
if se, ok := a.Value.Any().(interface{ Stack() string }); ok {
r.AddAttrs(slog.String("stack", se.Stack()))
}
}
return true
})
return h.next.Handle(ctx, r)
}
func (h *uniformHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return &uniformHandler{next: h.next.WithAttrs(attrs)}
}
func (h *uniformHandler) WithGroup(name string) slog.Handler {
return &uniformHandler{next: h.next.WithGroup(name)}
}
func NewLogger() *slog.Logger {
base := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})
return slog.New(&uniformHandler{next: base})
}
在 HTTP 中间件中统一捕获错误
Web 服务里,如果每个 handler 自己记日志,格式很容易漂移。用中间件在最后统一处理 panic 和返回的错误,可以保证所有请求错误走同一条日志路径。
下面的中间件在请求结束时检查上下文里的错误,并调用统一 logger 输出。它还会生成 request_id 写入日志,方便链路追踪。
package middleware
import (
"context"
"net/http"
"github.com/google/uuid"
"log/slog"
)
type ctxKey string
const errKey ctxKey = "req_err"
func Logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rid := uuid.NewString()
ctx := context.WithValue(r.Context(), "req_id", rid)
r = r.WithContext(ctx)
next.ServeHTTP(w, r)
if errVal := r.Context().Value(errKey); errVal != nil {
if e, ok := errVal.(error); ok {
slog.Error("request failed",
slog.String("req_id", rid),
slog.Any("err", e))
}
}
})
}
func SetError(r *http.Request, err error) {
ctx := context.WithValue(r.Context(), errKey, err)
*r = *r.WithContext(ctx)
}
配置与落地建议
统一格式不是写一次代码就结束,需要在项目初始化时集中配置 logger,并禁止业务包直接 import 标准 log。可以在 main 函数里调用 logutil.NewLogger 并赋值给全局变量,或者通过依赖注入传给各层。
对于已存在的旧代码,可以先用适配层把 log.Print 重定向到 slog,再逐步替换。日志级别也要约定清楚:用户输入错误用 Warn,系统异常用 Error,启动失败用 Fatal。配合采样和异步写入,能避免日志拖慢主流程。
| 方案 | 优点 | 缺点 |
|---|---|---|
| 自定义错误类型+ slog | 结构稳定,易检索 | 需改造已有错误返回 |
| 第三方库 zap + hook | 性能高,生态全 | 引入额外依赖 |
| 标准 log 改前缀 | 零依赖 | 无结构化,难扩展 |
小结
实现统一错误日志格式,核心是先定义字段规范,再用自定义错误保留堆栈,最后通过 slog Handler 与中间件集中输出。只要入口和出口受控,散落在代码里的日志自然会收敛到同一形态,为排查问题节省大量时间。
Golangerror_loglog_format修改时间:2026-08-02 16:18:20