b6a6875795
往"生产可运维"推一步(网关前门):
- Prometheus /metrics:sundynix_http_requests_total{method,route,status}、
request_duration_seconds 直方图、requests_in_flight。route 用 c.FullPath()
路由模板(/tasks/:id/...)避免按真实路径高基数。
- 结构化访问日志:slog JSON 到 stderr(request_id/method/route/status/latency_ms/
ip/uid/bytes),替代 gin 默认文本日志;gin.New()+Recovery 自管中间件链。
- RequestID 中间件:生成/透传 X-Request-ID,写上下文+响应头,供日志关联。
- 探针:/healthz(liveness,不查依赖)、/readyz(readiness,DB+Redis 就绪才 200,
否则 503),供 k8s 等导流判断;/api/v1/health 深度聚合保留。
- 三个根端点不挂业务鉴权(/metrics 生产应由网络层限制抓取来源)。
验证:单测(计数 +1 / X-Request-ID 生成与透传);实跑 /healthz 200、/readyz 200
(db,redis ready)、/metrics 输出真实指标、访问日志 JSON 正常、X-Request-ID 回写。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
95 lines
2.4 KiB
Go
95 lines
2.4 KiB
Go
package middleware
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"log/slog"
|
|
"os"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/prometheus/client_golang/prometheus"
|
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
|
)
|
|
|
|
// CtxRequestID 是请求 ID 在 gin.Context 中的键。
|
|
const CtxRequestID = "request_id"
|
|
|
|
// ---- Prometheus 指标 ----
|
|
|
|
var (
|
|
httpRequests = promauto.NewCounterVec(prometheus.CounterOpts{
|
|
Name: "sundynix_http_requests_total",
|
|
Help: "HTTP 请求总数(按方法/路由模板/状态码)。",
|
|
}, []string{"method", "route", "status"})
|
|
|
|
httpDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{
|
|
Name: "sundynix_http_request_duration_seconds",
|
|
Help: "HTTP 请求耗时(秒)。",
|
|
Buckets: prometheus.DefBuckets,
|
|
}, []string{"method", "route"})
|
|
|
|
httpInFlight = promauto.NewGauge(prometheus.GaugeOpts{
|
|
Name: "sundynix_http_requests_in_flight",
|
|
Help: "当前处理中的 HTTP 请求数。",
|
|
})
|
|
)
|
|
|
|
// accessLogger 是结构化访问日志器(JSON 到 stderr)。
|
|
var accessLogger = slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
|
|
|
// RequestID 为每个请求生成/透传 X-Request-ID,写入上下文与响应头,供日志关联。
|
|
func RequestID() gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
id := c.GetHeader("X-Request-ID")
|
|
if id == "" {
|
|
id = newRequestID()
|
|
}
|
|
c.Set(CtxRequestID, id)
|
|
c.Header("X-Request-ID", id)
|
|
c.Next()
|
|
}
|
|
}
|
|
|
|
// Observe 记录 Prometheus 指标 + 结构化访问日志。放在中间件链较前位置。
|
|
func Observe() gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
start := time.Now()
|
|
httpInFlight.Inc()
|
|
c.Next()
|
|
httpInFlight.Dec()
|
|
|
|
route := c.FullPath() // 路由模板(/tasks/:id/...),避免按真实路径产生高基数
|
|
if route == "" {
|
|
route = "unmatched"
|
|
}
|
|
status := c.Writer.Status()
|
|
dur := time.Since(start)
|
|
method := c.Request.Method
|
|
|
|
httpRequests.WithLabelValues(method, route, strconv.Itoa(status)).Inc()
|
|
httpDuration.WithLabelValues(method, route).Observe(dur.Seconds())
|
|
|
|
uid, _ := c.Get(CtxUserID)
|
|
rid, _ := c.Get(CtxRequestID)
|
|
accessLogger.Info("http",
|
|
"request_id", rid,
|
|
"method", method,
|
|
"route", route,
|
|
"path", c.Request.URL.Path,
|
|
"status", status,
|
|
"latency_ms", dur.Milliseconds(),
|
|
"ip", c.ClientIP(),
|
|
"uid", uid,
|
|
"bytes", c.Writer.Size(),
|
|
)
|
|
}
|
|
}
|
|
|
|
func newRequestID() string {
|
|
var b [8]byte
|
|
_, _ = rand.Read(b[:])
|
|
return hex.EncodeToString(b[:])
|
|
}
|