From eab464060b83fb92ecd05bf8a693242825346d71 Mon Sep 17 00:00:00 2001 From: meowrain Date: Sun, 15 Feb 2026 18:24:48 +0800 Subject: [PATCH] feat: add Docker support and logging enhancements - Introduced Dockerfile and .dockerignore for containerization of the backend service. - Added logging configuration with support for daily rolling logs and customizable log levels. - Enhanced the server to utilize structured logging with zap, including detailed request and error logging. - Updated configuration to include logging parameters and proxy settings. - Implemented tests for logging configuration and proxy settings. --- backend/.dockerignore | 13 ++ backend/Dockerfile | 23 +++ backend/README.md | 20 ++ backend/cmd/server/main.go | 320 +++++++++++++++++++++++++++-- backend/cmd/server/main_test.go | 134 ++++++++++++ backend/go.mod | 4 +- backend/internal/api/handler.go | 71 +++++++ backend/internal/api/ws_handler.go | 87 +++++++- backend/internal/logging/logger.go | 225 ++++++++++++++++++++ 9 files changed, 870 insertions(+), 27 deletions(-) create mode 100644 backend/.dockerignore create mode 100644 backend/Dockerfile create mode 100644 backend/internal/logging/logger.go diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..276cee0 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,13 @@ +.git +.gitignore +.claude + +docs + +cmd/server.exe +*.log +*.out + +config.json + +**/*_test.go diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..c80341d --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,23 @@ +FROM golang:1.25-alpine AS builder + +WORKDIR /src + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /out/server ./cmd/server + +FROM alpine:3.20 + +RUN addgroup -S app && adduser -S -G app app + +WORKDIR /app +COPY --from=builder /out/server /app/server +COPY config.example.json /app/config.example.json + +ENV PORT=8080 +EXPOSE 8080 + +USER app +ENTRYPOINT ["/app/server"] diff --git a/backend/README.md b/backend/README.md index facb92f..c11b9e1 100644 --- a/backend/README.md +++ b/backend/README.md @@ -72,6 +72,18 @@ CONFIG_FILE=./config.local.json go run ./cmd/server - `NACOS_IP`:实例注册 IP(建议注入 Pod/主机内网 IP) - `NACOS_PORT`:实例注册端口,默认取 `PORT` - `NACOS_EPHEMERAL`:是否临时实例,默认 `true` +- `APP_NAME`:日志应用名,默认 `lsp-gateway` +- `APP_ENV`:运行环境,默认 `dev` +- `LOG_PATH`:日志根目录,默认 `./logs/${APP_NAME}` +- `LOG_LEVEL`:日志级别(`debug/info/warn/error`),默认 `info` +- `LOG_MAX_SIZE_MB`:单文件滚动阈值(MB),默认 `100` +- `LOG_MAX_BACKUPS`:滚动文件保留数量,默认 `31` +- `LOG_MAX_AGE_DAYS`:日志保留天数,默认 `31` +- `LOG_COMPRESS`:滚动文件是否压缩,默认 `true` +- `LOG_CONSOLE_ENABLED`:是否输出控制台,默认 `true` +- `TRUSTED_PROXIES`:受信代理列表(逗号分隔 IP/CIDR),默认空(不信任代理头) +- `REMOTE_IP_HEADERS`:客户端 IP 头列表(逗号分隔),默认 `X-Forwarded-For,X-Real-IP` +- `FORWARDED_BY_CLIENT_IP`:是否从代理头解析客户端 IP,默认 `true` 语言服务器命令(可替换为企业内部镜像/封装): - `GO_LSP_COMMAND`,`GO_LSP_ARGS` @@ -94,6 +106,14 @@ NACOS_IP=10.0.2.15 NACOS_PORT=8080 ``` +日志输出说明(参考 Logback): +- 访问日志、业务日志都会写入文件和控制台(可关)。 +- 日志目录结构:`logs////` +- `info.log`:记录 `INFO/WARN` +- `error.log`:记录 `ERROR` 及以上 +- 请求链路字段:`traceId`(来自 `X-Request-Id`/`X-Trace-Id`) +- 如果服务部署在网关后,请务必配置 `TRUSTED_PROXIES` 为网关出口网段,否则会记录到网关 IP。 + ## 健康检查 - `GET /health` diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index bbf9afc..6094e17 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -9,6 +9,7 @@ import ( "os" "os/signal" "os/user" + "path/filepath" "strconv" "strings" "sync/atomic" @@ -16,10 +17,12 @@ import ( "time" "github.com/gin-gonic/gin" + "go.uber.org/zap" "monica-go-completion-backend/internal/api" "monica-go-completion-backend/internal/cluster" "monica-go-completion-backend/internal/completion" + "monica-go-completion-backend/internal/logging" "monica-go-completion-backend/internal/lsp" ) @@ -84,6 +87,30 @@ type config struct { NacosRegisterPort int // NacosEphemeral 控制是否使用临时实例模式。 NacosEphemeral bool + // AppName 为日志目录与日志字段中的应用名。 + AppName string + // AppEnv 为运行环境标识,如 dev/prod。 + AppEnv string + // LogPath 为日志根目录。 + LogPath string + // LogLevel 控制全局日志等级。 + LogLevel string + // LogMaxSizeMB 控制单个日志文件滚动阈值(MB)。 + LogMaxSizeMB int + // LogMaxBackups 控制保留滚动文件数量。 + LogMaxBackups int + // LogMaxAgeDays 控制日志保留天数。 + LogMaxAgeDays int + // LogCompress 控制滚动日志是否压缩。 + LogCompress bool + // LogConsoleEnabled 控制是否输出到控制台。 + LogConsoleEnabled bool + // TrustedProxies 为受信代理列表(IP/CIDR),用于安全解析 X-Forwarded-For。 + TrustedProxies []string + // RemoteIPHeaders 为提取客户端真实 IP 的请求头白名单。 + RemoteIPHeaders []string + // ForwardedByClientIP 控制是否按代理头解析客户端 IP。 + ForwardedByClientIP bool // Servers 定义各语言 LSP 服务的启动命令与参数。 Servers []completion.LanguageServerSpec } @@ -119,6 +146,18 @@ type fileConfig struct { NacosRegisterIP *string `json:"nacosRegisterIP"` NacosRegisterPort *int `json:"nacosRegisterPort"` NacosEphemeral *bool `json:"nacosEphemeral"` + AppName *string `json:"appName"` + AppEnv *string `json:"appEnv"` + LogPath *string `json:"logPath"` + LogLevel *string `json:"logLevel"` + LogMaxSizeMB *int `json:"logMaxSizeMB"` + LogMaxBackups *int `json:"logMaxBackups"` + LogMaxAgeDays *int `json:"logMaxAgeDays"` + LogCompress *bool `json:"logCompress"` + LogConsoleEnabled *bool `json:"logConsoleEnabled"` + TrustedProxies []string `json:"trustedProxies"` + RemoteIPHeaders []string `json:"remoteIPHeaders"` + ForwardedByClientIP *bool `json:"forwardedByClientIP"` Servers []completion.LanguageServerSpec `json:"servers"` } @@ -129,6 +168,28 @@ func main() { log.Fatalf("load config failed: %v", err) } + appLogger, logCleanup, err := logging.New(logging.Config{ + AppName: cfg.AppName, + Environment: cfg.AppEnv, + BasePath: cfg.LogPath, + Level: cfg.LogLevel, + MaxSizeMB: cfg.LogMaxSizeMB, + MaxBackups: cfg.LogMaxBackups, + MaxAgeDays: cfg.LogMaxAgeDays, + Compress: cfg.LogCompress, + ConsoleEnabled: cfg.LogConsoleEnabled, + }) + if err != nil { + log.Fatalf("init logger failed: %v", err) + } + defer func() { + _ = logCleanup() + }() + restoreGlobals := zap.ReplaceGlobals(appLogger) + defer restoreGlobals() + restoreStdLog := zap.RedirectStdLog(appLogger) + defer restoreStdLog() + var registry completion.SessionRegistry if cfg.EnableRedis { // Redis 注册中心用于多实例下的会话归属协调(粘性路由)。 @@ -196,9 +257,12 @@ func main() { // 注册通用中间件与业务路由。 router := gin.New() - router.Use(gin.Logger()) - router.Use(gin.Recovery()) + if err := configureProxySettings(router, cfg); err != nil { + log.Fatalf("configure trusted proxies failed: %v", err) + } router.Use(requestIDMiddleware()) + router.Use(accessLogMiddleware()) + router.Use(recoveryMiddleware()) router.Use(corsMiddleware(cfg.AllowOrigin)) router.Use(apiTokenMiddleware(cfg.APIToken)) @@ -259,25 +323,35 @@ func loadConfig() (config, error) { } cfg := config{ - Port: "8080", - WorkspaceDir: cwd, - AllowOrigin: "*", - RequestTimeout: 10 * time.Second, - MaxBodyBytes: 2 << 20, - SessionTTL: 20 * time.Minute, - CleanupInterval: 2 * time.Minute, - MaxSessions: 256, - EnableRedis: true, - RedisAddr: "10.0.0.10:6379", - RedisDB: 1, - RedisKeyPrefix: "lsp-gateway", - InstanceTTL: 30 * time.Second, - Heartbeat: 10 * time.Second, - NacosServerAddr: "10.0.0.10:8848", - NacosGroup: "DEFAULT_GROUP", - NacosServiceName: "lsp-gateway", - NacosEphemeral: true, - Servers: defaultLanguageServers(), + Port: "8080", + WorkspaceDir: cwd, + AllowOrigin: "*", + RequestTimeout: 10 * time.Second, + MaxBodyBytes: 2 << 20, + SessionTTL: 20 * time.Minute, + CleanupInterval: 2 * time.Minute, + MaxSessions: 256, + EnableRedis: true, + RedisAddr: "10.0.0.10:6379", + RedisDB: 1, + RedisKeyPrefix: "lsp-gateway", + InstanceTTL: 30 * time.Second, + Heartbeat: 10 * time.Second, + NacosServerAddr: "10.0.0.10:8848", + NacosGroup: "DEFAULT_GROUP", + NacosServiceName: "lsp-gateway", + NacosEphemeral: true, + AppName: "lsp-gateway", + AppEnv: "dev", + LogLevel: "info", + LogMaxSizeMB: 100, + LogMaxBackups: 31, + LogMaxAgeDays: 31, + LogCompress: true, + LogConsoleEnabled: true, + RemoteIPHeaders: []string{"X-Forwarded-For", "X-Real-IP"}, + ForwardedByClientIP: true, + Servers: defaultLanguageServers(), } if configPath, ok := resolveConfigFilePath(); ok { @@ -296,12 +370,15 @@ func loadConfig() (config, error) { applyEnvOverrides(&cfg) finalizeConfig(&cfg, cwd) log.Printf( - "effective config: port=%s, workspace=%s, redis=%t, nacos=%t, nacosServer=%s", + "effective config: port=%s, workspace=%s, redis=%t, nacos=%t, nacosServer=%s, logPath=%s, logLevel=%s, trustedProxies=%d", cfg.Port, cfg.WorkspaceDir, cfg.EnableRedis, cfg.EnableNacosRegister, cfg.NacosServerAddr, + cfg.LogPath, + cfg.LogLevel, + len(cfg.TrustedProxies), ) return cfg, nil } @@ -439,6 +516,42 @@ func applyFileConfig(cfg *config, fc fileConfig) error { if fc.NacosEphemeral != nil { cfg.NacosEphemeral = *fc.NacosEphemeral } + if fc.AppName != nil { + cfg.AppName = strings.TrimSpace(*fc.AppName) + } + if fc.AppEnv != nil { + cfg.AppEnv = strings.TrimSpace(*fc.AppEnv) + } + if fc.LogPath != nil { + cfg.LogPath = strings.TrimSpace(*fc.LogPath) + } + if fc.LogLevel != nil { + cfg.LogLevel = strings.TrimSpace(*fc.LogLevel) + } + if fc.LogMaxSizeMB != nil { + cfg.LogMaxSizeMB = *fc.LogMaxSizeMB + } + if fc.LogMaxBackups != nil { + cfg.LogMaxBackups = *fc.LogMaxBackups + } + if fc.LogMaxAgeDays != nil { + cfg.LogMaxAgeDays = *fc.LogMaxAgeDays + } + if fc.LogCompress != nil { + cfg.LogCompress = *fc.LogCompress + } + if fc.LogConsoleEnabled != nil { + cfg.LogConsoleEnabled = *fc.LogConsoleEnabled + } + if fc.TrustedProxies != nil { + cfg.TrustedProxies = filterEmptyStrings(fc.TrustedProxies) + } + if fc.RemoteIPHeaders != nil { + cfg.RemoteIPHeaders = filterEmptyStrings(fc.RemoteIPHeaders) + } + if fc.ForwardedByClientIP != nil { + cfg.ForwardedByClientIP = *fc.ForwardedByClientIP + } if fc.Servers != nil { cfg.Servers = normalizeServerSpecs(fc.Servers) } @@ -475,6 +588,18 @@ func applyEnvOverrides(cfg *config) { cfg.NacosRegisterIP = getenv("NACOS_IP", cfg.NacosRegisterIP) cfg.NacosRegisterPort = getenvInt("NACOS_PORT", cfg.NacosRegisterPort) cfg.NacosEphemeral = getenvBool("NACOS_EPHEMERAL", cfg.NacosEphemeral) + cfg.AppName = getenv("APP_NAME", cfg.AppName) + cfg.AppEnv = getenv("APP_ENV", cfg.AppEnv) + cfg.LogPath = getenv("LOG_PATH", cfg.LogPath) + cfg.LogLevel = getenv("LOG_LEVEL", cfg.LogLevel) + cfg.LogMaxSizeMB = getenvInt("LOG_MAX_SIZE_MB", cfg.LogMaxSizeMB) + cfg.LogMaxBackups = getenvInt("LOG_MAX_BACKUPS", cfg.LogMaxBackups) + cfg.LogMaxAgeDays = getenvInt("LOG_MAX_AGE_DAYS", cfg.LogMaxAgeDays) + cfg.LogCompress = getenvBool("LOG_COMPRESS", cfg.LogCompress) + cfg.LogConsoleEnabled = getenvBool("LOG_CONSOLE_ENABLED", cfg.LogConsoleEnabled) + cfg.TrustedProxies = getenvCSV("TRUSTED_PROXIES", cfg.TrustedProxies) + cfg.RemoteIPHeaders = getenvCSV("REMOTE_IP_HEADERS", cfg.RemoteIPHeaders) + cfg.ForwardedByClientIP = getenvBool("FORWARDED_BY_CLIENT_IP", cfg.ForwardedByClientIP) cfg.Servers = applyLanguageServerEnvOverrides(cfg.Servers) } @@ -534,6 +659,31 @@ func finalizeConfig(cfg *config, cwd string) { cfg.NacosRegisterPort = parsePortOrDefault(cfg.Port, 8080) } cfg.NacosRegisterIP = cluster.ResolveRegisterIP(cfg.NacosRegisterIP, cfg.InstanceURL) + if strings.TrimSpace(cfg.AppName) == "" { + cfg.AppName = "lsp-gateway" + } + if strings.TrimSpace(cfg.AppEnv) == "" { + cfg.AppEnv = "dev" + } + if strings.TrimSpace(cfg.LogLevel) == "" { + cfg.LogLevel = "info" + } + if cfg.LogMaxSizeMB <= 0 { + cfg.LogMaxSizeMB = 100 + } + if cfg.LogMaxBackups <= 0 { + cfg.LogMaxBackups = 31 + } + if cfg.LogMaxAgeDays <= 0 { + cfg.LogMaxAgeDays = 31 + } + if strings.TrimSpace(cfg.LogPath) == "" { + cfg.LogPath = filepath.Join(".", "logs", cfg.AppName) + } + if cfg.RemoteIPHeaders == nil || len(cfg.RemoteIPHeaders) == 0 { + cfg.RemoteIPHeaders = []string{"X-Forwarded-For", "X-Real-IP"} + } + cfg.TrustedProxies = filterEmptyStrings(cfg.TrustedProxies) if len(cfg.Servers) == 0 { cfg.Servers = defaultLanguageServers() } @@ -662,6 +812,40 @@ func lookupEnv(key string) (string, bool) { return v, ok } +func filterEmptyStrings(values []string) []string { + if len(values) == 0 { + return nil + } + out := make([]string, 0, len(values)) + for _, v := range values { + trimmed := strings.TrimSpace(v) + if trimmed != "" { + out = append(out, trimmed) + } + } + if len(out) == 0 { + return nil + } + return out +} + +func splitCSV(raw string) []string { + parts := strings.Split(raw, ",") + return filterEmptyStrings(parts) +} + +func getenvCSV(key string, fallback []string) []string { + v, ok := lookupEnv(key) + if !ok { + return fallback + } + parsed := splitCSV(v) + if parsed == nil { + return nil + } + return parsed +} + // defaultInstanceID 生成默认实例 ID,便于在日志和 Redis 中区分节点。 func defaultInstanceID() string { host, err := os.Hostname() @@ -786,11 +970,103 @@ func apiTokenMiddleware(token string) gin.HandlerFunc { func requestIDMiddleware() gin.HandlerFunc { return func(c *gin.Context) { rid := strings.TrimSpace(c.GetHeader("X-Request-Id")) + if rid == "" { + rid = strings.TrimSpace(c.GetHeader("X-Trace-Id")) + } if rid == "" { rid = fmt.Sprintf("req-%d", requestIDSeed.Add(1)) } c.Header("X-Request-Id", rid) + c.Header("X-Trace-Id", rid) c.Set("requestId", rid) + c.Set("traceId", rid) c.Next() } } + +// accessLogMiddleware 输出统一访问日志,便于排查前端请求链路。 +func accessLogMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + start := time.Now() + c.Next() + + latency := time.Since(start) + traceID := strings.TrimSpace(c.GetString("traceId")) + if traceID == "" { + traceID = strings.TrimSpace(c.GetString("requestId")) + } + + fields := []zap.Field{ + zap.String("traceId", traceID), + zap.String("method", c.Request.Method), + zap.String("path", c.FullPath()), + zap.String("rawPath", c.Request.URL.Path), + zap.Int("status", c.Writer.Status()), + zap.Duration("latency", latency), + zap.String("clientIP", c.ClientIP()), + zap.String("userAgent", c.Request.UserAgent()), + } + if len(c.Errors) > 0 { + fields = append(fields, zap.String("errors", c.Errors.String())) + } + + switch status := c.Writer.Status(); { + case status >= http.StatusInternalServerError: + zap.L().Error("http request finished", fields...) + case status >= http.StatusBadRequest: + zap.L().Warn("http request finished", fields...) + default: + zap.L().Info("http request finished", fields...) + } + } +} + +// recoveryMiddleware 捕获 panic 并落盘错误日志,避免进程崩溃无痕。 +func recoveryMiddleware() gin.HandlerFunc { + return gin.CustomRecovery(func(c *gin.Context, recovered any) { + traceID := strings.TrimSpace(c.GetString("traceId")) + if traceID == "" { + traceID = strings.TrimSpace(c.GetString("requestId")) + } + zap.L().Error( + "panic recovered", + zap.Any("panic", recovered), + zap.String("traceId", traceID), + zap.String("method", c.Request.Method), + zap.String("path", c.Request.URL.Path), + zap.String("clientIP", c.ClientIP()), + ) + c.AbortWithStatus(http.StatusInternalServerError) + }) +} + +func configureProxySettings(router *gin.Engine, cfg config) error { + if router == nil { + return fmt.Errorf("router is nil") + } + + router.ForwardedByClientIP = cfg.ForwardedByClientIP + router.RemoteIPHeaders = append([]string(nil), cfg.RemoteIPHeaders...) + if len(cfg.TrustedProxies) == 0 { + if err := router.SetTrustedProxies(nil); err != nil { + return err + } + zap.L().Warn( + "trusted proxies disabled; using direct peer IP", + zap.Strings("remoteIPHeaders", router.RemoteIPHeaders), + zap.Bool("forwardedByClientIP", router.ForwardedByClientIP), + ) + return nil + } + + if err := router.SetTrustedProxies(cfg.TrustedProxies); err != nil { + return err + } + zap.L().Info( + "trusted proxies configured", + zap.Strings("trustedProxies", cfg.TrustedProxies), + zap.Strings("remoteIPHeaders", router.RemoteIPHeaders), + zap.Bool("forwardedByClientIP", router.ForwardedByClientIP), + ) + return nil +} diff --git a/backend/cmd/server/main_test.go b/backend/cmd/server/main_test.go index a0a2732..2a30aaa 100644 --- a/backend/cmd/server/main_test.go +++ b/backend/cmd/server/main_test.go @@ -2,6 +2,7 @@ package main import ( "os" + "path/filepath" "testing" ) @@ -85,6 +86,139 @@ func TestLoadConfigNacosFromEnv(t *testing.T) { } } +func TestLoadConfigLoggingDefaults(t *testing.T) { + withEnv(t, "APP_NAME", "") + withEnv(t, "APP_ENV", "") + withEnv(t, "LOG_PATH", "") + withEnv(t, "LOG_LEVEL", "") + withEnv(t, "LOG_MAX_SIZE_MB", "") + withEnv(t, "LOG_MAX_BACKUPS", "") + withEnv(t, "LOG_MAX_AGE_DAYS", "") + withEnv(t, "LOG_COMPRESS", "") + withEnv(t, "LOG_CONSOLE_ENABLED", "") + + cfg, err := loadConfig() + if err != nil { + t.Fatalf("loadConfig() error = %v", err) + } + + if cfg.AppName != "lsp-gateway" { + t.Fatalf("expected default AppName lsp-gateway, got %q", cfg.AppName) + } + if cfg.AppEnv != "dev" { + t.Fatalf("expected default AppEnv dev, got %q", cfg.AppEnv) + } + expectedPath := filepath.Join(".", "logs", "lsp-gateway") + if cfg.LogPath != expectedPath { + t.Fatalf("expected default LogPath %q, got %q", expectedPath, cfg.LogPath) + } + if cfg.LogLevel != "info" { + t.Fatalf("expected default LogLevel info, got %q", cfg.LogLevel) + } + if cfg.LogMaxSizeMB != 100 { + t.Fatalf("expected default LogMaxSizeMB 100, got %d", cfg.LogMaxSizeMB) + } + if cfg.LogMaxBackups != 31 { + t.Fatalf("expected default LogMaxBackups 31, got %d", cfg.LogMaxBackups) + } + if cfg.LogMaxAgeDays != 31 { + t.Fatalf("expected default LogMaxAgeDays 31, got %d", cfg.LogMaxAgeDays) + } + if !cfg.LogCompress { + t.Fatalf("expected default LogCompress true") + } + if !cfg.LogConsoleEnabled { + t.Fatalf("expected default LogConsoleEnabled true") + } +} + +func TestLoadConfigLoggingFromEnv(t *testing.T) { + withEnv(t, "APP_NAME", "editor-bff") + withEnv(t, "APP_ENV", "prod") + withEnv(t, "LOG_PATH", "/data/logs/editor-bff") + withEnv(t, "LOG_LEVEL", "warn") + withEnv(t, "LOG_MAX_SIZE_MB", "200") + withEnv(t, "LOG_MAX_BACKUPS", "50") + withEnv(t, "LOG_MAX_AGE_DAYS", "15") + withEnv(t, "LOG_COMPRESS", "false") + withEnv(t, "LOG_CONSOLE_ENABLED", "false") + + cfg, err := loadConfig() + if err != nil { + t.Fatalf("loadConfig() error = %v", err) + } + + if cfg.AppName != "editor-bff" { + t.Fatalf("unexpected AppName %q", cfg.AppName) + } + if cfg.AppEnv != "prod" { + t.Fatalf("unexpected AppEnv %q", cfg.AppEnv) + } + if cfg.LogPath != "/data/logs/editor-bff" { + t.Fatalf("unexpected LogPath %q", cfg.LogPath) + } + if cfg.LogLevel != "warn" { + t.Fatalf("unexpected LogLevel %q", cfg.LogLevel) + } + if cfg.LogMaxSizeMB != 200 { + t.Fatalf("unexpected LogMaxSizeMB %d", cfg.LogMaxSizeMB) + } + if cfg.LogMaxBackups != 50 { + t.Fatalf("unexpected LogMaxBackups %d", cfg.LogMaxBackups) + } + if cfg.LogMaxAgeDays != 15 { + t.Fatalf("unexpected LogMaxAgeDays %d", cfg.LogMaxAgeDays) + } + if cfg.LogCompress { + t.Fatalf("expected LogCompress false") + } + if cfg.LogConsoleEnabled { + t.Fatalf("expected LogConsoleEnabled false") + } +} + +func TestLoadConfigProxyDefaults(t *testing.T) { + withEnv(t, "TRUSTED_PROXIES", "") + withEnv(t, "REMOTE_IP_HEADERS", "") + withEnv(t, "FORWARDED_BY_CLIENT_IP", "") + + cfg, err := loadConfig() + if err != nil { + t.Fatalf("loadConfig() error = %v", err) + } + + if len(cfg.TrustedProxies) != 0 { + t.Fatalf("expected default TrustedProxies empty, got %v", cfg.TrustedProxies) + } + if len(cfg.RemoteIPHeaders) != 2 || cfg.RemoteIPHeaders[0] != "X-Forwarded-For" || cfg.RemoteIPHeaders[1] != "X-Real-IP" { + t.Fatalf("unexpected default RemoteIPHeaders %v", cfg.RemoteIPHeaders) + } + if !cfg.ForwardedByClientIP { + t.Fatalf("expected default ForwardedByClientIP true") + } +} + +func TestLoadConfigProxyFromEnv(t *testing.T) { + withEnv(t, "TRUSTED_PROXIES", "10.0.0.0/8,172.16.0.0/12") + withEnv(t, "REMOTE_IP_HEADERS", "X-Forwarded-For,X-Real-IP") + withEnv(t, "FORWARDED_BY_CLIENT_IP", "false") + + cfg, err := loadConfig() + if err != nil { + t.Fatalf("loadConfig() error = %v", err) + } + + if len(cfg.TrustedProxies) != 2 || cfg.TrustedProxies[0] != "10.0.0.0/8" || cfg.TrustedProxies[1] != "172.16.0.0/12" { + t.Fatalf("unexpected TrustedProxies %v", cfg.TrustedProxies) + } + if len(cfg.RemoteIPHeaders) != 2 || cfg.RemoteIPHeaders[0] != "X-Forwarded-For" || cfg.RemoteIPHeaders[1] != "X-Real-IP" { + t.Fatalf("unexpected RemoteIPHeaders %v", cfg.RemoteIPHeaders) + } + if cfg.ForwardedByClientIP { + t.Fatalf("expected ForwardedByClientIP false") + } +} + func withEnv(t *testing.T, key, value string) { t.Helper() old, existed := os.LookupEnv(key) diff --git a/backend/go.mod b/backend/go.mod index 920100e..42b3797 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -7,6 +7,8 @@ require ( github.com/gorilla/websocket v1.5.3 github.com/nacos-group/nacos-sdk-go/v2 v2.3.5 github.com/redis/go-redis/v9 v9.17.3 + go.uber.org/zap v1.21.0 + gopkg.in/natefinch/lumberjack.v2 v2.0.0 ) require ( @@ -71,7 +73,6 @@ require ( go.uber.org/atomic v1.7.0 // indirect go.uber.org/mock v0.5.0 // indirect go.uber.org/multierr v1.6.0 // indirect - go.uber.org/zap v1.21.0 // indirect golang.org/x/arch v0.20.0 // indirect golang.org/x/crypto v0.40.0 // indirect golang.org/x/mod v0.25.0 // indirect @@ -85,5 +86,4 @@ require ( google.golang.org/grpc v1.67.3 // indirect google.golang.org/protobuf v1.36.9 // indirect gopkg.in/ini.v1 v1.67.0 // indirect - gopkg.in/natefinch/lumberjack.v2 v2.0.0 // indirect ) diff --git a/backend/internal/api/handler.go b/backend/internal/api/handler.go index b7e5000..96236fc 100644 --- a/backend/internal/api/handler.go +++ b/backend/internal/api/handler.go @@ -4,9 +4,11 @@ import ( "context" "errors" "net/http" + "strings" "time" "github.com/gin-gonic/gin" + "go.uber.org/zap" "monica-go-completion-backend/internal/completion" ) @@ -67,6 +69,13 @@ func RegisterRoutes(router *gin.Engine, service CompletionService, options ...Ro c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, opts.MaxBodyBytes) var req completion.Request if err := c.ShouldBindJSON(&req); err != nil { + zap.L().Warn( + "invalid completion json payload", + zap.String("traceId", traceIDFromGin(c)), + zap.String("routeLanguage", c.Param("language")), + zap.String("clientIP", c.ClientIP()), + zap.Error(err), + ) c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON payload"}) return } @@ -84,19 +93,51 @@ func RegisterRoutes(router *gin.Engine, service CompletionService, options ...Ro resp, err := service.Complete(ctx, req) if err != nil { if errors.Is(err, completion.ErrInvalidRequest) { + zap.L().Warn( + "completion request invalid", + zap.String("traceId", traceIDFromGin(c)), + zap.String("clientIP", c.ClientIP()), + zap.String("language", req.Language), + zap.String("uri", req.URI), + zap.Error(err), + ) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if errors.Is(err, completion.ErrUnsupportedLanguage) { + zap.L().Warn( + "completion language not supported", + zap.String("traceId", traceIDFromGin(c)), + zap.String("clientIP", c.ClientIP()), + zap.String("language", req.Language), + zap.Error(err), + ) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if errors.Is(err, completion.ErrTooManySessions) { + zap.L().Warn( + "completion rejected due to session limit", + zap.String("traceId", traceIDFromGin(c)), + zap.String("clientIP", c.ClientIP()), + zap.String("language", req.Language), + zap.String("sessionId", req.SessionID), + zap.Error(err), + ) c.JSON(http.StatusTooManyRequests, gin.H{"error": err.Error()}) return } var ownedErr *completion.ErrSessionOwnedByOtherInstance if errors.As(err, &ownedErr) { + zap.L().Info( + "completion routed to session owner", + zap.String("traceId", traceIDFromGin(c)), + zap.String("clientIP", c.ClientIP()), + zap.String("language", req.Language), + zap.String("sessionId", req.SessionID), + zap.String("ownerId", ownedErr.OwnerID), + zap.String("routeTo", ownedErr.OwnerEndpoint), + ) if ownedErr.OwnerEndpoint != "" { c.Header("X-LSP-Route-To", ownedErr.OwnerEndpoint) } @@ -107,9 +148,28 @@ func RegisterRoutes(router *gin.Engine, service CompletionService, options ...Ro }) return } + zap.L().Error( + "completion failed", + zap.String("traceId", traceIDFromGin(c)), + zap.String("clientIP", c.ClientIP()), + zap.String("language", req.Language), + zap.String("sessionId", req.SessionID), + zap.String("uri", req.URI), + zap.Error(err), + ) c.JSON(http.StatusInternalServerError, gin.H{"error": "completion failed"}) return } + zap.L().Info( + "completion success", + zap.String("traceId", traceIDFromGin(c)), + zap.String("clientIP", c.ClientIP()), + zap.String("language", req.Language), + zap.String("sessionId", req.SessionID), + zap.String("uri", req.URI), + zap.Int("items", len(resp.Items)), + zap.Bool("isIncomplete", resp.IsIncomplete), + ) c.JSON(http.StatusOK, resp) } @@ -118,3 +178,14 @@ func RegisterRoutes(router *gin.Engine, service CompletionService, options ...Ro handleCompletion(c) }) } + +func traceIDFromGin(c *gin.Context) string { + if c == nil { + return "" + } + traceID := strings.TrimSpace(c.GetString("traceId")) + if traceID != "" { + return traceID + } + return strings.TrimSpace(c.GetString("requestId")) +} diff --git a/backend/internal/api/ws_handler.go b/backend/internal/api/ws_handler.go index 231d730..748d220 100644 --- a/backend/internal/api/ws_handler.go +++ b/backend/internal/api/ws_handler.go @@ -9,6 +9,7 @@ import ( "github.com/gin-gonic/gin" "github.com/gorilla/websocket" + "go.uber.org/zap" "monica-go-completion-backend/internal/completion" ) @@ -62,10 +63,35 @@ func registerWSRoutes(router *gin.Engine, service CompletionService, opts RouteO handler := func(c *gin.Context, defaultLanguage string) { conn, err := wsUpgrader.Upgrade(c.Writer, c.Request, nil) if err != nil { + zap.L().Warn( + "ws upgrade failed", + zap.String("traceId", traceIDFromGin(c)), + zap.String("path", c.Request.URL.Path), + zap.String("clientIP", c.ClientIP()), + zap.Error(err), + ) return } defer conn.Close() + traceID := traceIDFromGin(c) + zap.L().Info( + "ws connection opened", + zap.String("traceId", traceID), + zap.String("path", c.Request.URL.Path), + zap.String("language", defaultLanguage), + zap.String("clientIP", c.ClientIP()), + ) + defer func() { + zap.L().Info( + "ws connection closed", + zap.String("traceId", traceID), + zap.String("path", c.Request.URL.Path), + zap.String("language", defaultLanguage), + zap.String("clientIP", c.ClientIP()), + ) + }() + conn.SetReadLimit(opts.MaxBodyBytes) var writeMu sync.Mutex @@ -74,9 +100,15 @@ func registerWSRoutes(router *gin.Engine, service CompletionService, opts RouteO // 单连接串行读取消息,写操作通过 writeMu 保证并发安全。 _, payload, err := conn.ReadMessage() if err != nil { + zap.L().Info( + "ws read loop ended", + zap.String("traceId", traceID), + zap.String("path", c.Request.URL.Path), + zap.Error(err), + ) break } - handleWSMessage(conn, &writeMu, service, payload, defaultLanguage, opts) + handleWSMessage(conn, &writeMu, service, payload, defaultLanguage, traceID, c.ClientIP(), opts) } } @@ -95,14 +127,22 @@ func handleWSMessage( service CompletionService, payload []byte, defaultLanguage string, + traceID string, + clientIP string, opts RouteOptions, ) { - if tryHandleRPCMessage(conn, writeMu, service, payload, defaultLanguage, opts) { + if tryHandleRPCMessage(conn, writeMu, service, payload, defaultLanguage, traceID, clientIP, opts) { return } var req wsCompletionRequest if err := json.Unmarshal(payload, &req); err != nil { + zap.L().Warn( + "ws invalid json payload", + zap.String("traceId", traceID), + zap.String("clientIP", clientIP), + zap.Error(err), + ) sendWSResponse(conn, writeMu, wsCompletionResponse{ ID: "", Error: "invalid JSON payload", @@ -113,7 +153,7 @@ func handleWSMessage( req.Language = defaultLanguage } - processWSCompletion(conn, writeMu, service, req, opts) + processWSCompletion(conn, writeMu, service, req, traceID, clientIP, opts) } // tryHandleRPCMessage 处理 JSON-RPC 2.0 请求,返回 true 表示消息已消费。 @@ -123,6 +163,8 @@ func tryHandleRPCMessage( service CompletionService, payload []byte, defaultLanguage string, + traceID string, + clientIP string, opts RouteOptions, ) bool { var rpcReq wsRPCRequest @@ -148,6 +190,13 @@ func tryHandleRPCMessage( var req wsCompletionRequest if err := json.Unmarshal(rpcReq.Params, &req); err != nil { + zap.L().Warn( + "ws rpc invalid params", + zap.String("traceId", traceID), + zap.String("clientIP", clientIP), + zap.String("method", rpcReq.Method), + zap.Error(err), + ) // 参数反序列化失败按 invalid params 处理。 sendWSRPCResponse(conn, writeMu, wsRPCResponse{ JSONRPC: "2.0", @@ -179,6 +228,16 @@ func tryHandleRPCMessage( Character: req.Character, }) if err != nil { + zap.L().Warn( + "ws rpc completion failed", + zap.String("traceId", traceID), + zap.String("clientIP", clientIP), + zap.String("method", rpcReq.Method), + zap.String("language", req.Language), + zap.String("sessionId", req.SessionID), + zap.String("uri", req.URI), + zap.Error(err), + ) sendWSRPCResponse(conn, writeMu, wsRPCResponse{ JSONRPC: "2.0", ID: rpcReq.ID, @@ -204,6 +263,8 @@ func processWSCompletion( writeMu *sync.Mutex, service CompletionService, req wsCompletionRequest, + traceID string, + clientIP string, opts RouteOptions, ) { ctx, cancel := context.WithTimeout(context.Background(), opts.RequestTimeout) @@ -237,6 +298,17 @@ func processWSCompletion( ownerID = ownedErr.OwnerID } } + zap.L().Warn( + "ws completion failed", + zap.String("traceId", traceID), + zap.String("clientIP", clientIP), + zap.String("language", req.Language), + zap.String("sessionId", req.SessionID), + zap.String("uri", req.URI), + zap.String("routeTo", routeTo), + zap.String("ownerId", ownerID), + zap.Error(err), + ) sendWSResponse(conn, writeMu, wsCompletionResponse{ ID: req.ID, RouteTo: routeTo, @@ -245,6 +317,15 @@ func processWSCompletion( }) return } + zap.L().Info( + "ws completion success", + zap.String("traceId", traceID), + zap.String("clientIP", clientIP), + zap.String("language", req.Language), + zap.String("sessionId", req.SessionID), + zap.String("uri", req.URI), + zap.Int("items", len(resp.Items)), + ) sendWSResponse(conn, writeMu, wsCompletionResponse{ ID: req.ID, diff --git a/backend/internal/logging/logger.go b/backend/internal/logging/logger.go new file mode 100644 index 0000000..4a6a155 --- /dev/null +++ b/backend/internal/logging/logger.go @@ -0,0 +1,225 @@ +package logging + +import ( + "fmt" + "io" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "gopkg.in/natefinch/lumberjack.v2" +) + +type Config struct { + AppName string + Environment string + BasePath string + Level string + MaxSizeMB int + MaxBackups int + MaxAgeDays int + Compress bool + ConsoleEnabled bool +} + +func New(cfg Config) (*zap.Logger, func() error, error) { + cfg = finalizeConfig(cfg) + + infoWriter, err := newDailyRollingWriter(cfg, "info.log") + if err != nil { + return nil, nil, err + } + errorWriter, err := newDailyRollingWriter(cfg, "error.log") + if err != nil { + _ = infoWriter.Close() + return nil, nil, err + } + + level := parseLevel(cfg.Level) + encoderConfig := zapcore.EncoderConfig{ + TimeKey: "time", + LevelKey: "level", + NameKey: "logger", + CallerKey: "caller", + MessageKey: "msg", + StacktraceKey: "stacktrace", + LineEnding: zapcore.DefaultLineEnding, + EncodeLevel: zapcore.CapitalLevelEncoder, + EncodeTime: zapcore.TimeEncoderOfLayout("2006-01-02 15:04:05.000"), + EncodeDuration: zapcore.StringDurationEncoder, + EncodeCaller: zapcore.ShortCallerEncoder, + } + jsonEncoder := zapcore.NewJSONEncoder(encoderConfig) + consoleEncoder := zapcore.NewConsoleEncoder(encoderConfig) + + cores := make([]zapcore.Core, 0, 3) + cores = append(cores, zapcore.NewCore( + jsonEncoder, + zapcore.AddSync(io.Writer(infoWriter)), + zap.LevelEnablerFunc(func(l zapcore.Level) bool { + return l >= level && l < zapcore.ErrorLevel + }), + )) + cores = append(cores, zapcore.NewCore( + jsonEncoder, + zapcore.AddSync(io.Writer(errorWriter)), + zap.LevelEnablerFunc(func(l zapcore.Level) bool { + return l >= maxLevel(level, zapcore.ErrorLevel) + }), + )) + if cfg.ConsoleEnabled { + cores = append(cores, zapcore.NewCore( + consoleEncoder, + zapcore.AddSync(os.Stdout), + zap.LevelEnablerFunc(func(l zapcore.Level) bool { + return l >= level + }), + )) + } + + logger := zap.New( + zapcore.NewTee(cores...), + zap.AddCaller(), + zap.AddCallerSkip(1), + zap.AddStacktrace(zapcore.ErrorLevel), + zap.Fields( + zap.String("app", cfg.AppName), + zap.String("env", cfg.Environment), + ), + ) + + cleanup := func() error { + var finalErr error + if err := infoWriter.Close(); err != nil && finalErr == nil { + finalErr = err + } + if err := errorWriter.Close(); err != nil && finalErr == nil { + finalErr = err + } + if err := logger.Sync(); err != nil && finalErr == nil { + finalErr = err + } + return finalErr + } + return logger, cleanup, nil +} + +type dailyRollingWriter struct { + mu sync.Mutex + + cfg Config + fileName string + + dayKey string + writer *lumberjack.Logger +} + +func newDailyRollingWriter(cfg Config, fileName string) (*dailyRollingWriter, error) { + w := &dailyRollingWriter{ + cfg: cfg, + fileName: fileName, + } + if err := w.ensureWriter(time.Now()); err != nil { + return nil, err + } + return w, nil +} + +func (w *dailyRollingWriter) Write(p []byte) (int, error) { + w.mu.Lock() + defer w.mu.Unlock() + if err := w.ensureWriter(time.Now()); err != nil { + return 0, err + } + return w.writer.Write(p) +} + +func (w *dailyRollingWriter) Close() error { + w.mu.Lock() + defer w.mu.Unlock() + if w.writer == nil { + return nil + } + err := w.writer.Close() + w.writer = nil + return err +} + +func (w *dailyRollingWriter) ensureWriter(now time.Time) error { + dayKey := now.Format("2006-01-02") + if w.writer != nil && w.dayKey == dayKey { + return nil + } + + monthDir := now.Format("2006-01") + logDir := filepath.Join(w.cfg.BasePath, monthDir, dayKey) + if err := os.MkdirAll(logDir, 0o755); err != nil { + return fmt.Errorf("mkdir log dir failed: %w", err) + } + + next := &lumberjack.Logger{ + Filename: filepath.Join(logDir, w.fileName), + MaxSize: w.cfg.MaxSizeMB, + MaxBackups: w.cfg.MaxBackups, + MaxAge: w.cfg.MaxAgeDays, + Compress: w.cfg.Compress, + } + + if w.writer != nil { + _ = w.writer.Close() + } + w.writer = next + w.dayKey = dayKey + return nil +} + +func finalizeConfig(cfg Config) Config { + if strings.TrimSpace(cfg.AppName) == "" { + cfg.AppName = "lsp-gateway" + } + if strings.TrimSpace(cfg.Environment) == "" { + cfg.Environment = "dev" + } + if strings.TrimSpace(cfg.BasePath) == "" { + cfg.BasePath = filepath.Join(".", "logs", cfg.AppName) + } + if strings.TrimSpace(cfg.Level) == "" { + cfg.Level = "info" + } + if cfg.MaxSizeMB <= 0 { + cfg.MaxSizeMB = 100 + } + if cfg.MaxBackups <= 0 { + cfg.MaxBackups = 31 + } + if cfg.MaxAgeDays <= 0 { + cfg.MaxAgeDays = 31 + } + return cfg +} + +func parseLevel(level string) zapcore.Level { + switch strings.ToLower(strings.TrimSpace(level)) { + case "debug": + return zapcore.DebugLevel + case "info": + return zapcore.InfoLevel + case "warn", "warning": + return zapcore.WarnLevel + case "error": + return zapcore.ErrorLevel + default: + return zapcore.InfoLevel + } +} + +func maxLevel(a, b zapcore.Level) zapcore.Level { + if a > b { + return a + } + return b +}