Files
ragflow/internal/common/logger.go
Zhichang Yu 2362210caf refactor(log): unify Go logging to zap with rotation, strip per-package levels (#16261)
Refactor the Go agent port's logging so every log line — gin access,
agent canvas events, harness warnings, fatal boot errors — flows through
a single common.Logger (zap) backed by a rotated file, with structured
fields, level filtering, and configurable rotation.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-23 16:21:46 +08:00

327 lines
9.3 KiB
Go

//
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
package common
import (
"errors"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"gopkg.in/natefinch/lumberjack.v2"
)
var (
Logger *zap.Logger
Sugar *zap.SugaredLogger
atomicLevel zap.AtomicLevel
)
// FileOutput describes the rotated log file destination.
//
// Path is required to enable file output; empty disables the file destination
// (stdout only). When Path is set, the file is written under ./logs/<Path>
// and rotated by lumberjack according to MaxSize / MaxBackups / MaxAge / Compress.
//
// Numeric zero values are replaced with defaults (100 MB / 10 / 30 days) inside
// Init. Compress is left as the caller-provided value; the project default is
// applied by callers (see resolveCompress) so that "not set" can be distinguished
// from "explicitly false" via the *bool LogConfig.Compress field.
type FileOutput struct {
Path string
MaxSize int
MaxBackups int
MaxAge int
Compress bool
}
const (
defaultMaxSizeMB = 100
defaultMaxBackups = 10
defaultMaxAgeDays = 30
)
func parseZapLevel(level string) (zapcore.Level, error) {
switch strings.ToLower(strings.TrimSpace(level)) {
case "debug":
return zapcore.DebugLevel, nil
case "info":
return zapcore.InfoLevel, nil
case "warn", "warning":
return zapcore.WarnLevel, nil
case "error":
return zapcore.ErrorLevel, nil
case "fatal":
return zapcore.FatalLevel, nil
case "panic":
return zapcore.PanicLevel, nil
default:
return zapcore.InfoLevel, fmt.Errorf("unknown log level: %s", level)
}
}
func logLevelName(level zapcore.Level) string {
if level == zapcore.WarnLevel {
return "WARNING"
}
return strings.ToUpper(level.String())
}
// Init initializes the global logger. stdout is always written. If file.Path
// is non-empty, a rotated file is also written via lumberjack.
//
// Callers should pass a non-empty Path so that file logging is preserved
// (each binary's hardcoded default goes through this parameter). The empty
// path case is reserved for CLI mode where stdout is the only output.
//
// Numeric fields (MaxSize, MaxBackups, MaxAge) are defaulted to 100/10/30
// when zero. Compress is taken as supplied.
func Init(level string, file FileOutput) error {
zapLevel, err := parseZapLevel(level)
if err != nil {
zapLevel = zapcore.InfoLevel
}
atomicLevel = zap.NewAtomicLevelAt(zapLevel)
encoderConfig := zapcore.EncoderConfig{
TimeKey: "timestamp",
LevelKey: "level",
NameKey: "logger",
CallerKey: "",
FunctionKey: "",
MessageKey: "msg",
StacktraceKey: "stacktrace",
LineEnding: zapcore.DefaultLineEnding,
EncodeLevel: zapcore.LowercaseLevelEncoder,
// RFC 3339 with fixed-width millisecond precision and explicit
// timezone offset (UTC rendered as "Z", other zones as "+HH:MM"
// / "-HH:MM"). Easier to ingest than the default "2006-01-02
// 15:04:05" layout — which had no ms and no zone — and avoids
// the variable-width output of RFC3339Nano.
EncodeTime: zapcore.TimeEncoderOfLayout("2006-01-02T15:04:05.000Z07:00"),
EncodeDuration: zapcore.SecondsDurationEncoder,
EncodeCaller: zapcore.ShortCallerEncoder,
}
maxSize := file.MaxSize
if maxSize <= 0 {
maxSize = defaultMaxSizeMB
}
maxBackups := file.MaxBackups
if maxBackups <= 0 {
maxBackups = defaultMaxBackups
}
maxAge := file.MaxAge
if maxAge <= 0 {
maxAge = defaultMaxAgeDays
}
syncers := []zapcore.WriteSyncer{zapcore.AddSync(os.Stdout)}
if file.Path != "" {
ljLogger := &lumberjack.Logger{
Filename: filepath.Join("logs", file.Path),
MaxSize: maxSize,
MaxBackups: maxBackups,
MaxAge: maxAge,
Compress: file.Compress,
LocalTime: true,
}
syncers = append(syncers, zapcore.AddSync(ljLogger))
}
core := zapcore.NewCore(
zapcore.NewConsoleEncoder(encoderConfig),
zap.CombineWriteSyncers(syncers...),
atomicLevel,
)
Logger = zap.New(core, zap.AddCallerSkip(1))
Sugar = Logger.Sugar()
return nil
}
// Sync flushes any buffered log entries.
func Sync() {
if Logger != nil {
_ = Logger.Sync()
}
}
// Fatal logs a fatal message using zap with caller info, then calls os.Exit(1).
func Fatal(msg string, fields ...zap.Field) {
if Logger == nil {
panic("logger not initialized")
}
_, file, line, ok := runtime.Caller(1)
if ok {
fields = append(fields, zap.String("caller", fmt.Sprintf("%s:%d", file, line)))
}
Logger.Fatal(msg, fields...)
}
// Info logs an info message.
func Info(msg string, fields ...zap.Field) {
if Logger == nil {
return
}
Logger.Info(msg, fields...)
}
// Error logs an error message. err may be nil; if non-nil it is appended as
// a zap.Error field. Additional fields follow.
func Error(msg string, err error, fields ...zap.Field) {
if Logger == nil {
return
}
if err != nil {
fields = append(fields, zap.Error(err))
}
Logger.Error(msg, fields...)
}
// Debug logs a debug message.
func Debug(msg string, fields ...zap.Field) {
if Logger == nil {
return
}
Logger.Debug(msg, fields...)
}
// Warn logs a warning message.
func Warn(msg string, fields ...zap.Field) {
if Logger == nil {
return
}
Logger.Warn(msg, fields...)
}
// IsDebugEnabled returns true if debug logging is enabled.
func IsDebugEnabled() bool {
return atomicLevel.Enabled(zapcore.DebugLevel)
}
// GetLevel returns the current log level.
func GetLevel() string {
return atomicLevel.String()
}
// SetLevel sets the log level at runtime.
func SetLevel(level string) error {
zapLevel, err := parseZapLevel(level)
if err != nil {
return err
}
atomicLevel.SetLevel(zapLevel)
return nil
}
// ResolveCompress applies the project default (true) when the config-level
// Compress is nil. When non-nil, the operator's choice is used as-is.
//
// The project default is compression on; operators can opt out by setting
// log.compress: false in service_conf.yaml. Because Go's bool zero value is
// false and would otherwise be indistinguishable from "not set", the YAML
// struct uses *bool and this helper resolves the defaulting at the cmd/
// boundary. The *bool does not live in this file because FileOutput itself
// takes a plain bool (the caller has already resolved the default by then).
func ResolveCompress(c *bool) bool {
if c == nil {
return true
}
return *c
}
// GinLogger returns a gin middleware that emits one log line per request
// through Logger. Level is chosen by status:
//
// 5xx → Error (with err from c.Errors, or sentinel if none)
// 4xx → Warn
// else → Info
//
// c.Errors content is always included as a zap.String("error", ...) field
// when present, regardless of level. This is the project-standard HTTP
// access log; it replaces gin.Logger() so every request line lands in the
// same log file as the rest of the application.
//
// The raw query string is intentionally NOT logged — the path field
// carries only the URL path. Query parameters frequently carry secrets
// (OAuth codes, SAML responses, signed state, API keys in callback
// URLs, etc.) and there is no way to redact them generically. The
// presence and length of a query string are recorded instead so
// operators can still see that one was sent.
func GinLogger() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
path := c.Request.URL.Path
raw := c.Request.URL.RawQuery
c.Next()
latency := time.Since(start)
status := c.Writer.Status()
fields := []zap.Field{
zap.Int("status", status),
zap.String("method", c.Request.Method),
zap.String("path", path),
zap.Duration("latency", latency),
zap.String("client_ip", c.ClientIP()),
zap.Int("size", c.Writer.Size()),
zap.Bool("has_query", raw != ""),
zap.Int("query_len", len(raw)),
}
var ginErr error
if len(c.Errors) > 0 {
last := c.Errors.Last()
// Only emit the string error field for non-5xx paths. The 5xx
// branch below routes ginErr through common.Error(), which
// already adds a structured zap.Error field; logging both
// creates two "error" fields in the same record and confuses
// log aggregation. 4xx / 2xx-3xx paths use Warn/Info which do
// not take an error arg, so the string form is their only
// way to surface c.Errors content.
if status < 500 {
fields = append(fields, zap.String("error", last.Error()))
}
ginErr = last.Err
}
msg := "HTTP request"
switch {
case status >= 500:
if ginErr == nil {
// Likely a panic recovered by gin.Recovery() with no c.Error attached.
// Use a sentinel so the err field is non-empty; operators can
// grep for this string in logs.
ginErr = errors.New("5xx response with no handler error attached")
}
Error(msg, ginErr, fields...)
case status >= 400:
Warn(msg, fields...)
default:
Info(msg, fields...)
}
}
}