// Package json implements a JSON encoder for lambdalog records. Unlike
// encoding/json, it avoids reflection for the common primitive types and
// reuses a pooled byte buffer between calls.
//
// Doc: mercemay.top/src/lambdalog/encoder/json.
package json
import (
"io"
"strconv"
"time"
"mercemay.top/src/lambdalog/internal/encoder"
"mercemay.top/src/lambdalog/internal/encoder/json/escape"
"mercemay.top/src/lambdalog/internal/encoder/json/fast"
)
// Encoder is the JSON implementation of encoder.Encoder.
type Encoder struct {
// EscapeHTML, when true, applies HTML escaping to string fields.
// The default is false to match most log pipelines.
EscapeHTML bool
// TimeFormat is passed to time.Time.Format for the top-level time field.
// Empty means time.RFC3339Nano.
TimeFormat string
}
// Name returns "json".
func (Encoder) Name() string { return "json" }
// Encode writes r as a single JSON object followed by a newline.
func (e Encoder) Encode(w io.Writer, r encoder.Record) error {
buf := fast.Get()
defer fast.Put(buf)
buf.AppendByte('{')
e.writeTime(buf, r.Time)
e.writeString(buf, ",\"level\":", r.Level)
e.writeString(buf, ",\"msg\":", r.Message)
if r.RequestID != "" {
e.writeString(buf, ",\"request_id\":", r.RequestID)
}
for _, f := range r.Fields {
buf.AppendByte(',')
e.writeKey(buf, f.Key)
e.writeValue(buf, f.Value)
}
buf.AppendByte('}')
buf.AppendByte('\n')
_, err := w.Write(buf.Bytes())
return err
}
func (e Encoder) writeTime(buf *fast.Buffer, t time.Time) {
if t.IsZero() {
t = time.Now().UTC()
}
format := e.TimeFormat
if format == "" {
format = time.RFC3339Nano
}
buf.AppendString("\"time\":\"")
buf.AppendString(t.Format(format))
buf.AppendByte('"')
}
func (e Encoder) writeString(buf *fast.Buffer, prefix, v string) {
buf.AppendString(prefix)
buf.AppendByte('"')
escape.String(buf, v, e.EscapeHTML)
buf.AppendByte('"')
}
func (e Encoder) writeKey(buf *fast.Buffer, k string) {
buf.AppendByte('"')
escape.String(buf, k, e.EscapeHTML)
buf.AppendString("\":")
}
func (e Encoder) writeValue(buf *fast.Buffer, v any) {
switch val := v.(type) {
case nil:
buf.AppendString("null")
case string:
buf.AppendByte('"')
escape.String(buf, val, e.EscapeHTML)
buf.AppendByte('"')
case bool:
if val {
buf.AppendString("true")
} else {
buf.AppendString("false")
}
case int:
fast.AppendInt(buf, int64(val))
case int64:
fast.AppendInt(buf, val)
case uint64:
fast.AppendUint(buf, val)
case float32:
fast.AppendFloat(buf, float64(val), 32)
case float64:
fast.AppendFloat(buf, val, 64)
case time.Time:
buf.AppendByte('"')
buf.AppendString(val.UTC().Format(time.RFC3339Nano))
buf.AppendByte('"')
case time.Duration:
fast.AppendInt(buf, int64(val/time.Millisecond))
case error:
buf.AppendByte('"')
if val != nil {
escape.String(buf, val.Error(), e.EscapeHTML)
}
buf.AppendByte('"')
case []byte:
buf.AppendByte('"')
escape.String(buf, string(val), e.EscapeHTML)
buf.AppendByte('"')
default:
// Fallback: stringify via fmt-style quoting so unknown types do not
// break the overall JSON document structure.
buf.AppendByte('"')
escape.String(buf, fallbackString(val), e.EscapeHTML)
buf.AppendByte('"')
}
}
func fallbackString(v any) string {
// Intentionally simple: if it implements Stringer, use it; otherwise
// format as "<type:addr>"-style placeholder to avoid allocations.
type stringer interface{ String() string }
if s, ok := v.(stringer); ok {
return s.String()
}
return "<unencodable:" + strconv.Quote(typeName(v)) + ">"
}
func typeName(v any) string {
if v == nil {
return "nil"
}
type named interface{ TypeName() string }
if n, ok := v.(named); ok {
return n.TypeName()
}
return "unknown"
}
func init() {
encoder.Default.Register(Encoder{})
}