diff --git a/README.md b/README.md index 3f6222e..fbd984e 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,12 @@ Released v1.0.0 2024-06-14. Works as intended. No known bugs. - if output is a tty, outputs pretty color logs - if output is not a tty, outputs json - supports delivering each log message via a webhook +- emits every `slog` attribute: those passed to a log call, those + accumulated with `WithAttrs`, and those qualified by `WithGroup`. + `slog.Group` values nest, and `slog.LogValuer` values are resolved. In + json output attributes are object fields (groups become nested objects); + in console output they are appended as `key=value` pairs, with grouped + keys written as `group.key=value` ## Planned Features diff --git a/TODO.md b/TODO.md index cbd9a8d..9a9aaec 100644 --- a/TODO.md +++ b/TODO.md @@ -24,6 +24,10 @@ files it depends on: .golangci.yml, REPO_POLICIES.md, .editorconfig, # Completed Steps +* 2026-08-10: fixed every handler discarding slog attributes: console, + JSON and webhook handlers now emit record attributes, accumulate + WithAttrs without mutating the receiver, and honour WithGroup; + slog.Group values nest and LogValuer values are resolved * 2026-02-08: fixed JSONHandler deadlock from recursive log.Println, with regression test; tagged 1.0.1 * 2024-06-14: 1.0 prep: lint and fmt enforced in Docker build, call @@ -46,6 +50,8 @@ files it depends on: .golangci.yml, REPO_POLICIES.md, .editorconfig, the working tree * Pick one tag scheme before the next release (v1.0.0 vs 1.0.1 are inconsistent) +* Tag v1.0.2, with the leading v, once the attribute fix lands, so + consuming repos can move off pseudo-version pins in one step * Fix RELP output to cache (from old TODO) * Re-add RELP delivery over TCP to remote rsyslog imrelp; removed 2024-06-14 because it did not build (README planned feature) diff --git a/attrs.go b/attrs.go new file mode 100644 index 0000000..16f34ea --- /dev/null +++ b/attrs.go @@ -0,0 +1,220 @@ +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 +} diff --git a/console_handler.go b/console_handler.go index 87552c9..5b60063 100644 --- a/console_handler.go +++ b/console_handler.go @@ -10,7 +10,9 @@ import ( "github.com/fatih/color" ) -type ConsoleHandler struct{} +type ConsoleHandler struct { + attrs handlerAttrs +} func NewConsoleHandler() *ConsoleHandler { return &ConsoleHandler{} @@ -42,12 +44,13 @@ func (c *ConsoleHandler) Handle( } fmt.Println( colorFunc( - "%s [%s] %s:%d: %s", + "%s [%s] %s:%d: %s%s", timestamp, record.Level, file, line, record.Message, + attrsToText(c.attrs.forRecord(record)), ), ) return nil @@ -61,9 +64,15 @@ func (c *ConsoleHandler) Enabled( } func (c *ConsoleHandler) WithAttrs(attrs []slog.Attr) slog.Handler { - return c + if len(attrs) == 0 { + return c + } + return &ConsoleHandler{attrs: c.attrs.withAttrs(attrs)} } func (c *ConsoleHandler) WithGroup(name string) slog.Handler { - return c + if name == "" { + return c + } + return &ConsoleHandler{attrs: c.attrs.withGroup(name)} } diff --git a/json_handler.go b/json_handler.go index 4c1c95b..8671535 100644 --- a/json_handler.go +++ b/json_handler.go @@ -8,15 +8,20 @@ import ( "os" ) -type JSONHandler struct{} +type JSONHandler struct { + attrs handlerAttrs +} func NewJSONHandler() *JSONHandler { return &JSONHandler{} } func (j *JSONHandler) Handle(ctx context.Context, record slog.Record) error { - jsonData, _ := json.Marshal(record) - fmt.Fprintln(os.Stdout, string(jsonData)) + jsonData, err := json.Marshal(recordToMap(record, j.attrs)) + if err != nil { + return fmt.Errorf("error marshaling log record: %w", err) + } + _, _ = fmt.Fprintln(os.Stdout, string(jsonData)) return nil } @@ -25,9 +30,15 @@ func (j *JSONHandler) Enabled(ctx context.Context, level slog.Level) bool { } func (j *JSONHandler) WithAttrs(attrs []slog.Attr) slog.Handler { - return j + if len(attrs) == 0 { + return j + } + return &JSONHandler{attrs: j.attrs.withAttrs(attrs)} } func (j *JSONHandler) WithGroup(name string) slog.Handler { - return j + if name == "" { + return j + } + return &JSONHandler{attrs: j.attrs.withGroup(name)} } diff --git a/webhook_handler.go b/webhook_handler.go index 2c377aa..07107d2 100644 --- a/webhook_handler.go +++ b/webhook_handler.go @@ -12,6 +12,7 @@ import ( type WebhookHandler struct { webhookURL string + attrs handlerAttrs } func (w *WebhookHandler) Enabled(ctx context.Context, level slog.Level) bool { @@ -19,11 +20,23 @@ func (w *WebhookHandler) Enabled(ctx context.Context, level slog.Level) bool { } func (w *WebhookHandler) WithAttrs(attrs []slog.Attr) slog.Handler { - return w + if len(attrs) == 0 { + return w + } + return &WebhookHandler{ + webhookURL: w.webhookURL, + attrs: w.attrs.withAttrs(attrs), + } } func (w *WebhookHandler) WithGroup(name string) slog.Handler { - return w + if name == "" { + return w + } + return &WebhookHandler{ + webhookURL: w.webhookURL, + attrs: w.attrs.withGroup(name), + } } func NewWebhookHandler(webhookURL string) (*WebhookHandler, error) { @@ -34,7 +47,7 @@ func NewWebhookHandler(webhookURL string) (*WebhookHandler, error) { } func (w *WebhookHandler) Handle(ctx context.Context, record slog.Record) error { - jsonData, err := json.Marshal(record) + jsonData, err := json.Marshal(recordToMap(record, w.attrs)) if err != nil { return fmt.Errorf("error marshaling event: %v", err) } @@ -42,6 +55,6 @@ func (w *WebhookHandler) Handle(ctx context.Context, record slog.Record) error { if err != nil { return err } - defer response.Body.Close() + defer func() { _ = response.Body.Close() }() return nil }