diff --git a/.gitstow/metadata.json b/.gitstow/metadata.json new file mode 100644 index 0000000..7fe6b48 --- /dev/null +++ b/.gitstow/metadata.json @@ -0,0 +1,9 @@ +{ + "remote": "hephaestus:/mnt/user/james/gitstow/log", + "last_sync": "0001-01-01T00:00:00Z", + "last_access": "2026-02-26T12:24:31.584375-05:00", + "size_bytes": 794903, + "head_ref": "", + "head_sha": "", + "status": "live" +} \ No newline at end of file diff --git a/console-handler-proposal.md b/console-handler-proposal.md new file mode 100644 index 0000000..365ef4b --- /dev/null +++ b/console-handler-proposal.md @@ -0,0 +1,113 @@ +# Proposal: Enable Timestamps by Default in log.New() + +## Problem + +`log.New()` omits timestamps while `log.Default()` includes them. This inconsistency surprises users. + +```go +// log.Default() includes timestamps +log.Info("Hello world!") +// Output: 2023/01/23 14:23:45 INFO Hello world! + +// log.New() does not +logger := log.New(os.Stderr) +logger.Info("Hello world!") +// Output: INFO Hello world! +``` + +## Solution + +Enable timestamps by default in `log.New()`: + +```go +func New(w io.Writer) *Logger { + return NewWithOptions(w, Options{ + ReportTimestamp: true, + }) +} +``` + +This uses `DefaultTimeFormat` (`"2006/01/02 15:04:05"`), matching `log.Default()` behavior. + +Users can disable via `NewWithOptions()`: + +```go +logger := log.NewWithOptions(os.Stderr, log.Options{ + ReportTimestamp: false, +}) +``` + +## Impact + +**Breaking change**: Existing code using `log.New()` will now include timestamps. + +**Benefits**: +- Consistent with `log.Default()` +- Matches user expectations +- Aligns with standard library behavior + +## API Compatibility with log/slog + +The `*Logger` type already implements `slog.Handler`: + +```go +handler := log.NewWithOptions(os.Stderr, log.Options{ + ReportTimestamp: true, + Level: log.DebugLevel, +}) +logger := slog.New(handler) +``` + +**For a familiar slog-style API, add:** + +```go +// HandlerOptions provides slog-style configuration. +type HandlerOptions struct { + AddSource bool + Level Level +} + +// NewTextHandler returns a Handler with text formatting. +// Timestamps are enabled by default. +func NewTextHandler(w io.Writer, opts *HandlerOptions) *Logger { + if opts == nil { + opts = &HandlerOptions{} + } + return NewWithOptions(w, Options{ + ReportTimestamp: true, + Formatter: TextFormatter, + Level: opts.Level, + ReportCaller: opts.AddSource, + }) +} + +// NewJSONHandler returns a Handler with JSON formatting. +// Timestamps are enabled by default. +func NewJSONHandler(w io.Writer, opts *HandlerOptions) *Logger { + if opts == nil { + opts = &HandlerOptions{} + } + return NewWithOptions(w, Options{ + ReportTimestamp: true, + Formatter: JSONFormatter, + Level: opts.Level, + ReportCaller: opts.AddSource, + }) +} +``` + +**Usage:** + +```go +handler := log.NewTextHandler(os.Stderr, &log.HandlerOptions{ + Level: log.DebugLevel, +}) +logger := slog.New(handler) +``` + +## Implementation + +- Update `New()` to set `ReportTimestamp: true` +- Add `HandlerOptions`, `NewTextHandler()`, `NewJSONHandler()` +- Update tests +- Document breaking change in release notes diff --git a/context_test.go b/context_test.go index afb95ac..7209322 100644 --- a/context_test.go +++ b/context_test.go @@ -14,14 +14,14 @@ func TestLogContext_empty(t *testing.T) { } func TestLogContext_simple(t *testing.T) { - l := New(io.Discard) + l := NewWithOptions(io.Discard, Options{}) ctx := WithContext(context.Background(), l) require.Equal(t, l, FromContext(ctx)) } func TestLogContext_fields(t *testing.T) { var buf bytes.Buffer - l := New(&buf) + l := NewWithOptions(&buf, Options{}) l.SetLevel(DebugLevel) ctx := WithContext(context.Background(), l.With("foo", "bar")) l = FromContext(ctx) diff --git a/json_test.go b/json_test.go index 048e415..48fde06 100644 --- a/json_test.go +++ b/json_test.go @@ -13,7 +13,7 @@ import ( func TestJson(t *testing.T) { var buf bytes.Buffer - l := New(&buf) + l := NewWithOptions(&buf, Options{}) l.SetFormatter(JSONFormatter) cases := []struct { name string @@ -132,7 +132,7 @@ func TestJson(t *testing.T) { func TestJsonCaller(t *testing.T) { var buf bytes.Buffer - l := New(&buf) + l := NewWithOptions(&buf, Options{}) l.SetFormatter(JSONFormatter) l.SetReportCaller(true) l.SetLevel(DebugLevel) @@ -174,7 +174,7 @@ func TestJsonCaller(t *testing.T) { func TestJsonTime(t *testing.T) { var buf bytes.Buffer - logger := New(&buf) + logger := NewWithOptions(&buf, Options{}) logger.SetTimeFunction(_zeroTime) logger.SetFormatter(JSONFormatter) logger.SetReportTimestamp(true) @@ -184,7 +184,7 @@ func TestJsonTime(t *testing.T) { func TestJsonPrefix(t *testing.T) { var buf bytes.Buffer - logger := New(&buf) + logger := NewWithOptions(&buf, Options{}) logger.SetFormatter(JSONFormatter) logger.SetPrefix("my-prefix") logger.Info("info") @@ -198,7 +198,7 @@ func TestJsonCustomKey(t *testing.T) { TimestampKey = oldTsKey }() TimestampKey = "other-time" - logger := New(&buf) + logger := NewWithOptions(&buf, Options{}) logger.SetTimeFunction(_zeroTime) logger.SetFormatter(JSONFormatter) logger.SetReportTimestamp(true) diff --git a/logfmt_test.go b/logfmt_test.go index 114024e..01f698f 100644 --- a/logfmt_test.go +++ b/logfmt_test.go @@ -10,7 +10,7 @@ import ( func TestLogfmt(t *testing.T) { var buf bytes.Buffer - l := New(&buf) + l := NewWithOptions(&buf, Options{}) l.SetFormatter(LogfmtFormatter) cases := []struct { name string diff --git a/logger_121_test.go b/logger_121_test.go index a34bc62..9eac6de 100644 --- a/logger_121_test.go +++ b/logger_121_test.go @@ -15,7 +15,7 @@ import ( func TestSlogSimple(t *testing.T) { var buf bytes.Buffer - h := New(&buf) + h := NewWithOptions(&buf, Options{}) h.SetLevel(DebugLevel) l := slog.New(h) cases := []struct { @@ -73,7 +73,7 @@ func TestSlogSimple(t *testing.T) { func TestSlogWith(t *testing.T) { var buf bytes.Buffer - h := New(&buf) + h := NewWithOptions(&buf, Options{}) h.SetLevel(DebugLevel) l := slog.New(h).With("a", "b") cases := []struct { @@ -124,7 +124,7 @@ func TestSlogWith(t *testing.T) { func TestSlogWithGroup(t *testing.T) { var buf bytes.Buffer - h := New(&buf) + h := NewWithOptions(&buf, Options{}) l := slog.New(h).WithGroup("charm").WithGroup("bracelet") cases := []struct { name string @@ -175,7 +175,7 @@ func TestSlogCustomLevel(t *testing.T) { for _, c := range cases { buf.Reset() t.Run(c.name, func(t *testing.T) { - l := New(&buf) + l := NewWithOptions(&buf, Options{}) l.SetLevel(c.minLevel) l.Handle(context.Background(), slog.NewRecord(time.Now(), c.level, "foo", 0)) assert.Equal(t, c.expected, buf.String()) diff --git a/logger_test.go b/logger_test.go index 4560394..65a1c46 100644 --- a/logger_test.go +++ b/logger_test.go @@ -13,7 +13,7 @@ import ( func TestSubLogger(t *testing.T) { var buf bytes.Buffer - l := New(&buf) + l := NewWithOptions(&buf, Options{}) cases := []struct { name string expected string @@ -80,7 +80,7 @@ func TestWrongLevel(t *testing.T) { for _, c := range cases { t.Run(c.name, func(t *testing.T) { buf.Reset() - l := New(&buf) + l := NewWithOptions(&buf, Options{}) l.SetLevel(c.level) l.Info("info") assert.Equal(t, c.expected, buf.String()) @@ -90,7 +90,7 @@ func TestWrongLevel(t *testing.T) { func TestLogFormatter(t *testing.T) { var buf bytes.Buffer - l := New(&buf) + l := NewWithOptions(&buf, Options{}) l.SetLevel(DebugLevel) cases := []struct { name string @@ -139,7 +139,7 @@ func TestLogFormatter(t *testing.T) { func TestEmptyMessage(t *testing.T) { var buf bytes.Buffer - l := New(&buf) + l := NewWithOptions(&buf, Options{}) cases := []struct { name string expected string @@ -196,7 +196,7 @@ func TestLogWithPrefix(t *testing.T) { for _, c := range cases { t.Run(c.name, func(t *testing.T) { buf.Reset() - l := New(&buf) + l := NewWithOptions(&buf, Options{}) l.SetPrefix(c.prefix) l.Info(c.msg) assert.Equal(t, c.expected, buf.String()) @@ -215,7 +215,7 @@ func TestLogWithRaceCondition(t *testing.T) { } for _, c := range cases { t.Run(c.name, func(t *testing.T) { - l := New(w) + l := NewWithOptions(w, Options{}) var done sync.WaitGroup @@ -248,7 +248,7 @@ func TestRace(t *testing.T) { t.Parallel() w := io.Discard - l := New(w) + l := NewWithOptions(w, Options{}) for i := 0; i < 100; i++ { t.Run("race", func(t *testing.T) { t.Parallel() @@ -280,7 +280,7 @@ func TestRace(t *testing.T) { func TestCustomLevel(t *testing.T) { var buf bytes.Buffer level500 := Level(500) - l := New(&buf) + l := NewWithOptions(&buf, Options{}) l.SetLevel(level500) l.Logf(level500, "foo") assert.Equal(t, "foo\n", buf.String()) diff --git a/pkg.go b/pkg.go index ea3f009..edddbbe 100644 --- a/pkg.go +++ b/pkg.go @@ -42,8 +42,43 @@ func SetDefault(logger *Logger) { } // New returns a new logger with the default options. +// Timestamps are enabled by default. func New(w io.Writer) *Logger { - return NewWithOptions(w, Options{}) + return NewWithOptions(w, Options{ReportTimestamp: true}) +} + +// HandlerOptions provides slog-style configuration. +type HandlerOptions struct { + AddSource bool + Level Level +} + +// NewTextHandler returns a Handler with text formatting. +// Timestamps are enabled by default. +func NewTextHandler(w io.Writer, opts *HandlerOptions) *Logger { + if opts == nil { + opts = &HandlerOptions{} + } + return NewWithOptions(w, Options{ + ReportTimestamp: true, + Formatter: TextFormatter, + Level: opts.Level, + ReportCaller: opts.AddSource, + }) +} + +// NewJSONHandler returns a Handler with JSON formatting. +// Timestamps are enabled by default. +func NewJSONHandler(w io.Writer, opts *HandlerOptions) *Logger { + if opts == nil { + opts = &HandlerOptions{} + } + return NewWithOptions(w, Options{ + ReportTimestamp: true, + Formatter: JSONFormatter, + Level: opts.Level, + ReportCaller: opts.AddSource, + }) } // NewWithOptions returns a new logger using the provided options. diff --git a/pkg_test.go b/pkg_test.go index 78801ee..092e2ae 100644 --- a/pkg_test.go +++ b/pkg_test.go @@ -241,3 +241,79 @@ func TestGlobalCustomLevel(t *testing.T) { Logf(lvl, "hey %s", "you") assert.Equal(t, "{\"msg\":\"info\"}\n{\"msg\":\"hey you\"}\n", buf.String()) } + +func TestNew(t *testing.T) { + var buf bytes.Buffer + l := New(&buf) + l.SetTimeFunction(_zeroTime) + l.Info("hello") + assert.Equal(t, "0002/01/01 00:00:00 INFO hello\n", buf.String()) + assert.True(t, l.reportTimestamp) +} + +func TestNewTextHandler(t *testing.T) { + var buf bytes.Buffer + l := NewTextHandler(&buf, &HandlerOptions{Level: DebugLevel}) + l.SetTimeFunction(_zeroTime) + + l.Debug("debug msg") + assert.Equal(t, "0002/01/01 00:00:00 DEBU debug msg\n", buf.String()) + assert.True(t, l.reportTimestamp) + assert.Equal(t, TextFormatter, l.formatter) + assert.Equal(t, DebugLevel, l.GetLevel()) +} + +func TestNewTextHandlerWithSource(t *testing.T) { + var buf bytes.Buffer + l := NewTextHandler(&buf, &HandlerOptions{AddSource: true}) + l.SetTimeFunction(_zeroTime) + _, file, line, _ := runtime.Caller(0) + l.Info("hello") + assert.Equal(t, fmt.Sprintf("0002/01/01 00:00:00 INFO hello\n", filepath.Base(file), line+1), buf.String()) + assert.True(t, l.reportCaller) +} + +func TestNewJSONHandler(t *testing.T) { + var buf bytes.Buffer + l := NewJSONHandler(&buf, &HandlerOptions{Level: WarnLevel}) + l.SetTimeFunction(_zeroTime) + + l.Warn("warn msg") + assert.Equal(t, "{\"time\":\"0002/01/01 00:00:00\",\"level\":\"warn\",\"msg\":\"warn msg\"}\n", buf.String()) + assert.True(t, l.reportTimestamp) + assert.Equal(t, JSONFormatter, l.formatter) + assert.Equal(t, WarnLevel, l.GetLevel()) +} + +func TestNewJSONHandlerWithSource(t *testing.T) { + var buf bytes.Buffer + l := NewJSONHandler(&buf, &HandlerOptions{AddSource: true}) + l.SetTimeFunction(_zeroTime) + _, file, line, _ := runtime.Caller(0) + l.Info("hello") + expected := fmt.Sprintf("{\"time\":\"0002/01/01 00:00:00\",\"level\":\"info\",\"caller\":\"%s:%d\",\"msg\":\"hello\"}\n", trimCallerPath(file, 2), line+1) + assert.Equal(t, expected, buf.String()) + assert.True(t, l.reportCaller) +} + +func TestHandlerOptionsNil(t *testing.T) { + var buf bytes.Buffer + + // NewTextHandler with nil opts + tl := NewTextHandler(&buf, nil) + tl.SetTimeFunction(_zeroTime) + tl.Info("text") + assert.Equal(t, "0002/01/01 00:00:00 INFO text\n", buf.String()) + assert.Equal(t, InfoLevel, tl.GetLevel()) + assert.False(t, tl.reportCaller) + + buf.Reset() + + // NewJSONHandler with nil opts + jl := NewJSONHandler(&buf, nil) + jl.SetTimeFunction(_zeroTime) + jl.Info("json") + assert.Equal(t, "{\"time\":\"0002/01/01 00:00:00\",\"level\":\"info\",\"msg\":\"json\"}\n", buf.String()) + assert.Equal(t, InfoLevel, jl.GetLevel()) + assert.False(t, jl.reportCaller) +} diff --git a/stdlog_test.go b/stdlog_test.go index 6cf73a7..fbdf485 100644 --- a/stdlog_test.go +++ b/stdlog_test.go @@ -14,7 +14,7 @@ import ( func TestStdLog(t *testing.T) { var buf bytes.Buffer - l := New(&buf) + l := NewWithOptions(&buf, Options{}) cases := []struct { f func(l *log.Logger) name string @@ -49,7 +49,7 @@ func TestStdLog(t *testing.T) { func TestStdLog_forceLevel(t *testing.T) { var buf bytes.Buffer - logger := New(&buf) + logger := NewWithOptions(&buf, Options{}) cases := []struct { name string expected string @@ -83,7 +83,7 @@ func TestStdLog_forceLevel(t *testing.T) { func TestStdLog_writer(t *testing.T) { var buf bytes.Buffer - logger := New(&buf) + logger := NewWithOptions(&buf, Options{}) logger.SetReportCaller(true) _, file, line, ok := runtime.Caller(0) require.True(t, ok) diff --git a/text_test.go b/text_test.go index b289c22..e34497f 100644 --- a/text_test.go +++ b/text_test.go @@ -24,14 +24,14 @@ func _zeroTime(time.Time) time.Time { func TestNilStyles(t *testing.T) { st := DefaultStyles() - l := New(io.Discard) + l := NewWithOptions(io.Discard, Options{}) l.SetStyles(nil) assert.Equal(t, st, l.styles) } func TestTextCaller(t *testing.T) { var buf bytes.Buffer - logger := New(&buf) + logger := NewWithOptions(&buf, Options{}) logger.SetReportCaller(true) // We calculate the caller offset based on the caller line number. _, file, line, _ := runtime.Caller(0) @@ -100,7 +100,7 @@ func TestTextCaller(t *testing.T) { func TestTextLogger(t *testing.T) { var buf bytes.Buffer - logger := New(&buf) + logger := NewWithOptions(&buf, Options{}) cases := []struct { name string expected string @@ -211,7 +211,7 @@ func TestTextLogger(t *testing.T) { func TestTextHelper(t *testing.T) { var buf bytes.Buffer - logger := New(&buf) + logger := NewWithOptions(&buf, Options{}) logger.SetReportCaller(true) helper := func() { logger.Helper() @@ -226,7 +226,7 @@ func TestTextHelper(t *testing.T) { func TestTextFatal(t *testing.T) { var buf bytes.Buffer - logger := New(&buf) + logger := NewWithOptions(&buf, Options{}) logger.SetReportCaller(true) if os.Getenv("FATAL") == "1" { logger.Fatal("i'm dead") @@ -244,7 +244,7 @@ func TestTextFatal(t *testing.T) { func TestTextValueStyles(t *testing.T) { var buf bytes.Buffer - logger := New(&buf) + logger := NewWithOptions(&buf, Options{}) logger.SetColorProfile(termenv.ANSI256) lipgloss.SetColorProfile(termenv.ANSI256) st := DefaultStyles() @@ -416,7 +416,7 @@ func TestColorProfile(t *testing.T) { termenv.ANSI256, termenv.TrueColor, } - l := New(io.Discard) + l := NewWithOptions(io.Discard, Options{}) for _, p := range cases { l.SetColorProfile(p) assert.Equal(t, p, l.re.ColorProfile()) @@ -425,7 +425,7 @@ func TestColorProfile(t *testing.T) { func TestCustomLevelStyle(t *testing.T) { var buf bytes.Buffer - l := New(&buf) + l := NewWithOptions(&buf, Options{}) st := DefaultStyles() lvl := Level(1234) st.Levels[lvl] = lipgloss.NewStyle().Bold(true).SetString("FUNKY")