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 }