internal/encoder/json/encoder.go

// 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{})
}