Skip to content

Commit 441e674

Browse files
committed
feat(logger): Add SetOutput and GetOutput for dynamic log redirection
Implement atomic-backed output redirection for all loggers (root and named) without requiring handler reconstruction. Uses sync/atomic.Value to wrap io.Writer in outputBox, and a sharedOutput indirection writer that all handlers capture. This allows SetOutput to retarget existing loggers immediately and safely for concurrent callers. Adds comprehensive test coverage including new logger redirection, existing logger retargeting, restore semantics, nil handling, and concurrent safety. Also adds .gavel.yaml configuration to ignore false-positive secret detections in test fixtures and certificates.
1 parent 412d718 commit 441e674

3 files changed

Lines changed: 187 additions & 1 deletion

File tree

.gavel.yaml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
commit:
2+
compatibility: {}
3+
linkedDeps: {}
4+
precommit: {}
5+
fixtures: {}
6+
lint:
7+
ignore:
8+
- file: certs/fixtures/k8s-ca-key.pem
9+
rule: private-key
10+
source: betterleaks
11+
- file: cmd/hx/test.out
12+
rule: generic-api-key
13+
source: betterleaks
14+
- file: certs/fixtures/literal.yml
15+
rule: private-key
16+
source: betterleaks
17+
secrets: {}
18+
ssh: {}
19+
verify:
20+
checks:
21+
disabled: null
22+
disabledCategories: null
23+
model: ""
24+
prompt: ""

logger/slog.go

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"os"
1010
"strconv"
1111
"strings"
12+
"sync/atomic"
1213
"time"
1314
"unicode"
1415

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

33+
// outputBox wraps the current io.Writer so all atomic stores carry the same
34+
// concrete type (*outputBox) — a hard requirement of sync/atomic.Value.
35+
// Without this, swapping from *os.File to *bytes.Buffer panics at runtime.
36+
type outputBox struct{ w io.Writer }
37+
38+
// currentOutput holds a *outputBox pointing at the io.Writer every slog
39+
// handler writes to. Handlers constructed via New / NewWithWriter receive a
40+
// thin indirection writer (sharedOutput) that reads this atomic on every
41+
// Write, so SetOutput swaps take effect immediately for every logger — named
42+
// or not, existing or newly created — without rebuilding handlers.
43+
var currentOutput atomic.Value // *outputBox
44+
45+
// sharedOutput is the single writer every non-explicit handler captures.
46+
// Its Write delegates to whatever currentOutput holds, making SetOutput a
47+
// per-call dispatch decision rather than a capture-at-handler-build one.
48+
type sharedOutput struct{}
49+
50+
func (sharedOutput) Write(p []byte) (int, error) {
51+
box, _ := currentOutput.Load().(*outputBox)
52+
if box == nil || box.w == nil {
53+
return os.Stderr.Write(p)
54+
}
55+
return box.w.Write(p)
56+
}
57+
58+
var sharedWriter io.Writer = sharedOutput{}
59+
60+
func init() {
61+
currentOutput.Store(&outputBox{w: os.Stderr})
62+
}
63+
64+
// SetOutput redirects log output for all loggers — root and every named
65+
// logger, existing or future — to w. Pair with a prior GetOutput() + deferred
66+
// SetOutput(old) when you want to restore the previous writer. Safe for
67+
// concurrent callers; the swap is a single atomic store.
68+
func SetOutput(w io.Writer) {
69+
if w == nil {
70+
w = os.Stderr
71+
}
72+
currentOutput.Store(&outputBox{w: w})
73+
}
74+
75+
// GetOutput returns the writer SetOutput most recently installed (or os.Stderr
76+
// if none has been set). Returns the writer itself, not the internal
77+
// indirection, so callers can wrap it or save it for restore.
78+
func GetOutput() io.Writer {
79+
if box, ok := currentOutput.Load().(*outputBox); ok && box != nil && box.w != nil {
80+
return box.w
81+
}
82+
return os.Stderr
83+
}
84+
3285
func GetNamedLoggingLevels() (levels map[string]string) {
3386
levels = make(map[string]string)
3487
namedLoggers.Range(func(key string, value *SlogLogger) bool {
@@ -99,7 +152,10 @@ func New(prefix string) *SlogLogger {
99152
}
100153
namedLevel := properties.String(rootLevel, "log.level."+prefix)
101154

102-
destination := os.Stderr
155+
// Handlers write through sharedWriter (a thin indirection over
156+
// currentOutput) so a later SetOutput retargets every existing logger,
157+
// not just those constructed after the swap.
158+
destination := sharedWriter
103159
if logJson {
104160
flags.color = false
105161
flags.jsonLogs = true

logger/slog_setoutput_test.go

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
package logger
2+
3+
import (
4+
"bytes"
5+
"io"
6+
"os"
7+
"strings"
8+
"sync"
9+
"testing"
10+
)
11+
12+
// TestSetOutput_RedirectsNewLogger verifies that a logger constructed after
13+
// SetOutput writes to the new destination.
14+
func TestSetOutput_RedirectsNewLogger(t *testing.T) {
15+
original := GetOutput()
16+
t.Cleanup(func() { SetOutput(original) })
17+
18+
var buf bytes.Buffer
19+
SetOutput(&buf)
20+
21+
lg := New("setoutput-new")
22+
lg.Infof("hello %s", "world")
23+
24+
if !strings.Contains(buf.String(), "hello world") {
25+
t.Fatalf("expected buffer to contain log line, got %q", buf.String())
26+
}
27+
}
28+
29+
// TestSetOutput_RedirectsExistingLogger verifies that a logger constructed
30+
// BEFORE SetOutput still honors the swap on its next write. This is the
31+
// important guarantee — handlers capture the indirect writer, not the
32+
// concrete fd, so a later swap retargets them without rebuilding.
33+
func TestSetOutput_RedirectsExistingLogger(t *testing.T) {
34+
original := GetOutput()
35+
t.Cleanup(func() { SetOutput(original) })
36+
37+
lg := New("setoutput-existing")
38+
39+
var buf bytes.Buffer
40+
SetOutput(&buf)
41+
42+
lg.Infof("retargeted %d", 42)
43+
44+
if !strings.Contains(buf.String(), "retargeted 42") {
45+
t.Fatalf("existing logger did not retarget on SetOutput, buf=%q", buf.String())
46+
}
47+
}
48+
49+
// TestSetOutput_RestoreSwap verifies that restoring the original writer
50+
// via GetOutput + SetOutput stops sending to the intermediate buffer.
51+
func TestSetOutput_RestoreSwap(t *testing.T) {
52+
original := GetOutput()
53+
t.Cleanup(func() { SetOutput(original) })
54+
55+
lg := New("setoutput-restore")
56+
57+
var buf bytes.Buffer
58+
saved := GetOutput()
59+
SetOutput(&buf)
60+
lg.Infof("recorded")
61+
SetOutput(saved)
62+
lg.Infof("not recorded")
63+
64+
if !strings.Contains(buf.String(), "recorded") {
65+
t.Fatalf("expected first write in buf, got %q", buf.String())
66+
}
67+
if strings.Contains(buf.String(), "not recorded") {
68+
t.Fatalf("buf contains post-restore write, swap didn't restore, got %q", buf.String())
69+
}
70+
}
71+
72+
// TestSetOutput_NilRestoresStderr verifies that SetOutput(nil) falls back
73+
// to os.Stderr rather than panicking on every log write.
74+
func TestSetOutput_NilRestoresStderr(t *testing.T) {
75+
original := GetOutput()
76+
t.Cleanup(func() { SetOutput(original) })
77+
78+
SetOutput(nil)
79+
got := GetOutput()
80+
if got != io.Writer(os.Stderr) {
81+
t.Fatalf("SetOutput(nil) did not reset to os.Stderr, got %T", got)
82+
}
83+
}
84+
85+
// TestSetOutput_ConcurrentSwaps verifies that a flood of concurrent SetOutput
86+
// + log calls does not race or deadlock. Run with -race.
87+
func TestSetOutput_ConcurrentSwaps(t *testing.T) {
88+
original := GetOutput()
89+
t.Cleanup(func() { SetOutput(original) })
90+
91+
lg := New("setoutput-concurrent")
92+
93+
var wg sync.WaitGroup
94+
for i := 0; i < 8; i++ {
95+
wg.Add(1)
96+
go func() {
97+
defer wg.Done()
98+
for j := 0; j < 100; j++ {
99+
var b bytes.Buffer
100+
SetOutput(&b)
101+
lg.Infof("n=%d", j)
102+
}
103+
}()
104+
}
105+
wg.Wait()
106+
}

0 commit comments

Comments
 (0)