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:
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
225
backend/internal/logging/logger.go
Normal file
225
backend/internal/logging/logger.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user