Skip to content

Commit 187c2ce

Browse files
authored
Add custom slog.Handler to replace zap slog adapter (#3844)
1 parent 6a9b2ce commit 187c2ce

File tree

8 files changed

+398
-174
lines changed

8 files changed

+398
-174
lines changed

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ require (
3030
github.com/jhump/protoreflect/v2 v2.0.0-beta.2
3131
github.com/klauspost/compress v1.18.0
3232
github.com/klauspost/pgzip v1.2.6
33+
github.com/mattn/go-colorable v0.1.14
3334
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
3435
github.com/quic-go/quic-go v0.51.0
3536
github.com/rs/cors v1.11.1
@@ -41,7 +42,6 @@ require (
4142
go.lsp.dev/protocol v0.12.0
4243
go.lsp.dev/uri v0.3.0
4344
go.uber.org/zap v1.27.0
44-
go.uber.org/zap/exp v0.3.0
4545
golang.org/x/crypto v0.38.0
4646
golang.org/x/mod v0.24.0
4747
golang.org/x/net v0.40.0

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,8 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
114114
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
115115
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
116116
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
117+
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
118+
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
117119
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
118120
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
119121
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
@@ -237,8 +239,6 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
237239
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
238240
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
239241
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
240-
go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U=
241-
go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ=
242242
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
243243
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
244244
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=

private/pkg/slogapp/console.go

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
// Copyright 2020-2025 Buf Technologies, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package slogapp
16+
17+
import (
18+
"bytes"
19+
"context"
20+
"fmt"
21+
"io"
22+
"log/slog"
23+
"os"
24+
"sync"
25+
26+
"github.com/mattn/go-colorable"
27+
)
28+
29+
const (
30+
// color codes for ANSI escape sequences.
31+
colorBlack color = iota + 30
32+
colorRed
33+
colorGreen
34+
colorYellow
35+
colorBlue
36+
colorMagenta
37+
colorCyan
38+
colorWhite
39+
40+
// consoleSeparator is the separator used in console output.
41+
consoleSeparator = "\t"
42+
)
43+
44+
// color represents an ANSI color code.
45+
type color uint8
46+
47+
type consoleHandlerOption func(*consoleHandlerOptions)
48+
49+
// withConsoleColor enables or disables color output for the console handler.
50+
//
51+
// If set to true, the console handler will use colors for log levels.
52+
// If the environment variable NO_COLOR is set, colors will be disabled regardless of this setting.
53+
func withConsoleColor(enable bool) consoleHandlerOption {
54+
return func(options *consoleHandlerOptions) {
55+
options.enableColor = enable
56+
}
57+
}
58+
59+
type consoleHandlerOptions struct {
60+
enableColor bool
61+
}
62+
63+
func newConsoleHandlerOptions() *consoleHandlerOptions {
64+
return &consoleHandlerOptions{}
65+
}
66+
67+
// consoleHandler is a custom slog.Handler that formats log messages for the console.
68+
type consoleHandler struct {
69+
enableColor bool
70+
out io.Writer
71+
lock *sync.Mutex // Lock protects access to the buffer.
72+
buffer *bytes.Buffer // Buffer output for the delegate's writer.
73+
delegate slog.Handler // Delegate writes to buffer.
74+
}
75+
76+
// newConsoleHandler creates a new consoleHandler with the specified output writer and log level.
77+
//
78+
// It pretty prints the level (optionally with color) and message with JSON encoded attributes.
79+
// It wraps the output writer with colorable if it's os.Stdout or os.Stderr to support color output on Windows.
80+
// It logs attributes formatted using the slog.JSONHandler as a delegate.
81+
// It uses a mutex to synchronize access to the output. Not suitable for high-throughput logging.
82+
func newConsoleHandler(out io.Writer, logLevel slog.Level, options ...consoleHandlerOption) *consoleHandler {
83+
consoleHandlerOptions := newConsoleHandlerOptions()
84+
for _, option := range options {
85+
option(consoleHandlerOptions)
86+
}
87+
// Disable color if the environment variable NO_COLOR is set.
88+
enableColor := consoleHandlerOptions.enableColor
89+
if e := os.Getenv("NO_COLOR"); e != "" {
90+
enableColor = false
91+
}
92+
// Wrap the output writer with colorable if it's os.Stdout or os.Stderr
93+
// to support color output on Windows.
94+
if enableColor && (out == os.Stderr || out == os.Stdout) {
95+
file, _ := out.(*os.File)
96+
out = colorable.NewColorable(file)
97+
}
98+
// A delegate handler is used to format the log attributes.
99+
// It uses a buffer to accumulate the log attributes before writing them to the output.
100+
// The buffer is protected by the lock.
101+
var (
102+
lock sync.Mutex
103+
buffer bytes.Buffer
104+
)
105+
delegateHandler := slog.NewJSONHandler(&buffer, &slog.HandlerOptions{
106+
Level: logLevel,
107+
ReplaceAttr: consoleReplaceAttr,
108+
})
109+
return &consoleHandler{
110+
enableColor: enableColor,
111+
out: out,
112+
lock: &lock,
113+
buffer: &buffer,
114+
delegate: delegateHandler,
115+
}
116+
}
117+
118+
// Enabled implements the slog.Handler interface.
119+
func (c *consoleHandler) Enabled(ctx context.Context, level slog.Level) bool {
120+
return c.delegate.Enabled(ctx, level)
121+
}
122+
123+
// Handle implements the slog.Handler interface.
124+
func (c *consoleHandler) Handle(ctx context.Context, r slog.Record) error {
125+
c.lock.Lock()
126+
defer c.lock.Unlock()
127+
c.buffer.Reset()
128+
if c.enableColor {
129+
c.buffer.WriteString(colorize(r.Level.String(), getColor(r.Level)))
130+
} else {
131+
c.buffer.WriteString(r.Level.String())
132+
}
133+
c.buffer.WriteString(consoleSeparator)
134+
c.buffer.WriteString(r.Message)
135+
bufN := c.buffer.Len()
136+
c.buffer.WriteString(consoleSeparator)
137+
// Delegate must always be called, as it may have attributes to write.
138+
if err := c.delegate.Handle(ctx, r); err != nil {
139+
return err
140+
}
141+
if c.buffer.Len() == bufN+len(consoleSeparator+"{}\n") {
142+
// No attributes to write, trim the buffer to remove the empty JSON object.
143+
c.buffer.Truncate(bufN)
144+
c.buffer.WriteByte('\n')
145+
}
146+
_, err := c.buffer.WriteTo(c.out)
147+
return err
148+
}
149+
150+
// WithAttrs implements the slog.Handler interface.
151+
func (c *consoleHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
152+
return c.cloneWithDelegate(c.delegate.WithAttrs(attrs))
153+
}
154+
155+
// WithGroup implements the slog.Handler interface.
156+
func (c *consoleHandler) WithGroup(name string) slog.Handler {
157+
return c.cloneWithDelegate(c.delegate.WithGroup(name))
158+
}
159+
160+
// cloneWithDelegate creates a new consoleHandler with a new delegate handler.
161+
func (c *consoleHandler) cloneWithDelegate(delegate slog.Handler) *consoleHandler {
162+
return &consoleHandler{
163+
enableColor: c.enableColor,
164+
delegate: delegate,
165+
out: c.out,
166+
lock: c.lock,
167+
buffer: c.buffer,
168+
}
169+
}
170+
171+
// getColor returns the color code for the specified log level.
172+
func getColor(level slog.Level) color {
173+
switch {
174+
case level >= slog.LevelError:
175+
return colorRed
176+
case level >= slog.LevelWarn:
177+
return colorYellow
178+
case level >= slog.LevelInfo:
179+
return colorBlue
180+
case level >= slog.LevelDebug:
181+
return colorMagenta
182+
default:
183+
return 0
184+
}
185+
}
186+
187+
// colorize formats the string with the specified color.
188+
func colorize(s string, color color) string {
189+
if color == 0 {
190+
return s
191+
}
192+
return fmt.Sprintf("\x1b[%dm%s\x1b[0m", color, s)
193+
}
194+
195+
// consoleReplaceAttr is a custom ReplaceAttr function for consoleHandler.
196+
// It silences the time, level, and message attributes to avoid duplication.
197+
func consoleReplaceAttr(groups []string, a slog.Attr) slog.Attr {
198+
switch a.Key {
199+
case slog.TimeKey, slog.LevelKey, slog.MessageKey:
200+
return slog.Attr{}
201+
default:
202+
return defaultReplaceAttr(groups, a)
203+
}
204+
}
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
// Copyright 2020-2025 Buf Technologies, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package slogapp
16+
17+
import (
18+
"bytes"
19+
"encoding/json"
20+
"fmt"
21+
"log/slog"
22+
"strings"
23+
"testing"
24+
25+
"github.com/stretchr/testify/assert"
26+
"github.com/stretchr/testify/require"
27+
)
28+
29+
func TestConsoleLogOutput(t *testing.T) {
30+
t.Parallel()
31+
32+
testConsolLogOutput(t, func(logger *slog.Logger) {
33+
logger.Info("hello", slog.String("a", "b"))
34+
logger.Info("hello world")
35+
}, []map[string]any{{
36+
slog.LevelKey: colorize("INFO", getColor(slog.LevelInfo)),
37+
slog.MessageKey: "hello",
38+
"a": "b",
39+
}, {
40+
slog.LevelKey: colorize("INFO", getColor(slog.LevelInfo)),
41+
slog.MessageKey: "hello world",
42+
}}, withConsoleColor(true))
43+
44+
testConsolLogOutput(t, func(logger *slog.Logger) {
45+
logger.Info("info", slog.String("a", "b"))
46+
logger.Error("error")
47+
}, []map[string]any{{
48+
slog.LevelKey: "INFO",
49+
slog.MessageKey: "info",
50+
"a": "b",
51+
}, {
52+
slog.LevelKey: "ERROR",
53+
slog.MessageKey: "error",
54+
}})
55+
56+
testConsolLogOutput(t, func(logger *slog.Logger) {
57+
logger = logger.With(slog.String("a", "b"))
58+
logger = logger.WithGroup("g")
59+
logger.Error("error message", slog.String("c", "d"))
60+
logger.Info("info message")
61+
logger.Debug("debuf message", slog.String("c", "d"))
62+
}, []map[string]any{{
63+
slog.LevelKey: colorize("ERROR", getColor(slog.LevelError)),
64+
slog.MessageKey: "error message",
65+
"a": "b",
66+
"g": map[string]any{
67+
"c": "d",
68+
},
69+
}, {
70+
slog.LevelKey: colorize("INFO", getColor(slog.LevelInfo)),
71+
slog.MessageKey: "info message",
72+
"a": "b",
73+
}}, withConsoleColor(true))
74+
75+
testConsolLogOutput(t, func(logger *slog.Logger) {
76+
logger.Info("key spaces", slog.String("a key", "with spaces"))
77+
}, []map[string]any{{
78+
slog.LevelKey: colorize("INFO", getColor(slog.LevelInfo)),
79+
slog.MessageKey: "key spaces",
80+
"a key": "with spaces",
81+
}}, withConsoleColor(true))
82+
}
83+
84+
func testConsolLogOutput(t *testing.T, run func(logger *slog.Logger), expects []map[string]any, options ...consoleHandlerOption) {
85+
t.Helper()
86+
var buf bytes.Buffer
87+
consoleHandler := newConsoleHandler(&buf, slog.LevelInfo, options...)
88+
logger := slog.New(consoleHandler)
89+
run(logger)
90+
91+
var outputs []map[string]any
92+
for _, line := range bytes.Split(buf.Bytes(), []byte{'\n'}) {
93+
if len(line) == 0 {
94+
continue
95+
}
96+
lineAttrs, err := testParseLogLine(line)
97+
if !assert.NoError(t, err) {
98+
continue
99+
}
100+
outputs = append(outputs, lineAttrs)
101+
}
102+
t.Log(buf.String())
103+
require.Equal(t, len(expects), len(outputs))
104+
for i := range len(outputs) {
105+
output, expect := outputs[i], expects[i]
106+
assert.Equal(t, expect, output)
107+
}
108+
}
109+
110+
// testParseLogLine passes the output of a single log line.
111+
func testParseLogLine(lineBytes []byte) (map[string]any, error) {
112+
top := map[string]any{}
113+
line := string(bytes.TrimSpace(lineBytes))
114+
index, line, _ := strings.Cut(line, consoleSeparator)
115+
top[slog.LevelKey] = index
116+
if len(line) == 0 {
117+
return top, nil
118+
}
119+
message, line := line, ""
120+
// Find the JSON attributes by looking for the first space followed by a '{'.
121+
// This may fail for complex messages but fine for testing.
122+
if jsonIndex := strings.Index(message, consoleSeparator+"{"); jsonIndex >= 0 {
123+
message, line = message[:jsonIndex], message[jsonIndex+1:]
124+
}
125+
top[slog.MessageKey] = message
126+
if len(line) > 0 {
127+
// Capture the JSON attributes.
128+
var attrs map[string]any
129+
if err := json.Unmarshal([]byte(line), &attrs); err != nil {
130+
return nil, fmt.Errorf("failed to unmarshal JSON attrs: %w", err)
131+
}
132+
// Merge the JSON attributes into the top-level map.
133+
for key, value := range attrs {
134+
if _, ok := top[key]; ok {
135+
return nil, fmt.Errorf("duplicate key %q in JSON attributes", key)
136+
}
137+
top[key] = value
138+
}
139+
}
140+
return top, nil
141+
}

0 commit comments

Comments
 (0)