mirror of
https://github.com/lukaszraczylo/graphql-monitoring-proxy.git
synced 2026-10-12 05:32:36 +00:00
SetFieldName wrote a package-global map with no lock while log read it from many goroutines, and the level, caller, and time-format setters mutated fields without synchronisation. A runtime setter call could crash the process with a concurrent map write. The scalar fields now use atomics on the hot log path and the global field-name map uses a read-write mutex. Behaviour for init-time configuration is unchanged.
263 lines
6.5 KiB
Go
263 lines
6.5 KiB
Go
// Package libpack_logger provides structured JSON logging with configurable
|
|
// log levels, caller information, and automatic sensitive data redaction.
|
|
// Supports debug, info, warning, and error log levels.
|
|
package libpack_logger
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/goccy/go-json"
|
|
)
|
|
|
|
const (
|
|
LEVEL_DEBUG = iota
|
|
LEVEL_INFO
|
|
LEVEL_WARN
|
|
LEVEL_ERROR
|
|
LEVEL_FATAL
|
|
)
|
|
|
|
var levelNames = []string{
|
|
"debug",
|
|
"info",
|
|
"warn",
|
|
"error",
|
|
"fatal",
|
|
}
|
|
|
|
const (
|
|
defaultTimeFormat = time.RFC3339
|
|
defaultMinLevel = LEVEL_INFO
|
|
defaultShowCaller = false
|
|
)
|
|
|
|
// Logger represents the logging object with configurations.
|
|
//
|
|
// timeFormat, minLogLevel and showCaller are read on every log() call, so
|
|
// they are stored as atomics rather than behind mu: this keeps the hot log
|
|
// path lock-free for the scalar/string config while still being safe to
|
|
// mutate concurrently from SetTimeFormat/SetMinLogLevel/SetShowCaller. mu
|
|
// continues to protect only the output writer, which requires exclusive
|
|
// access while writing bytes.
|
|
type Logger struct {
|
|
output io.Writer
|
|
timeFormat atomic.Pointer[string]
|
|
minLogLevel atomic.Int32
|
|
showCaller atomic.Bool
|
|
mu sync.Mutex // Mutex to protect concurrent access to output
|
|
}
|
|
|
|
// LogMessage represents a log message with optional pairs.
|
|
type LogMessage struct {
|
|
Pairs map[string]any
|
|
Message string
|
|
}
|
|
|
|
// bufferPool is used to reuse bytes.Buffer for efficiency.
|
|
var bufferPool = sync.Pool{
|
|
New: func() any {
|
|
return new(bytes.Buffer)
|
|
},
|
|
}
|
|
|
|
// fieldNames allows customization of output field names.
|
|
//
|
|
// It is package-global by design: a single process-wide field-name mapping
|
|
// is applied consistently across every Logger instance (this is the
|
|
// behavior existing callers rely on, e.g. main.go configures field names on
|
|
// one Logger and expects that naming to apply process-wide). fieldNamesMu
|
|
// guards it so SetFieldName (writer) and log (reader) can run concurrently
|
|
// without a data race.
|
|
var fieldNames = map[string]string{
|
|
"timestamp": "timestamp",
|
|
"level": "level",
|
|
"message": "message",
|
|
}
|
|
|
|
// fieldNamesMu protects concurrent access to the package-global fieldNames map.
|
|
var fieldNamesMu sync.RWMutex
|
|
|
|
// osExit is a variable to allow mocking os.Exit in tests
|
|
var osExit = os.Exit
|
|
|
|
// exitMutex ensures thread-safe access to osExit
|
|
var exitMutex sync.RWMutex
|
|
|
|
// New creates a new Logger with default settings.
|
|
func New() *Logger {
|
|
l := &Logger{
|
|
output: os.Stdout,
|
|
}
|
|
format := defaultTimeFormat
|
|
l.timeFormat.Store(&format)
|
|
l.minLogLevel.Store(int32(defaultMinLevel))
|
|
l.showCaller.Store(defaultShowCaller)
|
|
return l
|
|
}
|
|
|
|
// SetOutput sets the output destination for the logger.
|
|
func (l *Logger) SetOutput(output io.Writer) *Logger {
|
|
l.mu.Lock()
|
|
l.output = output
|
|
l.mu.Unlock()
|
|
return l
|
|
}
|
|
|
|
// GetLogLevel returns the log level integer corresponding to the given level name.
|
|
func GetLogLevel(level string) int {
|
|
level = strings.ToLower(level)
|
|
for i, name := range levelNames {
|
|
if name == level {
|
|
return i
|
|
}
|
|
}
|
|
return defaultMinLevel
|
|
}
|
|
|
|
// SetTimeFormat sets the time format for the logger's timestamp field.
|
|
func (l *Logger) SetTimeFormat(format string) *Logger {
|
|
l.timeFormat.Store(&format)
|
|
return l
|
|
}
|
|
|
|
// SetMinLogLevel sets the minimum log level for the logger.
|
|
func (l *Logger) SetMinLogLevel(level int) *Logger {
|
|
l.minLogLevel.Store(int32(level))
|
|
return l
|
|
}
|
|
|
|
// SetFieldName allows customizing the field names in log output.
|
|
func (l *Logger) SetFieldName(field, name string) *Logger {
|
|
fieldNamesMu.Lock()
|
|
fieldNames[field] = name
|
|
fieldNamesMu.Unlock()
|
|
return l
|
|
}
|
|
|
|
// SetShowCaller enables or disables including the caller information in log output.
|
|
func (l *Logger) SetShowCaller(show bool) *Logger {
|
|
l.showCaller.Store(show)
|
|
return l
|
|
}
|
|
|
|
// shouldLog determines if the message should be logged based on the logger's minimum log level.
|
|
func (l *Logger) shouldLog(level int) bool {
|
|
return level >= int(l.minLogLevel.Load())
|
|
}
|
|
|
|
// IsLevelEnabled reports whether the given level would be emitted by this logger.
|
|
// Useful to gate expensive log-field construction (map/slice allocations) behind a
|
|
// cheap level check when the log call would otherwise be dropped.
|
|
func (l *Logger) IsLevelEnabled(level int) bool {
|
|
return level >= int(l.minLogLevel.Load())
|
|
}
|
|
|
|
// log writes the log message with the given level.
|
|
func (l *Logger) log(level int, m *LogMessage) {
|
|
if m.Pairs == nil {
|
|
m.Pairs = make(map[string]any)
|
|
}
|
|
|
|
fieldNamesMu.RLock()
|
|
timestampField := fieldNames["timestamp"]
|
|
levelField := fieldNames["level"]
|
|
messageField := fieldNames["message"]
|
|
fieldNamesMu.RUnlock()
|
|
|
|
m.Pairs[timestampField] = time.Now().Format(*l.timeFormat.Load())
|
|
m.Pairs[levelField] = levelNames[level]
|
|
m.Pairs[messageField] = m.Message
|
|
|
|
if l.showCaller.Load() {
|
|
m.Pairs["caller"] = getCaller()
|
|
}
|
|
|
|
buffer := bufferPool.Get().(*bytes.Buffer)
|
|
buffer.Reset()
|
|
defer bufferPool.Put(buffer)
|
|
|
|
encoder := json.NewEncoder(buffer)
|
|
err := encoder.Encode(m.Pairs)
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, "Error marshalling log message:", err)
|
|
return
|
|
}
|
|
// Lock the mutex before writing to the output to prevent race conditions
|
|
l.mu.Lock()
|
|
_, err = l.output.Write(buffer.Bytes())
|
|
l.mu.Unlock()
|
|
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, "Error writing log message:", err)
|
|
}
|
|
}
|
|
|
|
// Debug logs a debug-level message.
|
|
func (l *Logger) Debug(m *LogMessage) {
|
|
if l.shouldLog(LEVEL_DEBUG) {
|
|
l.log(LEVEL_DEBUG, m)
|
|
}
|
|
}
|
|
|
|
// Info logs an info-level message.
|
|
func (l *Logger) Info(m *LogMessage) {
|
|
if l.shouldLog(LEVEL_INFO) {
|
|
l.log(LEVEL_INFO, m)
|
|
}
|
|
}
|
|
|
|
// Warn logs a warning-level message.
|
|
func (l *Logger) Warn(m *LogMessage) {
|
|
if l.shouldLog(LEVEL_WARN) {
|
|
l.log(LEVEL_WARN, m)
|
|
}
|
|
}
|
|
|
|
// Warning is an alias for Warn.
|
|
func (l *Logger) Warning(m *LogMessage) {
|
|
l.Warn(m)
|
|
}
|
|
|
|
// Error logs an error-level message.
|
|
func (l *Logger) Error(m *LogMessage) {
|
|
if l.shouldLog(LEVEL_ERROR) {
|
|
l.log(LEVEL_ERROR, m)
|
|
}
|
|
}
|
|
|
|
// Fatal logs a fatal-level message.
|
|
func (l *Logger) Fatal(m *LogMessage) {
|
|
if l.shouldLog(LEVEL_FATAL) {
|
|
l.log(LEVEL_FATAL, m)
|
|
}
|
|
}
|
|
|
|
// Critical logs a critical-level message and exits the application.
|
|
func (l *Logger) Critical(m *LogMessage) {
|
|
l.Fatal(m)
|
|
exitMutex.RLock()
|
|
defer exitMutex.RUnlock()
|
|
osExit(1)
|
|
}
|
|
|
|
// getCaller retrieves the file and line number of the caller.
|
|
func getCaller() string {
|
|
// Skip 3 stack frames: getCaller -> log -> [Debug|Info|...]
|
|
const depth = 3
|
|
_, file, line, ok := runtime.Caller(depth)
|
|
if !ok {
|
|
return "unknown:0"
|
|
}
|
|
file = filepath.Base(file)
|
|
return fmt.Sprintf("%s:%d", file, line)
|
|
}
|