feat: Add structured logging support with configurable format and level

This commit is contained in:
liugangjian 2026-03-04 20:31:47 +08:00
parent ebe425a86f
commit 6039e1ed93
5 changed files with 172 additions and 21 deletions

View file

@ -33,6 +33,11 @@ func agentCmd(message, sessionKey, model string, debug bool) error {
return fmt.Errorf("error loading config: %w", err) return fmt.Errorf("error loading config: %w", err)
} }
// Apply logging configuration from config file
if err := cfg.ApplyLoggingConfig(); err != nil {
fmt.Printf("Warning: Failed to apply logging config: %v\n", err)
}
if model != "" { if model != "" {
cfg.Agents.Defaults.ModelName = model cfg.Agents.Defaults.ModelName = model
} }

View file

@ -49,6 +49,11 @@ func gatewayCmd(debug bool) error {
return fmt.Errorf("error loading config: %w", err) return fmt.Errorf("error loading config: %w", err)
} }
// Apply logging configuration from config file
if err := cfg.ApplyLoggingConfig(); err != nil {
fmt.Printf("Warning: Failed to apply logging config: %v\n", err)
}
provider, modelID, err := providers.CreateProvider(cfg) provider, modelID, err := providers.CreateProvider(cfg)
if err != nil { if err != nil {
return fmt.Errorf("error creating provider: %w", err) return fmt.Errorf("error creating provider: %w", err)
@ -198,8 +203,6 @@ func gatewayCmd(debug bool) error {
// since the original ctx is already canceled. // since the original ctx is already canceled.
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 15*time.Second) shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 15*time.Second)
defer shutdownCancel() defer shutdownCancel()
channelManager.StopAll(shutdownCtx)
deviceService.Stop() deviceService.Stop()
heartbeatService.Stop() heartbeatService.Stop()
cronService.Stop() cronService.Stop()

View file

@ -15,6 +15,11 @@ func statusCmd() {
return return
} }
// Apply logging configuration from config file
if err := cfg.ApplyLoggingConfig(); err != nil {
fmt.Printf("Warning: Failed to apply logging config: %v\n", err)
}
configPath := internal.GetConfigPath() configPath := internal.GetConfigPath()
fmt.Printf("%s picoclaw Status\n", internal.Logo) fmt.Printf("%s picoclaw Status\n", internal.Logo)

View file

@ -4,11 +4,14 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
"strings"
"log"
"sync/atomic" "sync/atomic"
"github.com/caarlos0/env/v11" "github.com/caarlos0/env/v11"
"github.com/sipeed/picoclaw/pkg/fileutil" "github.com/sipeed/picoclaw/pkg/fileutil"
"github.com/sipeed/picoclaw/pkg/logger"
) )
// rrCounter is a global counter for round-robin load balancing across models. // rrCounter is a global counter for round-robin load balancing across models.
@ -594,6 +597,8 @@ type ToolsConfig struct {
Skills SkillsToolsConfig `json:"skills"` Skills SkillsToolsConfig `json:"skills"`
MediaCleanup MediaCleanupConfig `json:"media_cleanup"` MediaCleanup MediaCleanupConfig `json:"media_cleanup"`
MCP MCPConfig `json:"mcp"` MCP MCPConfig `json:"mcp"`
Logging LoggingConfig `json:"logging"`
} }
type SkillsToolsConfig struct { type SkillsToolsConfig struct {
@ -651,6 +656,16 @@ type MCPConfig struct {
Servers map[string]MCPServerConfig `json:"servers,omitempty"` Servers map[string]MCPServerConfig `json:"servers,omitempty"`
} }
// LoggingConfig holds the logging configuration.
type LoggingConfig struct {
Level string `json:"level" env:"PICOCLAW_LOGGING_LEVEL"`
Format string `json:"format" env:"PICOCLAW_LOGGING_FORMAT"`
Output string `json:"output" env:"PICOCLAW_LOGGING_OUTPUT"`
FilePath string `json:"file_path" env:"PICOCLAW_LOGGING_FILE_PATH"`
}
func LoadConfig(path string) (*Config, error) { func LoadConfig(path string) (*Config, error) {
cfg := DefaultConfig() cfg := DefaultConfig()
@ -832,3 +847,49 @@ func (c *Config) ValidateModelList() error {
} }
return nil return nil
} }
// ApplyLoggingConfig applies the logging configuration from the tools section
func (c *Config) ApplyLoggingConfig() error {
// Set log level from config
if c.Tools.Logging.Level != "" {
switch strings.ToLower(c.Tools.Logging.Level) {
case "debug":
logger.SetLevel(logger.DEBUG)
case "info":
logger.SetLevel(logger.INFO)
case "warn":
logger.SetLevel(logger.WARN)
case "error":
logger.SetLevel(logger.ERROR)
case "fatal":
logger.SetLevel(logger.FATAL)
default:
logger.SetLevel(logger.INFO) // default
}
}
// Set log format from config
if c.Tools.Logging.Format != "" {
switch strings.ToLower(c.Tools.Logging.Format) {
case "json":
logger.SetFormat(logger.JSONFormat)
case "text", "human":
logger.SetFormat(logger.TextFormat)
default:
logger.SetFormat(logger.JSONFormat) // default
}
}
// Set file logging from config
if c.Tools.Logging.Output == "file" || c.Tools.Logging.Output == "both" {
if c.Tools.Logging.FilePath != "" {
if err := logger.EnableFileLogging(c.Tools.Logging.FilePath); err != nil {
log.Printf("Failed to enable file logging to %s: %v", c.Tools.Logging.FilePath, err)
return err
}
}
}
return nil
}

View file

@ -21,6 +21,17 @@ const (
FATAL FATAL
) )
type LogFormat string
const (
TextFormat LogFormat = "text"
JSONFormat LogFormat = "json"
)
var (
logFormat = JSONFormat // Default format
)
var ( var (
logLevelNames = map[LogLevel]string{ logLevelNames = map[LogLevel]string{
DEBUG: "DEBUG", DEBUG: "DEBUG",
@ -66,6 +77,18 @@ func GetLevel() LogLevel {
defer mu.RUnlock() defer mu.RUnlock()
return currentLevel return currentLevel
} }
func SetFormat(format LogFormat) {
mu.Lock()
defer mu.Unlock()
logFormat = format
}
func GetFormat() LogFormat {
mu.RLock()
defer mu.RUnlock()
return logFormat
}
func EnableFileLogging(filePath string) error { func EnableFileLogging(filePath string) error {
mu.Lock() mu.Lock()
@ -116,35 +139,24 @@ func logMessage(level LogLevel, component string, message string, fields map[str
} }
} }
outputData := formatLogEntry(entry)
if logger.file != nil { if logger.file != nil {
jsonData, err := json.Marshal(entry) logger.file.Write(outputData)
if err == nil {
logger.file.Write(append(jsonData, '\n'))
}
} }
var fieldStr string // For console/terminal output, use the same format as configured
if len(fields) > 0 { consoleOutput := formatLogForConsole(entry)
fieldStr = " " + formatFields(fields) if len(consoleOutput) > 0 {
} else { logLine := string(consoleOutput[:len(consoleOutput)-1]) // Remove trailing newline
fieldStr = "" log.Println(logLine)
} }
logLine := fmt.Sprintf("[%s] [%s]%s %s%s",
entry.Timestamp,
logLevelNames[level],
formatComponent(component),
message,
fieldStr,
)
log.Println(logLine)
if level == FATAL { if level == FATAL {
os.Exit(1) os.Exit(1)
} }
} }
func formatComponent(component string) string { func formatComponent(component string) string {
if component == "" { if component == "" {
return "" return ""
@ -160,10 +172,75 @@ func formatFields(fields map[string]any) string {
return fmt.Sprintf("{%s}", strings.Join(parts, ", ")) return fmt.Sprintf("{%s}", strings.Join(parts, ", "))
} }
func formatLogEntryText(entry LogEntry) []byte {
var fieldStr string
if len(entry.Fields) > 0 {
fieldStr = " " + formatFields(entry.Fields)
} else {
fieldStr = ""
}
logLine := fmt.Sprintf("[%s] [%s]%s %s%s",
entry.Timestamp,
entry.Level,
formatComponent(entry.Component),
entry.Message,
fieldStr,
)
return []byte(fmt.Sprintf("%s\n", logLine))
}
func formatLogEntry(entry LogEntry) []byte {
switch logFormat {
case JSONFormat:
jsonData, err := json.Marshal(entry)
if err != nil {
// Fallback to text format if JSON marshal fails
return []byte(fmt.Sprintf("%s\n", entry.Message))
}
return append(jsonData, '\n')
default:
// Text format
var fieldStr string
if len(entry.Fields) > 0 {
fieldStr = " " + formatFields(entry.Fields)
} else {
fieldStr = ""
}
logLine := fmt.Sprintf("[%s] [%s]%s %s%s",
entry.Timestamp,
entry.Level,
formatComponent(entry.Component),
entry.Message,
fieldStr,
)
return []byte(fmt.Sprintf("%s\n", logLine))
}
}
func formatLogForConsole(entry LogEntry) []byte {
switch logFormat {
case JSONFormat:
jsonData, err := json.Marshal(entry)
if err != nil {
return []byte(fmt.Sprintf("%s\n", entry.Message))
}
return append(jsonData, '\n')
default:
// Use text format for console output
return formatLogEntryText(entry)
}
}
func Debug(message string) { func Debug(message string) {
logMessage(DEBUG, "", message, nil) logMessage(DEBUG, "", message, nil)
} }
func DebugC(component string, message string) { func DebugC(component string, message string) {
logMessage(DEBUG, component, message, nil) logMessage(DEBUG, component, message, nil)
} }