让服务真正可观测:slog、请求关联与错误上下文
日志不是把字符串写进文件,而是给一次请求留下可查询的因果线索。log/slog 提供结构化记录和可替换 Handler,但字段设计、关联方式和记录边界仍要由服务自己决定。
日志、指标、追踪各回答什么
flowchart TD
Q["服务为什么异常?"] --> M["指标:何时开始、影响多大"]
Q --> T["追踪:慢在哪个调用段"]
Q --> L["日志:这次请求发生了什么"]
M --> C["通过 service / route / trace_id 关联"]
T --> C
L --> C
不要用日志承担所有观测职责。请求量、错误率、延迟分布属于指标;跨服务调用关系属于追踪;包含业务上下文的离散事件才适合日志。
建立稳定的字段契约
level := new(slog.LevelVar)
level.Set(slog.LevelInfo)
handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: level,
AddSource: true,
})
logger := slog.New(handler).With(
"service", "order-api",
"env", os.Getenv("APP_ENV"),
)
slog.SetDefault(logger)生产环境通常使用 JSON Handler,便于日志平台索引;本地可以使用 Text Handler。字段名应稳定,例如统一使用 request_id、trace_id、user_id,不要在不同模块里出现三套拼写。
Logger.With 适合创建带固定上下文的子 Logger:
orderLog := logger.With(
"request_id", requestID,
"trace_id", traceID,
"component", "order_service",
)
orderLog.Info("order created", "order_id", order.ID, "amount_cent", order.Amount)嵌套对象用 Group 保持命名空间:
logger.Info("upstream completed",
slog.Group("http",
slog.String("method", method),
slog.Int("status", status),
slog.Duration("duration", elapsed),
),
)Context 不会自动变成日志字段
InfoContext 会把 Context 传给 Handler,但标准 Handler 不会自动提取 request ID 或 trace ID。可以在中间件构造带请求字段的 Logger,并通过小型辅助函数传递;不要把整个业务对象塞进 Context。
type ctxKey struct{}
func withLogger(ctx context.Context, l *slog.Logger) context.Context {
return context.WithValue(ctx, ctxKey{}, l)
}
func loggerFrom(ctx context.Context) *slog.Logger {
if l, ok := ctx.Value(ctxKey{}).(*slog.Logger); ok { return l }
return slog.Default()
}另一种方式是自定义 Handler,在 Handle 中从 Context 提取追踪信息。无论哪种方式,都要让提取逻辑集中,避免每条日志手工拷贝字段。
sequenceDiagram
participant C as 客户端
participant M as HTTP 中间件
participant S as Service
participant D as 下游
C->>M: request_id / trace context
M->>M: 构造请求 Logger
M->>S: ctx + logger
S->>D: 传播 ctx
D-->>S: wrapped error
S-->>M: 返回错误,不重复打印
M-->>C: 映射状态码并记录一次完成日志
错误在底层包装,在边界记录
底层应增加可用于定位的上下文,同时保留错误链:
return fmt.Errorf("load order %s: %w", orderID, err)如果 repository、service、handler 每层都打印同一个错误,一次失败会变成三条噪声。更清晰的策略是:底层包装,能够决定最终响应和严重级别的边界记录一次。日志中同时保留可分类的错误类型或码:
logger.ErrorContext(ctx, "request failed",
"error", err,
"error_code", code,
"route", routePattern,
"status", status,
"duration", time.Since(start),
)route 应使用低基数的模式 /users/{id},不能直接拿原始 URL 做指标标签。日志字段可以高基数,但仍要控制体积和查询成本。
安全与性能边界
- 密码、令牌、Cookie、身份证号等不进入日志;对类型实现
LogValuer可集中脱敏。 - 不记录完整请求/响应 Body 作为常规手段。必要的审计日志应有独立规范、访问控制和保留策略。
- 热路径先调用
logger.Enabled(ctx, level),避免为被过滤的日志做昂贵计算。 - 已知字段较多时用
LogAttrs和slog.Attr,减少临时分配。 LevelVar可动态调整级别,但临时 Debug 应有自动回收机制,避免日志量失控。
type Email string
func (e Email) LogValue() slog.Value {
s := string(e)
if i := strings.IndexByte(s, '@'); i > 1 {
s = s[:1] + "***" + s[i:]
}
return slog.StringValue(s)
}最后,记录一条统一的请求完成日志:方法、路由模板、状态码、耗时、响应大小和关联 ID。它既是排障入口,也能用来抽样核对指标与追踪。真正的可观测性不是“日志很多”,而是从告警能沿着共同标识快速走到一条具体失败链路。
进一步阅读:log/slog 包文档、Go 官方 slog 设计文章。