第一章:Go日志库选型灾难的根源性认知
日志不是“能打印就行”的附属品,而是系统可观测性的基石。在Go生态中,开发者常陷入“先用log标准库,再换zap,最后加zerolog”的螺旋式重构——每一次切换都伴随埋点逻辑重写、上下文透传断裂、采样策略失效和结构化字段丢失。这种反复踩坑的本质,从来不是API设计优劣之争,而是对日志本质的三重误判。
日志不是调试输出
log.Printf 生成的是无结构、无层级、无生命周期语义的字符串流。它无法携带traceID、spanID、requestID等关键上下文,也无法被自动解析为JSON字段供ELK或Loki消费。以下代码看似简洁,实则埋下隐患:
// ❌ 错误示范:字符串拼接破坏结构化能力
log.Printf("user %s failed login at %s, reason: %s",
userID, time.Now().Format(time.RFC3339), err.Error())
// ✅ 正确路径:结构化日志需显式字段声明
logger.Info("login_failed",
zap.String("user_id", userID),
zap.Time("timestamp", time.Now()),
zap.Error(err))
上下文传递被严重低估
Go的context.Context天然支持请求链路追踪,但标准库日志完全不感知context。若未在日志实例中注入request_id等字段,分布式调用链将彻底断裂。必须通过日志库的With()机制(如zap.With()或zerolog.With().Str())实现上下文快照。
性能幻觉掩盖真实成本
许多团队仅关注“每秒写入行数”,却忽略日志序列化、内存分配、锁竞争与I/O阻塞的复合开销。基准测试显示:在高并发场景下,未启用缓冲与异步写入的同步日志库(如未配置zap.NewDevelopmentConfig().EncoderConfig.EncodeLevel = zapcore.CapitalLevelEncoder)吞吐量可骤降70%。
| 对比维度 | 标准库 log | zap(同步) | zerolog(无反射) |
|---|---|---|---|
| 内存分配/条 | 3+ 次 | 0~1 次 | 0 次 |
| 字段动态添加支持 | ❌ | ✅ | ✅ |
| context集成支持 | ❌ | ✅(需手动) | ✅(WithContext) |
选型失败的起点,永远始于把日志当作“临时调试工具”,而非服务契约的一部分。
第二章:Zap字段丢失的五大典型场景与修复实践
2.1 字段复用导致的结构体嵌套覆盖问题(理论+zap.EncoderConfig实测)
当多个日志字段名重复(如 level、time)时,嵌套结构体字段会因 Go 的匿名字段提升机制发生意外覆盖。
zap.EncoderConfig 中的典型冲突
cfg := zap.NewProductionEncoderConfig()
cfg.LevelKey = "level" // ← 覆盖 EncoderConfig.levelKey
cfg.TimeKey = "level" // ← 再次写入同名字段!
此处
TimeKey = "level"会静默覆盖LevelKey的原始值,因EncoderConfig是扁平结构体,无字段隔离。Go 编译器不报错,但运行时level字段输出异常。
复用风险对比表
| 字段名 | 初始值 | 覆盖后值 | 影响 |
|---|---|---|---|
LevelKey |
"level" |
"level" |
无变化 |
TimeKey |
"ts" |
"level" |
时间字段被重命名为 level |
数据同步机制
graph TD
A[配置赋值] –> B{字段名是否唯一?}
B –>|否| C[后写字段覆盖前写]
B –>|是| D[正常序列化]
- 字段复用本质是命名空间缺失,非类型安全缺陷
- 解决方案:预校验键名唯一性(如
validateEncoderConfig()辅助函数)
2.2 Context传递中field.Key被意外重写(理论+zap.WithContext源码级调试)
问题根源:context.WithValue的Key类型擦除风险
当多个模块使用相同field.Key实例(如field.String("user_id", "123"))向同一context.Context反复写入时,后写入值会覆盖前值——因zap.WithContext内部调用context.WithValue(ctx, key, value),而field.Key本质是interface{},若未保证唯一性,极易冲突。
zap.WithContext关键逻辑解析
func WithContext(ctx context.Context, logger *Logger) *Logger {
return logger.WithOptions(Option(func(log *Logger) {
log.ctx = ctx // 直接持有ctx,不校验key唯一性
}))
}
⚠️ log.ctx被直接赋值,但后续logger.Info("msg", zap.String("user_id", "456"))触发的ctx = context.WithValue(ctx, field.Key, value)无防重机制。
复现路径(mermaid)
graph TD
A[初始化ctx] --> B[moduleA.Write user_id=123]
B --> C[moduleB.Write user_id=456]
C --> D[最终ctx中user_id=456 覆盖原值]
安全实践建议
- ✅ 使用
context.WithValue时,定义私有type userIDKey struct{}而非复用field.Key - ✅ 封装
WithContext为WithSafeContext(ctx, key, val)并校验key类型 - ❌ 避免跨包共享裸
field.Key变量
2.3 非线程安全的logger实例共享引发字段错乱(理论+goroutine race复现与sync.Pool修复)
问题根源:结构体字段被并发写入
log.Logger 本身非线程安全,若多个 goroutine 共享同一实例并调用 SetPrefix 或修改 out/prefix 字段,将触发 data race。
复现场景代码
var sharedLogger = log.New(os.Stdout, "", 0)
func raceDemo() {
for i := 0; i < 10; i++ {
go func(id int) {
sharedLogger.SetPrefix(fmt.Sprintf("[G%d] ", id)) // ⚠️ 竞态写入 prefix
sharedLogger.Println("msg")
}(i)
}
}
SetPrefix直接赋值l.prefix = prefix,无锁保护;10个 goroutine 并发修改同一内存地址,导致日志前缀交错、丢失或 panic。
修复方案对比
| 方案 | 线程安全 | 内存开销 | 初始化成本 |
|---|---|---|---|
每次新建 log.New |
✅ | 高(频繁 alloc) | 低 |
sync.Mutex 包裹 |
✅ | 低 | 中(锁开销) |
sync.Pool 复用 |
✅ | 极低 | 一次 init |
sync.Pool 实现
var loggerPool = sync.Pool{
New: func() interface{} {
return log.New(os.Stdout, "", log.LstdFlags)
},
}
func getSafeLogger() *log.Logger {
return loggerPool.Get().(*log.Logger)
}
func putSafeLogger(l *log.Logger) {
l.SetPrefix("") // 重置状态
loggerPool.Put(l)
}
sync.Pool提供无锁对象复用:Get()返回已初始化实例,Put()归还前清空可变字段(如prefix),避免状态污染。
graph TD
A[goroutine 调用 getSafeLogger] –> B{Pool 有空闲实例?}
B –>|是| C[返回复用 logger]
B –>|否| D[调用 New 创建新实例]
C & D –> E[业务逻辑写入日志]
E –> F[调用 putSafeLogger 归还]
F –> G[Pool 自动 GC 回收]
2.4 JSON Encoder配置缺失导致非字符串字段静默丢弃(理论+zap.NewDevelopmentEncoderConfig对比实验)
Zap 默认的 jsonEncoder 在未显式配置 EncodeLevel、EncodeTime 等字段编码器时,对非字符串类型(如 error、time.Time、[]byte)会直接跳过序列化,不报错、不警告、不占位,造成日志字段“消失”。
对比实验:默认 vs Development 配置
cfg := zap.NewProductionEncoderConfig() // 缺失 EncodeTime/EncodeLevel
// → time.Time 字段被完全忽略
devCfg := zap.NewDevelopmentEncoderConfig()
// → 自动启用 EncodeTime(ISO8601)、EncodeLevel(LowercaseColor) 等
逻辑分析:jsonEncoder 的 EncodeEntry 方法中,若 enc.timeKey != "" 且 enc.timeEncoder == nil,则 t 字段被跳过;同理 levelEncoder、nameEncoder 为空时对应字段静默丢弃。
关键差异表
| 配置项 | 默认 ProductionEncoderConfig | DevelopmentEncoderConfig |
|---|---|---|
EncodeTime |
nil(丢弃时间) |
zapcore.ISO8601TimeEncoder |
EncodeLevel |
nil(丢弃级别名) |
zapcore.LowercaseLevelEncoder |
修复路径
- 显式设置
EncodeTime/EncodeLevel - 或直接复用
NewDevelopmentEncoderConfig()基础模板再微调
2.5 自定义Core中错误调用Write()跳过字段序列化流程(理论+core.Write方法Hook注入验证)
当用户在自定义 core 类型中直接调用 Write() 方法(而非经由 Encoder.Encode() 统一流程),会绕过字段过滤、标签解析、钩子执行等关键序列化阶段,导致 json:",omitempty" 或 yaml:"name,omitempty" 等语义失效。
核心问题链
Encoder.Encode()→ 触发MarshalJSON()→ 执行字段遍历 + tag 解析 +Marshaler接口检查- 直接
core.Write()→ 跳过全部中间层 → 仅写入原始字节或未处理结构体内存布局
Hook 注入验证示例
// 在 core.Write 前注入校验钩子
func (c *CustomCore) Write(p []byte) (n int, err error) {
if !c.isSerializationContext() { // 检查是否处于 Encode 流程上下文
panic("illegal direct Write() call — bypasses field serialization logic")
}
return c.writer.Write(p)
}
该钩子通过
runtime.Caller()追溯调用栈,识别是否源自encoding/json.(*encodeState).encode()。若非,则拒绝写入并抛出明确错误,强制开发者回归标准编码路径。
| 验证维度 | 合规调用 | 错误调用 |
|---|---|---|
| 调用栈深度 | ≥8(含 json.encode) | ≤3(直连 core.Write) |
| 字段 omitempty | 生效 | 完全忽略 |
graph TD
A[Encoder.Encode] --> B[Build encodeState]
B --> C[Traverse struct fields]
C --> D[Apply tags & hooks]
D --> E[Call core.Write]
F[Direct core.Write] --> G[Skip B/C/D]
G --> H[Raw output - no serialization]
第三章:log/slog context污染的三重陷阱与隔离策略
3.1 Handler.Handle中隐式继承parent context造成键值污染(理论+slog.Handler接口生命周期分析)
slog.Handler 的 Handle 方法接收 context.Context,但标准实现(如 JSONHandler)未显式创建子 context,而是直接复用传入的 ctx——这导致父 context 中通过 context.WithValue 注入的键值对被无意透传至日志处理链路。
隐式继承的生命周期陷阱
Handler.Handle被调用时,context 已携带上游中间件/HTTP handler 注入的业务键(如userID,requestID)- 日志 handler 若未调用
context.WithValue(ctx, key, nil)清理或隔离,这些键将参与日志字段序列化 - 多 goroutine 并发写日志时,键值污染引发字段覆盖或 panic(
context.Value非并发安全写)
func (h *jsonHandler) Handle(ctx context.Context, r slog.Record) error {
// ❌ 隐式使用 ctx —— 未隔离 parent context
data := map[string]any{"time": r.Time, "level": r.Level.String()}
for _, a := range r.Attrs() {
data[a.Key] = a.Value.Any() // 若 a.Value 依赖 ctx.Value(key),则污染发生
}
return json.NewEncoder(h.w).Encode(data)
}
上述代码中
r.Attrs()可能包含slog.Group或自定义slog.Value,其Any()方法内部可能调用ctx.Value(),从而触发父 context 键值泄露。
污染传播路径(mermaid)
graph TD
A[HTTP Handler] -->|context.WithValue(ctx, userID, “u123”) | B[Business Logic]
B -->|slog.InfoContext(ctx, …)| C[slog.Handler.Handle]
C -->|直接使用 ctx| D[JSON serialization]
D --> E[Log output contains userID unexpectedly]
| 场景 | 是否隔离 context | 风险等级 |
|---|---|---|
slog.WithGroup().With() |
否 | ⚠️ 中 |
slog.New(handler).With() |
否 | ⚠️ 中 |
显式 slog.Handler 封装子 context |
是 | ✅ 安全 |
3.2 slog.WithGroup嵌套时group前缀与context.Value键名冲突(理论+group prefix哈希碰撞实测)
slog.WithGroup 在嵌套调用时,会将 group 名拼接为路径式前缀(如 "db" → "db.query"),但底层仍使用 context.WithValue 存储结构化上下文。问题在于:同一 group 名在不同嵌套层级可能生成相同哈希值,导致 context.Value 键名覆盖。
哈希碰撞实测示例
// Go 1.22+ slog 内部 key 生成逻辑(简化)
func groupKey(name string) interface{} {
// 实际使用 runtime/internal/abi.HashString,非安全哈希
h := fnv.New64a()
h.Write([]byte(name)) // 注意:仅对 name 本身哈希,未包含嵌套深度或路径
return h.Sum64()
}
⚠️ 关键缺陷:
WithGroup("a.b").WithGroup("c")与WithGroup("a").WithGroup("b.c")可能产生相同name字符串(若路径解析不当),或因 FNV64a 碰撞率升高(短字符串碰撞概率约 1/2³²)。
冲突影响链
context.WithValue(ctx, keyA, v1)context.WithValue(ctx, keyA, v2)→ v1 被静默覆盖- 后续
slog.Handler.Enabled()或slog.Handler.Handle()读取错误 group 上下文
| 场景 | group 路径 | 实际 key hash (hex) | 是否冲突 |
|---|---|---|---|
WithGroup("auth").WithGroup("token") |
"auth.token" |
0x8a3f...d210 |
— |
WithGroup("auth.token") |
"auth.token" |
0x8a3f...d210 |
✅ 冲突 |
graph TD
A[WithGroup\\n\"auth\"] --> B[WithGroup\\n\"token\"]
C[WithGroup\\n\"auth.token\"] --> D[Same key hash]
B --> D
C --> D
3.3 context.WithValue链式传递导致slog.Record.Attributes重复叠加(理论+Record.Attrs()内存布局dump验证)
根本原因:context.Value被多次注入同一key
当多层中间件调用 context.WithValue(parent, slog.KeyValue{}, kv) 时,slog 的 Handler 在构建 Record 时会遍历整个 context 链,逐层提取所有匹配的 slog.KeyValue 值,而非仅取最新一层。
内存布局验证(Record.Attrs() dump 示例)
// 模拟链式 context 注入
ctx := context.WithValue(context.Background(), slog.GroupKey, slog.String("svc", "api"))
ctx = context.WithValue(ctx, slog.GroupKey, slog.Int("retry", 3)) // ❌ 重复 key 导致叠加
r := slog.NewRecord(time.Now(), 0, "msg", nil)
r.AddAttrs(slog.String("op", "read"))
fmt.Printf("%+v\n", r.Attrs()) // 输出含 2 个 GroupKey 属性
r.Attrs()返回[]slog.Attr切片,底层为连续内存块;WithValue不覆盖而追加,导致Attrs()中出现重复GroupKey、String等属性。
属性叠加行为对比表
| 场景 | context 链深度 | Record.Attrs() 长度 | 是否含重复 key |
|---|---|---|---|
| 单层 WithValue | 1 | 1 | 否 |
| 三层同 key 注入 | 3 | 3 | 是(3× slog.String("x", "y")) |
正确实践路径
- ✅ 使用
slog.With()创建新Logger实例(隔离 attrs) - ✅ 避免在 context 中传递
slog.KeyValue—— 仅用于请求元数据(如 traceID) - ❌ 禁止
WithValue(ctx, slog.KeyValue{}, ...)链式调用
graph TD
A[Handler.Handle] --> B{遍历 context 链}
B --> C[提取所有 slog.KeyValue 值]
C --> D[append 到 r.attrs]
D --> E[Record.Attrs 返回重复切片]
第四章:结构化日志JSON嵌套断裂的四类根因与重建方案
4.1 嵌套map[string]any在slog.Group中被扁平化为顶层字段(理论+json.Marshal与slog.Group序列化路径对比)
slog.Group 并非简单嵌套结构,而是语义扁平化容器:其内部 map[string]any 的键值对在最终序列化时被提升至日志顶层,不保留原始嵌套路径。
序列化行为差异
| 序列化方式 | map[string]any{"user": map[string]any{"id": 123}} 输出片段 |
|---|---|
json.Marshal |
"user":{"id":123}(严格保留嵌套) |
slog.Group + JSON |
"user.id":123(自动点号展开,无嵌套对象) |
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
logger.Info("event",
slog.Group("user",
slog.String("name", "alice"),
slog.Int("id", 42),
),
)
// 输出: {"time":"...","level":"INFO","msg":"event","user.name":"alice","user.id":42}
逻辑分析:
slog.Group在JSONHandler的handleGroup方法中遍历字段,对每个子字段递归调用addAttrs,并拼接groupKey + "." + attrKey作为新键名。any值若为map[string]any,仍被逐层解构而非封装为 JSON 对象。
扁平化本质
- ✅ 避免 JSON 层级膨胀,利于日志平台字段检索
- ❌ 失去原始结构语义,
user.id与user_profile.id冲突风险上升
graph TD
A[Group{user}] --> B[encode as user.name]
A --> C[encode as user.id]
D[json.Marshal] --> E[emit {\"user\":{\"name\":\"alice\"}}]
4.2 Zap的ObjectEncoder对interface{}类型递归深度限制触发截断(理论+zap.ObjectEncoder.SetReflected设置实测)
Zap 默认对 interface{} 类型反射序列化时启用 递归深度限制(默认为10层),超出即截断并标记为 <truncated>。
递归截断行为验证
type Nested struct {
Inner *Nested `json:"inner,omitempty"`
}
obj := &Nested{Inner: &Nested{Inner: &Nested{}}} // 构造深度 > 10 的链
logger.Info("nested", zap.Any("data", obj))
// 输出:data={"inner":{"inner":{"inner":{...<truncated>}}}}
该行为由 zapcore.ReflectEncoder 内部 maxDepth 控制,不可直接修改。
SetReflected 实测效果
| 设置方式 | 是否生效 | 说明 |
|---|---|---|
enc.SetReflected(true) |
✅ | 启用完整反射(含深度扩展) |
enc.SetReflected(false) |
❌ | 回退至结构体字段直写 |
enc := zapcore.NewMapObjectEncoder()
enc.SetReflected(true) // 允许深度反射,但不解除 maxDepth 硬限制
SetReflected(true) 仅启用反射路径,不改变默认递归上限;需配合自定义 ReflectEncoder 才能调整深度。
4.3 第三方中间件(如gin、echo)日志适配器未正确透传嵌套结构(理论+gin-contrib/zap中间件源码补丁)
问题根源:上下文丢失导致嵌套字段扁平化
gin-contrib/zap 默认使用 c.Request.Context() 提取 zap.Logger,但未将 requestID、traceID 等嵌套结构体字段注入 logger 实例,导致 logger.With(zap.String("user_id", "123")) 在中间件链中被丢弃。
源码缺陷定位(v0.4.0)
// gin-contrib/zap/zap.go#L58-L62
func CustomZap(logger *zap.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
c.Set("logger", logger) // ❌ 仅传递原始 logger,未融合请求上下文字段
c.Next()
}
}
该处未调用
logger.With(...)注入c.MustGet("trace_id")等动态键值,造成嵌套结构无法透传。
补丁方案对比
| 方案 | 是否保留嵌套结构 | 需修改中间件 | 性能开销 |
|---|---|---|---|
原生 logger.With() |
✅ | 是 | 极低 |
zap.AddCallerSkip() |
❌ | 否 | 无 |
修复后中间件核心逻辑
func PatchedZap(logger *zap.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
fields := []zap.Field{
zap.String("path", c.Request.URL.Path),
zap.String("method", c.Request.Method),
}
if traceID, ok := c.Get("trace_id"); ok {
fields = append(fields, zap.String("trace_id", traceID.(string)))
}
c.Set("logger", logger.With(fields...)) // ✅ 动态注入嵌套字段
c.Next()
}
}
logger.With(fields...)返回新 logger 实例,确保每个请求携带独立嵌套上下文,避免 goroutine 间日志污染。
4.4 日志采样/限流逻辑中提前序列化破坏原始结构树(理论+sampled logger Core.WrapCore拦截验证)
当采样器在 Core.WrapCore 拦截阶段对日志事件执行提前 JSON 序列化(如为限流决策调用 ToString() 或 Serialize()),原始结构化日志的 Properties 字典与嵌套对象树即被扁平为字符串,导致后续 ILogger<T> 的结构化写入丢失层级语义。
核心问题链
- 采样逻辑误将
LogEvent视为“可序列化即安全”,忽略LogEvent是延迟求值的数据容器 WrapCore中调用event.ToString()→ 触发LogEventFormatter.Format()→ 强制展开所有ILogValue并递归序列化- 原始
new { User = new { Id = 123, Tags = new[] { "vip" } } }变为不可逆字符串"{"User":{"Id":123,"Tags":["vip"]}}"
验证代码片段
// 在 sampled logger 的 WrapCore 中注入检测点
public IDisposable BeginScope<TState>(TState state)
{
if (state is LogEvent evt && evt.Exception == null)
{
// ❌ 危险:触发隐式结构坍塌
var _ = evt.ToString(); // ← 此行销毁 Properties 树形引用
}
return Inner.BeginScope(state);
}
逻辑分析:
evt.ToString()内部调用LogEvent.ToString()→LogEventFormatter.Format()→ 对每个LogEntry调用value.Render(...)。若value是ObjectLogValue,则递归JsonSerializer.Serialize(value.Object),原始IDictionary<string, object>被转为JsonElement后丢弃元信息。
| 阶段 | 数据形态 | 是否保留结构 |
|---|---|---|
初始 LogEvent |
Properties["user"] = new User{...} |
✅ 引用完整、可遍历 |
ToString() 后 |
Properties["user"] = "{\"id\":123}" |
❌ 变为字符串,user.id 查询失效 |
graph TD
A[LogEvent.Create] --> B[Properties.Add user, new User{Id=123}]
B --> C[WrapCore 拦截]
C --> D{调用 evt.ToString?}
D -->|是| E[LogEventFormatter.Format]
E --> F[ObjectLogValue.Render → JsonSerializer.Serialize]
F --> G[Properties[\"user\"] = \"{}\" string]
D -->|否| H[保持 ObjectLogValue 包装]
第五章:构建可演进的Go日志治理规范
日志层级与语义契约设计
在真实微服务场景中,某支付网关项目曾因 log.Printf 泛滥导致排查耗时翻倍。我们重构后定义四层语义契约:DEBUG(仅限开发环境调试变量快照)、INFO(业务关键路径节点,如 order_id=123456, status=created)、WARN(可恢复异常,如第三方API超时但已降级)、ERROR(需告警的不可恢复错误,含完整堆栈+上下文ID)。所有日志必须携带 request_id、service_name、trace_id 三个结构化字段,通过 log.WithFields() 统一注入。
结构化日志输出规范
采用 zerolog 替代标准库,强制 JSON 输出并禁用非结构化字符串拼接:
logger := zerolog.New(os.Stdout).
With().
Str("service", "payment-gateway").
Str("env", os.Getenv("ENV")).
Logger()
// 正确示例
logger.Info().Str("order_id", orderID).Int64("amount", 99900).Msg("payment initiated")
// 禁止写法:log.Printf("payment initiated: %s, %d", orderID, amount)
日志采样与分级落盘策略
| 为平衡可观测性与存储成本,实施动态采样: | 日志级别 | 生产环境采样率 | 落盘位置 | 保留周期 |
|---|---|---|---|---|
| ERROR | 100% | ELK + S3归档 | 90天 | |
| WARN | 5% | Kafka → ES | 7天 | |
| INFO | 0.1% | 本地文件轮转 | 24小时 | |
| DEBUG | 0%(仅测试环境) | — | — |
可演进配置中心集成
通过 Consul KV 动态控制日志行为,避免重启生效:
// 监听配置变更
consulClient.KV.Get("log/level", &q)
if q.StatusCode == 200 {
level, _ := zerolog.ParseLevel(q.Value())
zerolog.SetGlobalLevel(level)
}
上下文传播与链路追踪对齐
使用 context.Context 注入日志上下文,确保与 OpenTelemetry trace ID 对齐:
func HandlePayment(ctx context.Context, req PaymentReq) {
// 从ctx提取traceID并注入logger
traceID := trace.SpanFromContext(ctx).SpanContext().TraceID().String()
logger := log.With().Str("trace_id", traceID).Logger()
logger.Info().Str("method", "POST").Msg("handling payment")
}
日志治理演进路线图
初期强制执行字段校验(通过 log-validator 工具扫描代码),中期接入日志质量看板(统计缺失 request_id 的日志占比),长期实现自动修复建议(当检测到 log.Printf 时,CI流水线自动提示替换为结构化调用)。某电商大促期间,该规范使故障定位平均耗时从 18 分钟降至 3.2 分钟,错误日志重复率下降 76%。
flowchart TD
A[代码提交] --> B[CI扫描日志调用]
B --> C{是否含非结构化log?}
C -->|是| D[阻断构建+推送修复建议]
C -->|否| E[注入标准化上下文]
E --> F[输出JSON日志]
F --> G[按采样策略路由] 