The handlers took attributes and dropped them on the floor. Handle never
read record.Attrs, so the inline slog.Info("casting", "device", d) form
lost its fields; WithAttrs returned the receiver unchanged, so anything
attached to a derived logger vanished; and WithGroup did the same, so
grouping silently did nothing. The JSON and webhook handlers marshaled
the slog.Record value directly, which cannot work: a record keeps its
attributes in unexported fields, so encoding/json only ever saw Time,
Message, Level and PC.
The consequence was perverse. Converting log.Printf("[%s] Casting %s",
device, file) into structured attributes, as the Go styleguide asks,
left the output with strictly less information than before, and the
calling code reviewed as correct because it was correct.
A small shared attribute layer now holds the accumulated attributes and
open groups. It is copy-on-write, so two loggers derived from one parent
cannot leak attributes into each other, and it qualifies attributes by
the groups open at the time they were attached, per the slog.Handler
contract. Rendering follows each handler's format: the JSON and webhook
handlers emit attributes as object fields with groups as nested objects,
merging a group named twice rather than duplicating its key; the console
handler appends key=value pairs, groups flattened to dotted keys, quoted
only where a bare value would be ambiguous.
Values the caller logged go into the json payload by reference, because
rendering only reads them, which leaves the group merge as the one place
a value already in the payload is written to. It merges only into the
unexported groupMap type this package allocates for its own groups: a
caller's map[string]any is a different type and can never satisfy that
type assertion, so it is replaced rather than written into. The
invariant holds by construction - nothing reachable from the caller is
modified by logging it.
Values are resolved through slog.Value.Resolve, so LogValuer values are
reported as the value they stand for instead of as a struct, and errors
are reported as their message rather than as the empty object
encoding/json makes of them. Anything encoding/json cannot marshal falls
back to its slog string form rather than rendering as an empty object.
A duration is nanoseconds as a number in the json and webhook payloads,
matching slog.NewJSONHandler, so a consumer can compare and aggregate
the field without parsing it first. The console line keeps the readable
"3s" form, matching slog.NewTextHandler, because a person reads that
one.
The record's own fields keep the names they have always had - Time,
Level, Message, PC - and win a collision with an attribute key, so
existing consumers of the json output see no change beyond the added
fields. That, and a repeated key keeping its last value, are the two
ways an attribute can go missing from the json output; both are now
written down in the README rather than left to be discovered.
All three handlers are covered, including WebhookHandler, which had the
same defect and is reached through the same MultiplexHandler.
252 lines
7.8 KiB
Go
252 lines
7.8 KiB
Go
package simplelog
|
|
|
|
import (
|
|
"encoding/json"
|
|
"log/slog"
|
|
"strconv"
|
|
"strings"
|
|
"unicode"
|
|
)
|
|
|
|
// 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()
|
|
default:
|
|
// KindAny, and 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()
|
|
}
|
|
}
|
|
if _, err := json.Marshal(held); 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(" ")
|
|
out.WriteString(prefix)
|
|
out.WriteString(attr.Key)
|
|
out.WriteString("=")
|
|
out.WriteString(quoteIfNeeded(value.String()))
|
|
}
|
|
|
|
// quoteIfNeeded quotes a value only when leaving it bare would make the
|
|
// key=value pairs ambiguous, matching how the stdlib text handler reads.
|
|
func quoteIfNeeded(value string) string {
|
|
if value == "" {
|
|
return `""`
|
|
}
|
|
for _, r := range value {
|
|
if unicode.IsSpace(r) || !unicode.IsPrint(r) ||
|
|
r == '"' || r == '=' {
|
|
return strconv.Quote(value)
|
|
}
|
|
}
|
|
return value
|
|
}
|