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.
This commit is contained in:
2026-02-15 18:24:48 +08:00
parent 3284ce07c7
commit eab464060b
9 changed files with 870 additions and 27 deletions

13
backend/.dockerignore Normal file
View File

@@ -0,0 +1,13 @@
.git
.gitignore
.claude
docs
cmd/server.exe
*.log
*.out
config.json
**/*_test.go

23
backend/Dockerfile Normal file
View File

@@ -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"]

View File

@@ -72,6 +72,18 @@ CONFIG_FILE=./config.local.json go run ./cmd/server
- `NACOS_IP`:实例注册 IP建议注入 Pod/主机内网 IP - `NACOS_IP`:实例注册 IP建议注入 Pod/主机内网 IP
- `NACOS_PORT`:实例注册端口,默认取 `PORT` - `NACOS_PORT`:实例注册端口,默认取 `PORT`
- `NACOS_EPHEMERAL`:是否临时实例,默认 `true` - `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` - `GO_LSP_COMMAND``GO_LSP_ARGS`
@@ -94,6 +106,14 @@ NACOS_IP=10.0.2.15
NACOS_PORT=8080 NACOS_PORT=8080
``` ```
日志输出说明(参考 Logback
- 访问日志、业务日志都会写入文件和控制台(可关)。
- 日志目录结构:`logs/<app>/<yyyy-MM>/<yyyy-MM-dd>/`
- `info.log`:记录 `INFO/WARN`
- `error.log`:记录 `ERROR` 及以上
- 请求链路字段:`traceId`(来自 `X-Request-Id`/`X-Trace-Id`
- 如果服务部署在网关后,请务必配置 `TRUSTED_PROXIES` 为网关出口网段,否则会记录到网关 IP。
## 健康检查 ## 健康检查
- `GET /health` - `GET /health`

View File

@@ -9,6 +9,7 @@ import (
"os" "os"
"os/signal" "os/signal"
"os/user" "os/user"
"path/filepath"
"strconv" "strconv"
"strings" "strings"
"sync/atomic" "sync/atomic"
@@ -16,10 +17,12 @@ import (
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"go.uber.org/zap"
"monica-go-completion-backend/internal/api" "monica-go-completion-backend/internal/api"
"monica-go-completion-backend/internal/cluster" "monica-go-completion-backend/internal/cluster"
"monica-go-completion-backend/internal/completion" "monica-go-completion-backend/internal/completion"
"monica-go-completion-backend/internal/logging"
"monica-go-completion-backend/internal/lsp" "monica-go-completion-backend/internal/lsp"
) )
@@ -84,6 +87,30 @@ type config struct {
NacosRegisterPort int NacosRegisterPort int
// NacosEphemeral 控制是否使用临时实例模式。 // NacosEphemeral 控制是否使用临时实例模式。
NacosEphemeral bool 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 定义各语言 LSP 服务的启动命令与参数。
Servers []completion.LanguageServerSpec Servers []completion.LanguageServerSpec
} }
@@ -119,6 +146,18 @@ type fileConfig struct {
NacosRegisterIP *string `json:"nacosRegisterIP"` NacosRegisterIP *string `json:"nacosRegisterIP"`
NacosRegisterPort *int `json:"nacosRegisterPort"` NacosRegisterPort *int `json:"nacosRegisterPort"`
NacosEphemeral *bool `json:"nacosEphemeral"` 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"` Servers []completion.LanguageServerSpec `json:"servers"`
} }
@@ -129,6 +168,28 @@ func main() {
log.Fatalf("load config failed: %v", err) 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 var registry completion.SessionRegistry
if cfg.EnableRedis { if cfg.EnableRedis {
// Redis 注册中心用于多实例下的会话归属协调(粘性路由)。 // Redis 注册中心用于多实例下的会话归属协调(粘性路由)。
@@ -196,9 +257,12 @@ func main() {
// 注册通用中间件与业务路由。 // 注册通用中间件与业务路由。
router := gin.New() router := gin.New()
router.Use(gin.Logger()) if err := configureProxySettings(router, cfg); err != nil {
router.Use(gin.Recovery()) log.Fatalf("configure trusted proxies failed: %v", err)
}
router.Use(requestIDMiddleware()) router.Use(requestIDMiddleware())
router.Use(accessLogMiddleware())
router.Use(recoveryMiddleware())
router.Use(corsMiddleware(cfg.AllowOrigin)) router.Use(corsMiddleware(cfg.AllowOrigin))
router.Use(apiTokenMiddleware(cfg.APIToken)) router.Use(apiTokenMiddleware(cfg.APIToken))
@@ -277,6 +341,16 @@ func loadConfig() (config, error) {
NacosGroup: "DEFAULT_GROUP", NacosGroup: "DEFAULT_GROUP",
NacosServiceName: "lsp-gateway", NacosServiceName: "lsp-gateway",
NacosEphemeral: true, 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(), Servers: defaultLanguageServers(),
} }
@@ -296,12 +370,15 @@ func loadConfig() (config, error) {
applyEnvOverrides(&cfg) applyEnvOverrides(&cfg)
finalizeConfig(&cfg, cwd) finalizeConfig(&cfg, cwd)
log.Printf( 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.Port,
cfg.WorkspaceDir, cfg.WorkspaceDir,
cfg.EnableRedis, cfg.EnableRedis,
cfg.EnableNacosRegister, cfg.EnableNacosRegister,
cfg.NacosServerAddr, cfg.NacosServerAddr,
cfg.LogPath,
cfg.LogLevel,
len(cfg.TrustedProxies),
) )
return cfg, nil return cfg, nil
} }
@@ -439,6 +516,42 @@ func applyFileConfig(cfg *config, fc fileConfig) error {
if fc.NacosEphemeral != nil { if fc.NacosEphemeral != nil {
cfg.NacosEphemeral = *fc.NacosEphemeral 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 { if fc.Servers != nil {
cfg.Servers = normalizeServerSpecs(fc.Servers) cfg.Servers = normalizeServerSpecs(fc.Servers)
} }
@@ -475,6 +588,18 @@ func applyEnvOverrides(cfg *config) {
cfg.NacosRegisterIP = getenv("NACOS_IP", cfg.NacosRegisterIP) cfg.NacosRegisterIP = getenv("NACOS_IP", cfg.NacosRegisterIP)
cfg.NacosRegisterPort = getenvInt("NACOS_PORT", cfg.NacosRegisterPort) cfg.NacosRegisterPort = getenvInt("NACOS_PORT", cfg.NacosRegisterPort)
cfg.NacosEphemeral = getenvBool("NACOS_EPHEMERAL", cfg.NacosEphemeral) 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) cfg.Servers = applyLanguageServerEnvOverrides(cfg.Servers)
} }
@@ -534,6 +659,31 @@ func finalizeConfig(cfg *config, cwd string) {
cfg.NacosRegisterPort = parsePortOrDefault(cfg.Port, 8080) cfg.NacosRegisterPort = parsePortOrDefault(cfg.Port, 8080)
} }
cfg.NacosRegisterIP = cluster.ResolveRegisterIP(cfg.NacosRegisterIP, cfg.InstanceURL) 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 { if len(cfg.Servers) == 0 {
cfg.Servers = defaultLanguageServers() cfg.Servers = defaultLanguageServers()
} }
@@ -662,6 +812,40 @@ func lookupEnv(key string) (string, bool) {
return v, ok 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 中区分节点。 // defaultInstanceID 生成默认实例 ID便于在日志和 Redis 中区分节点。
func defaultInstanceID() string { func defaultInstanceID() string {
host, err := os.Hostname() host, err := os.Hostname()
@@ -786,11 +970,103 @@ func apiTokenMiddleware(token string) gin.HandlerFunc {
func requestIDMiddleware() gin.HandlerFunc { func requestIDMiddleware() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
rid := strings.TrimSpace(c.GetHeader("X-Request-Id")) rid := strings.TrimSpace(c.GetHeader("X-Request-Id"))
if rid == "" {
rid = strings.TrimSpace(c.GetHeader("X-Trace-Id"))
}
if rid == "" { if rid == "" {
rid = fmt.Sprintf("req-%d", requestIDSeed.Add(1)) rid = fmt.Sprintf("req-%d", requestIDSeed.Add(1))
} }
c.Header("X-Request-Id", rid) c.Header("X-Request-Id", rid)
c.Header("X-Trace-Id", rid)
c.Set("requestId", rid) c.Set("requestId", rid)
c.Set("traceId", rid)
c.Next() 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
}

View File

@@ -2,6 +2,7 @@ package main
import ( import (
"os" "os"
"path/filepath"
"testing" "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) { func withEnv(t *testing.T, key, value string) {
t.Helper() t.Helper()
old, existed := os.LookupEnv(key) old, existed := os.LookupEnv(key)

View File

@@ -7,6 +7,8 @@ require (
github.com/gorilla/websocket v1.5.3 github.com/gorilla/websocket v1.5.3
github.com/nacos-group/nacos-sdk-go/v2 v2.3.5 github.com/nacos-group/nacos-sdk-go/v2 v2.3.5
github.com/redis/go-redis/v9 v9.17.3 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 ( require (
@@ -71,7 +73,6 @@ require (
go.uber.org/atomic v1.7.0 // indirect go.uber.org/atomic v1.7.0 // indirect
go.uber.org/mock v0.5.0 // indirect go.uber.org/mock v0.5.0 // indirect
go.uber.org/multierr v1.6.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/arch v0.20.0 // indirect
golang.org/x/crypto v0.40.0 // indirect golang.org/x/crypto v0.40.0 // indirect
golang.org/x/mod v0.25.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/grpc v1.67.3 // indirect
google.golang.org/protobuf v1.36.9 // indirect google.golang.org/protobuf v1.36.9 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.0.0 // indirect
) )

View File

@@ -4,9 +4,11 @@ import (
"context" "context"
"errors" "errors"
"net/http" "net/http"
"strings"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"go.uber.org/zap"
"monica-go-completion-backend/internal/completion" "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) c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, opts.MaxBodyBytes)
var req completion.Request var req completion.Request
if err := c.ShouldBindJSON(&req); err != nil { 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"}) c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON payload"})
return return
} }
@@ -84,19 +93,51 @@ func RegisterRoutes(router *gin.Engine, service CompletionService, options ...Ro
resp, err := service.Complete(ctx, req) resp, err := service.Complete(ctx, req)
if err != nil { if err != nil {
if errors.Is(err, completion.ErrInvalidRequest) { 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()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
if errors.Is(err, completion.ErrUnsupportedLanguage) { 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()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
if errors.Is(err, completion.ErrTooManySessions) { 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()}) c.JSON(http.StatusTooManyRequests, gin.H{"error": err.Error()})
return return
} }
var ownedErr *completion.ErrSessionOwnedByOtherInstance var ownedErr *completion.ErrSessionOwnedByOtherInstance
if errors.As(err, &ownedErr) { 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 != "" { if ownedErr.OwnerEndpoint != "" {
c.Header("X-LSP-Route-To", ownedErr.OwnerEndpoint) c.Header("X-LSP-Route-To", ownedErr.OwnerEndpoint)
} }
@@ -107,9 +148,28 @@ func RegisterRoutes(router *gin.Engine, service CompletionService, options ...Ro
}) })
return 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"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "completion failed"})
return 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) c.JSON(http.StatusOK, resp)
} }
@@ -118,3 +178,14 @@ func RegisterRoutes(router *gin.Engine, service CompletionService, options ...Ro
handleCompletion(c) 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"))
}

View File

@@ -9,6 +9,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
"go.uber.org/zap"
"monica-go-completion-backend/internal/completion" "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) { handler := func(c *gin.Context, defaultLanguage string) {
conn, err := wsUpgrader.Upgrade(c.Writer, c.Request, nil) conn, err := wsUpgrader.Upgrade(c.Writer, c.Request, nil)
if err != 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 return
} }
defer conn.Close() 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) conn.SetReadLimit(opts.MaxBodyBytes)
var writeMu sync.Mutex var writeMu sync.Mutex
@@ -74,9 +100,15 @@ func registerWSRoutes(router *gin.Engine, service CompletionService, opts RouteO
// 单连接串行读取消息,写操作通过 writeMu 保证并发安全。 // 单连接串行读取消息,写操作通过 writeMu 保证并发安全。
_, payload, err := conn.ReadMessage() _, payload, err := conn.ReadMessage()
if err != nil { 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 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, service CompletionService,
payload []byte, payload []byte,
defaultLanguage string, defaultLanguage string,
traceID string,
clientIP string,
opts RouteOptions, opts RouteOptions,
) { ) {
if tryHandleRPCMessage(conn, writeMu, service, payload, defaultLanguage, opts) { if tryHandleRPCMessage(conn, writeMu, service, payload, defaultLanguage, traceID, clientIP, opts) {
return return
} }
var req wsCompletionRequest var req wsCompletionRequest
if err := json.Unmarshal(payload, &req); err != nil { 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{ sendWSResponse(conn, writeMu, wsCompletionResponse{
ID: "", ID: "",
Error: "invalid JSON payload", Error: "invalid JSON payload",
@@ -113,7 +153,7 @@ func handleWSMessage(
req.Language = defaultLanguage req.Language = defaultLanguage
} }
processWSCompletion(conn, writeMu, service, req, opts) processWSCompletion(conn, writeMu, service, req, traceID, clientIP, opts)
} }
// tryHandleRPCMessage 处理 JSON-RPC 2.0 请求,返回 true 表示消息已消费。 // tryHandleRPCMessage 处理 JSON-RPC 2.0 请求,返回 true 表示消息已消费。
@@ -123,6 +163,8 @@ func tryHandleRPCMessage(
service CompletionService, service CompletionService,
payload []byte, payload []byte,
defaultLanguage string, defaultLanguage string,
traceID string,
clientIP string,
opts RouteOptions, opts RouteOptions,
) bool { ) bool {
var rpcReq wsRPCRequest var rpcReq wsRPCRequest
@@ -148,6 +190,13 @@ func tryHandleRPCMessage(
var req wsCompletionRequest var req wsCompletionRequest
if err := json.Unmarshal(rpcReq.Params, &req); err != nil { 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 处理。 // 参数反序列化失败按 invalid params 处理。
sendWSRPCResponse(conn, writeMu, wsRPCResponse{ sendWSRPCResponse(conn, writeMu, wsRPCResponse{
JSONRPC: "2.0", JSONRPC: "2.0",
@@ -179,6 +228,16 @@ func tryHandleRPCMessage(
Character: req.Character, Character: req.Character,
}) })
if err != nil { 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{ sendWSRPCResponse(conn, writeMu, wsRPCResponse{
JSONRPC: "2.0", JSONRPC: "2.0",
ID: rpcReq.ID, ID: rpcReq.ID,
@@ -204,6 +263,8 @@ func processWSCompletion(
writeMu *sync.Mutex, writeMu *sync.Mutex,
service CompletionService, service CompletionService,
req wsCompletionRequest, req wsCompletionRequest,
traceID string,
clientIP string,
opts RouteOptions, opts RouteOptions,
) { ) {
ctx, cancel := context.WithTimeout(context.Background(), opts.RequestTimeout) ctx, cancel := context.WithTimeout(context.Background(), opts.RequestTimeout)
@@ -237,6 +298,17 @@ func processWSCompletion(
ownerID = ownedErr.OwnerID 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{ sendWSResponse(conn, writeMu, wsCompletionResponse{
ID: req.ID, ID: req.ID,
RouteTo: routeTo, RouteTo: routeTo,
@@ -245,6 +317,15 @@ func processWSCompletion(
}) })
return 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{ sendWSResponse(conn, writeMu, wsCompletionResponse{
ID: req.ID, ID: req.ID,

View File

@@ -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
}