// The encoder in this file is an allocation-aware alternative to
// encoding/json for the narrow value shapes that log lines usually contain:
// strings, numbers, booleans, errors, and shallow maps/slices. It is not a
// general JSON encoder -- anything it doesn't recognise falls through to
// encoding/json so behaviour remains correct for unusual value types.
package lambdalog
import (
"bytes"
"encoding/json"
"strconv"
"sync"
"time"
"unicode/utf8"
)
// encPool reuses scratch buffers across encode calls.
var encPool = sync.Pool{
New: func() any { return new(bytes.Buffer) },
}
// encodeValue appends a JSON representation of v to dst and returns the new
// slice. Bytes are appended directly; the caller owns the backing array.
func encodeValue(dst []byte, v any) []byte {
switch x := v.(type) {
case nil:
return append(dst, 'n', 'u', 'l', 'l')
case string:
return appendQuoted(dst, x)
case []byte:
return appendQuoted(dst, string(x))
case bool:
if x {
return append(dst, 't', 'r', 'u', 'e')
}
return append(dst, 'f', 'a', 'l', 's', 'e')
case int:
return strconv.AppendInt(dst, int64(x), 10)
case int32:
return strconv.AppendInt(dst, int64(x), 10)
case int64:
return strconv.AppendInt(dst, x, 10)
case uint:
return strconv.AppendUint(dst, uint64(x), 10)
case uint32:
return strconv.AppendUint(dst, uint64(x), 10)
case uint64:
return strconv.AppendUint(dst, x, 10)
case float32:
return strconv.AppendFloat(dst, float64(x), 'f', -1, 32)
case float64:
return strconv.AppendFloat(dst, x, 'f', -1, 64)
case time.Time:
return appendQuoted(dst, x.UTC().Format(time.RFC3339Nano))
case time.Duration:
return appendQuoted(dst, x.String())
case error:
return appendQuoted(dst, x.Error())
case []string:
return encodeStringSlice(dst, x)
case map[string]any:
return encodeShallowMap(dst, x)
default:
b, err := json.Marshal(v)
if err != nil {
return appendQuoted(dst, "<unmarshallable>")
}
return append(dst, b...)
}
}
// appendQuoted writes s as a JSON string, escaping the minimal set of
// control characters that the spec requires. The fast path -- all bytes
// printable ASCII and none of them " or \ -- avoids the rune loop.
func appendQuoted(dst []byte, s string) []byte {
dst = append(dst, '"')
if isPlainASCII(s) {
dst = append(dst, s...)
return append(dst, '"')
}
for i := 0; i < len(s); {
r, size := utf8.DecodeRuneInString(s[i:])
switch r {
case '"', '\\':
dst = append(dst, '\\', byte(r))
case '\b':
dst = append(dst, '\\', 'b')
case '\f':
dst = append(dst, '\\', 'f')
case '\n':
dst = append(dst, '\\', 'n')
case '\r':
dst = append(dst, '\\', 'r')
case '\t':
dst = append(dst, '\\', 't')
default:
if r < 0x20 {
const hex = "0123456789abcdef"
dst = append(dst, '\\', 'u', '0', '0', hex[r>>4], hex[r&0xF])
} else {
dst = append(dst, s[i:i+size]...)
}
}
i += size
}
return append(dst, '"')
}
func isPlainASCII(s string) bool {
for i := 0; i < len(s); i++ {
c := s[i]
if c < 0x20 || c > 0x7e || c == '"' || c == '\\' {
return false
}
}
return true
}
func encodeStringSlice(dst []byte, xs []string) []byte {
dst = append(dst, '[')
for i, s := range xs {
if i > 0 {
dst = append(dst, ',')
}
dst = appendQuoted(dst, s)
}
return append(dst, ']')
}
// encodeShallowMap emits a map[string]any with keys in sorted order so logs
// are diff-stable across runs. Nested values recurse through encodeValue.
func encodeShallowMap(dst []byte, m map[string]any) []byte {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sortStrings(keys)
dst = append(dst, '{')
for i, k := range keys {
if i > 0 {
dst = append(dst, ',')
}
dst = appendQuoted(dst, k)
dst = append(dst, ':')
dst = encodeValue(dst, m[k])
}
return append(dst, '}')
}
// Minimal insertion sort to avoid pulling in sort for small key slices.
func sortStrings(xs []string) {
for i := 1; i < len(xs); i++ {
for j := i; j > 0 && xs[j-1] > xs[j]; j-- {
xs[j-1], xs[j] = xs[j], xs[j-1]
}
}
}
// encodeRecord builds a full log line into a pooled buffer. The returned
// slice is valid until the next call that puts the buffer back.
func encodeRecord(level Level, msg string, attrs []attr, extra []any) []byte {
buf := encPool.Get().(*bytes.Buffer)
buf.Reset()
defer encPool.Put(buf)
line := buf.Bytes()
line = append(line, '{')
line = appendQuoted(line, "level")
line = append(line, ':')
line = appendQuoted(line, string(level))
line = append(line, ',')
line = appendQuoted(line, "msg")
line = append(line, ':')
line = appendQuoted(line, msg)
for _, a := range attrs {
line = append(line, ',')
line = appendQuoted(line, a.Key)
line = append(line, ':')
line = encodeValue(line, a.Value)
}
for i := 0; i+1 < len(extra); i += 2 {
line = append(line, ',')
k, ok := extra[i].(string)
if !ok {
k = "MISSING"
}
line = appendQuoted(line, k)
line = append(line, ':')
line = encodeValue(line, extra[i+1])
}
line = append(line, '}', '\n')
out := make([]byte, len(line))
copy(out, line)
return out
}