diff --git a/go.mod b/go.mod index ada3c93..1f2db3d 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/lvlcn-t/loggerhead go 1.22 require ( + github.com/charmbracelet/lipgloss v0.12.1 github.com/charmbracelet/log v0.4.0 github.com/remychantenay/slog-otel v1.3.2 go.opentelemetry.io/otel v1.28.0 @@ -12,7 +13,6 @@ require ( require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/lipgloss v0.12.1 // indirect github.com/charmbracelet/x/ansi v0.1.4 // indirect github.com/go-logfmt/logfmt v0.6.0 // indirect github.com/go-logr/logr v1.4.2 // indirect @@ -20,11 +20,11 @@ require ( github.com/google/uuid v1.6.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect github.com/muesli/termenv v0.15.2 // indirect github.com/rivo/uniseg v0.4.7 // indirect go.opentelemetry.io/otel/metric v1.28.0 // indirect go.opentelemetry.io/otel/trace v1.28.0 // indirect - golang.org/x/exp v0.0.0-20240716175740-e3f259677ff7 // indirect + golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect golang.org/x/sys v0.22.0 // indirect ) diff --git a/go.sum b/go.sum index 35dec44..450a6bf 100644 --- a/go.sum +++ b/go.sum @@ -25,6 +25,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -48,6 +50,8 @@ go.opentelemetry.io/otel/trace v1.28.0 h1:GhQ9cUuQGmNDd5BTCP2dAvv75RdMxEfTmYejp+ go.opentelemetry.io/otel/trace v1.28.0/go.mod h1:jPyXzNPg6da9+38HEwElrQiHlVMTnVfM3/yv2OlIHaI= golang.org/x/exp v0.0.0-20240716175740-e3f259677ff7 h1:wDLEX9a7YQoKdKNQt88rtydkqDxeGaBUTnIYc3iG/mA= golang.org/x/exp v0.0.0-20240716175740-e3f259677ff7/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/internal/logger/core.go b/internal/logger/core.go index be79f99..5cdbde9 100644 --- a/internal/logger/core.go +++ b/internal/logger/core.go @@ -11,50 +11,50 @@ var _ Logger = (*logger)(nil) // Logger is a interface that provides logging methods. type Logger interface { - // Debug logs at LevelDebug. + // Debug logs at [LevelDebug]. Debug(msg string, args ...any) - // Debugf logs at LevelDebug. - // Arguments are handled in the manner of fmt.Printf. + // Debugf logs at [LevelDebug]. + // Arguments are handled in the manner of [fmt.Printf]. Debugf(msg string, args ...any) - // DebugContext logs at LevelDebug with the given context. + // DebugContext logs at [LevelDebug] with the given context. DebugContext(ctx context.Context, msg string, args ...any) - // Info logs at LevelInfo. + // Info logs at [LevelInfo]. Info(msg string, args ...any) - // Infof logs at LevelInfo. - // Arguments are handled in the manner of fmt.Printf. + // Infof logs at [LevelInfo]. + // Arguments are handled in the manner of [fmt.Printf]. Infof(msg string, args ...any) - // InfoContext logs at LevelInfo with the given context. + // InfoContext logs at [LevelInfo] with the given context. InfoContext(ctx context.Context, msg string, args ...any) - // Warn logs at LevelWarn. + // Warn logs at [LevelWarn]. Warn(msg string, args ...any) - // Warnf logs at LevelWarn. - // Arguments are handled in the manner of fmt.Printf. + // Warnf logs at [LevelWarn]. + // Arguments are handled in the manner of [fmt.Printf]. Warnf(msg string, args ...any) - // WarnContext logs at LevelWarn with the given context. + // WarnContext logs at [LevelWarn] with the given context. WarnContext(ctx context.Context, msg string, args ...any) - // Error logs at LevelError. + // Error logs at [LevelError]. Error(msg string, args ...any) - // Errorf logs at LevelError. - // Arguments are handled in the manner of fmt.Printf. + // Errorf logs at [LevelError]. + // Arguments are handled in the manner of [fmt.Printf]. Errorf(msg string, args ...any) - // ErrorContext logs at LevelError with the given context. + // ErrorContext logs at [LevelError] with the given context. ErrorContext(ctx context.Context, msg string, args ...any) - // Panic logs at LevelPanic and then panics with the given message. + // Panic logs at [LevelPanic] and then panics with the given message. Panic(msg string, args ...any) - // Panicf logs at LevelPanic and then panics. - // Arguments are handled in the manner of fmt.Printf. + // Panicf logs at [LevelPanic] and then panics. + // Arguments are handled in the manner of [fmt.Printf]. Panicf(msg string, args ...any) - // PanicContext logs at LevelPanic with the given context and then panics with the given message. + // PanicContext logs at [LevelPanic] with the given context and then panics with the given message. PanicContext(ctx context.Context, msg string, args ...any) - // Fatal logs at LevelFatal and then calls os.Exit(1). + // Fatal logs at [LevelFatal] and then calls [os.Exit](1). Fatal(msg string, args ...any) - // Fatalf logs at LevelFatal and then calls os.Exit(1). - // Arguments are handled in the manner of fmt.Printf. + // Fatalf logs at [LevelFatal] and then calls [os.Exit](1). + // Arguments are handled in the manner of [fmt.Printf]. Fatalf(msg string, args ...any) - // FatalContext logs at LevelFatal with the given context and then calls os.Exit(1). + // FatalContext logs at [LevelFatal] with the given context and then calls [os.Exit](1). FatalContext(ctx context.Context, msg string, args ...any) - // With calls Logger.With on the default logger. + // With returns a Logger that has the given attributes. With(args ...any) Logger // WithGroup returns a Logger that starts a group, if name is non-empty. // The keys of all attributes added to the Logger will be qualified by the given @@ -75,15 +75,15 @@ type Logger interface { // into an Attr. // - Otherwise, the argument is treated as a value with key "!BADKEY". Log(ctx context.Context, level Level, msg string, args ...any) - // LogAttrs is a more efficient version of [Logger.Log] that accepts only Attrs. + // LogAttrs is a more efficient version of [Logger].Log that accepts only Attrs. LogAttrs(ctx context.Context, level Level, msg string, attrs ...slog.Attr) - // Handler returns l's Handler. + // Handler returns the [slog.Handler] that the Logger emits log records to. Handler() slog.Handler - // Enabled reports whether l emits log records at the given context and level. + // Enabled reports whether the [Logger] emits log records at the given context and level. Enabled(ctx context.Context, level Level) bool - // ToSlog returns the underlying slog.Logger. + // ToSlog returns the underlying [slog.Logger]. ToSlog() *slog.Logger } @@ -152,14 +152,18 @@ func (l *logger) LogAttrs(ctx context.Context, level Level, msg string, attrs .. } // logAttrs emits a log record with the current time and the given level, message, and attributes. +// Must be called by a public log method to ensure that the caller is correct. func (l *logger) logAttrs(ctx context.Context, level Level, msg string, a ...any) { if !l.Enabled(ctx, level) { return } - pc := getCaller(3) - - r := slog.NewRecord(time.Now(), level, msg, pc) + // skip is the number of stack frames to skip to find the caller. + // We need to skip calling runtime.Callers, this function and the public log function. + const skip = 3 + var pcs [1]uintptr + runtime.Callers(skip, pcs[:]) + r := slog.NewRecord(time.Now(), level, msg, pcs[0]) r.Add(a...) if ctx == nil { ctx = context.Background() @@ -167,20 +171,3 @@ func (l *logger) logAttrs(ctx context.Context, level Level, msg string, a ...any _ = l.Handler().Handle(ctx, r) } - -// getCaller returns the program counter of the caller at a given depth. -// The depth is the number of stack frames to ascend, with 0 identifying the -// getCaller function itself, 1 identifying the caller that invoked getCaller, -// and so on. -// -// Example: -// -// pc := getCaller(1) // Returns the program counter of the caller of the function that invoked getCaller. -// pc := getCaller(2) // Returns the program counter of the caller of the function that invoked the function that invoked getCaller. -func getCaller(depth uint8) uintptr { - d := int(depth) + 1 - - var pcs [1]uintptr - runtime.Callers(d, pcs[:]) - return pcs[0] -} diff --git a/internal/logger/extensions.go b/internal/logger/extensions.go index ddf515d..70d0c3e 100644 --- a/internal/logger/extensions.go +++ b/internal/logger/extensions.go @@ -50,21 +50,24 @@ func (l *logger) PanicContext(ctx context.Context, msg string, args ...any) { panic(msg) } +// exit is a variable for [os.Exit]. +var exit = os.Exit + // Fatal logs at [LevelFatal] and then calls os.Exit(1). func (l *logger) Fatal(msg string, args ...any) { l.logAttrs(context.Background(), LevelFatal, msg, args...) - os.Exit(1) + exit(1) } // Fatalf logs at LevelFatal and then calls os.Exit(1). // Arguments are handled in the manner of fmt.Printf. func (l *logger) Fatalf(msg string, args ...any) { l.logAttrs(context.Background(), LevelFatal, fmt.Sprintf(msg, args...)) - os.Exit(1) + exit(1) } // FatalContext logs at [LevelFatal] and then calls os.Exit(1). func (l *logger) FatalContext(ctx context.Context, msg string, args ...any) { l.logAttrs(ctx, LevelFatal, msg, args...) - os.Exit(1) + exit(1) } diff --git a/internal/logger/extensions_test.go b/internal/logger/extensions_test.go index 4bbaa73..036b65d 100644 --- a/internal/logger/extensions_test.go +++ b/internal/logger/extensions_test.go @@ -157,7 +157,7 @@ func TestLogger_LevelExtensions(t *testing.T) { } } -func TestLogger_PanicLevel(t *testing.T) { +func TestLogger_Panic_FatalLevels(t *testing.T) { //nolint:gocyclo // Either higher complexity or code duplication tests := []struct { name string attrs []any @@ -220,10 +220,71 @@ func TestLogger_PanicLevel(t *testing.T) { }, }, }, + { + name: "fatal level", + logFunc: func(l Logger, msg string, args ...any) { + l.Fatal(msg, args...) + }, + handler: test.MockHandler{ + HandleFunc: func(ctx context.Context, r slog.Record) error { + level := LevelFatal + if r.Level != level { + t.Errorf("Expected level to be [%s], got [%s]", getLevelString(level), r.Level) + } + if r.NumAttrs() != 0 { + t.Errorf("Expected 0 attributes, got %d", r.NumAttrs()) + } + return nil + }, + }, + }, + { + name: "fatalf level", + logFunc: func(l Logger, msg string, args ...any) { + l.Fatalf(msg, args...) + }, + handler: test.MockHandler{ + HandleFunc: func(ctx context.Context, r slog.Record) error { + level := LevelFatal + if r.Level != level { + t.Errorf("Expected level to be [%s], got [%s]", getLevelString(level), r.Level) + } + if r.NumAttrs() != 0 { + t.Errorf("Expected 0 attributes, got %d", r.NumAttrs()) + } + return nil + }, + }, + }, + { + name: "fatal context level", + logFunc: func(l Logger, msg string, args ...any) { + l.FatalContext(context.Background(), msg, args...) + }, + handler: test.MockHandler{ + HandleFunc: func(ctx context.Context, r slog.Record) error { + level := LevelFatal + if r.Level != level { + t.Errorf("Expected level to be [%s], got [%s]", getLevelString(level), r.Level) + } + if r.NumAttrs() != 0 { + t.Errorf("Expected 0 attributes, got %d", r.NumAttrs()) + } + return nil + }, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + exit = func(code int) { + if code != 1 { + t.Errorf("Expected exit code 1, got %d", code) + } + panic("os.Exit(1)") + } + l := NewLogger(Options{Handler: tt.handler}) defer func() { if r := recover(); r == nil { diff --git a/internal/logger/level.go b/internal/logger/level.go index ab5a4f1..ecebcc1 100644 --- a/internal/logger/level.go +++ b/internal/logger/level.go @@ -19,6 +19,7 @@ const ( LevelFatal Level = slog.Level(16) ) +// LevelNames is a map of log levels to their respective names. var LevelNames = map[Level]string{ LevelTrace: "TRACE", LevelDebug: "DEBUG", @@ -30,6 +31,18 @@ var LevelNames = map[Level]string{ LevelFatal: "FATAL", } +// LevelColors is a map of log levels to their respective ansi color codes. +var LevelColors = map[Level]string{ + LevelTrace: "240", // TRACE - Light Gray + LevelDebug: "63", // DEBUG - Blue + LevelInfo: "86", // INFO - Cyan + LevelNotice: "220", // NOTICE - Yellow + LevelWarn: "192", // WARN - Orange + LevelError: "204", // ERROR - Red + LevelPanic: "134", // PANIC - Purple + LevelFatal: "160", // FATAL - Dark Red +} + // getLevel returns the integer value of the given level string. // If the level is not recognized, it returns LevelInfo. func getLevel(level string) int { diff --git a/internal/logger/opts.go b/internal/logger/opts.go index 022ceb0..a19c064 100644 --- a/internal/logger/opts.go +++ b/internal/logger/opts.go @@ -26,8 +26,8 @@ func newDefaultOptions() Options { } } -// getOptions returns the first Options in the slice if it exists; otherwise, it returns the default Options. -func getOptions(o ...Options) Options { +// newOptions creates a new Options instance with the provided Options merged with the default Options. +func newOptions(o ...Options) Options { opts := newDefaultOptions() if len(o) > 0 { return o[0].merge(opts) diff --git a/internal/logger/utils.go b/internal/logger/utils.go index 555388d..a8ed02f 100644 --- a/internal/logger/utils.go +++ b/internal/logger/utils.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "github.com/charmbracelet/lipgloss" clog "github.com/charmbracelet/log" otel "github.com/remychantenay/slog-otel" ) @@ -24,7 +25,7 @@ import ( // log.Info("Hello, world!") func NewLogger(o ...Options) Logger { return &logger{ - Logger: slog.New(getHandler(o...)), + Logger: slog.New(newHandler(o...)), } } @@ -37,7 +38,7 @@ func NewLogger(o ...Options) Logger { // log := logger.NewNamedLogger("myServiceLogger", opts) func NewNamedLogger(name string, o ...Options) Logger { return &logger{ - Logger: slog.New(getHandler(o...)).With("name", name), + Logger: slog.New(newHandler(o...)).With("name", name), } } @@ -81,22 +82,30 @@ func Middleware(ctx context.Context) func(http.Handler) http.Handler { // ToSlog returns the underlying slog.Logger. func (l *logger) ToSlog() *slog.Logger { + if l.Logger == nil { + return slog.New(newHandler()) + } + return l.Logger } // FromSlog returns a new Logger instance based on the provided slog.Logger. func FromSlog(l *slog.Logger) Logger { + if l == nil { + return NewLogger() + } + return &logger{l} } -// getHandler returns a new slog.Handler based on the provided options. +// newHandler returns a new slog.Handler based on the provided options. // // It returns the handler based on several conditions: // 1. If a handler is provided, it returns the handler. // 2. If OpenTelemetry support is enabled, it returns a new OtelHandler. // 3. Otherwise, it returns a new BaseHandler. -func getHandler(o ...Options) slog.Handler { - opts := getOptions(o...) +func newHandler(o ...Options) slog.Handler { + opts := newOptions(o...) if opts.Handler != nil { return opts.Handler } @@ -109,13 +118,14 @@ func getHandler(o ...Options) slog.Handler { // newBaseHandler returns a new slog.Handler based on the environment variables. func newBaseHandler(o Options) slog.Handler { if strings.EqualFold(o.Format, "TEXT") { - // TODO: customize level output as soon as clog v0.4.0 is released: https://github.com/charmbracelet/log/issues/88#issuecomment-2000161131 - return clog.NewWithOptions(os.Stderr, clog.Options{ + log := clog.NewWithOptions(os.Stderr, clog.Options{ TimeFormat: time.Kitchen, Level: clog.Level(getLevel(o.Level)), ReportTimestamp: true, ReportCaller: true, }) + log.SetStyles(newCustomStyles()) + return log } return slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{ @@ -125,6 +135,21 @@ func newBaseHandler(o Options) slog.Handler { }) } +// newCustomStyles returns the custom styles for the text logger. +func newCustomStyles() *clog.Styles { + styles := clog.DefaultStyles() + + for level, color := range LevelColors { + styles.Levels[clog.Level(int(level))] = lipgloss.NewStyle(). + SetString(getLevelString(level)). + Bold(true). + MaxWidth(4). //nolint:mnd // 4 is the max width for the level string + Foreground(lipgloss.Color(color)) + } + + return styles +} + // replaceAttr is the replacement function for slog.HandlerOptions. func replaceAttr(_ []string, a slog.Attr) slog.Attr { if a.Key == slog.LevelKey { diff --git a/internal/logger/utils_test.go b/internal/logger/utils_test.go index 1e8f64b..88fb340 100644 --- a/internal/logger/utils_test.go +++ b/internal/logger/utils_test.go @@ -178,6 +178,65 @@ func TestFromContext(t *testing.T) { } } +func TestFromSlog(t *testing.T) { + tests := []struct { + name string + l *slog.Logger + want Logger + }{ + { + name: "Slog logger", + l: slog.New(slog.NewJSONHandler(os.Stdout, nil)), + want: NewLogger(Options{Handler: slog.NewJSONHandler(os.Stdout, nil)}), + }, + { + name: "Nil slog logger", + l: nil, + want: NewLogger(), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := FromSlog(tt.l) + if _, ok := got.(*logger); !ok { + t.Errorf("FromSlog() = %T, want %T", got, tt.want) + } + + if reflect.TypeOf(got.Handler()) != reflect.TypeOf(tt.want.Handler()) { + t.Errorf("FromSlog().Handler() = %v, want %v", got.Handler(), tt.want.Handler()) + } + }) + } +} + +func TestLogger_ToSlog(t *testing.T) { + tests := []struct { + name string + l Logger + }{ + { + name: "Logger", + l: NewLogger(Options{Handler: slog.NewJSONHandler(os.Stdout, nil)}), + }, + { + name: "Nil logger", + l: &logger{nil}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.l != nil { + got := tt.l.ToSlog() + if got == nil { + t.Errorf("ToSlog() = %v, want %v", got, tt.l) + } + } + }) + } +} + func TestMiddleware(t *testing.T) { tests := []struct { name string diff --git a/logger/logger.go b/logger/logger.go index 3b7e874..b99f237 100644 --- a/logger/logger.go +++ b/logger/logger.go @@ -50,13 +50,13 @@ func NewContextWithLogger(parent context.Context) (context.Context, context.Canc return logger.NewContextWithLogger(parent) } -// IntoContext embeds the provided slog.Logger into the given context and returns the modified context. +// IntoContext embeds the provided [logger.Logger] into the given context and returns the modified context. // This function is used for passing loggers through context, allowing for context-aware logging. func IntoContext(ctx context.Context, log logger.Logger) context.Context { return logger.IntoContext(ctx, log) } -// FromContext extracts the slog.Logger from the provided context. +// FromContext extracts the [logger.Logger] from the provided context. // If the context does not have a logger, it returns a new logger with the default configuration. // This function is useful for retrieving loggers from context in different parts of an application. func FromContext(ctx context.Context) logger.Logger { @@ -68,7 +68,7 @@ func Middleware(ctx context.Context) func(http.Handler) http.Handler { return logger.Middleware(ctx) } -// FromSlog returns a new Logger instance from the provided slog.Logger. +// FromSlog returns a new [Logger] instance from the provided [slog.Logger]. func FromSlog(l *slog.Logger) logger.Logger { return logger.FromSlog(l) }