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/") }