Handle never read the record's attributes, and WithAttrs and WithGroup returned the receiver unchanged. The JSON and webhook handlers also marshaled slog.Record itself, whose attributes are unexported. Each handler now carries a handlerAttrs value, copied rather than mutated, so sibling loggers cannot leak attributes into each other. JSON and webhook output nest groups as objects; the console appends key=value pairs with dotted group keys, quoted as slog.NewTextHandler quotes them, invalid UTF-8 included. Logged values are never written to. In JSON the record's own fields win a key collision, and a repeated key keeps its last value unless both are groups, which merge; the README documents both. Deviation: DEL is quoted on the console; the stdlib leaves it bare. Model: opus-5-5
This commit is contained in:
@@ -0,0 +1,285 @@
|
||||
package simplelog
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// handlerAttrs is the attribute state every handler carries: the attributes
|
||||
// accumulated by WithAttrs, plus the groups opened by WithGroup. Its methods
|
||||
// never mutate the receiver, so handlers derived from a common parent stay
|
||||
// independent of each other.
|
||||
type handlerAttrs struct {
|
||||
attrs []slog.Attr
|
||||
groups []string
|
||||
}
|
||||
|
||||
// withAttrs returns a copy carrying attrs in addition to those already held.
|
||||
// The attributes are qualified by the groups that are open at the time they
|
||||
// are attached, as the slog.Handler contract requires.
|
||||
func (h handlerAttrs) withAttrs(attrs []slog.Attr) handlerAttrs {
|
||||
qualified := qualifyAttrs(h.groups, attrs)
|
||||
combined := make([]slog.Attr, 0, len(h.attrs)+len(qualified))
|
||||
combined = append(combined, h.attrs...)
|
||||
combined = append(combined, qualified...)
|
||||
|
||||
return handlerAttrs{attrs: combined, groups: h.groups}
|
||||
}
|
||||
|
||||
// withGroup returns a copy with a further group open. An empty name is a no-op,
|
||||
// per the slog.Handler contract.
|
||||
func (h handlerAttrs) withGroup(name string) handlerAttrs {
|
||||
if name == "" {
|
||||
return h
|
||||
}
|
||||
|
||||
groups := make([]string, 0, len(h.groups)+1)
|
||||
groups = append(groups, h.groups...)
|
||||
groups = append(groups, name)
|
||||
|
||||
return handlerAttrs{attrs: h.attrs, groups: groups}
|
||||
}
|
||||
|
||||
// forRecord returns the accumulated attributes followed by the record's own,
|
||||
// the latter qualified by any open groups.
|
||||
func (h handlerAttrs) forRecord(record slog.Record) []slog.Attr {
|
||||
own := qualifyAttrs(h.groups, recordAttrs(record))
|
||||
all := make([]slog.Attr, 0, len(h.attrs)+len(own))
|
||||
all = append(all, h.attrs...)
|
||||
all = append(all, own...)
|
||||
|
||||
return all
|
||||
}
|
||||
|
||||
// qualifyAttrs nests attrs inside the given open groups, innermost last.
|
||||
func qualifyAttrs(groups []string, attrs []slog.Attr) []slog.Attr {
|
||||
for i := len(groups) - 1; i >= 0; i-- {
|
||||
attrs = []slog.Attr{{
|
||||
Key: groups[i],
|
||||
Value: slog.GroupValue(attrs...),
|
||||
}}
|
||||
}
|
||||
|
||||
return attrs
|
||||
}
|
||||
|
||||
// recordAttrs collects the attributes a record carries. They live in
|
||||
// unexported fields, so they are only reachable through Record.Attrs - which
|
||||
// is why marshaling a slog.Record directly loses every one of them.
|
||||
func recordAttrs(record slog.Record) []slog.Attr {
|
||||
attrs := make([]slog.Attr, 0, record.NumAttrs())
|
||||
record.Attrs(func(attr slog.Attr) bool {
|
||||
attrs = append(attrs, attr)
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
return attrs
|
||||
}
|
||||
|
||||
// groupMap is a JSON object this package built for itself: the payload of a
|
||||
// record, or one of the nested objects a slog.Group becomes.
|
||||
//
|
||||
// The distinct type is what keeps the handler from writing into data the
|
||||
// caller still owns. A value handed to slog.Any goes into the payload by
|
||||
// reference - copying every logged map and slice would be a real cost for no
|
||||
// gain, since rendering only reads. The one place the handler writes into a
|
||||
// value already in the payload is the group merge below, and a type assertion
|
||||
// to groupMap can only succeed on a map this package allocated: a caller's
|
||||
// map[string]any is a different type and never matches, however it was keyed.
|
||||
// So the invariant holds by construction - nothing reachable from the caller
|
||||
// is ever written to, only read.
|
||||
type groupMap map[string]any
|
||||
|
||||
// recordToMap renders a record, with its handler's attributes, as the JSON
|
||||
// object the JSON and webhook handlers emit. The record's own fields keep the
|
||||
// names they have always had, and win a collision with an attribute key.
|
||||
func recordToMap(record slog.Record, attrs handlerAttrs) groupMap {
|
||||
fields := attrsToMap(attrs.forRecord(record))
|
||||
fields["Time"] = record.Time
|
||||
fields["Level"] = record.Level
|
||||
fields["Message"] = record.Message
|
||||
fields["PC"] = record.PC
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
// attrsToMap renders attributes as a JSON object in which groups are nested
|
||||
// objects. A group named more than once is merged rather than duplicated;
|
||||
// any other repeated key keeps the last value, as a JSON object must.
|
||||
func attrsToMap(attrs []slog.Attr) groupMap {
|
||||
fields := make(groupMap, len(attrs))
|
||||
for _, attr := range attrs {
|
||||
addAttrToMap(fields, attr)
|
||||
}
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
func addAttrToMap(fields groupMap, attr slog.Attr) {
|
||||
value := attr.Value.Resolve()
|
||||
if attr.Key == "" && value.Any() == nil {
|
||||
// An empty Attr is ignored, per the slog.Handler contract.
|
||||
return
|
||||
}
|
||||
|
||||
if value.Kind() == slog.KindGroup {
|
||||
group := value.Group()
|
||||
if len(group) == 0 {
|
||||
// An empty group is elided, as is its key.
|
||||
return
|
||||
}
|
||||
// A group with an empty key is inlined into its parent.
|
||||
target := fields
|
||||
if attr.Key != "" {
|
||||
// Merge only into a group this package built. Anything else
|
||||
// at this key - including a map the caller logged - is
|
||||
// replaced rather than written into, so the caller's own
|
||||
// data structure is never touched.
|
||||
nested, ok := fields[attr.Key].(groupMap)
|
||||
if !ok {
|
||||
nested = make(groupMap, len(group))
|
||||
fields[attr.Key] = nested
|
||||
}
|
||||
|
||||
target = nested
|
||||
}
|
||||
|
||||
for _, member := range group {
|
||||
addAttrToMap(target, member)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
fields[attr.Key] = jsonValue(value)
|
||||
}
|
||||
|
||||
// jsonValue converts a resolved slog.Value into something encoding/json can
|
||||
// render usefully. Values it cannot marshal - and errors, which marshal to an
|
||||
// empty object - fall back to their slog string form, so a value is never
|
||||
// rendered as an empty object.
|
||||
//
|
||||
// Values are returned as they were given, not copied: nothing here or in its
|
||||
// callers writes to a value the caller supplied.
|
||||
func jsonValue(value slog.Value) any {
|
||||
switch value.Kind() {
|
||||
case slog.KindString:
|
||||
return value.String()
|
||||
case slog.KindInt64:
|
||||
return value.Int64()
|
||||
case slog.KindUint64:
|
||||
return value.Uint64()
|
||||
case slog.KindFloat64:
|
||||
return value.Float64()
|
||||
case slog.KindBool:
|
||||
return value.Bool()
|
||||
case slog.KindDuration:
|
||||
// Nanoseconds as a number, which is what slog.NewJSONHandler
|
||||
// emits. A JSON consumer can then compare and aggregate the
|
||||
// field; the "3s" form would have to be parsed first, and no
|
||||
// common log pipeline knows how.
|
||||
return value.Duration().Nanoseconds()
|
||||
case slog.KindTime:
|
||||
return value.Time()
|
||||
case slog.KindAny, slog.KindGroup, slog.KindLogValuer:
|
||||
// Only KindAny arrives here in practice: addAttrToMap renders
|
||||
// groups itself and resolves every value first.
|
||||
return jsonAnyValue(value)
|
||||
default:
|
||||
// Anything a future Go release adds.
|
||||
return jsonAnyValue(value)
|
||||
}
|
||||
}
|
||||
|
||||
func jsonAnyValue(value slog.Value) any {
|
||||
held := value.Any()
|
||||
if _, ok := held.(json.Marshaler); !ok {
|
||||
if err, ok := held.(error); ok {
|
||||
return err.Error()
|
||||
}
|
||||
}
|
||||
|
||||
_, err := json.Marshal(held)
|
||||
if err != nil {
|
||||
return value.String()
|
||||
}
|
||||
|
||||
return held
|
||||
}
|
||||
|
||||
// attrsToText renders attributes as the space separated key=value pairs the
|
||||
// console handler appends to a log line. Groups become dotted key prefixes.
|
||||
//
|
||||
// Values take their slog string form, so a duration reads as "3s" here where
|
||||
// the json output carries nanoseconds as a number. That is the same split the
|
||||
// stdlib makes between slog.NewTextHandler and slog.NewJSONHandler: the
|
||||
// console line is read by a person, the json line by a program.
|
||||
func attrsToText(attrs []slog.Attr) string {
|
||||
var out strings.Builder
|
||||
for _, attr := range attrs {
|
||||
appendAttrText(&out, "", attr)
|
||||
}
|
||||
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func appendAttrText(out *strings.Builder, prefix string, attr slog.Attr) {
|
||||
value := attr.Value.Resolve()
|
||||
if attr.Key == "" && value.Any() == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if value.Kind() == slog.KindGroup {
|
||||
group := value.Group()
|
||||
if len(group) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
nested := prefix
|
||||
if attr.Key != "" {
|
||||
nested = prefix + attr.Key + "."
|
||||
}
|
||||
|
||||
for _, member := range group {
|
||||
appendAttrText(out, nested, member)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
out.WriteString(" ")
|
||||
// The key is quoted on the same terms as the value, and as a whole
|
||||
// including its group prefix, because that is the token a reader has to
|
||||
// find the "=" in. slog.NewTextHandler quotes "prefix+key" the same way,
|
||||
// so a key like "a=b" reads as "a=b"=v2 rather than the ambiguous
|
||||
// a=b=v2, which parses as the key "a" with the value "b=v2".
|
||||
out.WriteString(quoteIfNeeded(prefix + attr.Key))
|
||||
out.WriteString("=")
|
||||
out.WriteString(quoteIfNeeded(value.String()))
|
||||
}
|
||||
|
||||
// quoteIfNeeded quotes a key or a value, as slog.NewTextHandler does, when
|
||||
// leaving it bare would make the key=value pairs ambiguous or would write a
|
||||
// control character or invalid UTF-8 to the terminal. Unlike the stdlib, it
|
||||
// also quotes DEL (0x7f), which is a control character too.
|
||||
func quoteIfNeeded(text string) string {
|
||||
if text == "" {
|
||||
return `""`
|
||||
}
|
||||
|
||||
// Ranging over a string yields utf8.RuneError for each invalid byte;
|
||||
// strconv.Quote then escapes that byte, as in "bad\xffkey".
|
||||
for _, r := range text {
|
||||
if unicode.IsSpace(r) || !unicode.IsPrint(r) ||
|
||||
r == utf8.RuneError || r == '"' || r == '=' {
|
||||
return strconv.Quote(text)
|
||||
}
|
||||
}
|
||||
|
||||
return text
|
||||
}
|
||||
Reference in New Issue
Block a user