From 7b600f7eee81ac7285b707e709108b0ad32a1590 Mon Sep 17 00:00:00 2001 From: opsx Date: Tue, 27 Oct 2026 14:49:45 +0000 Subject: [PATCH] =?UTF-8?q?feat(log):=20Gin=20=E7=BB=93=E6=9E=84=E5=8C=96?= =?UTF-8?q?=20JSON=20=E8=AE=BF=E9=97=AE=E6=97=A5=E5=BF=97=20+=20=E7=94=9F?= =?UTF-8?q?=E4=BA=A7=E9=BB=98=E8=AE=A4=20release=20=E6=A8=A1=E5=BC=8F?= =?UTF-8?q?=EF=BC=88=E5=8E=BB=E6=8E=89=E5=90=AF=E5=8A=A8=E8=B7=AF=E7=94=B1?= =?UTF-8?q?=20dump=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit === 一、结构化 JSON 访问日志(middleware/accesslog.go)=== gin.Default() → gin.New() + AccessLog() + gin.Recovery() - 一行一个 JSON:ts/level/type/method/path/status/latency_ms/bytes/ip/ua - 自动分级:5xx=error / 4xx=warn / 其余=info - jwt 中间件写入的 userId/username 一并输出,便于审计 - 健康检查与 /assets 静音(探针每 10s 一次会淹没真实请求) - Recovery 显式加回(gin.Default 里包含它) ⚠️ Go map 序列化按 key 字母序,ts 会排到最后 → LogJSON 手工拼装, 把 ts/level/type/msg 固定在最前,其余排序输出。 === 二、生产默认 release 模式 === 代码里此前**完全没有 gin.SetMode**,一直跑在 debug: - 每次启动 dump 100+ 行路由表([GIN-debug] GET /api/v1/...) - 每请求有额外开销,且把完整路由暴露在日志里 现在:GIN_MODE 未显式设置且 DEBUG!=true 时自动 SetMode(ReleaseMode), 需要调试仍可用 DEBUG=true 或 GIN_MODE=debug 覆盖。 === 三、实测 === GIN-debug 路由 dump 行数: 0 ✅(修复前 100+) JSON 访问日志: {"ts":"2026-10-27T14:49:28.53Z","level":"info","type":"access","bytes":828, "ip":"172.19.16.92","latency_ms":173.5,"method":"POST", "path":"/api/v1/auth/login","status":200,"ua":"curl/8.4.0"} ✅ 带用户信息: ...,"user_id":1,"username":"admin" ✅ health 日志行数: 0 ✅ 总日志 15 行 / JSON 7 行 / 非 JSON 8 行(全部是启动日志) health ok、Promtail 12/12、lokistack 1/1 ✅ 镜像:lghost/lokistack:latest + v0.1 = sha256:8b25b0a29efa --- backend/main.go | 11 +++ backend/middleware/accesslog.go | 134 ++++++++++++++++++++++++++++++++ backend/routers/router.go | 7 +- 3 files changed, 151 insertions(+), 1 deletion(-) create mode 100644 backend/middleware/accesslog.go diff --git a/backend/main.go b/backend/main.go index c4b2d06..e96d235 100644 --- a/backend/main.go +++ b/backend/main.go @@ -5,10 +5,14 @@ import ( "errors" "log" "net/http" + "os" "os/signal" + "strings" "syscall" "time" + "github.com/gin-gonic/gin" + "lokistack/config" "lokistack/repository" "lokistack/routers" @@ -19,6 +23,13 @@ import ( func main() { cfg := config.GetConfig() + // gin 默认是 debug 模式:每次启动会把上百行路由表 dump 到日志里, + // 且每个请求有额外开销。生产默认切 release,需要调试时显式设 + // DEBUG=true 或 GIN_MODE=debug 覆盖。 + if os.Getenv("GIN_MODE") == "" && !strings.EqualFold(os.Getenv("DEBUG"), "true") { + gin.SetMode(gin.ReleaseMode) + } + log.Println("Initializing database...") if err := repository.InitDB(); err != nil { log.Fatalf("Failed to init DB: %v", err) diff --git a/backend/middleware/accesslog.go b/backend/middleware/accesslog.go new file mode 100644 index 0000000..57828d2 --- /dev/null +++ b/backend/middleware/accesslog.go @@ -0,0 +1,134 @@ +package middleware + +import ( + "bytes" + "encoding/json" + "os" + "sort" + "strings" + "time" + + "github.com/gin-gonic/gin" +) + +// LogJSON 输出一行 JSON 到标准输出(stdout)。 +// +// 统一走 stdout 的原因:容器里日志由容器运行时接管(kubelet 侧 +// containerLogMaxSize=10Mi / containerLogMaxFiles=5 自动轮转), +// 应用自己写文件会绕过这套轮转、把磁盘写爆。 +// +// 关键细节:Go 的 map 序列化是**按 key 字母序**输出,ts 会排到最后, +// kubectl logs 里一行以 {"bytes":... 开头完全看不出时间。 +// 这里手工拼装,把 ts/level/type/msg 固定在最前面,其余字段排序输出。 +func LogJSON(fields map[string]any) { + head := []string{"ts", "level", "type", "msg"} + + var buf bytes.Buffer + buf.WriteByte('{') + first := true + write := func(k string) { + v, ok := fields[k] + if !ok { + return + } + kb, err := json.Marshal(k) + if err != nil { + return + } + vb, err := json.Marshal(v) + if err != nil { + return + } + if !first { + buf.WriteByte(',') + } + buf.Write(kb) + buf.WriteByte(':') + buf.Write(vb) + first = false + } + for _, k := range head { + write(k) + } + rest := make([]string, 0, len(fields)) + for k := range fields { + switch k { + case "ts", "level", "type", "msg": + continue + } + rest = append(rest, k) + } + sort.Strings(rest) + for _, k := range rest { + write(k) + } + buf.WriteString("}\n") + + _, _ = os.Stdout.Write(buf.Bytes()) +} + +// AccessLog 结构化访问日志,替代 gin.Default() 自带的文本 Logger。 +// +// 与 gin.Logger() 的差异: +// - 一行一个 JSON 对象,字段可直接在 Loki 里过滤 +// - 按状态码自动分级:5xx=error / 4xx=warn / 其余=info +// - 健康检查与静态资源不打日志(K8s 探针每 10s 一次,会淹没真正的请求) +func AccessLog() gin.HandlerFunc { + return func(c *gin.Context) { + start := time.Now() + path := c.Request.URL.Path + + c.Next() + + if isQuietPath(path) { + return + } + + status := c.Writer.Status() + level := "info" + switch { + case status >= 500: + level = "error" + case status >= 400: + level = "warn" + } + + entry := map[string]any{ + "ts": time.Now().Format(time.RFC3339Nano), + "level": level, + "type": "access", + "method": c.Request.Method, + "path": path, + "status": status, + "latency_ms": float64(time.Since(start).Microseconds()) / 1000.0, + "bytes": c.Writer.Size(), + "ip": c.ClientIP(), + } + if q := c.Request.URL.RawQuery; q != "" { + entry["query"] = q + } + if ua := c.Request.UserAgent(); ua != "" { + entry["ua"] = ua + } + // jwt.go 中间件把用户信息放在 context,日志带上便于审计 + if uid, ok := c.Get("userId"); ok { + entry["user_id"] = uid + } + if uname, ok := c.Get("username"); ok { + entry["username"] = uname + } + if len(c.Errors) > 0 { + entry["error"] = c.Errors.String() + } + LogJSON(entry) + } +} + +// isQuietPath 探针/静态资源不记访问日志 +func isQuietPath(p string) bool { + switch p { + case "/health", "/api/v1/health", "/favicon.ico", "/favicon.svg", "/robots.txt": + return true + } + return strings.HasPrefix(p, "/assets/") +} diff --git a/backend/routers/router.go b/backend/routers/router.go index 3dc80e4..b3da124 100644 --- a/backend/routers/router.go +++ b/backend/routers/router.go @@ -15,7 +15,12 @@ import ( ) func SetupRouter() *gin.Engine { - r := gin.Default() + // 用 gin.New() 而不是 gin.Default():Default 自带文本格式的 gin.Logger(), + // 这里换成结构化 JSON 访问日志(middleware.AccessLog),便于 Promtail→Loki + // 按字段过滤,同时跳过健康检查/静态资源,避免探针轮询把日志量刷满。 + // Recovery 必须显式加回来(Default 里包含它),否则 panic 会打挂进程。 + r := gin.New() + r.Use(middleware.AccessLog(), gin.Recovery()) // CORS cfg := cors.DefaultConfig()