Files
ddcat666 a0f025aa96 feat(platform): 日志管理 - 统一日志出口并屏蔽查询与 Agent 流式事件日志
新增 internal/logging,slog、标准库 log、GORM 与 Gin 访问日志共用同一 Handler,由 CANVAS_LOG_LEVEL / CANVAS_LOG_FORMAT 控制。info 级别不再输出成功的查询类请求、成功 SQL、message_end 等 Agent 流式事件和常规画布自动保存;4xx/5xx、慢请求、慢 SQL、SQL 失败与 panic 仍会记录,SQL 不内联参数值。
2026-09-30 03:33:34 +08:00

112 lines
3.3 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package logging
import (
"io"
"log/slog"
"net/http"
"runtime/debug"
"strings"
"time"
"github.com/gin-gonic/gin"
)
// quietWriteSuffixes 是语义上只读、但用 POST 发出的高频路由(心跳、状态查询、批量读取)。
// 与 GET 一样,成功时只在 debug 级别输出。
var quietWriteSuffixes = []string{
"/heartbeat",
"/query",
"/query-provider",
"/query-task",
"/checkout/refresh",
"/assets/batch",
"/announcements/read",
"/diagnostics/preview",
"/system-update/check",
}
// AccessLog 替代 gin.Logger:成功的查询类请求(GET/HEAD/OPTIONS 与 quietWriteSuffixes)降为 debug,
// 写请求记 info,4xx 记 warn,5xx 记 error;超过慢请求阈值的非流式请求无论方法都记 warn。
// redactPath 用于脱敏 URL 中的凭据段(例如分享 token),为 nil 时原样输出。
func AccessLog(redactPath func(string) string) gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
c.Next()
latency := time.Since(start)
status := c.Writer.Status()
route := c.FullPath()
level := accessLevel(c.Request.Method, route, status, latency, isStreamResponse(c))
ctx := c.Request.Context()
if !slog.Default().Enabled(ctx, level) {
return
}
path := c.Request.URL.Path
if redactPath != nil {
path = redactPath(path)
}
attrs := []slog.Attr{
slog.String("method", c.Request.Method),
slog.String("path", path),
slog.Int("status", status),
slog.Int64("latency_ms", latency.Milliseconds()),
slog.String("ip", c.ClientIP()),
}
if requestID := c.Writer.Header().Get("X-Request-ID"); requestID != "" {
attrs = append(attrs, slog.String("request_id", requestID))
}
if message := c.Errors.ByType(gin.ErrorTypePrivate).String(); message != "" {
attrs = append(attrs, slog.String("error", strings.TrimSpace(message)))
}
slog.LogAttrs(ctx, level, "http request", attrs...)
}
}
func accessLevel(method, route string, status int, latency time.Duration, stream bool) slog.Level {
switch {
case status >= http.StatusInternalServerError:
return slog.LevelError
case status >= http.StatusBadRequest:
return slog.LevelWarn
}
if threshold := slowRequestThreshold(); threshold > 0 && !stream && latency >= threshold {
return slog.LevelWarn
}
if isQueryRequest(method, route) {
return slog.LevelDebug
}
return slog.LevelInfo
}
func isQueryRequest(method, route string) bool {
switch method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
return true
case http.MethodPost:
for _, suffix := range quietWriteSuffixes {
if strings.HasSuffix(route, suffix) {
return true
}
}
}
return false
}
// SSE 与分块下载天然是长连接,耗时不代表慢。
func isStreamResponse(c *gin.Context) bool {
return strings.Contains(c.Writer.Header().Get("Content-Type"), "text/event-stream")
}
// Recovery 替代 gin.Recovery:panic 与堆栈进入统一日志,而不是直接写带颜色码的 stderr。
func Recovery() gin.HandlerFunc {
return gin.CustomRecoveryWithWriter(io.Discard, func(c *gin.Context, recovered any) {
slog.ErrorContext(c.Request.Context(), "http panic recovered",
"method", c.Request.Method,
"route", c.FullPath(),
"request_id", c.Writer.Header().Get("X-Request-ID"),
"panic", recovered,
"stack", string(debug.Stack()),
)
c.AbortWithStatus(http.StatusInternalServerError)
})
}