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:
1 parent
02705382f1
commit
7b600f7eee
3 files changed
+151
-1
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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/")
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user