diff --git a/galog_stderr.go b/galog_stderr.go index 6c2bde4..ae9c940 100644 --- a/galog_stderr.go +++ b/galog_stderr.go @@ -15,38 +15,37 @@ package galog import ( - "context" - "fmt" "io" "os" ) const ( - // defaultStderrQueueSize defines the default queue size of the stderr backend - // implementation. In general writing to stderr doesn't require caching or - // queueing, we are set a limit to avoid the queue to grow indefinitely in - // case of any disastrous behavior of the OS - as 0 means "grow indefinitely". + // defaultStderrQueueSize defines the default queue size of the stderr + // backend implementation. In general writing to stderr doesn't require + // caching or queueing, we set a limit to avoid the queue to grow + // indefinitely in case of any disastrous behavior of the OS - as 0 means + // "grow indefinitely". defaultStderrQueueSize = 10 ) // StderrBackend is a simple backend implementation for logging to stderr. type StderrBackend struct { - // backendID is the internal id of this backend. - backendID string - // config is a pointer to the generic Config interface implementation. - config *backendConfig - // writer by default it's set to use os.Stderr, tests might override it to a - // local writer. - writer io.Writer + *writerBackend } // NewStderrBackend returns a Backend implementation that will log out to -// the process' stderr. -func NewStderrBackend(writer io.Writer) *StderrBackend { +// the process' stderr. Writers is deprecated and will be removed in the future. +// It is kept for compatibility with users who may have used it in the past. +// It always writes to os.Stderr. +func NewStderrBackend(writers ...io.Writer) *StderrBackend { res := &StderrBackend{ - backendID: "log-backend,stderr", - config: newBackendConfig(defaultStderrQueueSize), - writer: writer, + writerBackend: &writerBackend{ + backendID: "log-backend,stderr", + config: newBackendConfig(defaultStderrQueueSize), + writer: os.Stderr, + skip: func(lvl Level) bool { return lvl != ErrorLevel && lvl != FatalLevel }, + sync: os.Stderr.Sync, + }, } res.config.SetFormat(ErrorLevel, @@ -56,46 +55,3 @@ func NewStderrBackend(writer io.Writer) *StderrBackend { return res } - -// ID returns the stderr backend implementation's ID. -func (wb *StderrBackend) ID() string { - return wb.backendID -} - -// Log prints the log entry to stderr. -func (wb *StderrBackend) Log(entry *LogEntry) error { - if entry.Level != ErrorLevel { - return nil - } - - format := wb.config.Format(entry.Level) - - message, err := entry.Format(format + "\n") - if err != nil { - return fmt.Errorf("failed to format log level: %+v", err) - } - - n, err := wb.writer.Write([]byte(message)) - if err != nil { - return fmt.Errorf("failed to write log to stderr: %+v", err) - } - - if n != len(message) { - return fmt.Errorf("failed to write the message, wrote %d bytes out of %d bytes", n, len(message)) - } - - return nil -} - -// Config returns the backend configuration of the stderr backend. -func (wb *StderrBackend) Config() Config { - return wb.config -} - -// Shutdown flushes the stderr file. -func (wb *StderrBackend) Shutdown(context.Context) error { - if err := os.Stderr.Sync(); err != nil { - return fmt.Errorf("failed to flush stderr: %+v", err) - } - return nil -} diff --git a/galog_stderr_test.go b/galog_stderr_test.go index 8c1549d..f0b2bf0 100644 --- a/galog_stderr_test.go +++ b/galog_stderr_test.go @@ -42,7 +42,8 @@ func (ew errorWriter) Write(data []byte) (int, error) { func TestStderrWriteFailure(t *testing.T) { logBuffer := &errorWriter{failureType: writeFailure} - be := NewStderrBackend(logBuffer) + be := NewStderrBackend() + be.writer = logBuffer entry := newEntry(ErrorLevel, "", "foobar") err := be.Log(entry) @@ -53,7 +54,8 @@ func TestStderrWriteFailure(t *testing.T) { func TestStderrWriteLenFailure(t *testing.T) { logBuffer := &errorWriter{failureType: writeLenFailure} - be := NewStderrBackend(logBuffer) + be := NewStderrBackend() + be.writer = logBuffer entry := newEntry(ErrorLevel, "", "foobar") err := be.Log(entry) @@ -64,7 +66,8 @@ func TestStderrWriteLenFailure(t *testing.T) { func TestStderrInvalidFormat(t *testing.T) { logBuffer := bytes.NewBuffer(nil) - be := NewStderrBackend(logBuffer) + be := NewStderrBackend() + be.writer = logBuffer be.Config().SetFormat(ErrorLevel, "{{.Foobar}}") @@ -88,6 +91,12 @@ func TestStderrSuccess(t *testing.T) { level: ErrorLevel, want: "[ERROR]: foo bar\n", }, + { + desc: "fatal_level", + message: "foo bar", + level: FatalLevel, + want: "[FATAL]: foo bar\n", + }, { desc: "warning_level_skip", message: "foo bar", @@ -111,7 +120,8 @@ func TestStderrSuccess(t *testing.T) { for _, tc := range tests { t.Run(tc.desc, func(t *testing.T) { logBuffer := bytes.NewBuffer(nil) - be := NewStderrBackend(logBuffer) + be := NewStderrBackend() + be.writer = logBuffer if be.Config() == nil { t.Fatal("NewStderrBackend() failed: Config() returned nil") } diff --git a/galog_stdout.go b/galog_stdout.go new file mode 100644 index 0000000..260eef9 --- /dev/null +++ b/galog_stdout.go @@ -0,0 +1,54 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package galog + +import ( + "os" +) + +const ( + // defaultStdoutQueueSize defines the default queue size of the stdout + // backend implementation. In general writing to stdout doesn't require + // caching or queueing, we set a limit to avoid the queue to grow + // indefinitely in case of any disastrous behavior of the OS - as 0 means + // "grow indefinitely". + defaultStdoutQueueSize = 10 +) + +// StdoutBackend is a simple backend implementation for logging to stdout. +type StdoutBackend struct { + *writerBackend +} + +// NewStdoutBackend returns a Backend implementation that will log out to the +// process' stdout. +func NewStdoutBackend() *StdoutBackend { + res := &StdoutBackend{ + writerBackend: &writerBackend{ + backendID: "log-backend,stdout", + config: newBackendConfig(defaultStdoutQueueSize), + writer: os.Stdout, + skip: func(lvl Level) bool { return lvl == ErrorLevel || lvl == FatalLevel }, + sync: os.Stdout.Sync, + }, + } + + res.config.SetFormat(InfoLevel, + `{{.When.Format "2006-01-02T15:04:05.0000Z07:00"}} {{if .Prefix}} {{.Prefix}}: {{end}}[{{.Level}}]: {{.Message}}`) + res.config.SetFormat(DebugLevel, + `{{.When.Format "2006-01-02T15:04:05.0000Z07:00"}} {{if .Prefix}} {{.Prefix}}: {{end}}[{{.Level}}]: ({{.File}}:{{.Line}}) {{.Message}}`) + + return res +} diff --git a/galog_stdout_test.go b/galog_stdout_test.go new file mode 100644 index 0000000..f543f07 --- /dev/null +++ b/galog_stdout_test.go @@ -0,0 +1,152 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package galog + +import ( + "bytes" + "strings" + "testing" + "time" +) + +// helper1 and newTestEntry together wrapper functions to simulate a stack +// depth of 3 layers between the test code calling newTestEntry and the +// runtime.Caller(3) call site inside newEntry. +func helper1(level Level, prefix string, msg string) *LogEntry { + return newEntry(level, prefix, msg) +} + +func newTestEntry(level Level, prefix string, msg string) *LogEntry { + return helper1(level, prefix, msg) +} + +func TestStdoutWriteFailure(t *testing.T) { + logBuffer := &errorWriter{failureType: writeFailure} + be := NewStdoutBackend() + be.writer = logBuffer + + entry := newTestEntry(InfoLevel, "", "foobar") + if err := be.Log(entry); err == nil { + t.Fatalf("Log(%+v) succeeded, want error due to write failure", entry) + } +} + +func TestStdoutWriteLenFailure(t *testing.T) { + logBuffer := &errorWriter{failureType: writeLenFailure} + be := NewStdoutBackend() + be.writer = logBuffer + + entry := newTestEntry(InfoLevel, "", "foobar") + if err := be.Log(entry); err == nil { + t.Fatalf("Log(%+v) succeeded, want error due to write len failure", entry) + } +} + +func TestStdoutInvalidFormat(t *testing.T) { + logBuffer := bytes.NewBuffer(nil) + be := NewStdoutBackend() + be.writer = logBuffer + + be.Config().SetFormat(InfoLevel, "{{.Foobar}}") + + entry := newTestEntry(InfoLevel, "", "foobar") + if err := be.Log(entry); err == nil { + t.Fatalf("Log(%+v) succeeded, want error due to invalid format", entry) + } +} + +func TestStdoutSuccess(t *testing.T) { + tests := []struct { + desc string + message string + level Level + prefix string + want string + }{ + { + desc: "error_level_skip", + message: "foo bar", + level: ErrorLevel, + want: "", + }, + { + desc: "fatal_level_skip", + message: "foo bar", + level: FatalLevel, + want: "", + }, + { + desc: "warning_level", + message: "foo bar", + level: WarningLevel, + want: "[WARNING]: foo bar\n", + }, + { + desc: "info_level", + message: "foo bar", + level: InfoLevel, + want: "[INFO]: foo bar\n", + }, + { + desc: "info_level_with_prefix", + message: "foo bar", + level: InfoLevel, + prefix: "my-prefix", + want: " my-prefix: [INFO]: foo bar\n", + }, + { + desc: "debug_level", + message: "foo bar", + level: DebugLevel, + want: "[DEBUG]:", + }, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + logBuffer := bytes.NewBuffer(nil) + be := NewStdoutBackend() + be.writer = logBuffer + if be.Config() == nil { + t.Fatal("NewStdoutBackend() failed: Config() returned nil") + } + + entry := newTestEntry(tc.level, tc.prefix, tc.message) + if err := be.Log(entry); err != nil { + t.Fatalf("Log(%+v) failed: %v", entry, err) + } + + got := logBuffer.String() + + if tc.want == "" { + if logBuffer.Len() != 0 { + t.Fatalf("Log(%+v) output = %q, want empty", entry, got) + } + return + } + + if tc.level == DebugLevel { + if !strings.Contains(got, "[DEBUG]:") || !strings.Contains(got, "galog_stdout_test.go:") || !strings.HasSuffix(got, tc.message+"\n") { + t.Fatalf("Log(%+v) output = %q, want [DEBUG]:, galog_stdout_test.go:, and suffix %q", entry, got, tc.message+"\n") + } + } else { + if !strings.HasSuffix(got, tc.want) { + t.Fatalf("Log(%+v) output = %q, want suffix %q", entry, got, tc.want) + } + } + Shutdown(time.Millisecond) + }) + } +} diff --git a/galog_writer.go b/galog_writer.go new file mode 100644 index 0000000..1628588 --- /dev/null +++ b/galog_writer.go @@ -0,0 +1,81 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package galog + +import ( + "context" + "fmt" + "io" +) + +// writerBackend is a shared implementation for writer-based backends. +type writerBackend struct { + // backendID is the internal ID of the backend. + backendID string + // config is a pointer to the generic Config interface implementation. + config *backendConfig + // writer is the target writer (e.g., os.Stdout, os.Stderr, or a buffer). + writer io.Writer + // skip is a function determining if a log entry level should be skipped. + skip func(Level) bool + // sync is a function to flush/sync the backend storage. + sync func() error +} + +// ID returns the backend's ID. +func (wb *writerBackend) ID() string { + return wb.backendID +} + +// Config returns the backend's configuration. +func (wb *writerBackend) Config() Config { + return wb.config +} + +// Shutdown flushes/syncs the backend. +func (wb *writerBackend) Shutdown(context.Context) error { + if wb.sync == nil { + return nil + } + if err := wb.sync(); err != nil { + return fmt.Errorf("failed to flush backend %s: %w", wb.backendID, err) + } + return nil +} + +// Log formats and writes the entry to the output stream. +func (wb *writerBackend) Log(entry *LogEntry) error { + if wb.skip != nil && wb.skip(entry.Level) { + return nil + } + + format := wb.config.Format(entry.Level) + + message, err := entry.Format(format + "\n") + if err != nil { + return fmt.Errorf("failed to format log level: %w", err) + } + + n, err := wb.writer.Write([]byte(message)) + if err != nil { + return fmt.Errorf("failed to write log to backend %s: %w", wb.backendID, err) + } + + if n != len(message) { + return fmt.Errorf("failed to write message, wrote %d bytes out of %d bytes", n, len(message)) + } + + return nil +}