Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .gavel.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
commit:
compatibility: {}
linkedDeps: {}
precommit: {}
fixtures: {}
lint:
ignore:
- file: certs/fixtures/k8s-ca-key.pem
rule: private-key
source: betterleaks
- file: cmd/hx/test.out
rule: generic-api-key
source: betterleaks
- file: certs/fixtures/literal.yml
rule: private-key
source: betterleaks
secrets: {}
ssh: {}
verify:
checks:
disabled: null
disabledCategories: null
model: ""
prompt: ""
58 changes: 57 additions & 1 deletion logger/slog.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"os"
"strconv"
"strings"
"sync/atomic"
"time"
"unicode"

Expand All @@ -29,6 +30,58 @@ const rootName = "root"
var namedLoggers cmap.Map[string, *SlogLogger]
var todo = context.TODO()

// outputBox wraps the current io.Writer so all atomic stores carry the same
// concrete type (*outputBox) — a hard requirement of sync/atomic.Value.
// Without this, swapping from *os.File to *bytes.Buffer panics at runtime.
type outputBox struct{ w io.Writer }

// currentOutput holds a *outputBox pointing at the io.Writer every slog
// handler writes to. Handlers constructed via New / NewWithWriter receive a
// thin indirection writer (sharedOutput) that reads this atomic on every
// Write, so SetOutput swaps take effect immediately for every logger — named
// or not, existing or newly created — without rebuilding handlers.
var currentOutput atomic.Value // *outputBox

// sharedOutput is the single writer every non-explicit handler captures.
// Its Write delegates to whatever currentOutput holds, making SetOutput a
// per-call dispatch decision rather than a capture-at-handler-build one.
type sharedOutput struct{}

func (sharedOutput) Write(p []byte) (int, error) {
box, _ := currentOutput.Load().(*outputBox)
if box == nil || box.w == nil {
return os.Stderr.Write(p)
}
return box.w.Write(p)
}

var sharedWriter io.Writer = sharedOutput{}

func init() {
currentOutput.Store(&outputBox{w: os.Stderr})
}

// SetOutput redirects log output for all loggers — root and every named
// logger, existing or future — to w. Pair with a prior GetOutput() + deferred
// SetOutput(old) when you want to restore the previous writer. Safe for
// concurrent callers; the swap is a single atomic store.
func SetOutput(w io.Writer) {
if w == nil {
w = os.Stderr
}
currentOutput.Store(&outputBox{w: w})
}

// GetOutput returns the writer SetOutput most recently installed (or os.Stderr
// if none has been set). Returns the writer itself, not the internal
// indirection, so callers can wrap it or save it for restore.
func GetOutput() io.Writer {
if box, ok := currentOutput.Load().(*outputBox); ok && box != nil && box.w != nil {
return box.w
}
return os.Stderr
}

func GetNamedLoggingLevels() (levels map[string]string) {
levels = make(map[string]string)
namedLoggers.Range(func(key string, value *SlogLogger) bool {
Expand Down Expand Up @@ -99,7 +152,10 @@ func New(prefix string) *SlogLogger {
}
namedLevel := properties.String(rootLevel, "log.level."+prefix)

destination := os.Stderr
// Handlers write through sharedWriter (a thin indirection over
// currentOutput) so a later SetOutput retargets every existing logger,
// not just those constructed after the swap.
destination := sharedWriter
if logJson {
flags.color = false
flags.jsonLogs = true
Expand Down
106 changes: 106 additions & 0 deletions logger/slog_setoutput_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package logger

import (
"bytes"
"io"
"os"
"strings"
"sync"
"testing"
)

// TestSetOutput_RedirectsNewLogger verifies that a logger constructed after
// SetOutput writes to the new destination.
func TestSetOutput_RedirectsNewLogger(t *testing.T) {
original := GetOutput()
t.Cleanup(func() { SetOutput(original) })

var buf bytes.Buffer
SetOutput(&buf)

lg := New("setoutput-new")
lg.Infof("hello %s", "world")

if !strings.Contains(buf.String(), "hello world") {
t.Fatalf("expected buffer to contain log line, got %q", buf.String())
}
}

// TestSetOutput_RedirectsExistingLogger verifies that a logger constructed
// BEFORE SetOutput still honors the swap on its next write. This is the
// important guarantee — handlers capture the indirect writer, not the
// concrete fd, so a later swap retargets them without rebuilding.
func TestSetOutput_RedirectsExistingLogger(t *testing.T) {
original := GetOutput()
t.Cleanup(func() { SetOutput(original) })

lg := New("setoutput-existing")

var buf bytes.Buffer
SetOutput(&buf)

lg.Infof("retargeted %d", 42)

if !strings.Contains(buf.String(), "retargeted 42") {
t.Fatalf("existing logger did not retarget on SetOutput, buf=%q", buf.String())
}
}

// TestSetOutput_RestoreSwap verifies that restoring the original writer
// via GetOutput + SetOutput stops sending to the intermediate buffer.
func TestSetOutput_RestoreSwap(t *testing.T) {
original := GetOutput()
t.Cleanup(func() { SetOutput(original) })

lg := New("setoutput-restore")

var buf bytes.Buffer
saved := GetOutput()
SetOutput(&buf)
lg.Infof("recorded")
SetOutput(saved)
lg.Infof("not recorded")

if !strings.Contains(buf.String(), "recorded") {
t.Fatalf("expected first write in buf, got %q", buf.String())
}
if strings.Contains(buf.String(), "not recorded") {
t.Fatalf("buf contains post-restore write, swap didn't restore, got %q", buf.String())
}
}

// TestSetOutput_NilRestoresStderr verifies that SetOutput(nil) falls back
// to os.Stderr rather than panicking on every log write.
func TestSetOutput_NilRestoresStderr(t *testing.T) {
original := GetOutput()
t.Cleanup(func() { SetOutput(original) })

SetOutput(nil)
got := GetOutput()
if got != io.Writer(os.Stderr) {
t.Fatalf("SetOutput(nil) did not reset to os.Stderr, got %T", got)
}
}

// TestSetOutput_ConcurrentSwaps verifies that a flood of concurrent SetOutput
// + log calls does not race or deadlock. Run with -race.
func TestSetOutput_ConcurrentSwaps(t *testing.T) {
original := GetOutput()
t.Cleanup(func() { SetOutput(original) })

lg := New("setoutput-concurrent")

var wg sync.WaitGroup
for i := 0; i < 8; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 100; j++ {
var b bytes.Buffer
SetOutput(&b)
lg.Infof("n=%d", j)
}
}()
}
wg.Wait()
}
Loading