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()