5 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 40s
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
sneak 64c30e979d Merge pull request 'Update golangci-lint to v2.12.2 with canonical config' (#17) from golangci-v2.12.2 into main
check / check (push) Successful in 47s
Reviewed-on: #17
2026-08-10 15:40:04 +02:00
sneak 403ba4c42e build: update golangci-lint to v2.12.2 with canonical config
check / check (push) Successful in 29s
check / check (pull_request) Successful in 32s
Add the canonical .golangci.yml (v2 schema, all linters enabled with a
small documented disable list) and pin the Dockerfile lint stage to
golangci/golangci-lint:v2.12.2 by tag and digest, replacing the old
v1.64.8 digest-only pin.

Fix all findings surfaced by the v1 to v2 jump without changing any
exported signatures or behavior:

- add package and exported-symbol doc comments (revive)
- rename unused handler parameters to underscore (revive)
- check or explicitly discard error returns (errcheck, errchkjson)
- wrap errors with %w instead of %v (err113)
- use http.NewRequestWithContext instead of http.Post (noctx)
- replace fmt.Println with fmt.Fprintln(os.Stdout, ...) (forbidigo)
- name magic numbers as constants (mnd)
- add explicit slog.LevelDebug case (exhaustive)
- interface{} to any (modernize)
- move tests to the simplelog_test package (testpackage) and add
  t.Parallel() (paralleltest)
- move NewWebhookHandler above its methods (funcorder)
- whitespace, line-length, and blank-line fixes (wsl_v5, whitespace,
  nlreturn, lll, embeddedstructfieldcheck)
- nolint with justification for the intentional init/global design
  (gochecknoinits, gochecknoglobals) and interface-returning
  constructor (ireturn)
2026-08-07 17:10:03 +00:00
16 changed files with 498 additions and 194 deletions
+34
View File
@@ -0,0 +1,34 @@
version: "2"
# Config schema uses the golangci-lint v2 layout (settings live under
# linters.settings, not top-level linters-settings) so that the
# thresholds below are actually applied by golangci-lint >= v2.
run:
timeout: 5m
modules-download-mode: readonly
linters:
default: all
disable:
# Genuinely incompatible with project patterns
- exhaustruct # Requires all struct fields
- depguard # Dependency allow/block lists
- godot # Requires comments to end with periods
- wsl # Deprecated, replaced by wsl_v5
- wrapcheck # Too verbose for internal packages
- varnamelen # Short names like db, id are idiomatic Go
settings:
lll:
line-length: 88
funlen:
lines: 80
statements: 50
cyclop:
max-complexity: 15
dupl:
threshold: 100
issues:
max-issues-per-linter: 0
max-same-issues: 0
+2 -2
View File
@@ -1,6 +1,6 @@
# Lint stage: format check + golangci-lint
# golangci-lint v1.64.8 (2025-02-18)
FROM golangci/golangci-lint@sha256:2987913e27f4eca9c8a39129d2c7bc1e74fbcf77f181e01cea607be437aa5cb8 AS lint
# golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-07
FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
+5 -2
View File
@@ -91,8 +91,11 @@ it.
The record's own fields are named `Time`, `Level`, `Message` and `PC`, and
they own those names: an attribute keyed after one of them is dropped from
the json and webhook output. A key logged more than once keeps its last
value there for the same reason. Neither applies to the console output,
which is a line of text: both pairs appear, in order. If you need a field
value there for the same reason, with one exception: when both are
`slog.Group` values sharing a key, the two groups are merged into one
object holding the members of both, rather than the second replacing the
first. Neither the collision nor the merge applies to the console output,
which is a line of text: every pair appears, in order. If you need a field
called `message`, pick a key that does not collide - the collision is
silent.
+6 -1
View File
@@ -27,7 +27,12 @@ files it depends on: .golangci.yml, REPO_POLICIES.md, .editorconfig,
* 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
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
`Dockerfile` lint stage to golangci-lint v2.12.2 (tag+digest), and
fixed all findings the v1→v2 jump surfaced without changing any
exported signatures or behavior
* 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 -12
View File
@@ -6,6 +6,7 @@ import (
"strconv"
"strings"
"unicode"
"unicode/utf8"
)
// handlerAttrs is the attribute state every handler carries: the attributes
@@ -25,6 +26,7 @@ func (h handlerAttrs) withAttrs(attrs []slog.Attr) handlerAttrs {
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}
}
@@ -34,9 +36,11 @@ 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}
}
@@ -47,6 +51,7 @@ func (h handlerAttrs) forRecord(record slog.Record) []slog.Attr {
all := make([]slog.Attr, 0, len(h.attrs)+len(own))
all = append(all, h.attrs...)
all = append(all, own...)
return all
}
@@ -58,6 +63,7 @@ func qualifyAttrs(groups []string, attrs []slog.Attr) []slog.Attr {
Value: slog.GroupValue(attrs...),
}}
}
return attrs
}
@@ -68,8 +74,10 @@ 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
}
@@ -96,6 +104,7 @@ func recordToMap(record slog.Record, attrs handlerAttrs) groupMap {
fields["Level"] = record.Level
fields["Message"] = record.Message
fields["PC"] = record.PC
return fields
}
@@ -107,6 +116,7 @@ func attrsToMap(attrs []slog.Attr) groupMap {
for _, attr := range attrs {
addAttrToMap(fields, attr)
}
return fields
}
@@ -135,11 +145,14 @@ func addAttrToMap(fields groupMap, attr slog.Attr) {
nested = make(groupMap, len(group))
fields[attr.Key] = nested
}
target = nested
}
for _, member := range group {
addAttrToMap(target, member)
}
return
}
@@ -173,8 +186,12 @@ func jsonValue(value slog.Value) any {
return value.Duration().Nanoseconds()
case slog.KindTime:
return value.Time()
case slog.KindAny, slog.KindGroup, slog.KindLogValuer:
// Only KindAny arrives here in practice: addAttrToMap renders
// groups itself and resolves every value first.
return jsonAnyValue(value)
default:
// KindAny, and anything a future Go release adds.
// Anything a future Go release adds.
return jsonAnyValue(value)
}
}
@@ -186,9 +203,12 @@ func jsonAnyValue(value slog.Value) any {
return err.Error()
}
}
if _, err := json.Marshal(held); err != nil {
_, err := json.Marshal(held)
if err != nil {
return value.String()
}
return held
}
@@ -204,6 +224,7 @@ func attrsToText(attrs []slog.Attr) string {
for _, attr := range attrs {
appendAttrText(&out, "", attr)
}
return out.String()
}
@@ -218,34 +239,47 @@ func appendAttrText(out *strings.Builder, prefix string, attr slog.Attr) {
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)
// The key is quoted on the same terms as the value, and as a whole
// including its group prefix, because that is the token a reader has to
// find the "=" in. slog.NewTextHandler quotes "prefix+key" the same way,
// so a key like "a=b" reads as "a=b"=v2 rather than the ambiguous
// a=b=v2, which parses as the key "a" with the value "b=v2".
out.WriteString(quoteIfNeeded(prefix + 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 == "" {
// quoteIfNeeded quotes a key or a value, as slog.NewTextHandler does, when
// 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 {
if text == "" {
return `""`
}
for _, r := range value {
// 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 {
if unicode.IsSpace(r) || !unicode.IsPrint(r) ||
r == '"' || r == '=' {
return strconv.Quote(value)
r == utf8.RuneError || r == '"' || r == '=' {
return strconv.Quote(text)
}
}
return value
return text
}
+239 -141
View File
@@ -1,4 +1,5 @@
package simplelog
//nolint:paralleltest // tests swap the process-wide os.Stdout to read handler output
package simplelog_test
import (
"bytes"
@@ -7,13 +8,17 @@ import (
"errors"
"io"
"log/slog"
"maps"
"net/http"
"net/http/httptest"
"os"
"reflect"
"slices"
"strings"
"testing"
"time"
"sneak.berlin/go/simplelog"
)
// These tests assert on the bytes the handlers actually emit, because that is
@@ -22,7 +27,8 @@ import (
// captureStdout redirects os.Stdout for the duration of fn and returns what was
// written to it. Both the console and the JSON handler write to os.Stdout, so
// this is the only way to see their real output.
// this is the only way to see their real output. os.Stdout is shared by the
// whole process, so a test that uses this must not run in parallel.
func captureStdout(t *testing.T, fn func()) string {
t.Helper()
@@ -35,8 +41,10 @@ func captureStdout(t *testing.T, fn func()) string {
os.Stdout = w
collected := make(chan string, 1)
go func() {
var buf bytes.Buffer
_, _ = io.Copy(&buf, r)
collected <- buf.String()
}()
@@ -49,7 +57,9 @@ func captureStdout(t *testing.T, fn func()) string {
fn()
os.Stdout = original
if err := w.Close(); err != nil {
err = w.Close()
if err != nil {
t.Fatalf("close pipe writer: %v", err)
}
@@ -60,9 +70,21 @@ func captureStdout(t *testing.T, fn func()) string {
func testRecord(message string, attrs ...slog.Attr) slog.Record {
record := slog.NewRecord(time.Now(), slog.LevelInfo, message, 0)
record.AddAttrs(attrs...)
return record
}
// handle passes record to handler and fails the test if Handle returns an
// error.
func handle(t *testing.T, handler slog.Handler, record slog.Record) {
t.Helper()
err := handler.Handle(context.Background(), record)
if err != nil {
t.Fatalf("Handle: %v", err)
}
}
// decodeLine parses a single line of JSON handler output.
func decodeLine(t *testing.T, output string) map[string]any {
t.Helper()
@@ -73,9 +95,12 @@ func decodeLine(t *testing.T, output string) map[string]any {
}
var decoded map[string]any
if err := json.Unmarshal([]byte(line), &decoded); err != nil {
err := json.Unmarshal([]byte(line), &decoded)
if err != nil {
t.Fatalf("output is not valid JSON: %v\noutput: %s", err, line)
}
return decoded
}
@@ -87,6 +112,7 @@ func wantField(t *testing.T, decoded map[string]any, key string, want any) {
if !ok {
t.Fatalf("field %q missing from output: %v", key, decoded)
}
if got != want {
t.Fatalf("field %q = %v, want %v", key, got, want)
}
@@ -100,10 +126,12 @@ func wantGroup(t *testing.T, decoded map[string]any, key string) map[string]any
if !ok {
t.Fatalf("group %q missing from output: %v", key, decoded)
}
group, ok := got.(map[string]any)
if !ok {
t.Fatalf("field %q = %v, want a nested object", key, got)
}
return group
}
@@ -156,18 +184,19 @@ func (c castTarget) LogValue() slog.Value {
var _ slog.LogValuer = castTarget{}
// errConnectionRefused is the error the ResolvesValues tests log.
var errConnectionRefused = errors.New("connection refused")
func TestJSONHandlerEmitsRecordAttrs(t *testing.T) {
output := captureStdout(t, func() {
handler := NewJSONHandler()
handler := simplelog.NewJSONHandler()
record := testRecord(
"casting",
slog.String("device", "livingroom"),
slog.String("file", "movie.mp4"),
slog.Int("attempt", 3),
)
if err := handler.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
handle(t, handler, record)
})
decoded := decodeLine(t, output)
@@ -179,13 +208,11 @@ func TestJSONHandlerEmitsRecordAttrs(t *testing.T) {
func TestJSONHandlerWithAttrsAccumulates(t *testing.T) {
output := captureStdout(t, func() {
handler := NewJSONHandler().
handler := simplelog.NewJSONHandler().
WithAttrs([]slog.Attr{slog.String("service", "cattbox")}).
WithAttrs([]slog.Attr{slog.String("component", "caster")})
record := testRecord("casting", slog.String("device", "livingroom"))
if err := handler.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
handle(t, handler, record)
})
decoded := decodeLine(t, output)
@@ -195,43 +222,38 @@ func TestJSONHandlerWithAttrsAccumulates(t *testing.T) {
}
func TestJSONHandlerWithAttrsDoesNotMutateReceiver(t *testing.T) {
parent := NewJSONHandler()
parent := simplelog.NewJSONHandler()
first := parent.WithAttrs([]slog.Attr{slog.String("worker", "first")})
second := parent.WithAttrs([]slog.Attr{slog.String("worker", "second")})
firstOutput := captureStdout(t, func() {
if err := first.Handle(context.Background(), testRecord("work")); err != nil {
t.Fatalf("Handle: %v", err)
}
handle(t, first, testRecord("work"))
})
secondOutput := captureStdout(t, func() {
if err := second.Handle(context.Background(), testRecord("work")); err != nil {
t.Fatalf("Handle: %v", err)
}
handle(t, second, testRecord("work"))
})
parentOutput := captureStdout(t, func() {
if err := parent.Handle(context.Background(), testRecord("work")); err != nil {
t.Fatalf("Handle: %v", err)
}
handle(t, parent, testRecord("work"))
})
wantField(t, decodeLine(t, firstOutput), "worker", "first")
wantField(t, decodeLine(t, secondOutput), "worker", "second")
if _, present := decodeLine(t, parentOutput)["worker"]; present {
t.Fatalf("parent handler leaked an attribute from a derived handler: %s", parentOutput)
t.Fatalf(
"parent handler leaked an attribute from a derived handler: %s",
parentOutput,
)
}
}
func TestJSONHandlerWithGroupNestsAttrs(t *testing.T) {
output := captureStdout(t, func() {
handler := NewJSONHandler().
handler := simplelog.NewJSONHandler().
WithGroup("cast").
WithAttrs([]slog.Attr{slog.String("device", "livingroom")})
record := testRecord("casting", slog.String("file", "movie.mp4"))
if err := handler.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
handle(t, handler, record)
})
decoded := decodeLine(t, output)
@@ -244,16 +266,14 @@ func TestJSONHandlerWithGroupNestsAttrs(t *testing.T) {
func TestJSONHandlerResolvesValues(t *testing.T) {
output := captureStdout(t, func() {
handler := NewJSONHandler()
handler := simplelog.NewJSONHandler()
record := testRecord(
"cast failed",
slog.Group("request", slog.Int("status", 502), slog.String("method", "POST")),
slog.Any("target", castTarget{id: "chromecast-7"}),
slog.Any("error", errors.New("connection refused")),
slog.Any("error", errConnectionRefused),
)
if err := handler.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
handle(t, handler, record)
})
decoded := decodeLine(t, output)
@@ -267,16 +287,14 @@ func TestJSONHandlerResolvesValues(t *testing.T) {
func TestConsoleHandlerEmitsRecordAttrs(t *testing.T) {
output := captureStdout(t, func() {
handler := NewConsoleHandler()
handler := simplelog.NewConsoleHandler()
record := testRecord(
"casting",
slog.String("device", "livingroom"),
slog.String("file", "movie.mp4"),
slog.Int("attempt", 3),
)
if err := handler.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
handle(t, handler, record)
})
wantContains(t, output, "casting")
@@ -287,25 +305,129 @@ func TestConsoleHandlerEmitsRecordAttrs(t *testing.T) {
func TestConsoleHandlerQuotesValuesNeedingIt(t *testing.T) {
output := captureStdout(t, func() {
handler := NewConsoleHandler()
handler := simplelog.NewConsoleHandler()
record := testRecord("casting", slog.String("file", "The Movie.mp4"))
if err := handler.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
handle(t, handler, record)
})
wantContains(t, output, `file="The Movie.mp4"`)
}
// TestConsoleHandlerQuotesKeysNeedingIt pins the key side of the same rule.
// A key is quoted on the same terms as a value, and as one token including
// its group prefix, so that the "=" separating the pair is always the first
// one outside quotes. Every case here was compared against
// slog.NewTextHandler, which renders each of them identically.
func TestConsoleHandlerQuotesKeysNeedingIt(t *testing.T) {
tests := []struct {
name string
build func() slog.Handler
attrs []slog.Attr
want string
notWant string
}{
{
name: "an ordinary key is left bare",
build: func() slog.Handler { return simplelog.NewConsoleHandler() },
attrs: []slog.Attr{slog.String("device", "livingroom")},
want: " device=livingroom",
notWant: `"device"`,
},
{
name: "a key containing an equals sign is quoted",
build: func() slog.Handler { return simplelog.NewConsoleHandler() },
attrs: []slog.Attr{slog.String("a=b", "v2")},
want: ` "a=b"=v2`,
notWant: " a=b=v2",
},
{
name: "a key containing a space is quoted",
build: func() slog.Handler { return simplelog.NewConsoleHandler() },
attrs: []slog.Attr{slog.String("my key", "v")},
want: ` "my key"=v`,
notWant: " my key=v",
},
{
name: "a key containing a quote is escaped",
build: func() slog.Handler { return simplelog.NewConsoleHandler() },
attrs: []slog.Attr{slog.String(`he"llo`, "v")},
want: ` "he\"llo"=v`,
notWant: ` he"llo=v`,
},
{
name: "a printable non-ascii key is left bare",
build: func() slog.Handler { return simplelog.NewConsoleHandler() },
attrs: []slog.Attr{slog.String("キー", "v")},
want: " キー=v",
notWant: `"キー"`,
},
{
name: "a group prefix is quoted together with its key",
build: func() slog.Handler { return simplelog.NewConsoleHandler() },
attrs: []slog.Attr{
slog.Group("grp", slog.String("a=b", "v")),
},
want: ` "grp.a=b"=v`,
notWant: " grp.a=b=v",
},
{
name: "a WithGroup prefix needing quotes is quoted with its key",
build: func() slog.Handler {
return simplelog.NewConsoleHandler().WithGroup("my grp")
},
attrs: []slog.Attr{slog.String("k", "v")},
want: ` "my grp.k"=v`,
notWant: " my grp.k=v",
},
{
name: "an empty key is quoted rather than left as a gap",
build: func() slog.Handler { return simplelog.NewConsoleHandler() },
attrs: []slog.Attr{slog.String("", "v")},
want: ` ""=v`,
notWant: " =v",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
output := captureStdout(t, func() {
handle(t, test.build(), testRecord("casting", test.attrs...))
})
wantContains(t, output, test.want)
wantNotContains(t, output, test.notWant)
})
}
}
// 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) {
output := captureStdout(t, func() {
handler := NewConsoleHandler().
handler := simplelog.NewConsoleHandler().
WithAttrs([]slog.Attr{slog.String("service", "cattbox")}).
WithAttrs([]slog.Attr{slog.String("component", "caster")})
record := testRecord("casting", slog.String("device", "livingroom"))
if err := handler.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
handle(t, handler, record)
})
wantContains(t, output, "service=cattbox")
@@ -314,24 +436,18 @@ func TestConsoleHandlerWithAttrsAccumulates(t *testing.T) {
}
func TestConsoleHandlerWithAttrsDoesNotMutateReceiver(t *testing.T) {
parent := NewConsoleHandler()
parent := simplelog.NewConsoleHandler()
first := parent.WithAttrs([]slog.Attr{slog.String("worker", "first")})
second := parent.WithAttrs([]slog.Attr{slog.String("worker", "second")})
firstOutput := captureStdout(t, func() {
if err := first.Handle(context.Background(), testRecord("work")); err != nil {
t.Fatalf("Handle: %v", err)
}
handle(t, first, testRecord("work"))
})
secondOutput := captureStdout(t, func() {
if err := second.Handle(context.Background(), testRecord("work")); err != nil {
t.Fatalf("Handle: %v", err)
}
handle(t, second, testRecord("work"))
})
parentOutput := captureStdout(t, func() {
if err := parent.Handle(context.Background(), testRecord("work")); err != nil {
t.Fatalf("Handle: %v", err)
}
handle(t, parent, testRecord("work"))
})
wantContains(t, firstOutput, "worker=first")
@@ -343,13 +459,11 @@ func TestConsoleHandlerWithAttrsDoesNotMutateReceiver(t *testing.T) {
func TestConsoleHandlerWithGroupQualifiesAttrs(t *testing.T) {
output := captureStdout(t, func() {
handler := NewConsoleHandler().
handler := simplelog.NewConsoleHandler().
WithGroup("cast").
WithAttrs([]slog.Attr{slog.String("device", "livingroom")})
record := testRecord("casting", slog.String("file", "movie.mp4"))
if err := handler.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
handle(t, handler, record)
})
wantContains(t, output, "cast.device=livingroom")
@@ -358,16 +472,14 @@ func TestConsoleHandlerWithGroupQualifiesAttrs(t *testing.T) {
func TestConsoleHandlerResolvesValues(t *testing.T) {
output := captureStdout(t, func() {
handler := NewConsoleHandler()
handler := simplelog.NewConsoleHandler()
record := testRecord(
"cast failed",
slog.Group("request", slog.Int("status", 502)),
slog.Any("target", castTarget{id: "chromecast-7"}),
slog.Any("error", errors.New("connection refused")),
slog.Any("error", errConnectionRefused),
)
if err := handler.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
handle(t, handler, record)
})
wantContains(t, output, "request.status=502")
@@ -376,29 +488,32 @@ func TestConsoleHandlerResolvesValues(t *testing.T) {
}
func TestWebhookHandlerEmitsAttrs(t *testing.T) {
t.Parallel()
bodies := make(chan []byte, 1)
server := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
t.Errorf("read webhook body: %v", err)
}
bodies <- body
w.WriteHeader(http.StatusOK)
},
))
defer server.Close()
handler, err := NewWebhookHandler(server.URL)
handler, err := simplelog.NewWebhookHandler(server.URL)
if err != nil {
t.Fatalf("NewWebhookHandler: %v", err)
}
withAttrs := handler.WithAttrs([]slog.Attr{slog.String("service", "cattbox")})
record := testRecord("casting", slog.String("device", "livingroom"))
if err := withAttrs.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
handle(t, withAttrs, record)
var body []byte
select {
@@ -413,15 +528,15 @@ func TestWebhookHandlerEmitsAttrs(t *testing.T) {
}
// TestMultiplexHandlerPassesAttrsThrough guards the composite handler that
// package init installs: attributes must survive the multiplex too.
// package init installs: attributes must survive the multiplex too. It is
// built while stdout is the capture pipe, which is not a terminal, so it
// writes through the JSON handler.
func TestMultiplexHandlerPassesAttrsThrough(t *testing.T) {
output := captureStdout(t, func() {
handler := (&MultiplexHandler{handlers: []ExtendedHandler{NewJSONHandler()}}).
handler := simplelog.NewMultiplexHandler().
WithAttrs([]slog.Attr{slog.String("service", "cattbox")})
record := testRecord("casting", slog.String("device", "livingroom"))
if err := handler.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
handle(t, handler, record)
})
decoded := decodeLine(t, output)
@@ -438,20 +553,19 @@ func TestMultiplexHandlerPassesAttrsThrough(t *testing.T) {
func TestJSONHandlerDoesNotMutateCallerMap(t *testing.T) {
caller := map[string]any{"mine": "untouched"}
want := maps.Clone(caller)
output := captureStdout(t, func() {
handler := NewJSONHandler()
handler := simplelog.NewJSONHandler()
record := testRecord(
"casting",
slog.Any("g", caller),
slog.Group("g", slog.Int("injected", 1)),
)
if err := handler.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
handle(t, handler, record)
})
wantUnchanged(t, "map", caller, map[string]any{"mine": "untouched"})
wantUnchanged(t, "map", caller, want)
// The later attribute wins the key outright, as any repeated key does.
group := wantGroup(t, decodeLine(t, output), "g")
@@ -461,18 +575,17 @@ func TestJSONHandlerDoesNotMutateCallerMap(t *testing.T) {
func TestJSONHandlerDoesNotMutateCallerMapThroughWithAttrs(t *testing.T) {
caller := map[string]any{"id": "req-1"}
want := maps.Clone(caller)
output := captureStdout(t, func() {
handler := NewJSONHandler().
handler := simplelog.NewJSONHandler().
WithAttrs([]slog.Attr{slog.Any("req", caller)}).
WithGroup("req")
record := testRecord("cast failed", slog.Int("status", 502))
if err := handler.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
handle(t, handler, record)
})
wantUnchanged(t, "map", caller, map[string]any{"id": "req-1"})
wantUnchanged(t, "map", caller, want)
group := wantGroup(t, decodeLine(t, output), "req")
wantField(t, group, "status", float64(502))
@@ -488,26 +601,20 @@ func TestJSONHandlerDoesNotMutateCallerSlice(t *testing.T) {
caller := make([]any, 2, 4)
caller[0] = "first"
caller[1] = "second"
backing := slices.Clone(caller[:cap(caller)])
output := captureStdout(t, func() {
handler := NewJSONHandler()
handler := simplelog.NewJSONHandler()
record := testRecord(
"casting",
slog.Any("s", caller),
slog.Group("s", slog.Int("injected", 1)),
)
if err := handler.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
handle(t, handler, record)
})
wantUnchanged(t, "slice", caller, []any{"first", "second"})
wantUnchanged(
t,
"slice backing array",
caller[:cap(caller)],
[]any{"first", "second", nil, nil},
)
wantUnchanged(t, "slice", caller, backing[:2])
wantUnchanged(t, "slice backing array", caller[:cap(caller)], backing)
group := wantGroup(t, decodeLine(t, output), "s")
wantField(t, group, "injected", float64(1))
@@ -515,40 +622,45 @@ func TestJSONHandlerDoesNotMutateCallerSlice(t *testing.T) {
func TestConsoleHandlerDoesNotMutateCallerMap(t *testing.T) {
caller := map[string]any{"mine": "untouched"}
want := maps.Clone(caller)
output := captureStdout(t, func() {
handler := NewConsoleHandler()
handler := simplelog.NewConsoleHandler()
record := testRecord(
"casting",
slog.Any("g", caller),
slog.Group("g", slog.Int("injected", 1)),
)
if err := handler.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
handle(t, handler, record)
})
wantUnchanged(t, "map", caller, map[string]any{"mine": "untouched"})
wantUnchanged(t, "map", caller, want)
wantContains(t, output, "g.injected=1")
}
func TestWebhookHandlerDoesNotMutateCallerMap(t *testing.T) {
t.Parallel()
caller := map[string]any{"id": "req-1"}
want := maps.Clone(caller)
bodies := make(chan []byte, 1)
server := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
t.Errorf("read webhook body: %v", err)
}
bodies <- body
w.WriteHeader(http.StatusOK)
},
))
defer server.Close()
handler, err := NewWebhookHandler(server.URL)
handler, err := simplelog.NewWebhookHandler(server.URL)
if err != nil {
t.Fatalf("NewWebhookHandler: %v", err)
}
@@ -557,9 +669,7 @@ func TestWebhookHandlerDoesNotMutateCallerMap(t *testing.T) {
WithAttrs([]slog.Attr{slog.Any("req", caller)}).
WithGroup("req")
record := testRecord("cast failed", slog.Int("status", 502))
if err := derived.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
handle(t, derived, record)
var body []byte
select {
@@ -568,7 +678,7 @@ func TestWebhookHandlerDoesNotMutateCallerMap(t *testing.T) {
t.Fatal("webhook handler posted nothing")
}
wantUnchanged(t, "map", caller, map[string]any{"id": "req-1"})
wantUnchanged(t, "map", caller, want)
group := wantGroup(t, decodeLine(t, string(body)), "req")
wantField(t, group, "status", float64(502))
@@ -580,17 +690,16 @@ func TestWebhookHandlerDoesNotMutateCallerMap(t *testing.T) {
// form would silently arrive as a string.
func TestJSONHandlerRendersDurationAsNanoseconds(t *testing.T) {
output := captureStdout(t, func() {
handler := NewJSONHandler()
handler := simplelog.NewJSONHandler()
record := testRecord("casting", slog.Duration("elapsed", 3*time.Second))
if err := handler.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
handle(t, handler, record)
})
decoded := decodeLine(t, output)
if _, isNumber := decoded["elapsed"].(float64); !isNumber {
t.Fatalf("field \"elapsed\" = %#v, want a json number", decoded["elapsed"])
}
wantField(t, decoded, "elapsed", float64((3 * time.Second).Nanoseconds()))
}
@@ -599,11 +708,9 @@ func TestJSONHandlerRendersDurationAsNanoseconds(t *testing.T) {
// form slog.NewTextHandler uses.
func TestConsoleHandlerRendersDurationReadably(t *testing.T) {
output := captureStdout(t, func() {
handler := NewConsoleHandler()
handler := simplelog.NewConsoleHandler()
record := testRecord("casting", slog.Duration("elapsed", 3*time.Second))
if err := handler.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
handle(t, handler, record)
})
wantContains(t, output, "elapsed=3s")
@@ -625,9 +732,10 @@ func TestJSONHandlerContractEdgeCases(t *testing.T) {
}{
{
name: "empty attr is ignored",
build: func() slog.Handler { return NewJSONHandler() },
build: func() slog.Handler { return simplelog.NewJSONHandler() },
attrs: []slog.Attr{{}, slog.String("kept", "yes")},
verify: func(t *testing.T, decoded map[string]any) {
t.Helper()
wantField(t, decoded, "kept", "yes")
wantNoField(t, decoded, "")
},
@@ -635,22 +743,24 @@ func TestJSONHandlerContractEdgeCases(t *testing.T) {
{
name: "empty group is elided along with its key",
build: func() slog.Handler {
return NewJSONHandler().
return simplelog.NewJSONHandler().
WithAttrs([]slog.Attr{slog.Group("empty")})
},
attrs: []slog.Attr{slog.String("kept", "yes")},
verify: func(t *testing.T, decoded map[string]any) {
t.Helper()
wantField(t, decoded, "kept", "yes")
wantNoField(t, decoded, "empty")
},
},
{
name: "group with an empty key is inlined",
build: func() slog.Handler { return NewJSONHandler() },
build: func() slog.Handler { return simplelog.NewJSONHandler() },
attrs: []slog.Attr{
slog.Group("", slog.String("inner", "yes")),
},
verify: func(t *testing.T, decoded map[string]any) {
t.Helper()
wantField(t, decoded, "inner", "yes")
wantNoField(t, decoded, "")
},
@@ -658,10 +768,11 @@ func TestJSONHandlerContractEdgeCases(t *testing.T) {
{
name: "WithGroup with an empty name is a no-op",
build: func() slog.Handler {
return NewJSONHandler().WithGroup("")
return simplelog.NewJSONHandler().WithGroup("")
},
attrs: []slog.Attr{slog.String("kept", "yes")},
verify: func(t *testing.T, decoded map[string]any) {
t.Helper()
wantField(t, decoded, "kept", "yes")
wantNoField(t, decoded, "")
},
@@ -671,11 +782,7 @@ func TestJSONHandlerContractEdgeCases(t *testing.T) {
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
output := captureStdout(t, func() {
handler := test.build()
record := testRecord("casting", test.attrs...)
if err := handler.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
handle(t, test.build(), testRecord("casting", test.attrs...))
})
test.verify(t, decodeLine(t, output))
@@ -695,7 +802,7 @@ func TestConsoleHandlerContractEdgeCases(t *testing.T) {
}{
{
name: "empty attr is ignored",
build: func() slog.Handler { return NewConsoleHandler() },
build: func() slog.Handler { return simplelog.NewConsoleHandler() },
attrs: []slog.Attr{{}, slog.String("kept", "yes")},
want: "kept=yes",
notWant: " =",
@@ -703,7 +810,7 @@ func TestConsoleHandlerContractEdgeCases(t *testing.T) {
{
name: "empty group is elided along with its key",
build: func() slog.Handler {
return NewConsoleHandler().
return simplelog.NewConsoleHandler().
WithAttrs([]slog.Attr{slog.Group("empty")})
},
attrs: []slog.Attr{slog.String("kept", "yes")},
@@ -712,7 +819,7 @@ func TestConsoleHandlerContractEdgeCases(t *testing.T) {
},
{
name: "group with an empty key is inlined",
build: func() slog.Handler { return NewConsoleHandler() },
build: func() slog.Handler { return simplelog.NewConsoleHandler() },
attrs: []slog.Attr{
slog.Group("", slog.String("inner", "yes")),
},
@@ -722,7 +829,7 @@ func TestConsoleHandlerContractEdgeCases(t *testing.T) {
{
name: "WithGroup with an empty name is a no-op",
build: func() slog.Handler {
return NewConsoleHandler().WithGroup("")
return simplelog.NewConsoleHandler().WithGroup("")
},
attrs: []slog.Attr{slog.String("kept", "yes")},
want: " kept=yes",
@@ -733,11 +840,7 @@ func TestConsoleHandlerContractEdgeCases(t *testing.T) {
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
output := captureStdout(t, func() {
handler := test.build()
record := testRecord("casting", test.attrs...)
if err := handler.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
handle(t, test.build(), testRecord("casting", test.attrs...))
})
wantContains(t, output, test.want)
@@ -751,7 +854,7 @@ func TestConsoleHandlerContractEdgeCases(t *testing.T) {
// and an attribute keyed after one of them is dropped rather than emitted.
func TestJSONHandlerRecordFieldsWinKeyCollision(t *testing.T) {
output := captureStdout(t, func() {
handler := NewJSONHandler()
handler := simplelog.NewJSONHandler()
record := testRecord(
"the real message",
slog.String("Time", "hijacked"),
@@ -760,15 +863,14 @@ func TestJSONHandlerRecordFieldsWinKeyCollision(t *testing.T) {
slog.String("PC", "hijacked"),
slog.String("kept", "yes"),
)
if err := handler.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
handle(t, handler, record)
})
decoded := decodeLine(t, output)
wantField(t, decoded, "kept", "yes")
wantField(t, decoded, "Message", "the real message")
wantField(t, decoded, "Level", "INFO")
for _, reserved := range []string{"Time", "PC"} {
if decoded[reserved] == "hijacked" {
t.Fatalf("attribute overwrote the record's own %q field: %v", reserved, decoded)
@@ -780,15 +882,13 @@ func TestJSONHandlerRecordFieldsWinKeyCollision(t *testing.T) {
// payload being an object: a key logged twice collapses to its last value.
func TestJSONHandlerDuplicateKeysKeepLast(t *testing.T) {
output := captureStdout(t, func() {
handler := NewJSONHandler()
handler := simplelog.NewJSONHandler()
record := testRecord(
"casting",
slog.String("device", "kitchen"),
slog.String("device", "livingroom"),
)
if err := handler.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
handle(t, handler, record)
})
wantField(t, decodeLine(t, output), "device", "livingroom")
@@ -798,15 +898,13 @@ func TestJSONHandlerDuplicateKeysKeepLast(t *testing.T) {
// text is not an object, so both pairs survive there.
func TestConsoleHandlerKeepsDuplicateKeys(t *testing.T) {
output := captureStdout(t, func() {
handler := NewConsoleHandler()
handler := simplelog.NewConsoleHandler()
record := testRecord(
"casting",
slog.String("device", "kitchen"),
slog.String("device", "livingroom"),
)
if err := handler.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
handle(t, handler, record)
})
wantContains(t, output, "device=kitchen")
+6 -2
View File
@@ -1,3 +1,5 @@
// Command example demonstrates logging through simplelog's default
// slog handler.
package main
import (
@@ -6,13 +8,15 @@ import (
_ "sneak.berlin/go/simplelog"
)
func main() {
// attemptNumber is the example login attempt count logged below.
const attemptNumber = 3
func main() {
// log structured data with slog as usual:
slog.Info(
"User login attempt",
slog.String("user", "JohnDoe"),
slog.Int("attempt", 3),
slog.Int("attempt", attemptNumber),
)
slog.Warn(
"Configuration mismatch",
+30 -6
View File
@@ -4,28 +4,41 @@ import (
"context"
"fmt"
"log/slog"
"os"
"runtime"
"time"
"github.com/fatih/color"
)
// callerSkipFrames is the number of stack frames between runtime.Caller
// and the slog call site that produced the record.
const callerSkipFrames = 4
// ConsoleHandler writes human-readable, colored log lines to stdout.
type ConsoleHandler struct {
attrs handlerAttrs
}
// NewConsoleHandler returns a new ConsoleHandler.
func NewConsoleHandler() *ConsoleHandler {
return &ConsoleHandler{}
}
// Handle writes the record to stdout as a colored, timestamped line
// including the caller file and line, followed by the attributes as
// key=value pairs.
func (c *ConsoleHandler) Handle(
ctx context.Context,
_ context.Context,
record slog.Record,
) error {
timestamp := time.Now().UTC().Format("2006-01-02T15:04:05.000Z07:00")
var colorFunc func(format string, a ...interface{}) string
var colorFunc func(format string, a ...any) string
switch record.Level {
case slog.LevelDebug:
colorFunc = color.New(color.FgWhite).SprintfFunc()
case slog.LevelInfo:
colorFunc = color.New(color.FgBlue).SprintfFunc()
case slog.LevelWarn:
@@ -37,12 +50,14 @@ func (c *ConsoleHandler) Handle(
}
// Get the caller information
_, file, line, ok := runtime.Caller(4)
_, file, line, ok := runtime.Caller(callerSkipFrames)
if !ok {
file = "???"
line = 0
}
fmt.Println(
_, _ = fmt.Fprintln(
os.Stdout,
colorFunc(
"%s [%s] %s:%d: %s%s",
timestamp,
@@ -53,26 +68,35 @@ func (c *ConsoleHandler) Handle(
attrsToText(c.attrs.forRecord(record)),
),
)
return nil
}
// Enabled reports whether the handler processes records at the given
// level; it always returns true.
func (c *ConsoleHandler) Enabled(
ctx context.Context,
level slog.Level,
_ context.Context,
_ slog.Level,
) bool {
return true
}
// WithAttrs returns a new handler that also emits attrs, qualified by the
// groups open now. The receiver is not modified.
func (c *ConsoleHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
if len(attrs) == 0 {
return c
}
return &ConsoleHandler{attrs: c.attrs.withAttrs(attrs)}
}
// WithGroup returns a new handler that qualifies later attributes with
// the group name. An empty name returns the receiver.
func (c *ConsoleHandler) WithGroup(name string) slog.Handler {
if name == "" {
return c
}
return &ConsoleHandler{attrs: c.attrs.withGroup(name)}
}
+4
View File
@@ -7,6 +7,8 @@ import (
"github.com/google/uuid"
)
// Event is a single structured log entry with a unique ID and
// timestamp.
type Event struct {
ID uuid.UUID `json:"id"`
Timestamp time.Time `json:"timestamp"`
@@ -15,6 +17,8 @@ type Event struct {
Data json.RawMessage `json:"data"`
}
// NewEvent returns an Event with a fresh ID and the current UTC
// timestamp.
func NewEvent(level, message string, data json.RawMessage) Event {
return Event{
ID: uuid.New(),
+1 -1
View File
@@ -2,7 +2,7 @@ package simplelog
import "log/slog"
// Handler defines the interface for different log outputs.
// ExtendedHandler defines the interface for different log outputs.
type ExtendedHandler interface {
slog.Handler
}
+18 -4
View File
@@ -8,37 +8,51 @@ import (
"os"
)
// JSONHandler writes each log record to stdout as a JSON document.
type JSONHandler struct {
attrs handlerAttrs
}
// NewJSONHandler returns a new JSONHandler.
func NewJSONHandler() *JSONHandler {
return &JSONHandler{}
}
func (j *JSONHandler) Handle(ctx context.Context, record slog.Record) error {
// Handle marshals the record, with its attributes, to one JSON object and
// writes it to stdout.
func (j *JSONHandler) Handle(_ context.Context, record slog.Record) error {
jsonData, err := json.Marshal(recordToMap(record, j.attrs))
if err != nil {
return fmt.Errorf("error marshaling log record: %w", err)
return err
}
fmt.Fprintln(os.Stdout, string(jsonData))
_, _ = fmt.Fprintln(os.Stdout, string(jsonData))
return nil
}
func (j *JSONHandler) Enabled(ctx context.Context, level slog.Level) bool {
// Enabled reports whether the handler processes records at the given
// level; it always returns true.
func (j *JSONHandler) Enabled(_ context.Context, _ slog.Level) bool {
return true
}
// WithAttrs returns a new handler that also emits attrs, qualified by the
// groups open now. The receiver is not modified.
func (j *JSONHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
if len(attrs) == 0 {
return j
}
return &JSONHandler{attrs: j.attrs.withAttrs(attrs)}
}
// WithGroup returns a new handler that nests later attributes in an
// object named after the group. An empty name returns the receiver.
func (j *JSONHandler) WithGroup(name string) slog.Handler {
if name == "" {
return j
}
return &JSONHandler{attrs: j.attrs.withGroup(name)}
}
+7 -2
View File
@@ -1,22 +1,27 @@
package simplelog
package simplelog_test
import (
"log/slog"
"testing"
"time"
"sneak.berlin/go/simplelog"
)
// TestJSONHandlerDeadlock verifies that JSONHandler.Handle does not deadlock
// when the default slog handler routes log.Println back through slog.
// On the unfixed code this test will hang (deadlock); with the fix it completes.
func TestJSONHandlerDeadlock(t *testing.T) {
handler := NewJSONHandler()
t.Parallel()
handler := simplelog.NewJSONHandler()
// Set our handler as the default so log.Println routes through slog
logger := slog.New(handler)
slog.SetDefault(logger)
done := make(chan struct{})
go func() {
// This call deadlocks on unfixed code because Handle() calls
// log.Println() which re-enters slog → Handle() → log.Println() …
+41 -7
View File
@@ -1,3 +1,7 @@
// Package simplelog installs a multiplexing slog handler as the process
// default on import. It logs human-readable colored output when stdout is
// a terminal, JSON otherwise, and can additionally POST each record to a
// webhook configured via the LOGGER_WEBHOOK_URL environment variable.
package simplelog
import (
@@ -13,23 +17,31 @@ import (
"github.com/mattn/go-isatty"
)
//nolint:gochecknoglobals // webhook destination is read once from the environment
var webhookURL = os.Getenv("LOGGER_WEBHOOK_URL")
//nolint:gochecknoglobals // package-level default logger state is the package's design
var (
webhookURL = os.Getenv("LOGGER_WEBHOOK_URL")
ourCustomLogger *slog.Logger
ourCustomHandler slog.Handler
)
var ourCustomLogger *slog.Logger
var ourCustomHandler slog.Handler
//nolint:gochecknoinits // installs itself as slog default on import by design
func init() {
ourCustomHandler = NewMultiplexHandler()
ourCustomLogger = slog.New(ourCustomHandler)
slog.SetDefault(ourCustomLogger)
}
// MultiplexHandler fans each log record out to a set of underlying
// handlers.
type MultiplexHandler struct {
handlers []ExtendedHandler
}
// NewMultiplexHandler returns a handler that writes colored console
// output when stdout is a terminal and JSON otherwise, plus an optional
// webhook handler when LOGGER_WEBHOOK_URL is set.
func NewMultiplexHandler() slog.Handler {
cl := &MultiplexHandler{}
if isatty.IsTerminal(os.Stdout.Fd()) {
@@ -37,52 +49,69 @@ func NewMultiplexHandler() slog.Handler {
} else {
cl.handlers = append(cl.handlers, NewJSONHandler())
}
if webhookURL != "" {
handler, err := NewWebhookHandler(webhookURL)
if err != nil {
log.Fatalf("Failed to initialize Webhook handler: %v", err)
}
cl.handlers = append(cl.handlers, handler)
}
return cl
}
// Handle forwards the record to every underlying handler, stopping at
// the first error.
func (cl *MultiplexHandler) Handle(
ctx context.Context,
record slog.Record,
) error {
for _, handler := range cl.handlers {
if err := handler.Handle(ctx, record); err != nil {
err := handler.Handle(ctx, record)
if err != nil {
return err
}
}
return nil
}
// Enabled reports whether the handler processes records at the given
// level; it always returns true.
func (cl *MultiplexHandler) Enabled(
ctx context.Context,
level slog.Level,
_ context.Context,
_ slog.Level,
) bool {
// send us all events
return true
}
// WithAttrs returns a new MultiplexHandler whose underlying handlers
// each carry the given attributes.
func (cl *MultiplexHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
newHandlers := make([]ExtendedHandler, len(cl.handlers))
for i, handler := range cl.handlers {
newHandlers[i] = handler.WithAttrs(attrs)
}
return &MultiplexHandler{handlers: newHandlers}
}
// WithGroup returns a new MultiplexHandler whose underlying handlers
// each use the given group name.
func (cl *MultiplexHandler) WithGroup(name string) slog.Handler {
newHandlers := make([]ExtendedHandler, len(cl.handlers))
for i, handler := range cl.handlers {
newHandlers[i] = handler.WithGroup(name)
}
return &MultiplexHandler{handlers: newHandlers}
}
// ExtendedEvent describes an Event augmented with caller file and line
// information.
type ExtendedEvent interface {
GetID() uuid.UUID
GetTimestamp() time.Time
@@ -95,6 +124,7 @@ type ExtendedEvent interface {
type extendedEvent struct {
Event
File string `json:"file"`
Line int `json:"line"`
}
@@ -127,6 +157,10 @@ func (e extendedEvent) GetLine() int {
return e.Line
}
// NewExtendedEvent wraps baseEvent with the caller file and line it was
// logged from.
//
//nolint:ireturn // returning the interface is this constructor's public API
func NewExtendedEvent(baseEvent Event, file string, line int) ExtendedEvent {
return extendedEvent{
Event: baseEvent,
+2 -1
View File
@@ -1,8 +1,9 @@
package simplelog
package simplelog_test
import "testing"
// TestCompile checks if the package compiles successfully.
func TestCompile(t *testing.T) {
t.Parallel()
// This test ensures that the simplelog package compiles without error.
}
+15 -2
View File
@@ -1,3 +1,5 @@
// Command relp_log_trial emits sample log messages through simplelog's
// default slog handler for manual testing.
package main
import (
@@ -6,11 +8,22 @@ import (
_ "sneak.berlin/go/simplelog" // Using underscore to only invoke init()
)
// examplePort is the sample database port logged below.
const examplePort = 5432
func main() {
// Send some test messages with structured data
slog.Info("Starting the application", slog.String("status", "initialized"))
slog.Info("Attempting to connect to database", slog.String("host", "localhost"), slog.Int("port", 5432))
slog.Info(
"Attempting to connect to database",
slog.String("host", "localhost"),
slog.Int("port", examplePort),
)
slog.Warn("Using default configuration", slog.String("configuration", "default"))
slog.Error("Failed to load module", slog.String("module", "finance"), slog.String("error", "module not found"))
slog.Error(
"Failed to load module",
slog.String("module", "finance"),
slog.String("error", "module not found"),
)
slog.Info("Shutting down the application", slog.String("status", "stopped"))
}
+42 -11
View File
@@ -10,51 +10,82 @@ import (
"net/url"
)
// WebhookHandler POSTs each log record as JSON to a configured webhook
// URL.
type WebhookHandler struct {
webhookURL string
attrs handlerAttrs
}
func (w *WebhookHandler) Enabled(ctx context.Context, level slog.Level) bool {
// NewWebhookHandler returns a WebhookHandler that delivers records to
// the given URL, validating the URL first.
func NewWebhookHandler(webhookURL string) (*WebhookHandler, error) {
_, err := url.ParseRequestURI(webhookURL)
if err != nil {
return nil, fmt.Errorf("invalid webhook URL: %w", err)
}
return &WebhookHandler{webhookURL: webhookURL}, nil
}
// Enabled reports whether the handler processes records at the given
// level; it always returns true.
func (w *WebhookHandler) Enabled(_ context.Context, _ slog.Level) bool {
return true
}
// WithAttrs returns a new handler that also emits attrs, qualified by the
// groups open now. The receiver is not modified.
func (w *WebhookHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
if len(attrs) == 0 {
return w
}
return &WebhookHandler{
webhookURL: w.webhookURL,
attrs: w.attrs.withAttrs(attrs),
}
}
// WithGroup returns a new handler that nests later attributes in an
// object named after the group. An empty name returns the receiver.
func (w *WebhookHandler) WithGroup(name string) slog.Handler {
if name == "" {
return w
}
return &WebhookHandler{
webhookURL: w.webhookURL,
attrs: w.attrs.withGroup(name),
}
}
func NewWebhookHandler(webhookURL string) (*WebhookHandler, error) {
if _, err := url.ParseRequestURI(webhookURL); err != nil {
return nil, fmt.Errorf("invalid webhook URL: %v", err)
}
return &WebhookHandler{webhookURL: webhookURL}, nil
}
// Handle marshals the record, with its attributes, to one JSON object and
// POSTs it to the webhook URL.
func (w *WebhookHandler) Handle(ctx context.Context, record slog.Record) error {
jsonData, err := json.Marshal(recordToMap(record, w.attrs))
if err != nil {
return fmt.Errorf("error marshaling event: %v", err)
return fmt.Errorf("error marshaling event: %w", err)
}
response, err := http.Post(w.webhookURL, "application/json", bytes.NewBuffer(jsonData))
request, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
w.webhookURL,
bytes.NewReader(jsonData),
)
if err != nil {
return fmt.Errorf("error creating webhook request: %w", err)
}
request.Header.Set("Content-Type", "application/json")
response, err := http.DefaultClient.Do(request)
if err != nil {
return err
}
defer response.Body.Close()
defer func() { _ = response.Body.Close() }()
return nil
}