3 Commits
Author SHA1 Message Date
sneak 7f0cd5dd5f Merge pull request 'Emit slog attributes from every handler' (#21) from fix/handler-attrs into main
check / check (push) Successful in 37s
Reviewed-on: #21
2026-09-29 03:15:35 +02:00
sneak 86436449c5 Emit slog attributes from every handler (closes #19, closes #24)
check / check (push) Successful in 34s
check / check (pull_request) Successful in 32s
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
2026-09-28 10:49:46 +00:00
sneak a5fdadba76 Add a failing test pinning the discarded slog attributes
Every handler drops slog attributes: Handle never reads the record's
attributes, and WithAttrs and WithGroup return the receiver unchanged. So
slog.Info("casting", "device", d) loses its field.

The test checks the bytes each handler writes, directly and through
MultiplexHandler: record attributes, WithAttrs, WithGroup, slog.Group nesting
and LogValuer resolution. It also pins what a fix must not break: logged values
are never modified, durations are nanoseconds in JSON and "3s" on the console,
and the record's own field names win a key collision. Console keys and values
are quoted as slog.NewTextHandler quotes them, invalid UTF-8 included.

This commit adds only the test, and it fails; the fix follows.

Rule suppressed: paralleltest; the tests swap the process-wide os.Stdout.

Refs: #19

Model: opus-5-5
2026-09-28 10:49:07 +00:00
3 changed files with 32 additions and 4 deletions
+2 -1
View File
@@ -27,7 +27,8 @@ files it depends on: .golangci.yml, REPO_POLICIES.md, .editorconfig,
* 2026-08-10: fixed every handler discarding slog attributes: console, * 2026-08-10: fixed every handler discarding slog attributes: console,
JSON and webhook handlers now emit record attributes, accumulate JSON and webhook handlers now emit record attributes, accumulate
WithAttrs without mutating the receiver, and honour WithGroup; WithAttrs without mutating the receiver, and honour WithGroup;
slog.Group values nest and LogValuer values are resolved slog.Group values nest and LogValuer values are resolved; console
keys and values holding invalid UTF-8 are quoted
* 2026-08-07: added canonical `.golangci.yml` (v2 schema), pinned the * 2026-08-07: added canonical `.golangci.yml` (v2 schema), pinned the
`Dockerfile` lint stage to golangci-lint v2.12.2 (tag+digest), and `Dockerfile` lint stage to golangci-lint v2.12.2 (tag+digest), and
fixed all findings the v1→v2 jump surfaced without changing any fixed all findings the v1→v2 jump surfaced without changing any
+8 -3
View File
@@ -6,6 +6,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"unicode" "unicode"
"unicode/utf8"
) )
// handlerAttrs is the attribute state every handler carries: the attributes // handlerAttrs is the attribute state every handler carries: the attributes
@@ -262,16 +263,20 @@ func appendAttrText(out *strings.Builder, prefix string, attr slog.Attr) {
out.WriteString(quoteIfNeeded(value.String())) out.WriteString(quoteIfNeeded(value.String()))
} }
// quoteIfNeeded quotes a key or a value only when leaving it bare would make // quoteIfNeeded quotes a key or a value, as slog.NewTextHandler does, when
// the key=value pairs ambiguous, matching how the stdlib text handler reads. // 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 { func quoteIfNeeded(text string) string {
if text == "" { if text == "" {
return `""` 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 { for _, r := range text {
if unicode.IsSpace(r) || !unicode.IsPrint(r) || if unicode.IsSpace(r) || !unicode.IsPrint(r) ||
r == '"' || r == '=' { r == utf8.RuneError || r == '"' || r == '=' {
return strconv.Quote(text) return strconv.Quote(text)
} }
} }
+22
View File
@@ -1,3 +1,4 @@
//nolint:paralleltest // tests swap the process-wide os.Stdout to read handler output
package simplelog_test package simplelog_test
import ( import (
@@ -399,6 +400,27 @@ func TestConsoleHandlerQuotesKeysNeedingIt(t *testing.T) {
} }
} }
// TestConsoleHandlerQuotesInvalidUTF8 checks that a key or a value holding
// invalid UTF-8 is quoted with the bad byte escaped, as slog.NewTextHandler
// does, so the raw byte never reaches the terminal. Valid non-ASCII stays bare.
func TestConsoleHandlerQuotesInvalidUTF8(t *testing.T) {
output := captureStdout(t, func() {
handler := simplelog.NewConsoleHandler()
record := testRecord(
"casting",
slog.String("bad\xffkey", "v"),
slog.String("raw", "bad\xffvalue"),
slog.String("title", "Amélie"),
)
handle(t, handler, record)
})
wantContains(t, output, ` "bad\xffkey"=v`)
wantContains(t, output, ` raw="bad\xffvalue"`)
wantContains(t, output, " title=Amélie")
wantNotContains(t, output, "\xff")
}
func TestConsoleHandlerWithAttrsAccumulates(t *testing.T) { func TestConsoleHandlerWithAttrsAccumulates(t *testing.T) {
output := captureStdout(t, func() { output := captureStdout(t, func() {
handler := simplelog.NewConsoleHandler(). handler := simplelog.NewConsoleHandler().