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 } // 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) map[string]any { 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. func attrsToMap(attrs []slog.Attr) map[string]any { fields := make(map[string]any, len(attrs)) for _, attr := range attrs { addAttrToMap(fields, attr) } return fields } func addAttrToMap(fields map[string]any, 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 != "" { nested, ok := fields[attr.Key].(map[string]any) if !ok { nested = make(map[string]any, 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 an attribute is never // silently emptied. 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: return value.Duration().String() 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. 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 }