blob: 4abe4a42f5a76df69593cfdfb6a01d87ccb2f1e4 [file] [log] [blame]
// Copyright 2022 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package slog
import (
"encoding/json"
"fmt"
"io"
"math"
"strconv"
"time"
"unicode/utf8"
"golang.org/x/exp/slog/internal/buffer"
)
// JSONHandler is a Handler that writes Records to an io.Writer as
// line-delimited JSON objects.
type JSONHandler struct {
*commonHandler
}
// NewJSONHandler creates a JSONHandler that writes to w,
// using the default options.
func NewJSONHandler(w io.Writer) *JSONHandler {
return (HandlerOptions{}).NewJSONHandler(w)
}
// NewJSONHandler creates a JSONHandler with the given options that writes to w.
func (opts HandlerOptions) NewJSONHandler(w io.Writer) *JSONHandler {
return &JSONHandler{
&commonHandler{
newAppender: newJSONAppender,
w: w,
opts: opts,
},
}
}
func newJSONAppender(buf *buffer.Buffer) appender {
return (*jsonAppender)(buf)
}
// With returns a new JSONHandler whose attributes consists
// of h's attributes followed by attrs.
func (h *JSONHandler) With(attrs []Attr) Handler {
return &JSONHandler{commonHandler: h.commonHandler.with(attrs)}
}
// Handle formats its argument Record as a JSON object on a single line.
//
// If the Record's time is zero, the time is omitted.
// Otherwise, the key is "time"
// and the value is output in RFC3339 format with millisecond precision.
//
// If the Record's level is zero, the level is omitted.
// Otherwise, the key is "level"
// and the value of [Level.String] is output.
//
// If the AddSource option is set and source information is available,
// the key is "source"
// and the value is output as "FILE:LINE".
//
// The message's key is "msg".
//
// To modify these or other attributes, or remove them from the output, use
// [HandlerOptions.ReplaceAttr].
//
// Values are formatted as with encoding/json.Marshal, with the following
// exceptions:
// - Floating-point NaNs and infinities are formatted as one of the strings
// "NaN", "+Inf" or "-Inf".
// - Levels are formatted as with Level.String.
//
// Each call to Handle results in a single serialized call to io.Writer.Write.
func (h *JSONHandler) Handle(r Record) error {
return h.commonHandler.handle(r)
}
type jsonAppender buffer.Buffer
func (a *jsonAppender) buf() *buffer.Buffer { return (*buffer.Buffer)(a) }
func (a *jsonAppender) appendKey(key string) {
a.appendString(key)
a.buf().WriteByte(':')
}
func (a *jsonAppender) appendString(s string) {
*a.buf() = appendQuotedJSONString(*a.buf(), s)
}
func (a *jsonAppender) appendStart() { a.buf().WriteByte('{') }
func (a *jsonAppender) appendEnd() { a.buf().WriteByte('}') }
func (a *jsonAppender) appendSep() { a.buf().WriteByte(',') }
func (a *jsonAppender) appendTime(t time.Time) error {
b, err := t.MarshalJSON()
if err != nil {
return err
}
a.buf().Write(b)
return nil
}
func (a *jsonAppender) appendSource(file string, line int) {
a.buf().WriteByte('"')
*a.buf() = appendJSONString(*a.buf(), file)
a.buf().WriteByte(':')
itoa((*[]byte)(a), line, -1)
a.buf().WriteByte('"')
}
func (ap *jsonAppender) appendAttrValue(a Attr) error {
switch a.Kind() {
case StringKind:
ap.appendString(a.str())
case Int64Kind:
*ap.buf() = strconv.AppendInt(*ap.buf(), a.Int64(), 10)
case Uint64Kind:
*ap.buf() = strconv.AppendUint(*ap.buf(), a.Uint64(), 10)
case Float64Kind:
f := a.Float64()
// json.Marshal fails on special floats, so handle them here.
switch {
case math.IsInf(f, 1):
ap.buf().WriteString(`"+Inf"`)
case math.IsInf(f, -1):
ap.buf().WriteString(`"-Inf"`)
case math.IsNaN(f):
ap.buf().WriteString(`"NaN"`)
default:
// json.Marshal is funny about floats; it doesn't
// always match strconv.AppendFloat. So just call it.
// That's expensive, but floats are rare.
if err := ap.appendJSONMarshal(f); err != nil {
return err
}
}
case BoolKind:
*ap.buf() = strconv.AppendBool(*ap.buf(), a.Bool())
case DurationKind:
// Do what json.Marshal does.
*ap.buf() = strconv.AppendInt(*ap.buf(), int64(a.Duration()), 10)
case TimeKind:
if err := ap.appendTime(a.Time()); err != nil {
return err
}
case AnyKind:
v := a.Value()
if l, ok := v.(Level); ok {
ap.appendString(l.String())
} else if err := ap.appendJSONMarshal(v); err != nil {
return err
}
default:
panic(fmt.Sprintf("bad kind: %d", a.Kind()))
}
return nil
}
func (a *jsonAppender) appendJSONMarshal(v any) error {
b, err := json.Marshal(v)
if err != nil {
return err
}
a.buf().Write(b)
return nil
}
func appendQuotedJSONString(buf []byte, s string) []byte {
buf = append(buf, '"')
buf = appendJSONString(buf, s)
return append(buf, '"')
}
// appendJSONString escapes s for JSON and appends it to buf.
// It does not surround the string in quotation marks.
//
// Modified from encoding/json/encode.go:encodeState.string,
// with escapeHTML set to true.
//
// TODO: review whether HTML escaping is necessary.
func appendJSONString(buf []byte, s string) []byte {
char := func(b byte) { buf = append(buf, b) }
str := func(s string) { buf = append(buf, s...) }
start := 0
for i := 0; i < len(s); {
if b := s[i]; b < utf8.RuneSelf {
if htmlSafeSet[b] {
i++
continue
}
if start < i {
str(s[start:i])
}
char('\\')
switch b {
case '\\', '"':
char(b)
case '\n':
char('n')
case '\r':
char('r')
case '\t':
char('t')
default:
// This encodes bytes < 0x20 except for \t, \n and \r.
// It also escapes <, >, and &
// because they can lead to security holes when
// user-controlled strings are rendered into JSON
// and served to some browsers.
str(`u00`)
char(hex[b>>4])
char(hex[b&0xF])
}
i++
start = i
continue
}
c, size := utf8.DecodeRuneInString(s[i:])
if c == utf8.RuneError && size == 1 {
if start < i {
str(s[start:i])
}
str(`\ufffd`)
i += size
start = i
continue
}
// U+2028 is LINE SEPARATOR.
// U+2029 is PARAGRAPH SEPARATOR.
// They are both technically valid characters in JSON strings,
// but don't work in JSONP, which has to be evaluated as JavaScript,
// and can lead to security holes there. It is valid JSON to
// escape them, so we do so unconditionally.
// See http://timelessrepo.com/json-isnt-a-javascript-subset for discussion.
if c == '\u2028' || c == '\u2029' {
if start < i {
str(s[start:i])
}
str(`\u202`)
char(hex[c&0xF])
i += size
start = i
continue
}
i += size
}
if start < len(s) {
str(s[start:])
}
return buf
}
var hex = "0123456789abcdef"
// Copied from encoding/json/encode.go:encodeState.string.
//
// htmlSafeSet holds the value true if the ASCII character with the given
// array position can be safely represented inside a JSON string, embedded
// inside of HTML <script> tags, without any additional escaping.
//
// All values are true except for the ASCII control characters (0-31), the
// double quote ("), the backslash character ("\"), HTML opening and closing
// tags ("<" and ">"), and the ampersand ("&").
var htmlSafeSet = [utf8.RuneSelf]bool{
' ': true,
'!': true,
'"': false,
'#': true,
'$': true,
'%': true,
'&': false,
'\'': true,
'(': true,
')': true,
'*': true,
'+': true,
',': true,
'-': true,
'.': true,
'/': true,
'0': true,
'1': true,
'2': true,
'3': true,
'4': true,
'5': true,
'6': true,
'7': true,
'8': true,
'9': true,
':': true,
';': true,
'<': false,
'=': true,
'>': false,
'?': true,
'@': true,
'A': true,
'B': true,
'C': true,
'D': true,
'E': true,
'F': true,
'G': true,
'H': true,
'I': true,
'J': true,
'K': true,
'L': true,
'M': true,
'N': true,
'O': true,
'P': true,
'Q': true,
'R': true,
'S': true,
'T': true,
'U': true,
'V': true,
'W': true,
'X': true,
'Y': true,
'Z': true,
'[': true,
'\\': false,
']': true,
'^': true,
'_': true,
'`': true,
'a': true,
'b': true,
'c': true,
'd': true,
'e': true,
'f': true,
'g': true,
'h': true,
'i': true,
'j': true,
'k': true,
'l': true,
'm': true,
'n': true,
'o': true,
'p': true,
'q': true,
'r': true,
's': true,
't': true,
'u': true,
'v': true,
'w': true,
'x': true,
'y': true,
'z': true,
'{': true,
'|': true,
'}': true,
'~': true,
'\u007f': true,
}