// Package logx provides the application logger used by GoOmni and business // packages. Its public API is deliberately independent of the network layer so the same // logger can be used from HTTP handlers, jobs and command line programs. package logx import ( "context" "errors" "fmt" "io" "log/slog" "os" "runtime/debug" "strings" "sync" "sync/atomic" ) // Config controls a logger created by New or Setup. // // Writer is useful for tests and embedding. When it is nil, Output selects a // standard stream. File takes precedence over Output and appends to the named // file. type Config struct { Level string `json:"level"` Format string `json:"format"` Output string `json:"output"` File string `json:"file"` Service string `json:"service"` Environment string `json:"environment"` AddSource bool `json:"addSource"` Writer io.Writer `json:"-"` Rotation RotationConfig `json:"rotation"` RedactKeys []string `json:"redactKeys"` } // Default returns the default production-friendly logger configuration. func Default() Config { return Config{ Level: "info", Format: "json", Output: "stderr", } } // Logger is a structured logger. Child loggers returned by With and WithGroup // share the root logger's lifecycle and must not be closed independently. type Logger struct { logger *slog.Logger closer *closeState level *slog.LevelVar } type closeState struct { once sync.Once fn func() error err error } var ( defaultLogger atomic.Pointer[Logger] setupMu sync.Mutex ) func init() { // Keep package-level logging useful without requiring every service to // repeat setup. Applications can replace this logger with Setup. logger, _ := New(Default()) defaultLogger.Store(logger) } // New creates an independent logger. The caller owns its lifecycle and should // call Close when the process is shutting down. func New(cfg Config) (*Logger, error) { cfg = normalizeConfig(cfg) if err := cfg.Validate(); err != nil { return nil, err } level, err := parseLevel(cfg.Level) if err != nil { return nil, err } writer, closeWriter, err := openWriter(cfg) if err != nil { return nil, err } dynamicLevel := new(slog.LevelVar) dynamicLevel.Set(level) opts := &slog.HandlerOptions{Level: dynamicLevel, AddSource: cfg.AddSource, ReplaceAttr: redactor(cfg.RedactKeys)} var handler slog.Handler if cfg.Format == "text" { handler = slog.NewTextHandler(writer, opts) } else { handler = slog.NewJSONHandler(writer, opts) } logger := slog.New(handler) if cfg.Service != "" { logger = logger.With("service", cfg.Service) } if cfg.Environment != "" { logger = logger.With("environment", cfg.Environment) } var closer *closeState if closeWriter != nil { closer = &closeState{fn: closeWriter} } return &Logger{logger: logger, closer: closer, level: dynamicLevel}, nil } // Setup replaces the process-wide logger used by the package-level helpers. // The returned function closes the newly configured logger. Calling Close is // usually more convenient for application shutdown. func Setup(cfg Config) (func() error, error) { logger, err := New(cfg) if err != nil { return nil, err } setupMu.Lock() previous := defaultLogger.Swap(logger) setupMu.Unlock() if previous != nil { _ = previous.Close() } return logger.Close, nil } // SetDefault installs an existing logger as the package-wide logger. func SetDefault(logger *Logger) { if logger == nil { logger = &Logger{logger: slog.New(slog.NewJSONHandler(io.Discard, nil))} } setupMu.Lock() previous := defaultLogger.Swap(logger) setupMu.Unlock() if previous != nil && previous != logger { _ = previous.Close() } } // Close closes the current process-wide logger and disables further output. func Close() error { discard := &Logger{logger: slog.New(slog.NewJSONHandler(io.Discard, nil))} setupMu.Lock() previous := defaultLogger.Swap(discard) setupMu.Unlock() if previous == nil { return nil } return previous.Close() } // With returns a child logger carrying stable attributes. func With(args ...any) *Logger { return defaultLogger.Load().With(args...) } // WithGroup returns a child logger writing attributes under a group. func WithGroup(name string) *Logger { return defaultLogger.Load().WithGroup(name) } func (l *Logger) With(args ...any) *Logger { if l == nil || l.logger == nil { return defaultLogger.Load().With(args...) } return &Logger{logger: l.logger.With(args...), closer: l.closer, level: l.level} } func (l *Logger) WithGroup(name string) *Logger { if l == nil || l.logger == nil { return defaultLogger.Load().WithGroup(name) } return &Logger{logger: l.logger.WithGroup(name), closer: l.closer, level: l.level} } // SetLevel atomically changes the threshold shared by this logger and children. func (l *Logger) SetLevel(value string) error { if l == nil || l.level == nil { return errors.New("logx: logger does not support dynamic levels") } level, err := parseLevel(value) if err != nil { return err } l.level.Set(level) return nil } // Close releases resources owned by the root logger. It is safe to call more // than once and is a no-op for loggers writing to standard streams. func (l *Logger) Close() error { if l == nil || l.closer == nil { return nil } l.closer.once.Do(func() { l.closer.err = l.closer.fn() }) return l.closer.err } func (l *Logger) DebugContext(ctx context.Context, msg string, args ...any) { ctx = normalizeContext(ctx) l.logger.Log(ctx, slog.LevelDebug, msg, contextAttrs(ctx, args)...) } func (l *Logger) InfoContext(ctx context.Context, msg string, args ...any) { ctx = normalizeContext(ctx) l.logger.Log(ctx, slog.LevelInfo, msg, contextAttrs(ctx, args)...) } func (l *Logger) WarnContext(ctx context.Context, msg string, args ...any) { ctx = normalizeContext(ctx) l.logger.Log(ctx, slog.LevelWarn, msg, contextAttrs(ctx, args)...) } func (l *Logger) ErrorContext(ctx context.Context, msg string, args ...any) { ctx = normalizeContext(ctx) l.logger.Log(ctx, slog.LevelError, msg, contextAttrs(ctx, args)...) } // ErrorStackContext records the current goroutine's stack at the logging call, // not the historical location where an error was originally constructed. func (l *Logger) ErrorStackContext(ctx context.Context, msg string, args ...any) { attrs := make([]any, 0, len(args)+2) attrs = append(attrs, args...) attrs = append(attrs, "stack", string(debug.Stack())) l.ErrorContext(ctx, msg, attrs...) } func ErrorStackContext(ctx context.Context, msg string, args ...any) { FromContext(ctx).ErrorStackContext(ctx, msg, args...) } func (l *Logger) Debug(msg string, args ...any) { l.DebugContext(context.Background(), msg, args...) } func (l *Logger) Info(msg string, args ...any) { l.InfoContext(context.Background(), msg, args...) } func (l *Logger) Warn(msg string, args ...any) { l.WarnContext(context.Background(), msg, args...) } func (l *Logger) Error(msg string, args ...any) { l.ErrorContext(context.Background(), msg, args...) } func DebugContext(ctx context.Context, msg string, args ...any) { FromContext(ctx).DebugContext(ctx, msg, args...) } func InfoContext(ctx context.Context, msg string, args ...any) { FromContext(ctx).InfoContext(ctx, msg, args...) } func WarnContext(ctx context.Context, msg string, args ...any) { FromContext(ctx).WarnContext(ctx, msg, args...) } func ErrorContext(ctx context.Context, msg string, args ...any) { FromContext(ctx).ErrorContext(ctx, msg, args...) } func Debug(msg string, args ...any) { DebugContext(context.Background(), msg, args...) } func Info(msg string, args ...any) { InfoContext(context.Background(), msg, args...) } func Warn(msg string, args ...any) { WarnContext(context.Background(), msg, args...) } func Error(msg string, args ...any) { ErrorContext(context.Background(), msg, args...) } type contextKey uint8 const ( requestIDKey contextKey = iota + 1 traceIDKey ) // WithRequestID adds a request correlation ID to a context. func WithRequestID(ctx context.Context, requestID string) context.Context { return context.WithValue(ctx, requestIDKey, requestID) } // RequestID returns the request correlation ID stored in ctx. func RequestID(ctx context.Context) string { if ctx == nil { return "" } value, _ := ctx.Value(requestIDKey).(string) return value } // WithTraceID adds a trace correlation ID to a context. func WithTraceID(ctx context.Context, traceID string) context.Context { return context.WithValue(ctx, traceIDKey, traceID) } // TraceID returns the trace correlation ID stored in ctx. func TraceID(ctx context.Context) string { if ctx == nil { return "" } value, _ := ctx.Value(traceIDKey).(string) return value } func contextAttrs(ctx context.Context, args []any) []any { ctx = normalizeContext(ctx) attrs := make([]any, 0, len(args)+4) if requestID := RequestID(ctx); requestID != "" { attrs = append(attrs, "requestId", requestID) } if traceID := TraceID(ctx); traceID != "" { attrs = append(attrs, "traceId", traceID) } return append(attrs, args...) } func normalizeContext(ctx context.Context) context.Context { if ctx == nil { return context.Background() } return ctx } func normalizeConfig(cfg Config) Config { if strings.TrimSpace(cfg.Level) == "" { cfg.Level = "info" } cfg.Level = strings.ToLower(strings.TrimSpace(cfg.Level)) if strings.TrimSpace(cfg.Format) == "" { cfg.Format = "json" } cfg.Format = strings.ToLower(strings.TrimSpace(cfg.Format)) if strings.TrimSpace(cfg.Output) == "" { cfg.Output = "stderr" } cfg.Output = strings.ToLower(strings.TrimSpace(cfg.Output)) return cfg } func parseLevel(value string) (slog.Level, error) { switch strings.ToLower(strings.TrimSpace(value)) { case "debug": return slog.LevelDebug, nil case "info": return slog.LevelInfo, nil case "warn", "warning": return slog.LevelWarn, nil case "error": return slog.LevelError, nil default: return 0, fmt.Errorf("logx: unsupported level %q", value) } } func openWriter(cfg Config) (io.Writer, func() error, error) { if cfg.Writer != nil { return cfg.Writer, nil, nil } if cfg.File != "" { if cfg.Rotation.Enabled { return rotatingWriter(cfg) } file, err := os.OpenFile(cfg.File, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) if err != nil { return nil, nil, err } return file, file.Close, nil } switch cfg.Output { case "stdout": return os.Stdout, nil, nil case "stderr": return os.Stderr, nil, nil case "discard", "none": return io.Discard, nil, nil default: return nil, nil, errors.New("logx: output must be stdout, stderr, discard, or a file must be set") } }