当三套日志框架遇上分布式追踪:Go 微服务网关日志架构重构实录
在一个基于 Hertz + Kitex 的企业级微服务系统中,API 网关层混用了 zerolog、hertz-zerolog、slog 三套日志框架,约 100 处日志调用实际是
fmt.Sprint拼接而非结构化输出,且无任何追踪上下文注入。本文记录了从问题发现到统一重构的完整过程,包括方案设计中的关键取舍、实施中踩过的坑,以及重构完成后对 OpenTelemetry 集成的进一步思考。
一、问题是怎么长出来的
没有人会在第一天就写出三套日志框架混用的代码。问题是随着项目演进逐渐积累的。
我们的系统是一个标准的 Go 微服务架构:Hertz 做 HTTP 网关,Kitex 做 RPC 服务,中间通过 metainfo + TTHeader 传递追踪信息。网关层的分层结构如下:
bash
api/gateway/
├── biz/handler/ # Hertz 生成的 HTTP Handler
├── internal/
│ ├── application/ # 中间件层 (JWT, CORS, Error, Trace)
│ ├── domain/service/ # 域服务层 (20+ 个服务)
│ ├── infrastructure/ # 基础设施层 (RPC Client, Redis, Config)
│ └── wire/ # 依赖注入
└── pkg/log/ # tracelog 日志工具包
日志代码大致沿着三条路径各自演化:
路径一:域服务层 --- 通过 BaseService 基类暴露 Logger() 方法,返回的是 *hertzZerolog.Logger。24 个域服务在此之上调用 s.Logger().Error("msg", "key", val) 这种 printf 风格 API。看起来像结构化日志,实际上 hertzZerolog 的 Error(v ...interface{}) 内部走的是 fmt.Sprint 拼接 ,所有的 key-value 对被拼成一个字符串塞进 message 字段。
路径二:中间件层 --- JWT、CORS、Error Handler 中间件各自持有 *hertzZerolog.Logger,使用 Warnf/Errorf 等 printf 风格方法。其中错误处理中间件的作者了解 tracelog 包,正确使用了 tracelog.Event(ctx, logger.Error()) 注入追踪字段;但其他中间件没有。
路径三:SSE 服务 --- 独立使用 Go 标准库的 log/slog,完全脱离了项目的日志体系。
路径四:tracelog 包 --- 项目已经有一个设计良好的 pkg/log/trace_logger.go,提供了 WithTrace()、Event()、BindToContext() 等方法来注入 OpenTelemetry 追踪字段。但这个包只被错误处理中间件正确使用了,其余代码一概不知。
最终结果:生产环境的日志里,只有错误处理中间件的日志带有 trace_id 和 request_id,域服务、JWT 中间件、Redis 缓存层的日志都是裸奔的,无法做分布式追踪关联。
二、看清问题之前,先看清框架
重构之前必须搞清楚一个基本问题:项目中出现的 zerolog、hertzZerolog、hlog 到底是什么关系?
三者的包装链
bash
github.com/rs/zerolog → 日志引擎(核心)
github.com/hertz-contrib/logger/zerolog → Hertz hlog 适配层(壳)
github.com/cloudwego/hertz/pkg/common/hlog → Hertz 框架日志接口(契约)
rs/zerolog 是实际干活的日志引擎,提供高性能的结构化链式 API:
go
logger.Info().Str("user_id", "123").Int("age", 25).Msg("user created")
// 输出: {"level":"info","user_id":"123","age":25,"message":"user created"}
hlog 是 Hertz 框架定义的日志接口(hlog.FullLogger),框架内部所有日志都通过这个接口输出。它的 API 设计是 printf 风格的:
go
type FullLogger interface {
Debugf(format string, v ...interface{})
Infof(format string, v ...interface{})
Errorf(format string, v ...interface{})
// ...
}
hertzZerolog (hertz-contrib/logger/zerolog)的存在意义只有一个:实现 hlog.FullLogger 接口,让 Hertz 框架内部能通过 zerolog 输出日志 。你调用 hlog.SetLogger(hertzZerolog.New()),Hertz 的路由、中间件等内部组件就会通过 zerolog 输出。
它的关键方法是 Unwrap(),用于获取底层的 zerolog.Logger:
go
hertzLogger := hertzZerolog.New(hertzZerolog.WithLevel(hlog.LevelInfo))
rawLogger := hertzLogger.Unwrap() // → zerolog.Logger
问题出在哪
我们的项目犯了一个常见错误:把适配层当作日志 API 来用。
hertzZerolog.Logger 本应只出现在一个地方 --- hlog.SetLogger() 调用处。但在 Wire 依赖注入的传递链中,它成了全局的日志类型:
scss
config.CreateLogger() → *hertzZerolog.Logger
→ Wire 注入 → AppContainer.Logger
→ ProvideTraceMiddleware(logger) → 拿到后 Unwrap()
→ ProvideJWTMiddleware(logger) → 拿到后 Unwrap()
→ NewBaseService(logger) → 拿到后 Unwrap()
→ ProvideCORSMiddleware(logger) → 拿到后 Unwrap()
→ ...
每个消费者拿到 *hertzZerolog.Logger 后,第一件事都是 Unwrap() 拆壳。整个 Wire 链路传递的是一个没人直接使用的适配壳。
更糟糕的是,域服务通过 BaseService.Logger() 拿到的也是 *hertzZerolog.Logger,开发者调用 s.Logger().Error("检查失败", "error", err) 时,以为在写结构化日志,实际输出的是:
json
{ "level": "error", "message": "检查失败error<nil>" }
所有的 key-value 对被 fmt.Sprint 拼成了一个字符串。代码看起来是对的,日志输出却是错的。
三、重构方案设计
设计原则
- 统一到 zerolog 原生 API --- 所有业务日志使用
logger.Info().Str(...).Msg(...)链式调用 - 追踪字段自动注入 --- 通过已有的
tracelog包确保每条日志携带trace_id、request_id、span_id - 最小接口变更 --- 24 个域服务通过
BaseService间接调用日志,优先改BaseService核心方法 - hertzZerolog 只保留在入口 --- Wire 链路继续传递
*hertzZerolog.Logger(避免改动 Wire 签名),但消费者内部统一使用*zerolog.Logger
分阶段执行
| 阶段 | 内容 | 文件数 | 修改处数 |
|---|---|---|---|
| 0 | 编写日志规范文档 + Claude 技能文件 | 2 (新建) | --- |
| 1 | 重构 BaseService 核心(新增 Log(ctx) 方法) |
1 | ~60 行 |
| 2 | 替换域服务直接日志调用 | 10 | ~100 处 |
| 3 | 重构中间件日志(JWT/CORS/Redis) | 4-5 | ~30 处 |
| 4 | SSE 服务从 slog 迁移到 zerolog | 3 | ~15 处 |
| 5 | TraceMiddleware 增强(BindToContext) |
1-2 | ~10 行 |
| 6 | 清理收尾 + lint 检查 | --- | --- |
阶段的排列有讲究:先改核心(BaseService),再改使用方(域服务),再改独立模块(中间件、SSE),最后增强基础设施(TraceMiddleware)。每个阶段完成后都可以独立编译验证,不会出现"改了一半编译不过"的尴尬。
四、BaseService 核心改造
BaseService 是 24 个域服务的公共基类,改它等于改所有服务的日志行为。
改造前
go
type BaseService struct {
logger *hertzZerolog.Logger
serviceName string
}
// Logger 暴露 hertzZerolog 的 printf 风格 API
func (bs *BaseService) Logger() *hertzZerolog.Logger {
return bs.logger
}
域服务调用方式:
go
s.Logger().Error("获取用户失败", "user_id", userID, "error", err)
// 实际输出: {"message":"获取用户失败user_id12345error<nil>"} ← 全拼接了
改造后
go
// Log 返回带追踪信息的 zerolog.Logger(推荐使用)
func (bs *BaseService) Log(ctx context.Context) *zerolog.Logger {
return bs.getTracedLogger(ctx)
}
func (bs *BaseService) getTracedLogger(ctx context.Context) *zerolog.Logger {
// 优先从 context 获取(TraceMiddleware 已绑定带追踪字段的 logger)
logger := zerolog.Ctx(ctx)
if logger != nil && logger.GetLevel() != zerolog.Disabled {
return logger
}
// 回退:手动注入追踪字段
base := bs.logger.Unwrap()
traced := tracelog.WithTrace(ctx, base)
return &traced
}
// Deprecated: 使用 Log(ctx) 替代
func (bs *BaseService) Logger() *hertzZerolog.Logger {
return bs.logger
}
关键设计点:
Log(ctx)要求传入 context --- 这是刻意的。没有 context 就没有追踪信息,API 签名本身就在约束正确用法。- 优先从 context 获取 logger --- TraceMiddleware 在请求入口处已经将带追踪字段的 logger 绑定到 context,域服务直接取用即可,避免每次重复构建。
- 保留
Logger()但标记废弃 --- 避免一次性改所有调用点,允许渐进式迁移。
域服务的新调用方式:
go
s.Log(ctx).Error().Str("user_id", userID).Err(err).Msg("获取用户失败")
// 输出: {"level":"error","trace_id":"abc...","request_id":"req-123",
// "user_id":"12345","error":"...","message":"获取用户失败"}
五、批量替换:正则不是银弹
10 个域服务文件、约 100 处日志调用需要从 printf 风格迁移到链式 API。手动改太慢,我用 Python 脚本做正则批量替换。
替换规则
| 场景 | 改造前 | 改造后 |
|---|---|---|
| 单参数 | s.Logger().Error("msg") |
s.Log(ctx).Error().Msg("msg") |
| 带 error | s.Logger().Error("msg", "error", err) |
s.Log(ctx).Error().Err(err).Msg("msg") |
| 带字段 | s.Logger().Info("msg", "k1", v1) |
s.Log(ctx).Info().Str("k1", v1).Msg("msg") |
| 多字段 | s.Logger().Info("msg", "k1", v1, "k2", v2) |
s.Log(ctx).Info().Str("k1", v1).Str("k2", v2).Msg("msg") |
看起来很规整?实际执行中遇到了两个痛点。
痛点一:正则无法理解 Go 表达式边界
多参数场景的正则模式需要匹配任意 Go 表达式作为值,但 Go 表达式可以包含括号、点号、方法调用:
go
// 原始代码
s.Logger().Info("获取成功", "count", len(resp.GetData()))
// 正则错误替换结果 --- Msg() 被嵌套进了 Str() 的值参数里
s.Log(ctx).Info().Str("count", len(resp.GetData()).Msg("获取成功"))
// ↑ 括号配对出错
正则引擎无法正确处理嵌套括号。最终的解决方案是分两遍处理:第一遍用正则做粗略替换,第二遍用针对性的修复脚本处理每个错误模式。
痛点二:类型推断是人的工作
正则不知道值的类型,统一生成 .Str("key", val)。但实际代码中:
go
s.Logger().Info("上传成功", "success", rpcResp.Success) // bool 类型
s.Logger().Info("获取完成", "count", len(tokenHashes)) // int 类型
这些需要人工审查,改为 .Bool("success", rpcResp.Success) 和 .Int("count", len(tokenHashes))。
顺便修正的问题
批量替换过程中还发现了原始代码的日志级别错误:
go
// 用 Error 级别记录成功信息
s.Logger().Error(ctx, "检查数据源使用状态成功", slog.String("data_source_id", id))
// → 修正为 Info 级别
s.Log(ctx).Info().Str("data_source_id", id).Msg("检查数据源使用状态成功")
// 用 Error 级别记录参数校验
s.Logger().Error("Upload ID is required")
// → 修正为 Warn 级别(参数校验不是系统错误)
s.Log(ctx).Warn().Msg("Upload ID is required")
批量重构的额外价值就在这里 --- 你被迫逐行审视每一条日志,那些藏了几个月的小问题自然浮出水面。
六、中间件层:从 printf 到结构化
中间件的改造模式比较统一。以 JWT 中间件为例:
改造前
go
type JWTMiddlewareImpl struct {
logger *hertzZerolog.Logger // 适配壳类型
}
// printf 风格,无追踪上下文
m.logger.Warnf("Access denied: token has been revoked")
m.logger.Errorf("Failed to revoke token: %v", err)
改造后
go
type JWTMiddlewareImpl struct {
logger *zerolog.Logger // 直接用 zerolog
}
// 结构化 + 追踪上下文
tracelog.Event(ctx, m.logger.Warn()).
Str("component", "jwt_middleware").
Msg("Access denied: token has been revoked")
tracelog.Event(ctx, m.logger.Error()).
Str("component", "jwt_middleware").
Err(err).
Msg("Failed to revoke token")
中间件和域服务的日志调用方式不同:域服务通过 s.Log(ctx) 从 context 获取 logger,中间件通过 tracelog.Event(ctx, m.logger.Level()) 对已有 logger 注入追踪字段。两种方式各有适用场景:
- 域服务用
s.Log(ctx)--- logger 已在 TraceMiddleware 中绑定到 context,直接取用最简洁 - 中间件用
tracelog.Event()--- 某些中间件在 TraceMiddleware 之前执行(如 CORS),context 中还没有 logger
七、TraceMiddleware 增强:让一切自动化
整个重构的最后一块拼图是让 TraceMiddleware 在请求入口处完成 logger 的 context 绑定:
go
func (m *TraceMiddlewareImpl) MiddlewareFunc() app.HandlerFunc {
return func(ctx context.Context, c *app.RequestContext) {
requestID := requestid.Get(c)
ctx = errors.InjectRequestIDToContext(ctx, requestID)
ctx = errors.InjectTraceToContext(ctx)
// 关键:将带追踪字段的 logger 绑定到 context
if m.logger != nil {
path := string(c.Request.Path())
ctx = tracelog.BindToContext(ctx, *m.logger, "api-gateway", path)
}
c.Next(ctx)
}
}
BindToContext 做了三件事:
- 从 context 提取
trace_id、span_id、request_id - 将这些字段附加到 logger 上
- 将 logger 存入 context(通过
zerolog.WithContext)
之后所有的 zerolog.Ctx(ctx) 或 s.Log(ctx) 调用都能直接拿到这个带追踪字段的 logger,零成本、无感知。
中间件的执行顺序决定了这个方案能否工作:
scss
hertztracing.ServerMiddleware → 1. 创建 OTel Span
requestid.New() → 2. 生成 RequestID
traceMiddleware.MiddlewareFunc → 3. 绑定 logger 到 context ★
corsMiddleware → 4. 已经可以用 tracelog.Event()
errorMiddleware → 5. 已经可以用 tracelog.Event()
jwtMiddleware → 6. 已经可以用 tracelog.Event()
→ 域服务 → 7. s.Log(ctx) 直接获取
八、重构之后,新的问题浮出水面
重构完成,编译通过,lint 检查干净。但当我打开 Jaeger 查看链路追踪时,发现了一个新问题:
Jaeger 的 Span 详情中只有框架自动生成的 "start" 和 "finish" 事件,看不到任何业务日志。
这不是 bug,而是架构层面的认知偏差。
日志输出 ≠ 链路追踪
我们的 tracelog.Event() 做的事情是:把 trace_id 写进日志的 JSON 字段。
json
{
"level": "error",
"trace_id": "abc123",
"span_id": "def456",
"message": "RPC调用失败"
}
这让你可以在 Grafana Loki 或 ELK 中通过 trace_id 检索相关日志。但 Jaeger 不会去读你的日志文件 --- Jaeger 只展示 OpenTelemetry Span 中记录的事件。
要让业务日志出现在 Jaeger 中,需要调用 span.AddEvent() 或 span.RecordError(),将日志作为 Span Event 写入 OpenTelemetry 链路。我们的代码从未这样做过。
hertz-contrib/obs-opentelemetry/logging/zerolog
CloudWeGo 官方提供了一个解决方案:hertz-contrib/obs-opentelemetry/logging/zerolog。我去读了它的源码,核心实现是一个 zerolog Hook:
go
func (cfg config) defaultZerologHookFn() zerolog.HookFunc {
return func(e *zerolog.Event, level zerolog.Level, message string) {
ctx := e.GetCtx() // 从 event 获取 context
span := trace.SpanFromContext(ctx) // 从 context 获取 span
// 自动注入 trace_id/span_id 到日志输出
e.Any("trace_id", spanCtx.TraceID())
e.Any("span_id", spanCtx.SpanID())
// Error 级别日志 → 自动记录到 Span(Jaeger 可见)
if level >= cfg.traceConfig.errorSpanLevel {
span.SetStatus(codes.Error, "")
span.RecordError(errors.New(message))
}
}
}
这个 Hook 通过 zerolog 的 e.GetCtx() 获取 context(而非我们的 tracelog.Event(ctx, event) 显式传递),然后:
- 将 trace_id/span_id 注入日志输出(替代我们的手动注入)
- 对 Error 级别日志调用
span.RecordError(),让它出现在 Jaeger 中
但它也有局限:
- 只处理 Error 级别,Warn/Info 不会出现在 Jaeger
- 不处理
request_id(这是我们的业务字段,通过 metainfo 传播) - 依赖
e.GetCtx()--- 需要 logger 在创建时通过.Ctx(ctx)绑定了请求 context
这意味着下一步的集成方案需要:引入官方 Hook 自动处理 trace_id 注入和 Error 记录,同时补充一个自定义 Hook 处理 Warn 级别的 Span Event 和 request_id 注入。
日志与追踪的正确关系
展示 Span + Span Events"] Hook["zerolog Hook
拦截日志事件 → 写入 Span
OTel SDK: span.AddEvent / RecordError"] Jaeger <-->|双向通信| Hook end subgraph LogChannel["日志通道"] Logger["zerolog Logger
结构化 JSON 日志输出"] Storage["Loki / ELK / 日志文件
通过 trace_id 关联检索"] Logger -->|io.Writer| Storage end Hook -.->|拦截并处理| Logger style Jaeger fill:#F39C12,stroke:#E67E22,color:#34495E style Hook fill:#7F8C8D,stroke:#95A5A6,color:#ECF0F1 style Logger fill:#2C3E50,stroke:#34495E,color:#ECF0F1 style Storage fill:#7F8C8D,stroke:#95A5A6,color:#ECF0F1
关键理解:日志和追踪是两条独立的数据通道
- 关联检索 :在 Loki/ELK 中通过
trace_id检索相关日志 - 链路可视化 :在 Jaeger 中通过
span.AddEvent()将业务事件写入链路 - 两者不冲突,也不能互相替代
日志和追踪是两条独立的数据通道。trace_id 写进日志 JSON 是为了关联检索 (在 Loki 中搜 trace_id=xxx 找到相关日志),而 span.AddEvent() 是为了在链路中直接看到事件(Jaeger Span 详情页)。两者不冲突,也不能互相替代。
九、回顾与反思
收益
| 维度 | 改造前 | 改造后 |
|---|---|---|
| 日志格式 | fmt.Sprint 拼接,字段丢失 |
zerolog 结构化,字段独立可查询 |
| 追踪关联 | 仅错误中间件有 trace_id | 所有业务日志自动携带 trace_id/request_id/span_id |
| 日志级别 | 多处误用(Error 记成功信息) | 审查修正 |
| 框架统一 | zerolog + hertzZerolog + slog 混用 | 统一 zerolog,hertzZerolog 仅留在 hlog 入口 |
经验
1. 适配层不是 API 层。 hertzZerolog 是给 Hertz 框架用的适配壳,不是给业务代码用的日志 API。这个认知偏差是问题的根源。
2. 正则批量替换需要两遍。 第一遍粗替换,第二遍修复正则无法处理的模式(嵌套括号、类型推断)。别指望一遍搞定。
3. 强制传 context 是好的 API 设计。 Log(ctx) 强制调用者提供 context,从 API 层面杜绝了"忘记注入追踪信息"的可能。
4. 写进日志 ≠ 写进链路。 trace_id 出现在日志 JSON 中只能做关联检索,要在 Jaeger 中看到业务事件,必须通过 span.AddEvent() / span.RecordError() 写入 OTel Span。这是两条独立的数据通道。
5. 分层验证比最终验证重要。 每改完一个阶段就 go build -o /dev/null .,能在几秒内发现问题。如果等全部改完再编译,面对 25 个编译错误的堆栈会让人崩溃。
后续计划
本次重构统一了日志框架和追踪字段注入,但还缺最后一环:让业务日志作为 Span Event 出现在 Jaeger 中 。下一步将引入 hertz-contrib/obs-opentelemetry/logging/zerolog 的 Hook 机制,并扩展自定义 Hook 以覆盖 Warn 级别和 request_id 注入。
本文基于一个真实项目的重构过程撰写。项目使用 Go 1.24+,Hertz v0.10.2,Kitex,zerolog v1.34.0,OpenTelemetry SDK v1.39.0。