必须包装ResponseWriter才能获取真实状态码,否则日志全为200;需实现WriteHeader和Write方法捕获状态码与响应体,同时兼顾Hijack、Flush等接口以避免panic。

HTTP服务端耗时记录必须包装 ResponseWriter
不包装 ResponseWriter,就拿不到真实状态码,日志里全是 200 —— 即使接口返回了 404 或 500。因为 Go 标准库默认状态码是 200,只有显式调用 WriteHeader 才会改,而很多错误路径(比如 JSON 序列化失败、panic 后 recovery 中间件)根本没走这一步。
正确做法是定义一个结构体实现 http.ResponseWriter 接口,重写 WriteHeader 和 Write:
type timingResponseWriter struct {
http.ResponseWriter
statusCode int
}
func (w *timingResponseWriter) WriteHeader(code int) {
w.statusCode = code
w.ResponseWriter.WriteHeader(code)
}
func (w *timingResponseWriter) Write(b []byte) (int, error) {
if w.statusCode == 0 {
w.statusCode = http.StatusOK
}
return w.ResponseWriter.Write(b)
}
-
WriteHeader是唯一可靠捕获状态码的位置,必须在这里赋值 -
Write里补默认 200,覆盖隐式写入导致的状态码丢失 - 别漏掉
Hijack、Flush等方法——否则 gzip、streaming 场景会 panic - 状态码字段必须是
int类型,不是指针;避免并发写冲突
客户端请求耗时必须自定义 RoundTripper
只在业务层用 time.Now() + Do() 包裹,漏掉 DNS、连接池等待、TLS 握手、响应体读取 —— 这些阶段加起来可能占总耗时 70% 以上,尤其在跨区域调用或高延迟网络下。
唯一能拿到全链路时间点的位置是 http.RoundTripper.RoundTrip 方法:
立即学习“go语言免费学习笔记(深入)”;
初始化并管理阿里云SDK Go 客户端,涵盖 sync.Once 单例、goroutine 安全、endpoint 与 region 配置、VPC 端点及调试。
type TimingRoundTripper struct {
base http.RoundTripper
}
func (t *TimingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
start := time.Now()
resp, err := t.base.RoundTrip(req)
if err != nil {
recordError(req.URL.Host, time.Since(start).Milliseconds())
return resp, err
}
resp.Body = &timingReadCloser{
ReadCloser: resp.Body,
req: req,
start: start,
}
return resp, nil
}
-
base必须是线程安全的 transport(如http.DefaultTransport),不能 new 出临时实例 - 错误路径也要记录耗时,否则超时、拒绝连接等场景完全无数据
- 必须包装
resp.Body实现io.ReadCloser,否则大响应体或 SSE 流式接口的读取耗时无法捕获 - 别直接改
http.DefaultTransport字段——它是全局可变结构体,高并发下不安全
中间件里 time.Since() 必须传参进 defer,不能闭包引用变量
写成 defer fmt.Printf("cost: %v", time.Since(start)) 看似简洁,但在 panic、context 超时、提前 http.Error 时,defer 根本不会执行,耗时就丢了。
更隐蔽的问题是:Go 1.22 之前,闭包捕获的是变量地址,不是值,多个 goroutine 并发时 start 可能被覆盖。
- 正确写法:
defer func(s time.Time) { logCost(s) }(start) - 起始时间必须在
c.Next()(Gin)或next.ServeHTTP()(标准库)前获取 - 结束时间必须在
c.Next()返回后立刻获取,不能等 defer 触发 - 单位统一转
Milliseconds()后四舍五入,避免12.000000ms这类干扰项
trace_id 透传和日志字段必须对齐,否则查不到完整链路
没有 trace_id 的耗时日志等于废日志 —— 你没法把一次 API 请求的入口耗时、下游 HTTP 调用耗时、DB 查询耗时串在一起。但很多人把 trace_id 存进 context 后,日志里却没打出来,或者字段名不一致(trace_id vs traceId vs tid),导致 Loki 或 ELK 聚合失败。
- trace_id 必须在最外层中间件生成,用
uuid.Must(uuid.NewRandom()).String() - 存进 context 时用不可变 string,别传 map 或 struct 指针
- 日志字段固定为
method、path、status、duration_ms、trace_id、remote_addr - 路径用
r.URL.Path,别试图还原路由模板(/user/{id}),匹配失败时会 panic - query 参数一律剔除,防止 token、password 泄露
真正难的不是埋点,而是让所有环节(HTTP server、HTTP client、DB driver、gRPC)都用同一套 trace_id 注入逻辑,且不因中间件顺序错乱。一旦顺序不对,下游拿到的就是空 context,整条链就断了。

















