367 lines
10 KiB
Go
367 lines
10 KiB
Go
// 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.Rotation.validate(cfg); 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) {
|
|
defaultLogger.Load().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) {
|
|
defaultLogger.Load().DebugContext(ctx, msg, args...)
|
|
}
|
|
|
|
func InfoContext(ctx context.Context, msg string, args ...any) {
|
|
defaultLogger.Load().InfoContext(ctx, msg, args...)
|
|
}
|
|
|
|
func WarnContext(ctx context.Context, msg string, args ...any) {
|
|
defaultLogger.Load().WarnContext(ctx, msg, args...)
|
|
}
|
|
|
|
func ErrorContext(ctx context.Context, msg string, args ...any) {
|
|
defaultLogger.Load().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")
|
|
}
|
|
}
|