99package log // import "go.followtheprocess.codes/log"
1010
1111import (
12- "bytes"
1312 "io"
1413 "log/slog"
1514 "slices"
1615 "strconv"
17- "strings"
1816 "sync"
19- "sync/atomic"
2017 "time"
2118 "unicode"
2219 "unicode/utf8"
2320
2421 "go.followtheprocess.codes/hue"
2522)
2623
24+ const (
25+ // scratchSize is the size of the stack buffer used to format a timestamp
26+ // before styling it; comfortably larger than any standard time layout.
27+ scratchSize = 64
28+
29+ // bufferSize is the initial capacity of a pooled log line buffer, chosen to
30+ // hold a typical line without reallocating.
31+ bufferSize = 256
32+
33+ // base10 is the radix used to format integer attribute values.
34+ base10 = 10
35+
36+ // float64Bits is the bit size used to format floating point attribute values.
37+ float64Bits = 64
38+ )
39+
2740// Styles.
2841const (
2942 timestampStyle = hue .Dim
@@ -37,15 +50,17 @@ const (
3750
3851// Logger is a command line logger. It is safe to use across concurrently
3952// executing goroutines.
53+ //
54+ // The zero value is not usable; construct a Logger with [New].
4055type Logger struct {
4156 w io.Writer // Where to write logs to
4257 timeFunc func () time.Time // A function to get the current time, defaults to [time.Now] (with UTC)
4358 mu * sync.Mutex // Protects w, pointer so that child loggers share the same mutex
4459 timeFormat string // The time format layout string, defaults to [time.RFC3339]
45- prefix string // Optional prefix to prepend to all log messages
60+ prefix [] byte // Optional prefix to prepend to all log messages, stored as bytes for the hot path
4661 attrs []slog.Attr // Persistent key value pairs
4762 level Level // The configured level of this logger, logs below this level are not shown
48- isDiscard atomic. Bool // w == [io.Discard], cached
63+ isDiscard bool // w == [io.Discard], cached. Only written during construction, before the logger is shared
4964}
5065
5166// New returns a new [Logger] configured to write to w.
@@ -59,10 +74,9 @@ func New(w io.Writer, options ...Option) *Logger {
5974 timeFormat : time .RFC3339 ,
6075 timeFunc : func () time.Time { return time .Now ().UTC () },
6176 mu : & sync.Mutex {},
77+ isDiscard : w == io .Discard ,
6278 }
6379
64- logger .isDiscard .Store (w == io .Discard )
65-
6680 for _ , option := range options {
6781 option (logger )
6882 }
@@ -87,7 +101,7 @@ func (l *Logger) With(attrs ...slog.Attr) *Logger {
87101func (l * Logger ) Prefixed (prefix string ) * Logger {
88102 sub := l .clone ()
89103
90- sub .prefix = prefix
104+ sub .prefix = [] byte ( prefix )
91105
92106 return sub
93107}
@@ -114,62 +128,111 @@ func (l *Logger) Error(msg string, attrs ...slog.Attr) {
114128
115129// log logs the given levelled message.
116130func (l * Logger ) log (level Level , msg string , attrs ... slog.Attr ) {
117- if l .isDiscard . Load () || l .level > level {
131+ if l .isDiscard || l .level > level {
118132 // Do as little work as possible
119133 return
120134 }
121135
122- // Buffer the output as e.g. stderr is not buffered by default. Do this
123- // by fetching and putting buffers from a [sync.Pool] so we don't have to
124- // constantly allocate new buffers
125- buf := getBuffer ()
126- defer putBuffer (buf )
136+ // Build the line in a byte buffer fetched from a [sync.Pool] so we don't
137+ // constantly allocate. Styled, known-ahead text (timestamp, level, prefix)
138+ // is appended with hue's allocation-free AppendText.
139+ bufp := getBuffer ()
140+ defer putBuffer (bufp )
127141
128- buf .WriteString (timestampStyle .Text (l .timeFunc ().Format (l .timeFormat )))
129- buf .WriteByte (' ' )
130- buf .WriteString (level .String ())
142+ // Dereference the working copy so we don't have to dereference every call
143+ buf := * bufp
131144
132- if l .prefix != "" {
133- buf .WriteString (" " )
134- buf .WriteString (prefixStyle .Text (l .prefix ))
135- }
145+ // Format the timestamp into a stack scratch buffer so we avoid allocating
146+ // an intermediate string before styling it.
147+ var scratch [scratchSize ]byte
136148
137- buf .WriteByte (':' )
149+ timestamp := l .timeFunc ().AppendFormat (scratch [:0 ], l .timeFormat )
150+ buf = timestampStyle .AppendText (buf , timestamp )
138151
139- padding := 2
140- if level == LevelDebug || level == LevelError {
141- padding = 1
142- }
152+ buf = append (buf , ' ' )
153+ buf = level .appendTo (buf )
143154
144- buf .WriteString (strings .Repeat (" " , padding ))
145- buf .WriteString (msg )
155+ if len (l .prefix ) != 0 {
156+ buf = append (buf , ' ' )
157+ buf = prefixStyle .AppendText (buf , l .prefix )
158+ }
146159
147- if totalAttrs := len (l .attrs ) + len (attrs ); totalAttrs != 0 {
148- all := slices .Concat (l .attrs , attrs )
160+ buf = append (buf , ':' )
149161
150- for _ , attr := range all {
151- buf .WriteByte (' ' )
162+ // DEBUG and ERROR are 5 characters, INFO and WARN are 4. Pad the shorter
163+ // labels with an extra space so the message always starts in the same column.
164+ buf = append (buf , ' ' )
165+ if level == LevelInfo || level == LevelWarn {
166+ buf = append (buf , ' ' )
167+ }
152168
153- key := keyStyle .Text (attr .Key )
154- val := attr .Value .String ()
169+ buf = append (buf , msg ... )
155170
156- if needsQuotes ( val ) || val == "" {
157- val = strconv . Quote ( val )
158- }
171+ for _ , attr := range l . attrs {
172+ buf = appendAttr ( buf , attr )
173+ }
159174
160- buf .WriteString (key )
161- buf .WriteByte ('=' )
162- buf .WriteString (val )
163- }
175+ for _ , attr := range attrs {
176+ buf = appendAttr (buf , attr )
164177 }
165178
166- buf .WriteByte ('\n' )
179+ buf = append (buf , '\n' )
180+
181+ // Put it back
182+ * bufp = buf
167183
168- // WriteTo drains the buffer
169184 l .mu .Lock ()
170185 defer l .mu .Unlock ()
171186
172- buf .WriteTo (l .w ) //nolint: errcheck // Just like printing
187+ l .w .Write (buf ) //nolint: errcheck // Just like printing
188+ }
189+
190+ // appendAttr appends a single " key=value" pair to dst and returns the
191+ // extended slice. The key is quoted if it contains whitespace or is empty.
192+ func appendAttr (dst []byte , attr slog.Attr ) []byte {
193+ dst = append (dst , ' ' )
194+
195+ key := attr .Key
196+ if key == "" || needsQuotes (key ) {
197+ key = strconv .Quote (key )
198+ }
199+
200+ dst = append (dst , keyStyle .Text (key )... )
201+ dst = append (dst , '=' )
202+
203+ return appendValue (dst , attr .Value )
204+ }
205+
206+ // appendValue appends the textual form of v to dst and returns the extended slice.
207+ //
208+ // Scalar kinds are written straight into the buffer, skipping the "needs quotes" check
209+ // as their text can never contain whitespace and so are never quoted.
210+ //
211+ // Other kinds fall back to [slog.Value.String], quoted if they contain whitespace or are empty.
212+ func appendValue (dst []byte , v slog.Value ) []byte {
213+ // Resolve any [slog.LogValuer]
214+ // See https://github.com/golang/example/blob/master/slog-handler-guide/README.md
215+ if v .Kind () == slog .KindLogValuer {
216+ v = v .Resolve ()
217+ }
218+
219+ switch v .Kind () {
220+ case slog .KindInt64 :
221+ return strconv .AppendInt (dst , v .Int64 (), base10 )
222+ case slog .KindUint64 :
223+ return strconv .AppendUint (dst , v .Uint64 (), base10 )
224+ case slog .KindFloat64 :
225+ return strconv .AppendFloat (dst , v .Float64 (), 'g' , - 1 , float64Bits )
226+ case slog .KindBool :
227+ return strconv .AppendBool (dst , v .Bool ())
228+ default :
229+ s := v .String ()
230+ if s == "" || needsQuotes (s ) {
231+ return strconv .AppendQuote (dst , s )
232+ }
233+
234+ return append (dst , s ... )
235+ }
173236}
174237
175238// clone returns an exact clone of the calling logger.
@@ -182,32 +245,32 @@ func (l *Logger) clone() *Logger {
182245 attrs : l .attrs ,
183246 level : l .level ,
184247 mu : l .mu ,
248+ isDiscard : l .isDiscard ,
185249 }
186250
187- clone .isDiscard .Store (l .isDiscard .Load ())
188-
189251 return clone
190252}
191253
192254// Each log method (Debug, Info, Warn) etc. gets a buffer from this pool
193255// so as not to keep re-allocating and destroying them.
194256var bufPool = sync.Pool {
195257 New : func () any {
196- return new (bytes.Buffer )
258+ buf := make ([]byte , 0 , bufferSize )
259+ return & buf
197260 },
198261}
199262
200263// getBuffer fetches a buffer from the pool, the returned buffer
201264// is empty and ready to use.
202- func getBuffer () * bytes. Buffer {
203- buf := bufPool .Get ().(* bytes. Buffer ) //nolint:revive,errcheck,forcetypeassert // We are in total control of this
204- buf . Reset ()
265+ func getBuffer () * [] byte {
266+ bufp := bufPool .Get ().(* [] byte ) //nolint:revive,errcheck,forcetypeassert // We are in total control of this
267+ * bufp = ( * bufp )[: 0 ] // Reset
205268
206- return buf
269+ return bufp
207270}
208271
209272// putBuffer puts the buffer back into the pool.
210- func putBuffer (buf * bytes. Buffer ) {
273+ func putBuffer (bufp * [] byte ) {
211274 // Proper usage of a sync.Pool requires each entry to have approximately
212275 // the same memory cost. To obtain this property when the stored type
213276 // contains a variably-sized buffer, we add a hard limit on the maximum buffer
@@ -217,19 +280,34 @@ func putBuffer(buf *bytes.Buffer) {
217280
218281 // Approx 65kb
219282 const maxSize = 64 << 10
220- if buf . Cap ( ) > maxSize {
283+ if cap ( * bufp ) > maxSize {
221284 return
222285 }
223286
224- bufPool .Put (buf )
287+ bufPool .Put (bufp )
225288}
226289
227290// needsQuotes returns whether s should be displayed as "s".
228291func needsQuotes (s string ) bool {
229- for _ , char := range s {
292+ for i := 0 ; i < len (s ); {
293+ // ASCII fast path: most keys and values are printable ASCII
294+ // Anything <= space (control characters and space itself) or DEL needs quoting.
295+ if b := s [i ]; b < utf8 .RuneSelf {
296+ if b <= ' ' || b == 0x7f {
297+ return true
298+ }
299+
300+ i ++
301+
302+ continue
303+ }
304+
305+ char , size := utf8 .DecodeRuneInString (s [i :])
230306 if char == utf8 .RuneError || unicode .IsSpace (char ) || ! unicode .IsPrint (char ) {
231307 return true
232308 }
309+
310+ i += size
233311 }
234312
235313 return false
0 commit comments