feat(log): Gin 结构化 JSON 访问日志 + 生产默认 release 模式(去掉启动路由 dump)

=== 一、结构化 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
This commit is contained in:
opsx committed 2026-10-27 14:49:45 +00:00
1 parent 02705382f1
commit 7b600f7eee
3 files changed
+151 -1

No files matched your search

+11
View File
@@ -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)
+134
View File
@@ -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/")
}
+6 -1
View File
@@ -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()