2 Commits

Author SHA1 Message Date
64c30e979d Merge pull request 'Update golangci-lint to v2.12.2 with canonical config' (#17) from golangci-v2.12.2 into main
All checks were successful
check / check (push) Successful in 47s
Reviewed-on: #17
2026-08-10 15:40:04 +02:00
403ba4c42e build: update golangci-lint to v2.12.2 with canonical config
All checks were successful
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 208 additions and 727 deletions

34
.golangci.yml Normal file
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

View File

@@ -1,6 +1,6 @@
# Lint stage: format check + golangci-lint # Lint stage: format check + golangci-lint
# golangci-lint v1.64.8 (2025-02-18) # golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-07
FROM golangci/golangci-lint@sha256:2987913e27f4eca9c8a39129d2c7bc1e74fbcf77f181e01cea607be437aa5cb8 AS lint FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint
WORKDIR /src WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download

View File

@@ -18,12 +18,6 @@ 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 a tty, outputs pretty color logs
- if output is not a tty, outputs json - if output is not a tty, outputs json
- supports delivering each log message via a webhook - 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 ## Planned Features

10
TODO.md
View File

@@ -24,10 +24,10 @@ files it depends on: .golangci.yml, REPO_POLICIES.md, .editorconfig,
# Completed Steps # Completed Steps
* 2026-08-10: fixed every handler discarding slog attributes: console, * 2026-08-07: added canonical `.golangci.yml` (v2 schema), pinned the
JSON and webhook handlers now emit record attributes, accumulate `Dockerfile` lint stage to golangci-lint v2.12.2 (tag+digest), and
WithAttrs without mutating the receiver, and honour WithGroup; fixed all findings the v1→v2 jump surfaced without changing any
slog.Group values nest and LogValuer values are resolved exported signatures or behavior
* 2026-02-08: fixed JSONHandler deadlock from recursive log.Println, * 2026-02-08: fixed JSONHandler deadlock from recursive log.Println,
with regression test; tagged 1.0.1 with regression test; tagged 1.0.1
* 2024-06-14: 1.0 prep: lint and fmt enforced in Docker build, call * 2024-06-14: 1.0 prep: lint and fmt enforced in Docker build, call
@@ -50,8 +50,6 @@ files it depends on: .golangci.yml, REPO_POLICIES.md, .editorconfig,
the working tree the working tree
* Pick one tag scheme before the next release (v1.0.0 vs 1.0.1 are * Pick one tag scheme before the next release (v1.0.0 vs 1.0.1 are
inconsistent) 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) * Fix RELP output to cache (from old TODO)
* Re-add RELP delivery over TCP to remote rsyslog imrelp; removed * Re-add RELP delivery over TCP to remote rsyslog imrelp; removed
2024-06-14 because it did not build (README planned feature) 2024-06-14 because it did not build (README planned feature)

220
attrs.go
View File

@@ -1,220 +0,0 @@
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
}

View File

@@ -1,410 +0,0 @@
package simplelog
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
)
// These tests assert on the bytes the handlers actually emit, because that is
// where the defect lives: the handlers accept attributes and then throw them
// away, so nothing short of reading the output proves they survived.
// 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.
func captureStdout(t *testing.T, fn func()) string {
t.Helper()
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("os.Pipe: %v", err)
}
original := os.Stdout
os.Stdout = w
collected := make(chan string, 1)
go func() {
var buf bytes.Buffer
_, _ = io.Copy(&buf, r)
collected <- buf.String()
}()
defer func() {
os.Stdout = original
_ = r.Close()
}()
fn()
os.Stdout = original
if err := w.Close(); err != nil {
t.Fatalf("close pipe writer: %v", err)
}
return <-collected
}
// testRecord builds an INFO record carrying the given attributes.
func testRecord(message string, attrs ...slog.Attr) slog.Record {
record := slog.NewRecord(time.Now(), slog.LevelInfo, message, 0)
record.AddAttrs(attrs...)
return record
}
// decodeLine parses a single line of JSON handler output.
func decodeLine(t *testing.T, output string) map[string]any {
t.Helper()
line := strings.TrimSpace(output)
if line == "" {
t.Fatal("handler emitted no output")
}
var decoded map[string]any
if err := json.Unmarshal([]byte(line), &decoded); err != nil {
t.Fatalf("output is not valid JSON: %v\noutput: %s", err, line)
}
return decoded
}
// wantField asserts that a decoded JSON object has key with the given value.
func wantField(t *testing.T, decoded map[string]any, key string, want any) {
t.Helper()
got, ok := decoded[key]
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)
}
}
// wantGroup asserts that a decoded JSON object has key holding a nested object.
func wantGroup(t *testing.T, decoded map[string]any, key string) map[string]any {
t.Helper()
got, ok := decoded[key]
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
}
// wantContains asserts that console output contains a fragment.
func wantContains(t *testing.T, output, fragment string) {
t.Helper()
if !strings.Contains(output, fragment) {
t.Fatalf("output does not contain %q\noutput: %s", fragment, output)
}
}
// wantNotContains asserts that console output does not contain a fragment.
func wantNotContains(t *testing.T, output, fragment string) {
t.Helper()
if strings.Contains(output, fragment) {
t.Fatalf("output unexpectedly contains %q\noutput: %s", fragment, output)
}
}
// castTarget is a slog.LogValuer: the handler must resolve it rather than
// serialising the struct itself.
type castTarget struct {
id string
}
func (c castTarget) LogValue() slog.Value {
return slog.StringValue(c.id)
}
var _ slog.LogValuer = castTarget{}
func TestJSONHandlerEmitsRecordAttrs(t *testing.T) {
output := captureStdout(t, func() {
handler := 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)
}
})
decoded := decodeLine(t, output)
wantField(t, decoded, "Message", "casting")
wantField(t, decoded, "device", "livingroom")
wantField(t, decoded, "file", "movie.mp4")
wantField(t, decoded, "attempt", float64(3))
}
func TestJSONHandlerWithAttrsAccumulates(t *testing.T) {
output := captureStdout(t, func() {
handler := 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)
}
})
decoded := decodeLine(t, output)
wantField(t, decoded, "service", "cattbox")
wantField(t, decoded, "component", "caster")
wantField(t, decoded, "device", "livingroom")
}
func TestJSONHandlerWithAttrsDoesNotMutateReceiver(t *testing.T) {
parent := 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)
}
})
secondOutput := captureStdout(t, func() {
if err := second.Handle(context.Background(), testRecord("work")); err != nil {
t.Fatalf("Handle: %v", err)
}
})
parentOutput := captureStdout(t, func() {
if err := parent.Handle(context.Background(), testRecord("work")); err != nil {
t.Fatalf("Handle: %v", err)
}
})
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)
}
}
func TestJSONHandlerWithGroupNestsAttrs(t *testing.T) {
output := captureStdout(t, func() {
handler := 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)
}
})
decoded := decodeLine(t, output)
wantField(t, decoded, "Message", "casting")
group := wantGroup(t, decoded, "cast")
wantField(t, group, "device", "livingroom")
wantField(t, group, "file", "movie.mp4")
}
func TestJSONHandlerResolvesValues(t *testing.T) {
output := captureStdout(t, func() {
handler := 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")),
)
if err := handler.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
})
decoded := decodeLine(t, output)
wantField(t, decoded, "target", "chromecast-7")
wantField(t, decoded, "error", "connection refused")
group := wantGroup(t, decoded, "request")
wantField(t, group, "status", float64(502))
wantField(t, group, "method", "POST")
}
func TestConsoleHandlerEmitsRecordAttrs(t *testing.T) {
output := captureStdout(t, func() {
handler := 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)
}
})
wantContains(t, output, "casting")
wantContains(t, output, "device=livingroom")
wantContains(t, output, "file=movie.mp4")
wantContains(t, output, "attempt=3")
}
func TestConsoleHandlerQuotesValuesNeedingIt(t *testing.T) {
output := captureStdout(t, func() {
handler := NewConsoleHandler()
record := testRecord("casting", slog.String("file", "The Movie.mp4"))
if err := handler.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
})
wantContains(t, output, `file="The Movie.mp4"`)
}
func TestConsoleHandlerWithAttrsAccumulates(t *testing.T) {
output := captureStdout(t, func() {
handler := 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)
}
})
wantContains(t, output, "service=cattbox")
wantContains(t, output, "component=caster")
wantContains(t, output, "device=livingroom")
}
func TestConsoleHandlerWithAttrsDoesNotMutateReceiver(t *testing.T) {
parent := 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)
}
})
secondOutput := captureStdout(t, func() {
if err := second.Handle(context.Background(), testRecord("work")); err != nil {
t.Fatalf("Handle: %v", err)
}
})
parentOutput := captureStdout(t, func() {
if err := parent.Handle(context.Background(), testRecord("work")); err != nil {
t.Fatalf("Handle: %v", err)
}
})
wantContains(t, firstOutput, "worker=first")
wantNotContains(t, firstOutput, "worker=second")
wantContains(t, secondOutput, "worker=second")
wantNotContains(t, secondOutput, "worker=first")
wantNotContains(t, parentOutput, "worker=")
}
func TestConsoleHandlerWithGroupQualifiesAttrs(t *testing.T) {
output := captureStdout(t, func() {
handler := 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)
}
})
wantContains(t, output, "cast.device=livingroom")
wantContains(t, output, "cast.file=movie.mp4")
}
func TestConsoleHandlerResolvesValues(t *testing.T) {
output := captureStdout(t, func() {
handler := 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")),
)
if err := handler.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
})
wantContains(t, output, "request.status=502")
wantContains(t, output, "target=chromecast-7")
wantContains(t, output, `error="connection refused"`)
}
func TestWebhookHandlerEmitsAttrs(t *testing.T) {
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)
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)
}
var body []byte
select {
case body = <-bodies:
case <-time.After(5 * time.Second):
t.Fatal("webhook handler posted nothing")
}
decoded := decodeLine(t, string(body))
wantField(t, decoded, "service", "cattbox")
wantField(t, decoded, "device", "livingroom")
}
// TestMultiplexHandlerPassesAttrsThrough guards the composite handler that
// package init installs: attributes must survive the multiplex too.
func TestMultiplexHandlerPassesAttrsThrough(t *testing.T) {
output := captureStdout(t, func() {
handler := (&MultiplexHandler{handlers: []ExtendedHandler{NewJSONHandler()}}).
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)
}
})
decoded := decodeLine(t, output)
wantField(t, decoded, "service", "cattbox")
wantField(t, decoded, "device", "livingroom")
}

View File

@@ -1,3 +1,5 @@
// Command example demonstrates logging through simplelog's default
// slog handler.
package main package main
import ( import (
@@ -6,13 +8,15 @@ import (
_ "sneak.berlin/go/simplelog" _ "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: // log structured data with slog as usual:
slog.Info( slog.Info(
"User login attempt", "User login attempt",
slog.String("user", "JohnDoe"), slog.String("user", "JohnDoe"),
slog.Int("attempt", 3), slog.Int("attempt", attemptNumber),
) )
slog.Warn( slog.Warn(
"Configuration mismatch", "Configuration mismatch",

View File

@@ -4,28 +4,38 @@ import (
"context" "context"
"fmt" "fmt"
"log/slog" "log/slog"
"os"
"runtime" "runtime"
"time" "time"
"github.com/fatih/color" "github.com/fatih/color"
) )
type ConsoleHandler struct { // callerSkipFrames is the number of stack frames between runtime.Caller
attrs handlerAttrs // and the slog call site that produced the record.
} const callerSkipFrames = 4
// ConsoleHandler writes human-readable, colored log lines to stdout.
type ConsoleHandler struct{}
// NewConsoleHandler returns a new ConsoleHandler.
func NewConsoleHandler() *ConsoleHandler { func NewConsoleHandler() *ConsoleHandler {
return &ConsoleHandler{} return &ConsoleHandler{}
} }
// Handle writes the record to stdout as a colored, timestamped line
// including the caller file and line.
func (c *ConsoleHandler) Handle( func (c *ConsoleHandler) Handle(
ctx context.Context, _ context.Context,
record slog.Record, record slog.Record,
) error { ) error {
timestamp := time.Now().UTC().Format("2006-01-02T15:04:05.000Z07:00") 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 { switch record.Level {
case slog.LevelDebug:
colorFunc = color.New(color.FgWhite).SprintfFunc()
case slog.LevelInfo: case slog.LevelInfo:
colorFunc = color.New(color.FgBlue).SprintfFunc() colorFunc = color.New(color.FgBlue).SprintfFunc()
case slog.LevelWarn: case slog.LevelWarn:
@@ -37,42 +47,42 @@ func (c *ConsoleHandler) Handle(
} }
// Get the caller information // Get the caller information
_, file, line, ok := runtime.Caller(4) _, file, line, ok := runtime.Caller(callerSkipFrames)
if !ok { if !ok {
file = "???" file = "???"
line = 0 line = 0
} }
fmt.Println(
_, _ = fmt.Fprintln(
os.Stdout,
colorFunc( colorFunc(
"%s [%s] %s:%d: %s%s", "%s [%s] %s:%d: %s",
timestamp, timestamp,
record.Level, record.Level,
file, file,
line, line,
record.Message, record.Message,
attrsToText(c.attrs.forRecord(record)),
), ),
) )
return nil return nil
} }
// Enabled reports whether the handler processes records at the given
// level; it always returns true.
func (c *ConsoleHandler) Enabled( func (c *ConsoleHandler) Enabled(
ctx context.Context, _ context.Context,
level slog.Level, _ slog.Level,
) bool { ) bool {
return true return true
} }
func (c *ConsoleHandler) WithAttrs(attrs []slog.Attr) slog.Handler { // WithAttrs returns the handler unchanged; attributes are not rendered.
if len(attrs) == 0 { func (c *ConsoleHandler) WithAttrs(_ []slog.Attr) slog.Handler {
return c return c
}
return &ConsoleHandler{attrs: c.attrs.withAttrs(attrs)}
} }
func (c *ConsoleHandler) WithGroup(name string) slog.Handler { // WithGroup returns the handler unchanged; groups are not rendered.
if name == "" { func (c *ConsoleHandler) WithGroup(_ string) slog.Handler {
return c return c
}
return &ConsoleHandler{attrs: c.attrs.withGroup(name)}
} }

View File

@@ -7,6 +7,8 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
) )
// Event is a single structured log entry with a unique ID and
// timestamp.
type Event struct { type Event struct {
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
Timestamp time.Time `json:"timestamp"` Timestamp time.Time `json:"timestamp"`
@@ -15,6 +17,8 @@ type Event struct {
Data json.RawMessage `json:"data"` 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 { func NewEvent(level, message string, data json.RawMessage) Event {
return Event{ return Event{
ID: uuid.New(), ID: uuid.New(),

View File

@@ -2,7 +2,7 @@ package simplelog
import "log/slog" import "log/slog"
// Handler defines the interface for different log outputs. // ExtendedHandler defines the interface for different log outputs.
type ExtendedHandler interface { type ExtendedHandler interface {
slog.Handler slog.Handler
} }

View File

@@ -8,37 +8,38 @@ import (
"os" "os"
) )
type JSONHandler struct { // JSONHandler writes each log record to stdout as a JSON document.
attrs handlerAttrs type JSONHandler struct{}
}
// NewJSONHandler returns a new JSONHandler.
func NewJSONHandler() *JSONHandler { func NewJSONHandler() *JSONHandler {
return &JSONHandler{} return &JSONHandler{}
} }
func (j *JSONHandler) Handle(ctx context.Context, record slog.Record) error { // Handle marshals the record to JSON and writes it to stdout.
jsonData, err := json.Marshal(recordToMap(record, j.attrs)) func (j *JSONHandler) Handle(_ context.Context, record slog.Record) error {
jsonData, err := json.Marshal(record)
if err != nil { 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 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 return true
} }
func (j *JSONHandler) WithAttrs(attrs []slog.Attr) slog.Handler { // WithAttrs returns the handler unchanged; attributes are not rendered.
if len(attrs) == 0 { func (j *JSONHandler) WithAttrs(_ []slog.Attr) slog.Handler {
return j return j
}
return &JSONHandler{attrs: j.attrs.withAttrs(attrs)}
} }
func (j *JSONHandler) WithGroup(name string) slog.Handler { // WithGroup returns the handler unchanged; groups are not rendered.
if name == "" { func (j *JSONHandler) WithGroup(_ string) slog.Handler {
return j return j
}
return &JSONHandler{attrs: j.attrs.withGroup(name)}
} }

View File

@@ -1,22 +1,27 @@
package simplelog package simplelog_test
import ( import (
"log/slog" "log/slog"
"testing" "testing"
"time" "time"
"sneak.berlin/go/simplelog"
) )
// TestJSONHandlerDeadlock verifies that JSONHandler.Handle does not deadlock // TestJSONHandlerDeadlock verifies that JSONHandler.Handle does not deadlock
// when the default slog handler routes log.Println back through slog. // 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. // On the unfixed code this test will hang (deadlock); with the fix it completes.
func TestJSONHandlerDeadlock(t *testing.T) { func TestJSONHandlerDeadlock(t *testing.T) {
handler := NewJSONHandler() t.Parallel()
handler := simplelog.NewJSONHandler()
// Set our handler as the default so log.Println routes through slog // Set our handler as the default so log.Println routes through slog
logger := slog.New(handler) logger := slog.New(handler)
slog.SetDefault(logger) slog.SetDefault(logger)
done := make(chan struct{}) done := make(chan struct{})
go func() { go func() {
// This call deadlocks on unfixed code because Handle() calls // This call deadlocks on unfixed code because Handle() calls
// log.Println() which re-enters slog → Handle() → log.Println() … // log.Println() which re-enters slog → Handle() → log.Println() …

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 package simplelog
import ( import (
@@ -13,23 +17,31 @@ import (
"github.com/mattn/go-isatty" "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 ( var (
webhookURL = os.Getenv("LOGGER_WEBHOOK_URL") ourCustomLogger *slog.Logger
ourCustomHandler slog.Handler
) )
var ourCustomLogger *slog.Logger //nolint:gochecknoinits // installs itself as slog default on import by design
var ourCustomHandler slog.Handler
func init() { func init() {
ourCustomHandler = NewMultiplexHandler() ourCustomHandler = NewMultiplexHandler()
ourCustomLogger = slog.New(ourCustomHandler) ourCustomLogger = slog.New(ourCustomHandler)
slog.SetDefault(ourCustomLogger) slog.SetDefault(ourCustomLogger)
} }
// MultiplexHandler fans each log record out to a set of underlying
// handlers.
type MultiplexHandler struct { type MultiplexHandler struct {
handlers []ExtendedHandler 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 { func NewMultiplexHandler() slog.Handler {
cl := &MultiplexHandler{} cl := &MultiplexHandler{}
if isatty.IsTerminal(os.Stdout.Fd()) { if isatty.IsTerminal(os.Stdout.Fd()) {
@@ -37,52 +49,69 @@ func NewMultiplexHandler() slog.Handler {
} else { } else {
cl.handlers = append(cl.handlers, NewJSONHandler()) cl.handlers = append(cl.handlers, NewJSONHandler())
} }
if webhookURL != "" { if webhookURL != "" {
handler, err := NewWebhookHandler(webhookURL) handler, err := NewWebhookHandler(webhookURL)
if err != nil { if err != nil {
log.Fatalf("Failed to initialize Webhook handler: %v", err) log.Fatalf("Failed to initialize Webhook handler: %v", err)
} }
cl.handlers = append(cl.handlers, handler) cl.handlers = append(cl.handlers, handler)
} }
return cl return cl
} }
// Handle forwards the record to every underlying handler, stopping at
// the first error.
func (cl *MultiplexHandler) Handle( func (cl *MultiplexHandler) Handle(
ctx context.Context, ctx context.Context,
record slog.Record, record slog.Record,
) error { ) error {
for _, handler := range cl.handlers { for _, handler := range cl.handlers {
if err := handler.Handle(ctx, record); err != nil { err := handler.Handle(ctx, record)
if err != nil {
return err return err
} }
} }
return nil return nil
} }
// Enabled reports whether the handler processes records at the given
// level; it always returns true.
func (cl *MultiplexHandler) Enabled( func (cl *MultiplexHandler) Enabled(
ctx context.Context, _ context.Context,
level slog.Level, _ slog.Level,
) bool { ) bool {
// send us all events // send us all events
return true return true
} }
// WithAttrs returns a new MultiplexHandler whose underlying handlers
// each carry the given attributes.
func (cl *MultiplexHandler) WithAttrs(attrs []slog.Attr) slog.Handler { func (cl *MultiplexHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
newHandlers := make([]ExtendedHandler, len(cl.handlers)) newHandlers := make([]ExtendedHandler, len(cl.handlers))
for i, handler := range cl.handlers { for i, handler := range cl.handlers {
newHandlers[i] = handler.WithAttrs(attrs) newHandlers[i] = handler.WithAttrs(attrs)
} }
return &MultiplexHandler{handlers: newHandlers} 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 { func (cl *MultiplexHandler) WithGroup(name string) slog.Handler {
newHandlers := make([]ExtendedHandler, len(cl.handlers)) newHandlers := make([]ExtendedHandler, len(cl.handlers))
for i, handler := range cl.handlers { for i, handler := range cl.handlers {
newHandlers[i] = handler.WithGroup(name) newHandlers[i] = handler.WithGroup(name)
} }
return &MultiplexHandler{handlers: newHandlers} return &MultiplexHandler{handlers: newHandlers}
} }
// ExtendedEvent describes an Event augmented with caller file and line
// information.
type ExtendedEvent interface { type ExtendedEvent interface {
GetID() uuid.UUID GetID() uuid.UUID
GetTimestamp() time.Time GetTimestamp() time.Time
@@ -95,6 +124,7 @@ type ExtendedEvent interface {
type extendedEvent struct { type extendedEvent struct {
Event Event
File string `json:"file"` File string `json:"file"`
Line int `json:"line"` Line int `json:"line"`
} }
@@ -127,6 +157,10 @@ func (e extendedEvent) GetLine() int {
return e.Line 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 { func NewExtendedEvent(baseEvent Event, file string, line int) ExtendedEvent {
return extendedEvent{ return extendedEvent{
Event: baseEvent, Event: baseEvent,

View File

@@ -1,8 +1,9 @@
package simplelog package simplelog_test
import "testing" import "testing"
// TestCompile checks if the package compiles successfully. // TestCompile checks if the package compiles successfully.
func TestCompile(t *testing.T) { func TestCompile(t *testing.T) {
t.Parallel()
// This test ensures that the simplelog package compiles without error. // This test ensures that the simplelog package compiles without error.
} }

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 package main
import ( import (
@@ -6,11 +8,22 @@ import (
_ "sneak.berlin/go/simplelog" // Using underscore to only invoke init() _ "sneak.berlin/go/simplelog" // Using underscore to only invoke init()
) )
// examplePort is the sample database port logged below.
const examplePort = 5432
func main() { func main() {
// Send some test messages with structured data // Send some test messages with structured data
slog.Info("Starting the application", slog.String("status", "initialized")) 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.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")) slog.Info("Shutting down the application", slog.String("status", "stopped"))
} }

View File

@@ -10,51 +10,64 @@ import (
"net/url" "net/url"
) )
// WebhookHandler POSTs each log record as JSON to a configured webhook
// URL.
type WebhookHandler struct { type WebhookHandler struct {
webhookURL string webhookURL string
attrs handlerAttrs
}
func (w *WebhookHandler) Enabled(ctx context.Context, level slog.Level) bool {
return true
}
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),
}
}
func (w *WebhookHandler) WithGroup(name string) slog.Handler {
if name == "" {
return w
}
return &WebhookHandler{
webhookURL: w.webhookURL,
attrs: w.attrs.withGroup(name),
}
} }
// NewWebhookHandler returns a WebhookHandler that delivers records to
// the given URL, validating the URL first.
func NewWebhookHandler(webhookURL string) (*WebhookHandler, error) { func NewWebhookHandler(webhookURL string) (*WebhookHandler, error) {
if _, err := url.ParseRequestURI(webhookURL); err != nil { _, err := url.ParseRequestURI(webhookURL)
return nil, fmt.Errorf("invalid webhook URL: %v", err) if err != nil {
return nil, fmt.Errorf("invalid webhook URL: %w", err)
} }
return &WebhookHandler{webhookURL: webhookURL}, nil 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 the handler unchanged; attributes are not rendered.
func (w *WebhookHandler) WithAttrs(_ []slog.Attr) slog.Handler {
return w
}
// WithGroup returns the handler unchanged; groups are not rendered.
func (w *WebhookHandler) WithGroup(_ string) slog.Handler {
return w
}
// Handle marshals the record to JSON and POSTs it to the webhook URL.
func (w *WebhookHandler) Handle(ctx context.Context, record slog.Record) error { func (w *WebhookHandler) Handle(ctx context.Context, record slog.Record) error {
jsonData, err := json.Marshal(recordToMap(record, w.attrs)) jsonData, err := json.Marshal(record)
if err != nil { 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 { if err != nil {
return err return err
} }
defer func() { _ = response.Body.Close() }() defer func() { _ = response.Body.Close() }()
return nil return nil
} }